diff --git a/docker-compose.yml b/docker-compose.yml index 7c5a135..e339da6 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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 diff --git a/op25-container/Dockerfile b/op25-container/Dockerfile index 1e4f63d..b0c2ffc 100644 --- a/op25-container/Dockerfile +++ b/op25-container/Dockerfile @@ -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"] \ No newline at end of file +# 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"] \ No newline at end of file diff --git a/op25-container/app/config.py b/op25-container/app/config.py new file mode 100644 index 0000000..fbf18b4 --- /dev/null +++ b/op25-container/app/config.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" diff --git a/op25-container/app/main.py b/op25-container/app/main.py index 6a4fc92..671a3ab 100644 --- a/op25-container/app/main.py +++ b/op25-container/app/main.py @@ -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) diff --git a/op25-container/app/models.py b/op25-container/app/models.py index fb4596b..bac097a 100644 --- a/op25-container/app/models.py +++ b/op25-container/app/models.py @@ -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" \ No newline at end of file +# (IcecastConfig itself is defined above ConfigGenerator, which references it.) \ No newline at end of file diff --git a/op25-container/requirements.txt b/op25-container/requirements.txt index 1d2bcfd..8bb6223 100644 --- a/op25-container/requirements.txt +++ b/op25-container/requirements.txt @@ -1,2 +1,3 @@ uvicorn -fastapi \ No newline at end of file +fastapi +pydantic-settings \ No newline at end of file