Bind OP25 control API and terminal to loopback by default
Both :8001 (FastAPI control API) and :8081 (OP25's HTTP terminal) listened on 0.0.0.0 with no authentication, on a container that is privileged with /dev mounted and network_mode: host. Nodes get deployed to third-party sites, so that exposed start/stop/retune to anyone on the host's LAN. All three containers share the host network namespace, so edge-node still reaches both over 127.0.0.1 unchanged. OP25_DEBUG_EXPOSE=true restores the old 0.0.0.0 binding and logs a loud warning; it is off by default. Confirmed against boatbod/op25 gr310 that the terminal's http:<host>:<port> string is honoured as a real bind address (http_server.py splits it and hands the host to create_server), so no flag was invented. Also reorder models.py so IcecastConfig precedes ConfigGenerator, which annotates a field with it. That only worked because python:slim-trixie is currently Python 3.14, where PEP 649 defers annotation evaluation; on 3.13 or earlier the same file is a hard NameError at import. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
d6dfe5a293
commit
a61a7b2c31
@@ -8,6 +8,11 @@ services:
|
||||
ICECAST_SOURCE_PASSWORD: ${ICECAST_SOURCE_PASSWORD:-hackme}
|
||||
ICECAST_ADMIN_PASSWORD: ${ICECAST_ADMIN_PASSWORD:-admin}
|
||||
|
||||
# No `ports:` here — network_mode: host makes it a no-op either way. The
|
||||
# control API (:8001) and OP25's HTTP terminal (:8081) are unauthenticated,
|
||||
# so they bind 127.0.0.1 by default (see OP25_DEBUG_EXPOSE in .env.example)
|
||||
# rather than being exposed. edge-node still reaches both over localhost
|
||||
# because it shares this host network namespace.
|
||||
op25:
|
||||
image: ${IMAGE_REGISTRY:-git.vpn.cusano.net}/${DOCKER_ORG:-logan}/${DOCKER_REPO:-node-26}/op25-client:stable
|
||||
build: ./op25-container
|
||||
|
||||
@@ -50,5 +50,8 @@ RUN sed -i 's/\r$//' /usr/local/bin/docker-entrypoint.sh && \
|
||||
# 2. Update ENTRYPOINT to use the wrapper script
|
||||
ENTRYPOINT ["/usr/local/bin/docker-entrypoint.sh"]
|
||||
|
||||
# 3. Use CMD to pass the uvicorn command as arguments to the ENTRYPOINT script
|
||||
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8001", "--reload"]
|
||||
# 3. Use CMD to pass the launch command as arguments to the ENTRYPOINT script.
|
||||
# main.py starts uvicorn itself (see `if __name__ == "__main__"`) so the bind
|
||||
# address can be driven by OP25_DEBUG_EXPOSE at runtime instead of being baked
|
||||
# into this image at build time.
|
||||
CMD ["python", "main.py"]
|
||||
@@ -0,0 +1,33 @@
|
||||
from pydantic_settings import BaseSettings
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
# ------------------------------------------------------------------
|
||||
# OP25_DEBUG_EXPOSE — debugging aid, NOT a deployment mode.
|
||||
#
|
||||
# False (default): the op25 FastAPI control API (:8001, start/stop/
|
||||
# generate-config) and OP25's own HTTP terminal (:8081, live talkgroup
|
||||
# metadata) both bind 127.0.0.1. All three Client containers share the
|
||||
# host network namespace (network_mode: host), so edge-node still reaches
|
||||
# both over localhost with no functional change — nothing off-box can.
|
||||
# Neither surface has authentication, so this is the only thing closing
|
||||
# that hole.
|
||||
#
|
||||
# True: both bind 0.0.0.0 — reachable by anything on the node's LAN with
|
||||
# NO authentication (start/stop OP25, rewrite its config, raw terminal
|
||||
# access). Only ever set this for local development off a real deployed
|
||||
# node. A loud warning naming both ports is logged at startup whenever
|
||||
# this is true.
|
||||
# ------------------------------------------------------------------
|
||||
op25_debug_expose: bool = False
|
||||
|
||||
class Config:
|
||||
env_file = ".env"
|
||||
|
||||
|
||||
settings = Settings()
|
||||
|
||||
|
||||
def bind_host() -> str:
|
||||
"""Resolve the single bind address for both :8001 and :8081 from the flag."""
|
||||
return "0.0.0.0" if settings.op25_debug_expose else "127.0.0.1"
|
||||
@@ -5,12 +5,20 @@ import routers.op25_controller as op25_controller
|
||||
from internal.logger import create_logger
|
||||
from internal.liquidsoap_config_utils import generate_liquid_script
|
||||
from models import IcecastConfig
|
||||
from config import settings, bind_host
|
||||
|
||||
LOGGER = create_logger(__name__)
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
if settings.op25_debug_expose:
|
||||
LOGGER.warning(
|
||||
"OP25_DEBUG_EXPOSE=true — the op25 control API (:8001) and OP25's "
|
||||
"HTTP terminal (:8081) are bound to 0.0.0.0 and reachable by "
|
||||
"ANYTHING on this node's LAN with NO authentication. This is a "
|
||||
"debugging aid only; do not leave it set on a deployed node."
|
||||
)
|
||||
try:
|
||||
config = IcecastConfig(
|
||||
icecast_host=os.getenv("ICECAST_HOST", "localhost"),
|
||||
@@ -28,3 +36,12 @@ async def lifespan(app: FastAPI):
|
||||
app = FastAPI(lifespan=lifespan)
|
||||
|
||||
app.include_router(op25_controller.create_op25_router(), prefix="/op25")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
# Launched directly (see Dockerfile CMD) instead of via `uvicorn main:app
|
||||
# --host ...` so the bind address is driven by OP25_DEBUG_EXPOSE (config.py)
|
||||
# rather than a value baked into the image at build time.
|
||||
import uvicorn
|
||||
|
||||
uvicorn.run("main:app", host=bind_host(), port=8001, reload=True)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
from pydantic import BaseModel
|
||||
from typing import List, Optional, Union
|
||||
from enum import Enum
|
||||
from config import bind_host
|
||||
|
||||
# Preset device settings for common RTL-SDR hardware.
|
||||
# gains: OP25 gain string passed to the device block.
|
||||
@@ -34,6 +35,20 @@ class TalkgroupTag(BaseModel):
|
||||
talkgroup: str
|
||||
tagDec: int
|
||||
|
||||
# Defined before ConfigGenerator, which annotates a field with it. Under
|
||||
# Python 3.14 (PEP 649) annotations are evaluated lazily, so the original
|
||||
# order happened to work in the container; on 3.13 or earlier it is a hard
|
||||
# NameError at import. Keep the definition above its first use so this file
|
||||
# does not depend on the base image's Python version.
|
||||
class IcecastConfig(BaseModel):
|
||||
icecast_host: str
|
||||
icecast_port: int
|
||||
icecast_mountpoint: str
|
||||
icecast_password: str
|
||||
icecast_description: Optional[str] = "OP25"
|
||||
icecast_genre: Optional[str] = "Public Safety"
|
||||
|
||||
|
||||
class ConfigGenerator(BaseModel):
|
||||
type: DecodeMode
|
||||
systemName: str
|
||||
@@ -115,7 +130,9 @@ class MetadataConfig(BaseModel):
|
||||
|
||||
class TerminalConfig(BaseModel):
|
||||
module: Optional[str] = "terminal.py"
|
||||
terminal_type: Optional[str] = "http:0.0.0.0:8081"
|
||||
# Bind address comes from OP25_DEBUG_EXPOSE (config.py) — 127.0.0.1 unless
|
||||
# that flag is set. See config.py for why.
|
||||
terminal_type: Optional[str] = f"http:{bind_host()}:8081"
|
||||
terminal_timeout: Optional[float] = 5.0
|
||||
curses_plot_interval: Optional[float] = 0.2
|
||||
http_plot_interval: Optional[float] = 1.0
|
||||
@@ -127,10 +144,4 @@ class TerminalConfig(BaseModel):
|
||||
|
||||
### ======================================================
|
||||
# Icecast models
|
||||
class IcecastConfig(BaseModel):
|
||||
icecast_host: str
|
||||
icecast_port: int
|
||||
icecast_mountpoint: str
|
||||
icecast_password: str
|
||||
icecast_description: Optional[str] = "OP25"
|
||||
icecast_genre: Optional[str] = "Public Safety"
|
||||
# (IcecastConfig itself is defined above ConfigGenerator, which references it.)
|
||||
@@ -1,2 +1,3 @@
|
||||
uvicorn
|
||||
fastapi
|
||||
fastapi
|
||||
pydantic-settings
|
||||
Reference in New Issue
Block a user