diff --git a/Caddyfile b/Caddyfile index 9140933..9b2ab6e 100644 --- a/Caddyfile +++ b/Caddyfile @@ -9,5 +9,7 @@ # CA / internal cert, use `tls /path/cert.pem /path/key.pem`. :80 { encode gzip - reverse_proxy server:3000 + # Host network (prod): the server runs on the host's net namespace (to reach the booth LAN / + # device VLAN), so reach it over loopback, not the compose service name `server`. + reverse_proxy 127.0.0.1:3000 } diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index 0b4a7d7..79316e2 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -7,24 +7,25 @@ # See wiki/decisions/container-deployment.md. services: - # Reverse proxy: :80 → server:3000 (WebSocket /api/ws upgrades pass through natively). - # Caddy is a single static binary with a one-line proxy config; swapping http:// for the - # site's real hostname later enables automatic HTTPS. The booth is reached at - # http:/// (the name set via hosts/DNS on-site — NOT baked into any image). + # Reverse proxy: :80 → server (127.0.0.1:3000). On the HOST network (see the server note), + # so it reaches the host-net server over loopback and publishes :80 directly on the host. + # WebSocket /api/ws upgrades pass through natively. Swapping http:// for the site's real + # hostname later enables automatic HTTPS. Reached at http:/// (name via hosts/DNS + # on-site — NOT baked into any image). proxy: image: caddy:2-alpine restart: always - ports: - - "80:80" - # - "443:443" # uncomment when moving to TLS (and set a real hostname in Caddyfile) + # Host network: Caddy listens on the host's :80 and proxies the host-net server on + # 127.0.0.1:3000. (No `ports:` mapping — host mode publishes directly.) + network_mode: host + # host mode is mutually exclusive with a named network; the base file doesn't attach proxy, + # so nothing to null here (server does — see below). volumes: - ./Caddyfile:/etc/caddy/Caddyfile:ro - caddy-data:/data - caddy-config:/config depends_on: - server - networks: - - parking logging: driver: json-file options: @@ -33,14 +34,22 @@ services: server: restart: always - # No published port — only the proxy reaches the server, over the private network. - expose: - - "3000" - # Let the server ICMP-ping push-only readers (Dingtian/GEE QR) for an honest - # online/offline status WITHOUT CAP_NET_RAW: opening ping_group_range to all gids - # enables `/bin/ping` in unprivileged SOCK_DGRAM mode for the non-root runtime user. - # (The reader exposes no TCP port, so a connect-probe can't work — see reader.ts / - # wiki/entities/dingtian-qr-reader.md.) + # HOST NETWORK — the crux of the appliance. The server is the ONLY container doing device + # I/O (camera ISAPI snapshots, relay control, receiving reader/alarm pushes), all on the + # booth's LAN / isolated device VLAN (10.0.10.x). On a bridge network it sees only the Docker + # subnet (172.18.0.x) — it can't reach the relay, can't be reached by push devices, and the + # backend-IP picker (net.ts networkInterfaces) only sees eth0. Host mode puts it on the real + # NICs. Vision stays bridged (it never touches a device — the server hands it JPEG bytes). + network_mode: host + # host mode is mutually exclusive with a named network — detach the base file's `parking` + # attachment (compose errors otherwise: "network_mode and networks cannot both be set"). + networks: !reset [] + # Listens on :3000 directly on the host (Caddy proxies it). Loopback to vision: + environment: + VISION_URL: http://127.0.0.1:8089 + # Reader liveness ping (Dingtian/GEE QR push-only readers): on host net the server uses the + # HOST's net.ipv4.ping_group_range — ensure the host sets it (see appliance-provisioning). + # The container-level sysctl below is a no-op under host net but kept as documentation. sysctls: - net.ipv4.ping_group_range=0 2147483647 logging: @@ -52,9 +61,13 @@ services: vision: restart: always # The real ANPR engine. The image baked the model weights at build (offline-first). + # Stays on the bridge network (isolated — it makes NO outbound device calls), but PUBLISHES + # 8089 on the host LOOPBACK ONLY so the host-net server can reach it. 127.0.0.1 binding keeps + # it off the booth LAN — nothing on the network can hit the ANPR service. environment: VISION_RECOGNIZER: fast_alpr - # No published ports — vision is reached only by the server over the private network. + ports: + - "127.0.0.1:8089:8089" logging: driver: json-file options: diff --git a/docker-compose.yml b/docker-compose.yml index e9b7279..17ae275 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -13,8 +13,11 @@ services: restart: unless-stopped environment: DATABASE_URL: /data/parking.sqlite - # Reach the vision service over the private compose network by service name. - VISION_URL: http://vision:8089 + # Reach the vision service. DEV: the private compose-network service name (`vision`). + # PROD: the server runs on the HOST network (to see the booth LAN / device VLAN — it's the + # only container doing device I/O), where compose DNS doesn't resolve, so the prod override + # sets VISION_URL=http://127.0.0.1:8089 and vision publishes 8089 on the host loopback. + VISION_URL: ${VISION_URL:-http://vision:8089} VISION_ENABLED: ${VISION_ENABLED:-1} # JWT signing secret MUST be provided at deploy (no insecure default — see auth.ts). JWT_SECRET: ${JWT_SECRET:?set JWT_SECRET in the env/.env}