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:
Logan Cusano
2026-08-16 09:33:25 -04:00
co-authored by Claude Opus 5
parent d6dfe5a293
commit a61a7b2c31
6 changed files with 81 additions and 11 deletions
+5
View File
@@ -8,6 +8,11 @@ services:
ICECAST_SOURCE_PASSWORD: ${ICECAST_SOURCE_PASSWORD:-hackme} ICECAST_SOURCE_PASSWORD: ${ICECAST_SOURCE_PASSWORD:-hackme}
ICECAST_ADMIN_PASSWORD: ${ICECAST_ADMIN_PASSWORD:-admin} 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: op25:
image: ${IMAGE_REGISTRY:-git.vpn.cusano.net}/${DOCKER_ORG:-logan}/${DOCKER_REPO:-node-26}/op25-client:stable image: ${IMAGE_REGISTRY:-git.vpn.cusano.net}/${DOCKER_ORG:-logan}/${DOCKER_REPO:-node-26}/op25-client:stable
build: ./op25-container build: ./op25-container
+5 -2
View File
@@ -50,5 +50,8 @@ RUN sed -i 's/\r$//' /usr/local/bin/docker-entrypoint.sh && \
# 2. Update ENTRYPOINT to use the wrapper script # 2. Update ENTRYPOINT to use the wrapper script
ENTRYPOINT ["/usr/local/bin/docker-entrypoint.sh"] ENTRYPOINT ["/usr/local/bin/docker-entrypoint.sh"]
# 3. Use CMD to pass the uvicorn command as arguments to the ENTRYPOINT script # 3. Use CMD to pass the launch command as arguments to the ENTRYPOINT script.
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8001", "--reload"] # 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"]
+33
View File
@@ -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"
+17
View File
@@ -5,12 +5,20 @@ import routers.op25_controller as op25_controller
from internal.logger import create_logger from internal.logger import create_logger
from internal.liquidsoap_config_utils import generate_liquid_script from internal.liquidsoap_config_utils import generate_liquid_script
from models import IcecastConfig from models import IcecastConfig
from config import settings, bind_host
LOGGER = create_logger(__name__) LOGGER = create_logger(__name__)
@asynccontextmanager @asynccontextmanager
async def lifespan(app: FastAPI): 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: try:
config = IcecastConfig( config = IcecastConfig(
icecast_host=os.getenv("ICECAST_HOST", "localhost"), icecast_host=os.getenv("ICECAST_HOST", "localhost"),
@@ -28,3 +36,12 @@ async def lifespan(app: FastAPI):
app = FastAPI(lifespan=lifespan) app = FastAPI(lifespan=lifespan)
app.include_router(op25_controller.create_op25_router(), prefix="/op25") 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)
+19 -8
View File
@@ -1,6 +1,7 @@
from pydantic import BaseModel from pydantic import BaseModel
from typing import List, Optional, Union from typing import List, Optional, Union
from enum import Enum from enum import Enum
from config import bind_host
# Preset device settings for common RTL-SDR hardware. # Preset device settings for common RTL-SDR hardware.
# gains: OP25 gain string passed to the device block. # gains: OP25 gain string passed to the device block.
@@ -34,6 +35,20 @@ class TalkgroupTag(BaseModel):
talkgroup: str talkgroup: str
tagDec: int 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): class ConfigGenerator(BaseModel):
type: DecodeMode type: DecodeMode
systemName: str systemName: str
@@ -115,7 +130,9 @@ class MetadataConfig(BaseModel):
class TerminalConfig(BaseModel): class TerminalConfig(BaseModel):
module: Optional[str] = "terminal.py" 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 terminal_timeout: Optional[float] = 5.0
curses_plot_interval: Optional[float] = 0.2 curses_plot_interval: Optional[float] = 0.2
http_plot_interval: Optional[float] = 1.0 http_plot_interval: Optional[float] = 1.0
@@ -127,10 +144,4 @@ class TerminalConfig(BaseModel):
### ====================================================== ### ======================================================
# Icecast models # Icecast models
class IcecastConfig(BaseModel): # (IcecastConfig itself is defined above ConfigGenerator, which references it.)
icecast_host: str
icecast_port: int
icecast_mountpoint: str
icecast_password: str
icecast_description: Optional[str] = "OP25"
icecast_genre: Optional[str] = "Public Safety"
+2 -1
View File
@@ -1,2 +1,3 @@
uvicorn uvicorn
fastapi fastapi
pydantic-settings