From 91a2dda969d05f243e6b319159ac32ae8a6548f4 Mon Sep 17 00:00:00 2001 From: gti Date: Wed, 23 Sep 2026 07:46:11 -0300 Subject: [PATCH] feat: publish the Hub through a reverse proxy on another host MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GTI terminates TLS in its Nginx Proxy Manager (10.0.10.100), not on the Hub host, so the Hub must be reachable on the LAN — by the proxy only. - docker-compose.prod.yml passes FORWARDED_ALLOW_IPS (uvicorn then trusts X-Forwarded-For/-Proto from the proxy only: real client IPs in audit and session records, https scheme) and WEB_COOKIE_SECURE. Defaults keep the previous behaviour. - restrict_hub_port.sh + gti-fiscal-hub-port.service: Docker-published ports bypass UFW, so the source restriction lives in DOCKER-USER, matched on the conntrack original destination with --ctdir ORIGINAL, and is re-applied whenever docker.service starts (the chain is not persisted across reboots). - *.service forced to LF like *.sh; tests cover both. Co-Authored-By: Claude Opus 5.5 --- .gitattributes | 2 ++ deploy/hub/.env.example | 6 ++++++ deploy/hub/docker-compose.prod.yml | 5 +++++ deploy/hub/gti-fiscal-hub-port.service | 20 ++++++++++++++++++++ deploy/hub/restrict_hub_port.sh | 23 +++++++++++++++++++++++ docs/PILOT_DEPLOYMENT.md | 11 +++++++++++ tests/test_deploy_scripts.py | 12 +++++++++++- 7 files changed, 78 insertions(+), 1 deletion(-) create mode 100644 deploy/hub/gti-fiscal-hub-port.service create mode 100644 deploy/hub/restrict_hub_port.sh diff --git a/.gitattributes b/.gitattributes index f3eef0b..aaff4f1 100644 --- a/.gitattributes +++ b/.gitattributes @@ -2,3 +2,5 @@ # Windows release machine, `git archive`/checkout would otherwise ship them # with CRLF and `sh` rejects them ("set: Illegal option -"). *.sh text eol=lf +# systemd units are parsed on the same host; a trailing \r corrupts ExecStart. +*.service text eol=lf diff --git a/deploy/hub/.env.example b/deploy/hub/.env.example index ca778af..9545995 100644 --- a/deploy/hub/.env.example +++ b/deploy/hub/.env.example @@ -8,9 +8,15 @@ POSTGRES_PASSWORD= BASE_URL=https://fiscal.example.gti # Interface the Hub listens on. Keep 127.0.0.1 when a reverse proxy runs on the same host; use the LAN IP only behind a firewall. +# Docker-published ports bypass UFW: restrict the source in the DOCKER-USER chain (see docs/PILOT_DEPLOYMENT.md). HUB_BIND=127.0.0.1 HUB_PORT=8000 +# IP(s) of the reverse proxy allowed to set X-Forwarded-For/-Proto (comma-separated). 127.0.0.1 = same host. +FORWARDED_ALLOW_IPS=127.0.0.1 +# 1 once HTTPS terminates in front of the Hub (session/CSRF cookies become Secure). +WEB_COOKIE_SECURE=0 + LOG_LEVEL=INFO AGENT_ONLINE_THRESHOLD_SECONDS=120 diff --git a/deploy/hub/docker-compose.prod.yml b/deploy/hub/docker-compose.prod.yml index c99bfb2..b83265d 100644 --- a/deploy/hub/docker-compose.prod.yml +++ b/deploy/hub/docker-compose.prod.yml @@ -47,6 +47,11 @@ services: LOG_LEVEL: ${LOG_LEVEL:-INFO} BASE_URL: ${BASE_URL:?BASE_URL is required (public HTTPS URL of the Hub)} AGENT_ONLINE_THRESHOLD_SECONDS: ${AGENT_ONLINE_THRESHOLD_SECONDS:-120} + # Reverse proxy on another host: uvicorn only honours X-Forwarded-For/-Proto + # from these addresses (real client IP in audit/session records, scheme=https). + FORWARDED_ALLOW_IPS: ${FORWARDED_ALLOW_IPS:-127.0.0.1} + # 1 = session/CSRF cookies are sent only over HTTPS. Set once TLS is in front. + WEB_COOKIE_SECURE: ${WEB_COOKIE_SECURE:-0} # AGENT_DEV_TOKEN is intentionally NOT set: real Agents are registered with central/scripts/register_agent.py. ports: - "${HUB_BIND:-127.0.0.1}:${HUB_PORT:-8000}:8000" diff --git a/deploy/hub/gti-fiscal-hub-port.service b/deploy/hub/gti-fiscal-hub-port.service new file mode 100644 index 0000000..0eb7dcb --- /dev/null +++ b/deploy/hub/gti-fiscal-hub-port.service @@ -0,0 +1,20 @@ +# Re-applies the Hub port restriction (restrict_hub_port.sh) whenever Docker starts. +# Install: +# sudo install -m 0755 restrict_hub_port.sh /usr/local/sbin/gti-fiscal-restrict-hub-port +# printf 'HUB_BIND=10.0.10.117\nHUB_PORT=8000\nPROXY_IP=10.0.10.100\nIFACE=ens18\n' | sudo tee /etc/default/gti-fiscal-hub-port +# sudo install -m 0644 gti-fiscal-hub-port.service /etc/systemd/system/ +# sudo systemctl daemon-reload && sudo systemctl enable --now gti-fiscal-hub-port.service +[Unit] +Description=GTI Fiscal: allow the Hub port only from the reverse proxy +After=docker.service +Requires=docker.service +PartOf=docker.service + +[Service] +Type=oneshot +RemainAfterExit=yes +EnvironmentFile=/etc/default/gti-fiscal-hub-port +ExecStart=/usr/local/sbin/gti-fiscal-restrict-hub-port + +[Install] +WantedBy=docker.service diff --git a/deploy/hub/restrict_hub_port.sh b/deploy/hub/restrict_hub_port.sh new file mode 100644 index 0000000..d8d660d --- /dev/null +++ b/deploy/hub/restrict_hub_port.sh @@ -0,0 +1,23 @@ +#!/usr/bin/env sh +# Restrict the published Hub port so that only the reverse proxy host reaches it. +# +# HUB_BIND=10.0.10.117 HUB_PORT=8000 PROXY_IP=10.0.10.100 IFACE=ens18 sh restrict_hub_port.sh +# +# Docker-published ports never pass through UFW/INPUT: the packets are DNATed +# and forwarded, so the only supported place for this rule is DOCKER-USER. +# Matching on the conntrack *original* destination selects this published port +# (after DNAT the packet already points at the container); --ctdir ORIGINAL keeps +# the container's replies out of the DROP rule. Idempotent: safe to re-run. +# DOCKER-USER is not persisted across reboots — gti-fiscal-hub-port.service +# re-applies it every time docker.service starts. +set -eu +: "${HUB_BIND:?HUB_BIND is required}" "${PROXY_IP:?PROXY_IP is required}" "${IFACE:?IFACE is required}" +HUB_PORT="${HUB_PORT:-8000}" +MATCH="-i $IFACE -p tcp -m conntrack --ctdir ORIGINAL --ctorigdst $HUB_BIND --ctorigdstport $HUB_PORT" + +iptables -N DOCKER-USER 2>/dev/null || true +while iptables -D DOCKER-USER $MATCH -s "$PROXY_IP" -j RETURN 2>/dev/null; do :; done +while iptables -D DOCKER-USER $MATCH -j DROP 2>/dev/null; do :; done +iptables -I DOCKER-USER 1 $MATCH -j DROP +iptables -I DOCKER-USER 1 $MATCH -s "$PROXY_IP" -j RETURN +iptables -S DOCKER-USER diff --git a/docs/PILOT_DEPLOYMENT.md b/docs/PILOT_DEPLOYMENT.md index 888c5d7..27024f9 100644 --- a/docs/PILOT_DEPLOYMENT.md +++ b/docs/PILOT_DEPLOYMENT.md @@ -144,6 +144,17 @@ forwards to `fiscal-hub:8000`. Nothing in the app assumes a particular proxy; `BASE_URL` in `.env` is informational only (not currently used to generate absolute links, but keep it accurate for anyone reading the config). +**GTI production (2026-09-23):** TLS terminates in the GTI Nginx Proxy Manager +(10.0.10.100), which forwards `http://:8000`. On the Hub host: + +- `.env`: `HUB_BIND=` (not `0.0.0.0`), `FORWARDED_ALLOW_IPS=` + (real client IP and `https` scheme from `X-Forwarded-*`), `BASE_URL=https://…`, + and `WEB_COOKIE_SECURE=1` once HTTPS works. Redirects are relative, so the + internal address never leaks. +- Docker-published ports bypass UFW: install `restrict_hub_port.sh` + + `gti-fiscal-hub-port.service` (instructions in the unit file) so only the + NPM reaches the port, re-applied on every Docker start. + ## Registering an Agent (per client installation) Run once per Agent installation, from anywhere with access to Postgres — diff --git a/tests/test_deploy_scripts.py b/tests/test_deploy_scripts.py index 9516c36..359f595 100644 --- a/tests/test_deploy_scripts.py +++ b/tests/test_deploy_scripts.py @@ -14,10 +14,20 @@ def test_shell_scripts_are_forced_to_lf(): # shipped CRLF and `sh` failed on the first line ("set: Illegal option -"). rules = [line.split() for line in (ROOT / ".gitattributes").read_text(encoding="utf-8").splitlines() if line.strip() and not line.startswith("#")] assert ["*.sh", "text", "eol=lf"] in rules - for script in HUB.glob("*.sh"): + assert ["*.service", "text", "eol=lf"] in rules + for script in [*HUB.glob("*.sh"), *HUB.glob("*.service")]: assert b"\r" not in script.read_bytes(), script.name +def test_hub_port_restriction_uses_docker_user_chain(): + # Docker-published ports bypass UFW/INPUT; only DOCKER-USER filters them. + # --ctdir ORIGINAL keeps the container's replies out of the DROP rule. + script = (HUB / "restrict_hub_port.sh").read_text(encoding="utf-8") + assert "DOCKER-USER" in script and "--ctdir ORIGINAL" in script and "--ctorigdstport" in script + unit = (HUB / "gti-fiscal-hub-port.service").read_text(encoding="utf-8") + assert "After=docker.service" in unit and "PartOf=docker.service" in unit + + def test_backup_retention_does_not_delete_on_the_host(): # Docker creates the bind-mounted backup directory as root, so a host-side # rm by the deploy user fails once retention kicks in.