Playbook — DOCKER-USER firewall for container-published ports
Status: MECHANISM SHIPPED, NOT YET APPLIED on bms-4 (observe-first, #7660; partially implements #7609) Applies to: any Docker host that publishes ports on a public interface (bms-4 first; bms-1/2/3 and vps-h1 are follow-ups).
⚠️ Owner boundary (2026-10-09). The first cut of this mechanism (#7661, merged) used a single global
FLEET_IPS=217.154.82.162and a trailing blanket DROP. Applied as written it would have cut production Pinbox24 off RabbitMQ: bms-1 (94.23.26.113) holds live AMQP connections to bms-4:5672, anddocs/playbooks/pinbox24-w3-w4-outage-diagnosis.mdconfirms W4 request handlers fail without that broker. The mechanism was corrected to observe-first and nothing has been installed on bms-4. Do not install enforcement blindly — follow the staged procedure below.
Why
Docker publishes container ports through its own nftables/iptables DNAT rules (the DOCKER
chain), evaluated on the FORWARD path before ufw’s docker handling. A container port published
on 0.0.0.0 is therefore reachable from the whole internet even while ufw is active with no rule
allowing it — ufw only guards host-process (INPUT) listeners, not container-published
(FORWARD/DNAT) ones. This is exactly how the MongoDB restore-drill port and, per the #7660 audit,
the bms-4 exporters/RabbitMQ/n8n/pushgateway ports were all internet-reachable despite active ufw.
The only supported layer that can filter container-published ports is the DOCKER-USER chain,
which Docker guarantees to traverse before its own rules and never flushes except on a full daemon
restart.
bms-4 exposure + consumer map (audit 2026-10-09, corrected)
Every port below was published on 0.0.0.0/:: and WAN-reachable. The original consumer map was
derived from the Prometheus scrape config only and therefore missed application clients —
the cause of the RabbitMQ near-miss. The corrected map adds them:
| Port | Container | Consumer(s) | Policy in this mechanism |
|---|---|---|---|
| 80, 443 | traefik | internet (reverse proxy) | public (always allowed) |
| 8080 | cadvisor | vps-i1 Prometheus | fleet (managed) |
| 9091 | pushgateway | vps-i1 Prometheus (local pushers use the docker net) | fleet (managed) |
| 9121 | redis-exporter | vps-i1 Prometheus | fleet (managed) |
| 9216 / 9217 | mongodb-exporter-bms2 / -bms3 | vps-i1 Prometheus | fleet (managed) |
| 9477 | wa-web-collector | vps-i1 Prometheus | fleet (managed) |
| 5679 / 5680 / 5681 | n8n worker 1/2/3 | vps-i1 Prometheus (metrics) | fleet (managed) |
| 5672 | rabbitmq | vps-i1 blackbox (tcp_connect) + bms-1 94.23.26.113 production AMQP (evidence: rabbitmqctl list_connections peer_host showed 3 live connections from bms-1) | NOT restricted by this mechanism |
| 5678 | n8n main | vps-i1 Prometheus metrics; UI/webhooks via Traefik 443 | NOT restricted by this mechanism (pending counter evidence) |
| 15672 | rabbitmq mgmt | local only | already bound to 127.0.0.1 — correct ✓ |
5672 and 5678 are deliberately excluded from MANAGED_PORTS in
bms-4/docker-user-firewall.conf, so P24-FW emits no rule for them and they pass through the chain’s
final RETURN untouched — even in enforce mode. They keep their current WAN binding until counters
(below) prove the full set of real sources and the owner approves restricting them.
No bms-4 port can be rebound to 127.0.0.1: every WAN-bound port is a legitimate cross-host
consumer target, so the fix is the DOCKER-USER mechanism, not a rebind.
RabbitMQ default-credential posture (read-only check, 2026-10-09)
rabbitmqctl list_users on bms-4-rabbitmq-1 returns exactly one user: pinbox24 (administrator).
The default guest account does not exist (deleted), so the default-credential remote-login
vector is closed even though loopback_users is empty (empty is expected once guest is gone). The
one remaining account must stay remotely reachable because bms-1 connects to it — which is precisely
why 5672 is on the unrestricted list above. No change made; re-check with list_users after any
RabbitMQ rebuild.
Mechanism (in repo)
scripts/docker-user-firewall.sh— builds an idempotent privateP24-FWchain thatDOCKER-USERjumps to first. Subcommands:install— count-only (DEFAULT): per-port countingRETURNrules, never a DROP. Use this to observe who actually connects before restricting anything.enforce— explicit, separate: on managed ports,RETURNthe allow-listed source(s) andDROPeverything else. Unmanaged ports (5672/5678) still pass the finalRETURN.confirm— cancel the dead-man auto-rollback so the current rules persist.remove— rollback (also cancels the dead-man).status/counters— inspect the chain and per-rule packet counters.render count|enforce— print the exact rules without root/without applying (dry run; used byscripts/tests/test_docker_user_firewall.pyand for review).
bms-4/docker-user-firewall.conf—PUB_IF=eno1,PUBLIC_PORTS=80,443,MANAGED_PORTS(5672/5678 excluded), per-portPORT_ALLOW_<port>source lists,DEADMAN_MINUTES=15.bms-4/docker-user-firewall.service+.timer— persistence layer (reassert after a docker daemon restart viaPartOf=docker.service, and every 15 min). Runs count-onlyinstallwithP24_FW_NO_DEADMAN=1. Enable only AFTER a confirmed manual apply.scripts/check-docker-port-exposure.sh+bms-4/docker-port-allowlist.conf— regression guard: fails if any container publishes a WAN port not in the allowlist (each entry records consumer + owner).
Every rule is scoped to -i eno1, so inter-container (br-*/docker0) and host-local (lo)
traffic is never affected. The chain’s final rule is always RETURN, so a port that is not in
MANAGED_PORTS can never receive a DROP.
Observe → enforce procedure (ROOT, maintenance window)
Hard pre-conditions (#7660): do nothing on bms-4 before the Q4 restore drill (Sat 2026-10-10 01:30 UTC) and the arbiter restart (Sun 2026-10-11 03:30 UTC) have both finished. Keep a second SSH session open (OVH KVM console as the recovery path) before any apply. Run the W3/W4 health check (
docs/playbooks/pinbox24-w3-w4-health-verification.md) before and after every step; roll back immediately if it degrades.
Phase 1 — observe (count-only, no traffic dropped)
cd /opt/p24-infra && git pull
# Dry-run review first (no root, no apply):
bash scripts/docker-user-firewall.sh render count bms-4/docker-user-firewall.conf
# Apply count-only. Harmless (no DROP), but it arms a 15-min dead-man rollback:
sudo scripts/docker-user-firewall.sh install bms-4/docker-user-firewall.conf
sudo scripts/docker-user-firewall.sh status bms-4/docker-user-firewall.conf
# Verify monitoring is unaffected (it will be — nothing is dropped), then make it persist:
sudo scripts/docker-user-firewall.sh confirm bms-4/docker-user-firewall.confLeave it for 24–48 h, then read who actually connected to each managed port:
sudo scripts/docker-user-firewall.sh counters bms-4/docker-user-firewall.confFor each managed port compare the per-source RETURN counter (known source, e.g. vps-i1) against
the per-port catch-all RETURN counter (all NEW connections). A non-trivial gap means an
unlisted source is connecting — identify it before enforcing, and add it to PORT_ALLOW_<port>
(or escalate). Do the same sanity read for 5672/5678 if you are ever asked to bring them in-scope.
Phase 2 — enforce (DROP), one port at a time, owner-approved only
Enforcement is not part of #7660 and must not be switched on without owner sign-off on the observed counters. When approved:
# Review the DROP rules first:
bash scripts/docker-user-firewall.sh render enforce bms-4/docker-user-firewall.conf
# Apply (arms the dead-man again):
sudo scripts/docker-user-firewall.sh enforce bms-4/docker-user-firewall.confThen run the verification below. If anything degrades, do nothing — the dead-man auto-removes
the chain after DEADMAN_MINUTES. If healthy, confirm within that window.
Verify (every step, before confirm)
# A. From an UNLISTED host (NOT vps-i1) — in ENFORCE these must FAIL/time out (in count-only they stay OPEN):
for p in 8080 9091 9121 9216 9217 9477 5679 5680 5681; do
timeout 5 bash -c "echo > /dev/tcp/54.36.123.110/$p" 2>/dev/null \
&& echo "$p OPEN" || echo "$p filtered"
done
# 443 must stay OPEN (Traefik). 5672 and 5678 must stay OPEN (unmanaged, by design).
# B. From vps-i1 (217.154.82.162) — scrapes must still connect in both modes:
# ssh root@217.154.82.162 'for p in 8080 9121 9216 9217 9091 9477 5679 5680 5681; do timeout 5 bash -c "echo > /dev/tcp/54.36.123.110/$p" && echo "$p ok"; done'
# C. Prometheus targets for bms-4 stay UP (no new *Down alerts for 2 scrape intervals):
# up{instance=~"54.36.123.110:.*"} == 1 in Grafana / Thanos.
# D. W3/W4 health: docs/playbooks/pinbox24-w3-w4-health-verification.md — must stay green.
# RabbitMQ (5672) consumers from bms-1 unaffected: ssh root@54.36.123.110 \
# 'docker exec bms-4-rabbitmq-1 rabbitmqctl list_connections peer_host' still shows 94.23.26.113.Dead-man’s-switch
Any live install/enforce schedules a transient systemd unit (p24-fw-deadman) that runs
docker-user-firewall.sh remove after DEADMAN_MINUTES (default 15). If you apply a rule set that
breaks monitoring or W3/W4 and then lose your session, it rolls itself back automatically. To keep a
verified-healthy rule set, run confirm within the window (cancels the timer). remove also cancels
it. The persistence .service/.timer set P24_FW_NO_DEADMAN=1 so re-asserts never self-remove.
Rollback (immediate, if any bms-4 target drops)
sudo scripts/docker-user-firewall.sh remove bms-4/docker-user-firewall.conf
# Ports are WAN-reachable again (pre-#7660 state). Re-diagnose PORT_ALLOW_* / PUB_IF before retrying.After a confirmed-healthy apply, log it:
source /opt/p24-infra/scripts/lib/log_op.sh
log_op "human" "config_change" "docker-user-firewall" "success" \
"DOCKER-USER <count-only|enforce> installed; external ports as expected, vps-i1 scrapes UP, W3/W4 green" "bms-4"Follow-ups (other hosts, #7660 step 5)
Run the same read-only audit (docker ps + external TCP connect) on bms-1, bms-2, bms-3, vps-h1
and any Docker-running dev machine, and open a per-host hardening issue. Do not touch the rs0
members’ own firewall rules on bms-2/bms-3 without the db-admin role and a maintenance window.