diff --git a/apps/bitcoin-ui/manifest.yml b/apps/bitcoin-ui/manifest.yml index a9e10f4d..6fb05656 100644 --- a/apps/bitcoin-ui/manifest.yml +++ b/apps/bitcoin-ui/manifest.yml @@ -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 diff --git a/apps/botfights/manifest.yml b/apps/botfights/manifest.yml index eba5410c..a7e3e1fd 100644 --- a/apps/botfights/manifest.yml +++ b/apps/botfights/manifest.yml @@ -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 diff --git a/apps/electrs-ui/manifest.yml b/apps/electrs-ui/manifest.yml index 0d5f6be3..4f224565 100644 --- a/apps/electrs-ui/manifest.yml +++ b/apps/electrs-ui/manifest.yml @@ -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: [] diff --git a/apps/fedimint-gateway/manifest.yml b/apps/fedimint-gateway/manifest.yml index 9363bb10..42ed8239 100644 --- a/apps/fedimint-gateway/manifest.yml +++ b/apps/fedimint-gateway/manifest.yml @@ -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 diff --git a/apps/fedimint/manifest.yml b/apps/fedimint/manifest.yml index c3c7985a..e93fcf0e 100644 --- a/apps/fedimint/manifest.yml +++ b/apps/fedimint/manifest.yml @@ -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 diff --git a/apps/fips-ui/manifest.yml b/apps/fips-ui/manifest.yml index 6983387c..e16c49f9 100644 --- a/apps/fips-ui/manifest.yml +++ b/apps/fips-ui/manifest.yml @@ -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: [] diff --git a/apps/lightning-stack/manifest.yml b/apps/lightning-stack/manifest.yml index ac7fc9fe..e9befcd6 100644 --- a/apps/lightning-stack/manifest.yml +++ b/apps/lightning-stack/manifest.yml @@ -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 diff --git a/apps/lnd-ui/manifest.yml b/apps/lnd-ui/manifest.yml index 713a1afe..cf186bcf 100644 --- a/apps/lnd-ui/manifest.yml +++ b/apps/lnd-ui/manifest.yml @@ -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: [] diff --git a/apps/netbird-server/manifest.yml b/apps/netbird-server/manifest.yml index 994949f6..287be710 100644 --- a/apps/netbird-server/manifest.yml +++ b/apps/netbird-server/manifest.yml @@ -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 diff --git a/apps/netbird/manifest.yml b/apps/netbird/manifest.yml index 6464335a..32cf44d5 100644 --- a/apps/netbird/manifest.yml +++ b/apps/netbird/manifest.yml @@ -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 diff --git a/apps/pine/manifest.yml b/apps/pine/manifest.yml index 3984cd3f..c673df3e 100644 --- a/apps/pine/manifest.yml +++ b/apps/pine/manifest.yml @@ -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 diff --git a/apps/router/manifest.yml b/apps/router/manifest.yml index fb4300d4..bada2040 100644 --- a/apps/router/manifest.yml +++ b/apps/router/manifest.yml @@ -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 diff --git a/core/archipelago/src/container/bitcoin_ui.rs b/core/archipelago/src/container/bitcoin_ui.rs index eec7526a..37759a54 100644 --- a/core/archipelago/src/container/bitcoin_ui.rs +++ b/core/archipelago/src/container/bitcoin_ui.rs @@ -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")); } } diff --git a/core/archipelago/src/container/bitcoin_ui_nginx.conf.template b/core/archipelago/src/container/bitcoin_ui_nginx.conf.template index 6e7e2bdb..ddd4c1b6 100644 --- a/core/archipelago/src/container/bitcoin_ui_nginx.conf.template +++ b/core/archipelago/src/container/bitcoin_ui_nginx.conf.template @@ -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; diff --git a/docker/electrs-ui/nginx.conf b/docker/electrs-ui/nginx.conf index 9f927d8f..b9d4932f 100644 --- a/docker/electrs-ui/nginx.conf +++ b/docker/electrs-ui/nginx.conf @@ -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; diff --git a/docker/fedimint-ui/nginx.conf b/docker/fedimint-ui/nginx.conf index 8a8539af..a4136378 100644 --- a/docker/fedimint-ui/nginx.conf +++ b/docker/fedimint-ui/nginx.conf @@ -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; diff --git a/docker/fips-ui/nginx.conf b/docker/fips-ui/nginx.conf index 5faf33c5..b2d8eae6 100644 --- a/docker/fips-ui/nginx.conf +++ b/docker/fips-ui/nginx.conf @@ -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; diff --git a/docker/lnd-ui/nginx.conf b/docker/lnd-ui/nginx.conf index 2c372562..c6ad7386 100644 --- a/docker/lnd-ui/nginx.conf +++ b/docker/lnd-ui/nginx.conf @@ -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)://: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; diff --git a/releases/app-catalog.json b/releases/app-catalog.json index 11feb497..5a304b38 100644 --- a/releases/app-catalog.json +++ b/releases/app-catalog.json @@ -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" } diff --git a/scripts/self-update.sh b/scripts/self-update.sh index 99ea24f6..fb2ed45f 100755 --- a/scripts/self-update.sh +++ b/scripts/self-update.sh @@ -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- 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/, 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-. + *) + log " $ui synced; rebuild is owned by the daemon" ;; + esac else ok "UI source unchanged for $ui" fi