fix(security): close the five host-networked app UIs the audit could not see

Scanning archi-dev-box from OUTSIDE found five ports serving their screens
with no login — lnd-ui 18083, bitcoin-ui 8334, fips-ui 8336, electrs-ui
50002 and the Fedimint Guardian 8175 — none of which appeared in the gate's
unprotected list. They are host-networked, so Podman publishes nothing to
pin and their manifests declared 'ports: []'; the gate builds its map from
declared ports, so it neither protected them nor reported them. An audit
that reports success while five screens are open is worse than no audit.

Their nginx now listens on 127.0.0.1 instead of 0.0.0.0, and each port is
declared 'auth: gated' so the daemon owns the outside. 'bind:' on a
host-networked app is a statement of where the container listens, not a
publish instruction — quadlet already skips PublishPort in host mode.
Guardian 8175 is declared on the fedimint app because its companion has no
manifest, and the gate keys on port, not container.

Credential paths were NOT exposed and are verified so: /lnd-connect-info,
the /proxy/lnd/ passthrough, container logs and every RPC method through
these screens all return 401 unauthenticated. What leaked was the page
shell.

Also fixes the delivery gap that would have made this unshippable: only
bitcoin-ui, lnd-ui and electrs-ui were ever rsynced to
/opt/archipelago/docker, so edits to fips-ui and fedimint-ui reached nodes
through no path at all. All five now sync; the two whose rebuilds the
daemon owns are synced without being handed to container-specs.

Every remaining undeclared port is now declared with a stated reason —
gated: botfights 9100, router 8084, pine 10380; exempt with rationale:
fedimint consensus 8173/8174, gateway 8176/9737, netbird 8086/8087 (TLS +
own auth, and enrolled devices cannot hold a session), pine TLS 10381,
lightning-stack REST 8091 (macaroon, mirrors lnd). Zero undeclared ports
remain across all 56 manifests.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
archipelago
2026-08-05 08:46:09 -04:00
co-authored by Claude Fable 5
parent 634640944c
commit abf0f56afc
20 changed files with 230 additions and 20 deletions
+12 -1
View File
@@ -31,7 +31,18 @@ app:
# proxies to 127.0.0.1:8332 which is where the bitcoin backend binds
# its RPC. `ports:` is intentionally empty because host networking
# bypasses port mapping.
ports: []
# Declared so the APP GATE can see this port. Host networking means Podman
# publishes nothing (quadlet skips PublishPort in host mode), so `bind:` here
# is a statement of where the container's own nginx listens — 127.0.0.1 —
# not a publish instruction. Without this declaration the gate had no idea
# the port existed: it was neither protected nor listed as unprotected, and
# served the Bitcoin screen unauthenticated on every interface.
ports:
- host: 8334
container: 8334
protocol: tcp
bind: 127.0.0.1
auth: gated
volumes:
# Bind-mount the rendered nginx.conf read-only. The prod orchestrator
+2
View File
@@ -62,6 +62,8 @@ app:
- host: 9100
container: 9100
protocol: tcp # Web UI + API
bind: 127.0.0.1
auth: gated
volumes:
# A bare relative source (was "botfights-data", no leading slash) is
+12 -1
View File
@@ -23,7 +23,18 @@ app:
network_policy: host
# Host networking: nginx listens on 50002 directly on the host IP.
ports: []
# Declared so the APP GATE can see this port. Host networking means Podman
# publishes nothing (quadlet skips PublishPort in host mode), so `bind:` here
# is a statement of where the container's own nginx listens — 127.0.0.1 —
# not a publish instruction. Without this declaration the gate had no idea
# the port existed: it was neither protected nor listed as unprotected, and
# served the Electrs screen unauthenticated on every interface.
ports:
- host: 50002
container: 50002
protocol: tcp
bind: 127.0.0.1
auth: gated
volumes: []
+8
View File
@@ -60,9 +60,17 @@ app:
- host: 8176
container: 8176
protocol: tcp
auth: none
auth_rationale: >-
Fedimint gateway API, protected by its own bcrypt password (--bcrypt-password-hash)
and reached by federation peers and clients that cannot hold a browser session.
- host: 9737
container: 9737
protocol: tcp
auth: none
auth_rationale: >-
LDK Lightning p2p for the gateway. The BOLT-8 noise handshake authenticates and
encrypts the connection itself.
volumes:
- type: bind
+18
View File
@@ -50,11 +50,29 @@ app:
- host: 8173
container: 8173
protocol: tcp
auth: none
auth_rationale: >-
Fedimint guardian consensus. Other guardians speak the federation's own
authenticated protocol here; a login page would break consensus.
- host: 8174
container: 8174
protocol: tcp
auth: none
auth_rationale: >-
Fedimint guardian API for federation clients, which authenticate to the
federation itself and cannot hold a browser session.
# Public launch port 8175 is owned by archy-fedimint-ui, which serves a
# wait page while Bitcoin syncs and proxies here after fedimintd starts.
# Declared HERE because that companion has no manifest of its own, and the
# gate keys on the port rather than the container: without this entry it
# served the Guardian UI unauthenticated on every interface and never
# appeared in the audit. Its nginx is pinned to 127.0.0.1
# (docker/fedimint-ui/nginx.conf) so the gate can own the outside.
- host: 8175
container: 8175
protocol: tcp
bind: 127.0.0.1
auth: gated
- host: 8177
container: 8175
protocol: tcp
+12 -1
View File
@@ -27,7 +27,18 @@ app:
# Host networking: nginx listens on 8336 directly on the host IP and
# proxies to 127.0.0.1:5678 (the archipelago RPC). `ports:` is
# intentionally empty because host networking bypasses port mapping.
ports: []
# Declared so the APP GATE can see this port. Host networking means Podman
# publishes nothing (quadlet skips PublishPort in host mode), so `bind:` here
# is a statement of where the container's own nginx listens — 127.0.0.1 —
# not a publish instruction. Without this declaration the gate had no idea
# the port existed: it was neither protected nor listed as unprotected, and
# served the FIPS mesh screen unauthenticated on every interface.
ports:
- host: 8336
container: 8336
protocol: tcp
bind: 127.0.0.1
auth: gated
volumes: []
+5
View File
@@ -41,9 +41,14 @@ app:
auth: none
auth_rationale: >-
LND gRPC, authenticated by macaroon over TLS. Remote wallets depend on reaching this directly.
# Mirrors lnd's 18080 exemption — same LND REST API, same macaroon auth.
- host: 8091
container: 8080
protocol: tcp # REST/Web UI
auth: none
auth_rationale: >-
LND REST, authenticated by macaroon over TLS. A browser login page would break
Zeus and every non-browser wallet client, exactly as for lnd's 18080.
volumes:
- type: bind
+12 -1
View File
@@ -35,7 +35,18 @@ app:
# port to a container port where nothing listens. scripts/container-specs.sh
# carried the identical mistake and was fixed alongside this; recreating from
# it on archi-dev-box left :18083 refusing connections.
ports: []
# Declared so the APP GATE can see this port. Host networking means Podman
# publishes nothing (quadlet skips PublishPort in host mode), so `bind:` here
# is a statement of where the container's own nginx listens — 127.0.0.1 —
# not a publish instruction. Without this declaration the gate had no idea
# the port existed: it was neither protected nor listed as unprotected, and
# served the LND screen unauthenticated on every interface.
ports:
- host: 18083
container: 18083
protocol: tcp
bind: 127.0.0.1
auth: gated
volumes: []
+5
View File
@@ -48,6 +48,11 @@ app:
- host: 8086
container: 80
protocol: tcp # management API + embedded OIDC issuer (/oauth2)
auth: none
auth_rationale: >-
NetBird management API and its OIDC issuer. Enrolled devices authenticate
themselves with setup keys and JWTs, and they cannot hold a browser session —
a login page here would disconnect every VPN client on the network.
- host: 3478
container: 3478
protocol: udp # STUN — must be UDP; tcp here breaks relay discovery
+5
View File
@@ -44,6 +44,11 @@ app:
- host: 8087
container: 443
protocol: tcp
auth: none
auth_rationale: >-
NetBird dashboard over TLS, with its own login. The gate speaks plain HTTP,
so fronting this port would break the secure context the dashboard requires
(issue #15) and the certificate clients pin.
volumes:
- type: bind
+7
View File
@@ -53,9 +53,16 @@ app:
- host: 10380
container: 80
protocol: tcp
bind: 127.0.0.1
auth: gated
- host: 10381
container: 443
protocol: tcp
auth: none
auth_rationale: >-
Pine's TLS listener. The gate speaks plain HTTP, so fronting this port would
break the secure context navigator.bluetooth needs for WiFi provisioning.
The plain-HTTP entry point (10380) is gated, and it is what the UI opens.
volumes:
- type: bind
+2
View File
@@ -30,6 +30,8 @@ app:
- host: 8084
container: 8080
protocol: tcp # Web UI
bind: 127.0.0.1
auth: gated
- host: 5353
container: 5353
protocol: udp # mDNS/Bonjour
+1 -1
View File
@@ -293,6 +293,6 @@ mod tests {
// Lock in the core shape so a bad template edit doesn't ship.
assert!(TEMPLATE.contains("proxy_pass http://127.0.0.1:8332/"));
assert!(TEMPLATE.contains("location /bitcoin-rpc/"));
assert!(TEMPLATE.contains("listen 8334"));
assert!(TEMPLATE.contains("listen 127.0.0.1:8334"));
}
}
@@ -1,5 +1,12 @@
server {
listen 8334;
# Loopback ONLY. This container is host-networked, so this nginx binds the
# HOST's address directly — `listen 8334;` meant every interface, and the
# app gate could never stand in front of it (there is no podman publish to
# pin, and the manifest declared no port, so the gate neither protected it
# nor reported it — it served this page to anyone who asked, on LAN,
# Tailscale and the mesh alike). Binding loopback lets the daemon claim the
# external addresses and authenticate them; see appgate::listener.
listen 127.0.0.1:8334;
server_name _;
root /usr/share/nginx/html;
index index.html;
+8 -1
View File
@@ -1,5 +1,12 @@
server {
listen 50002;
# Loopback ONLY. This container is host-networked, so this nginx binds the
# HOST's address directly — `listen 50002;` meant every interface, and the
# app gate could never stand in front of it (there is no podman publish to
# pin, and the manifest declared no port, so the gate neither protected it
# nor reported it — it served this page to anyone who asked, on LAN,
# Tailscale and the mesh alike). Binding loopback lets the daemon claim the
# external addresses and authenticate them; see appgate::listener.
listen 127.0.0.1:50002;
server_name _;
root /usr/share/nginx/html;
+8 -1
View File
@@ -1,5 +1,12 @@
server {
listen 8175;
# Loopback ONLY. This container is host-networked, so this nginx binds the
# HOST's address directly — `listen 8175;` meant every interface, and the
# app gate could never stand in front of it (there is no podman publish to
# pin, and the manifest declared no port, so the gate neither protected it
# nor reported it — it served this page to anyone who asked, on LAN,
# Tailscale and the mesh alike). Binding loopback lets the daemon claim the
# external addresses and authenticate them; see appgate::listener.
listen 127.0.0.1:8175;
server_name _;
proxy_intercept_errors on;
+8 -1
View File
@@ -1,5 +1,12 @@
server {
listen 8336;
# Loopback ONLY. This container is host-networked, so this nginx binds the
# HOST's address directly — `listen 8336;` meant every interface, and the
# app gate could never stand in front of it (there is no podman publish to
# pin, and the manifest declared no port, so the gate neither protected it
# nor reported it — it served this page to anyone who asked, on LAN,
# Tailscale and the mesh alike). Binding loopback lets the daemon claim the
# external addresses and authenticate them; see appgate::listener.
listen 127.0.0.1:8336;
server_name _;
root /usr/share/nginx/html;
index index.html;
+8 -1
View File
@@ -1,7 +1,14 @@
server {
# Host-networked: listen on the app's own port directly (NOT 80, which the
# host's main nginx already owns). The app is reached at http(s)://<node>:18083.
listen 18083;
# Loopback ONLY. This container is host-networked, so this nginx binds the
# HOST's address directly — `listen 18083;` meant every interface, and the
# app gate could never stand in front of it (there is no podman publish to
# pin, and the manifest declared no port, so the gate neither protected it
# nor reported it — it served this page to anyone who asked, on LAN,
# Tailscale and the mesh alike). Binding loopback lets the daemon claim the
# external addresses and authenticate them; see appgate::listener.
listen 127.0.0.1:18083;
server_name _;
root /usr/share/nginx/html;
+66 -7
View File
@@ -711,7 +711,15 @@
},
"id": "bitcoin-ui",
"name": "Bitcoin UI",
"ports": [],
"ports": [
{
"auth": "gated",
"bind": "127.0.0.1",
"container": 8334,
"host": 8334,
"protocol": "tcp"
}
],
"resources": {
"memory_limit": "128Mi"
},
@@ -805,6 +813,8 @@
"name": "BotFights",
"ports": [
{
"auth": "gated",
"bind": "127.0.0.1",
"container": 9100,
"host": 9100,
"protocol": "tcp"
@@ -1159,7 +1169,15 @@
},
"id": "electrs-ui",
"name": "Electrs UI",
"ports": [],
"ports": [
{
"auth": "gated",
"bind": "127.0.0.1",
"container": 50002,
"host": 50002,
"protocol": "tcp"
}
],
"resources": {
"memory_limit": "64Mi"
},
@@ -1355,15 +1373,26 @@
"name": "Fedimint Guardian",
"ports": [
{
"auth": "none",
"auth_rationale": "Fedimint guardian consensus. Other guardians speak the federation's own authenticated protocol here; a login page would break consensus.",
"container": 8173,
"host": 8173,
"protocol": "tcp"
},
{
"auth": "none",
"auth_rationale": "Fedimint guardian API for federation clients, which authenticate to the federation itself and cannot hold a browser session.",
"container": 8174,
"host": 8174,
"protocol": "tcp"
},
{
"auth": "gated",
"bind": "127.0.0.1",
"container": 8175,
"host": 8175,
"protocol": "tcp"
},
{
"auth": "local",
"bind": "127.0.0.1",
@@ -1548,11 +1577,15 @@
"name": "Fedimint Gateway",
"ports": [
{
"auth": "none",
"auth_rationale": "Fedimint gateway API, protected by its own bcrypt password (--bcrypt-password-hash) and reached by federation peers and clients that cannot hold a browser session.",
"container": 8176,
"host": 8176,
"protocol": "tcp"
},
{
"auth": "none",
"auth_rationale": "LDK Lightning p2p for the gateway. The BOLT-8 noise handshake authenticates and encrypts the connection itself.",
"container": 9737,
"host": 9737,
"protocol": "tcp"
@@ -1700,7 +1733,15 @@
},
"id": "fips-ui",
"name": "FIPS Mesh",
"ports": [],
"ports": [
{
"auth": "gated",
"bind": "127.0.0.1",
"container": 8336,
"host": 8336,
"protocol": "tcp"
}
],
"resources": {
"memory_limit": "128Mi"
},
@@ -2909,6 +2950,8 @@
"protocol": "tcp"
},
{
"auth": "none",
"auth_rationale": "LND REST, authenticated by macaroon over TLS. A browser login page would break Zeus and every non-browser wallet client, exactly as for lnd's 18080.",
"container": 8080,
"host": 8091,
"protocol": "tcp"
@@ -3078,7 +3121,15 @@
},
"id": "lnd-ui",
"name": "LND UI",
"ports": [],
"ports": [
{
"auth": "gated",
"bind": "127.0.0.1",
"container": 18083,
"host": 18083,
"protocol": "tcp"
}
],
"resources": {
"memory_limit": "64Mi"
},
@@ -3413,6 +3464,8 @@
"name": "NetBird",
"ports": [
{
"auth": "none",
"auth_rationale": "NetBird dashboard over TLS, with its own login. The gate speaks plain HTTP, so fronting this port would break the secure context the dashboard requires (issue #15) and the certificate clients pin.",
"container": 443,
"host": 8087,
"protocol": "tcp"
@@ -3617,6 +3670,8 @@
"name": "NetBird Server",
"ports": [
{
"auth": "none",
"auth_rationale": "NetBird management API and its OIDC issuer. Enrolled devices authenticate themselves with setup keys and JWTs, and they cannot hold a browser session — a login page here would disconnect every VPN client on the network.",
"container": 80,
"host": 8086,
"protocol": "tcp"
@@ -4012,11 +4067,15 @@
"name": "Pine",
"ports": [
{
"auth": "gated",
"bind": "127.0.0.1",
"container": 80,
"host": 10380,
"protocol": "tcp"
},
{
"auth": "none",
"auth_rationale": "Pine's TLS listener. The gate speaks plain HTTP, so fronting this port would break the secure context navigator.bluetooth needs for WiFi provisioning. The plain-HTTP entry point (10380) is gated, and it is what the UI opens.",
"container": 443,
"host": 10381,
"protocol": "tcp"
@@ -4458,6 +4517,8 @@
},
"ports": [
{
"auth": "gated",
"bind": "127.0.0.1",
"container": 8080,
"host": 8084,
"protocol": "tcp"
@@ -4837,7 +4898,5 @@
}
},
"schema": 1,
"signature": "cc83d0be50ce6144e2b5693a7175d7743d4a19141f4ef9a46a3c88d2dadd848acda9c25063e7a8b5643cecb2ccde279762a00a9715a5d26c99a95b492bc82a05",
"signed_by": "did:key:z6MkkidEnEpo6qHMCNSZoNKWtvQvxq3whnaME9wGgEFhq7ur",
"updated": "2026-08-04"
"updated": "2026-08-05"
}
+23 -3
View File
@@ -321,7 +321,17 @@ fi
UI_DOCKER_DEST="/opt/archipelago/docker"
sudo mkdir -p "$UI_DOCKER_DEST"
UI_REBUILD_LIST=""
for ui in bitcoin-ui lnd-ui electrs-ui; do
# fips-ui and fedimint-ui are synced but NOT added to UI_REBUILD_LIST below:
# container-specs.sh has no spec for either (and their container names break
# the archy-<ui> assumption — the FIPS one is plain `fips-ui`). Their rebuilds
# come from elsewhere — the daemon's companion installer for fedimint-ui, the
# orchestrator's build context for fips-ui — but BOTH read
# /opt/archipelago/docker/<ui>, and nothing was ever updating that directory.
# So source edits to those two trees reached nodes through no path at all:
# their nginx kept listening on 0.0.0.0 and served the Guardian and FIPS
# screens unauthenticated on every interface (found by scanning archi-dev-box
# from outside, 2026-08-05 — the in-node audit could not see them).
for ui in bitcoin-ui lnd-ui electrs-ui fips-ui fedimint-ui; do
src="$REPO_DIR/docker/$ui"
dst="$UI_DOCKER_DEST/$ui"
[ -d "$src" ] || continue
@@ -335,9 +345,19 @@ for ui in bitcoin-ui lnd-ui electrs-ui; do
dst_hash=$( (cd "$dst" && find . -type f | LC_ALL=C sort | xargs sha256sum 2>/dev/null) | sha256sum | cut -d' ' -f1)
fi
if [ "$src_hash" != "$dst_hash" ]; then
log "UI source changed for $ui; syncing and marking for rebuild"
log "UI source changed for $ui; syncing"
sudo rsync -a --delete "$src/" "$dst/"
UI_REBUILD_LIST="$UI_REBUILD_LIST $ui"
case "$ui" in
# Rebuilt below from container-specs.sh.
bitcoin-ui|lnd-ui|electrs-ui)
UI_REBUILD_LIST="$UI_REBUILD_LIST $ui" ;;
# Synced only — rebuilt by the daemon (companion installer /
# orchestrator build context), which watches this directory.
# Adding them to the rebuild list would fail: no spec exists and
# the container names are not archy-<ui>.
*)
log " $ui synced; rebuild is owned by the daemon" ;;
esac
else
ok "UI source unchanged for $ui"
fi