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:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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 —
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user