From 9f122f47aa8614ce98a49df954cd4d9cfa4ee4e7 Mon Sep 17 00:00:00 2001 From: root Date: Wed, 10 Jun 2026 09:32:36 -0700 Subject: [PATCH] v1.1.1: harden deployment for easier fielding MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Make a fresh deploy (especially Docker) work with no manual steps and behave well on real-world hosts: - Auto-scan skips subnets larger than /20 (e.g. a docker bridge 172.17.0.0/16 = 65k hosts) so AUTO discovery no longer stalls startup or hammers the network on any host with Docker. Manual scan shares the same MIN_SCAN_PREFIX. - Docker: config.json is auto-seeded from config.json.example by a new docker-entrypoint.sh and persisted on a ./data directory mount — no more single-file bind-mount footgun, no manual cp before first run. - Config path is overridable via ADSBIT_CONFIG (defaults to ./config.json for bare metal; /app/data/config.json in the container). - New /health endpoint (status/version/receivers/flights) + Docker HEALTHCHECK. - Clean SIGTERM/SIGINT shutdown (docker stop / systemctl stop) — no traceback. - .gitignore/.dockerignore exclude the data/ config volume. - README/CLAUDE.md updated; VERSION bumped to 1.1.1. Verified: image builds, fresh `docker run` seeds config and reports healthy, oversized subnets skipped in-container, /health responds, graceful shutdown. Co-Authored-By: Claude Opus 4.8 (1M context) --- .dockerignore | 1 + .gitignore | 3 +++ CLAUDE.md | 8 ++++++++ Dockerfile | 11 +++++++++-- README.md | 16 +++++++++------- docker-compose.yml | 6 ++++-- docker-entrypoint.sh | 16 ++++++++++++++++ server.py | 45 +++++++++++++++++++++++++++++++++++++++----- 8 files changed, 90 insertions(+), 16 deletions(-) create mode 100644 docker-entrypoint.sh diff --git a/.dockerignore b/.dockerignore index 7fe5658..4e14e73 100644 --- a/.dockerignore +++ b/.dockerignore @@ -3,6 +3,7 @@ __pycache__ *.pyc *.pyo config.json +data/ screenshots/ .claude/ CLAUDE.md diff --git a/.gitignore b/.gitignore index 25215dc..c1e815c 100644 --- a/.gitignore +++ b/.gitignore @@ -26,3 +26,6 @@ Thumbs.db # Local config — contains the admin password hash and session secret. # Fresh installs start from config.json.example (see CONFIG.md). config.json + +# Docker config volume (holds the live config.json with secrets) +data/ diff --git a/CLAUDE.md b/CLAUDE.md index ee5587f..ab55120 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -18,6 +18,14 @@ python3 server.py Access at http://localhost:{web_port} (configured in config.json) +### Deployment / fielding notes + +- **Config path** is overridable via the `ADSBIT_CONFIG` env var (defaults to `config.json` beside `server.py`). Docker uses `/app/data/config.json` on a mounted volume. +- **`/health`** (no auth) returns status/version/receiver/flight counts — used by the Docker `HEALTHCHECK` and any external monitoring. +- **`VERSION`** constant in `server.py` is the single source of truth; surfaced at startup, via `GET /api/config`, and in the admin header. Bump it for releases. +- **Auto-scan** (`scan_for_receivers`) skips subnets larger than `MIN_SCAN_PREFIX` (/20) so a docker `/16` doesn't stall startup; the manual scan endpoint enforces the same limit. +- **`docker-entrypoint.sh`** seeds `config.json` from `config.json.example` on first run, so a fresh `docker compose up` needs no manual steps. SIGTERM/SIGINT shut down cleanly (no traceback). + ## Architecture ### Components diff --git a/Dockerfile b/Dockerfile index 49130cb..637a223 100644 --- a/Dockerfile +++ b/Dockerfile @@ -13,9 +13,16 @@ RUN pip install --no-cache-dir -r requirements.txt && \ rm -rf /var/lib/apt/lists/* COPY . . +RUN chmod +x /app/docker-entrypoint.sh EXPOSE 2001 -ENV PYTHONUNBUFFERED=1 +# Persist config on a mounted volume by default (see docker-compose.yml). +ENV PYTHONUNBUFFERED=1 \ + ADSBIT_CONFIG=/app/data/config.json -CMD ["python3", "server.py"] +# Liveness probe against the unauthenticated /health endpoint. +HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \ + CMD python3 -c "import urllib.request,sys; sys.exit(0 if urllib.request.urlopen('http://127.0.0.1:2001/health',timeout=4).status==200 else 1)" || exit 1 + +ENTRYPOINT ["/app/docker-entrypoint.sh"] diff --git a/README.md b/README.md index 91ff94e..0f762ff 100644 --- a/README.md +++ b/README.md @@ -44,14 +44,16 @@ On first run, visit http://localhost:2001 and the setup wizard will guide you th ### First Run ```bash -# Create a config file (the setup wizard runs if you skip this) -cp config.json.example config.json - -# Build and start +# Build and start — no config step needed docker compose up -d ``` -The web UI is available at http://localhost:2001. +A config file is created automatically in `data/` on first run, then the +browser **setup wizard** walks you through receiver, location, and password +setup. The web UI is available at http://localhost:2001. + +A `/health` endpoint (used by the container healthcheck) reports status, +version, and receiver/flight counts: `curl http://localhost:2001/health`. ### Useful Commands @@ -82,11 +84,11 @@ The following paths are bind-mounted from the repo directory and persist across | Path | Contents | |------|----------| -| `config.json` | Server configuration and credentials | +| `data/` | Server configuration and credentials (`config.json`, auto-seeded) | | `images/` | Aircraft and UI sprites (including uploads) | | `backgrounds/` | Theme background images (including custom themes) | -Host networking (`network_mode: host`) is used so the server can auto-scan your LAN for ADS-B receivers. +Host networking (`network_mode: host`) is used so the server can auto-scan your LAN for ADS-B receivers. Auto-scan skips oversized subnets (larger than /20, e.g. a Docker bridge `172.17.0.0/16`); if your receiver lives on such a network, set it explicitly via the admin Receivers tab. ## Requirements diff --git a/docker-compose.yml b/docker-compose.yml index e162bde..43a4518 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -6,9 +6,11 @@ services: restart: unless-stopped environment: - PYTHONUNBUFFERED=1 + - ADSBIT_CONFIG=/app/data/config.json volumes: - # Persist config (copy config.json.example to config.json before first run) - - ./config.json:/app/config.json + # Config persists here; auto-seeded from config.json.example on first run + # (a directory mount, so a fresh `docker compose up` just works). + - ./data:/app/data # Persist custom sprite uploads - ./images:/app/images # Persist custom theme backgrounds diff --git a/docker-entrypoint.sh b/docker-entrypoint.sh new file mode 100644 index 0000000..af46d20 --- /dev/null +++ b/docker-entrypoint.sh @@ -0,0 +1,16 @@ +#!/bin/sh +# Seed a config file on first run so `docker compose up` works on a fresh +# clone with no manual steps. The config lives on a mounted data volume so it +# persists across container rebuilds. +set -e + +: "${ADSBIT_CONFIG:=/app/config.json}" +CONFIG_DIR=$(dirname "$ADSBIT_CONFIG") +mkdir -p "$CONFIG_DIR" + +if [ ! -f "$ADSBIT_CONFIG" ]; then + echo "[entrypoint] No config at $ADSBIT_CONFIG — seeding from config.json.example" + cp /app/config.json.example "$ADSBIT_CONFIG" +fi + +exec python3 server.py diff --git a/server.py b/server.py index 80ed40b..ddef493 100755 --- a/server.py +++ b/server.py @@ -17,10 +17,16 @@ from aiohttp import web, ClientSession, ClientTimeout import netifaces import bcrypt -VERSION = "1.1.0" +VERSION = "1.1.1" WEB_DIR = Path(__file__).parent -CONFIG_FILE = WEB_DIR / "config.json" +# Config path is overridable (handy for containers persisting config on a +# named volume); defaults to config.json next to this script for bare-metal. +CONFIG_FILE = Path(os.environ.get("ADSBIT_CONFIG") or (WEB_DIR / "config.json")) + +# Smallest subnet prefix (largest network) we'll auto-scan: /20 = 4094 hosts. +# Anything bigger (e.g. a docker /16) is skipped to keep scans fast and safe. +MIN_SCAN_PREFIX = 20 # Track server start time SERVER_START_TIME = time.time() @@ -414,6 +420,15 @@ async def scan_for_receivers(): if ip and netmask and not ip.startswith('127.'): try: network = ipaddress.IPv4Network(f"{ip}/{netmask}", strict=False) + # Skip subnets too large to scan quickly (e.g. a docker + # bridge /16 = 65k hosts). Without this, AUTO discovery + # hammers the network and stalls startup on any host with + # Docker or a large/16. Use MANUAL or a custom subnet for + # receivers that live on such networks. + if network.prefixlen < MIN_SCAN_PREFIX: + print(f"Skipping {network} on {iface}: too large " + f"({network.num_addresses} hosts) for auto-scan") + continue found.extend(await _scan_subnet(network, port)) except ValueError as e: print(f"Invalid network {ip}/{netmask}: {e}") @@ -984,9 +999,10 @@ async def handle_admin_scan_receivers(request): return web.json_response( {"error": f"Invalid subnet: {subnet}"}, status=400) - if network.prefixlen < 20: + if network.prefixlen < MIN_SCAN_PREFIX: return web.json_response( - {"error": "Subnet too large (max /20 = 4096 hosts)"}, status=400) + {"error": f"Subnet too large (max /{MIN_SCAN_PREFIX} = " + f"{2 ** (32 - MIN_SCAN_PREFIX) - 2} hosts)"}, status=400) port = config['receiver_port'] found = await _scan_subnet(network, port) @@ -1172,6 +1188,17 @@ async def handle_http(request): return web.Response(status=404, text="Not Found") +async def handle_health(request): + """GET /health - liveness/readiness probe (no auth, no secrets).""" + return web.json_response({ + "status": "ok", + "version": VERSION, + "receivers": len(receivers), + "flights": len(flights), + "uptime_seconds": int(time.time() - SERVER_START_TIME), + }) + + # --------------------------------------------------------------------------- # Application setup # --------------------------------------------------------------------------- @@ -1184,6 +1211,7 @@ async def start_http_server(): app.router.add_get('/ws', websocket_handler) # Public API + app.router.add_get('/health', handle_health) app.router.add_get('/api/receiver-location', handle_receiver_location) app.router.add_get('/api/config', handle_config) @@ -1282,4 +1310,11 @@ async def main(): if __name__ == "__main__": - asyncio.run(main()) + # Make SIGTERM (docker stop, systemctl stop) raise KeyboardInterrupt like + # SIGINT does, so shutdown is clean and quiet instead of a stack trace. + import signal + signal.signal(signal.SIGTERM, signal.default_int_handler) + try: + asyncio.run(main()) + except (KeyboardInterrupt, SystemExit): + print("ADS-Bit server shutting down.")