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
|
# Windows release machine, `git archive`/checkout would otherwise ship them
|
||||||
# with CRLF and `sh` rejects them ("set: Illegal option -").
|
# with CRLF and `sh` rejects them ("set: Illegal option -").
|
||||||
*.sh text eol=lf
|
*.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
|
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.
|
# 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_BIND=127.0.0.1
|
||||||
HUB_PORT=8000
|
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
|
LOG_LEVEL=INFO
|
||||||
AGENT_ONLINE_THRESHOLD_SECONDS=120
|
AGENT_ONLINE_THRESHOLD_SECONDS=120
|
||||||
|
|
||||||
|
|||||||
@@ -47,6 +47,11 @@ services:
|
|||||||
LOG_LEVEL: ${LOG_LEVEL:-INFO}
|
LOG_LEVEL: ${LOG_LEVEL:-INFO}
|
||||||
BASE_URL: ${BASE_URL:?BASE_URL is required (public HTTPS URL of the Hub)}
|
BASE_URL: ${BASE_URL:?BASE_URL is required (public HTTPS URL of the Hub)}
|
||||||
AGENT_ONLINE_THRESHOLD_SECONDS: ${AGENT_ONLINE_THRESHOLD_SECONDS:-120}
|
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.
|
# AGENT_DEV_TOKEN is intentionally NOT set: real Agents are registered with central/scripts/register_agent.py.
|
||||||
ports:
|
ports:
|
||||||
- "${HUB_BIND:-127.0.0.1}:${HUB_PORT:-8000}:8000"
|
- "${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
|
proxy; `BASE_URL` in `.env` is informational only (not currently used to
|
||||||
generate absolute links, but keep it accurate for anyone reading the config).
|
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)
|
## Registering an Agent (per client installation)
|
||||||
|
|
||||||
Run once per Agent installation, from anywhere with access to Postgres —
|
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 -").
|
# 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("#")]
|
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
|
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
|
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():
|
def test_backup_retention_does_not_delete_on_the_host():
|
||||||
# Docker creates the bind-mounted backup directory as root, so a host-side
|
# Docker creates the bind-mounted backup directory as root, so a host-side
|
||||||
# rm by the deploy user fails once retention kicks in.
|
# rm by the deploy user fails once retention kicks in.
|
||||||
|
|||||||
Reference in New Issue
Block a user