feat: publish the Hub through a reverse proxy on another host

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 <noreply@anthropic.com>
This commit is contained in:
gti
2026-09-23 07:46:11 -03:00
co-authored by Claude Opus 5.5
parent d33149f5ee
commit 91a2dda969
7 changed files with 78 additions and 1 deletions
+2
View File
@@ -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
+6
View File
@@ -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
+5
View File
@@ -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"
+20
View File
@@ -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
+23
View File
@@ -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
+11
View File
@@ -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://<hub-lan-ip>:8000`. On the Hub host:
- `.env`: `HUB_BIND=<hub LAN IP>` (not `0.0.0.0`), `FORWARDED_ALLOW_IPS=<NPM IP>`
(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 —
+11 -1
View File
@@ -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.