4e5514146dcb168fa1e72709bcdf15c05ed5ccea
8
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
6ba0599639 |
security: remove all infrastructure and internal process material from the repo
Demo images / Build & push demo images (push) Failing after 2m13s
The repo is source code and guidelines only. Nothing about how Archipelago's own fleet is run, or how the team works, stays in it. Untracked (kept on disk, gitignored) — 250 files: - .planning/ (199) and loop/ — internal development process - fleet operations tooling that targets specific nodes: deploy-to-target, deploy-tailscale, deploy-config-defaults, setup-target-dev, setup-aiui-server, setup-https-dev, debug-frontend, node-profile, fleet-fips-pair/unpair, image-recipe/sync-from-live.sh - image-recipe/INTEGRATION-GUIDE.md and docs/multinode-testing-plan.md, both of which are live-server workflow and fleet node inventories - the Phase 10 on-node verification and evidence records, which cite .planning/ as their evidence base KEY-05-ENTROPY-ENFORCEMENT.md was initially moved out with the other Phase 10 docs and then put back: it is cited as normative rationale from ten places in the codebase, including core/clippy.toml, which bans rand::thread_rng and points at it for the reason. That makes it a guideline, not an internal record. Node names removed from source (48 occurrences across comments, manifests and test fixtures): archi-dev-box, archy-x250*, shorty-s, framework-pt, zaza-optiplex, archi-thinkpad. Comments keep the engineering context and the date, which is what carried the meaning; the machine name did not. Three of those were live test values rather than comments and were replaced with valid stand-ins, not prose: two mDNS hostnames and a mesh peer name. An earlier pass substituted "a test node" into a hostname assertion, producing an invalid hostname; caught and fixed as test-node.local. Wipe mechanism: .local-only/manifest.txt inventories every local-only path and .local-only/wipe.sh deletes them on one confirmation, refusing to touch anything git still tracks. Both are themselves untracked, so the public repo does not carry a map of internal filenames. Verified: cargo check -p archipelago --all-features clean; archipelago-container 75/75 tests pass; appOrigin vitest 7/7; audit-secrets 5/5; every relative link in tracked markdown resolves (0 broken). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
6c0fc366b3 |
fix(appgate): classify ports from the catalog even for on-node-built apps
The port map deferred to DISK manifests for any app with a build source — which is exactly the four companion UIs (lnd-ui, bitcoin-ui, electrs-ui, fips-ui). Their disk manifests reach nodes only via the frontend runtime payload or a per-node repo checkout, and in the v1.7.125 rollout both proved stale or entirely absent: one node had no checkout at all, others restored an older payload over apps/ at every boot. Result: session_passthrough never reached the gate, so the node's own screens 401'd on every data call, and on nodes whose UI rebuilt from a stale context the app held its port UNGATED. Classification now uses a ports-only overlay that accepts build-source manifests (install/orchestration still defers to disk — unchanged). The signed catalog is the freshest, operator-signed source, and the gate's address binds fail safely against a container publishing differently (logged CANNOT PROTECT), so this can only tighten policy, never expose. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
ada59acdd5 |
fix(appgate+container): stop stripping app cookies; create named volumes correctly
Two daemon bugs, one debugging arc (2026-08-05, operator-reported):
1. The app gate removed the ENTIRE Cookie header before proxying. That
broke the data plane of every first-party companion UI behind the gate
(lnd-ui/bitcoin-ui/electrs-ui/fips-ui render their shell, then every
/proxy/* and /lnd-connect-info call 401s — observed as "LND UI
unreachable"), and silently logged users out of every gated app with
its own cookie login (vaultwarden, nextcloud, gitea) on each request.
The gate now strips only its own cookie pairs (session, csrf_token);
a new per-port manifest opt-in `session_passthrough: true` forwards
the node session to first-party UIs whose nginx proxies the daemon's
authenticated endpoints. Undeclared ports never get passthrough.
2. podman_client::create_container sent named volumes to the libpod API
as bind mounts with the bare volume name as source, so creating any
manifest app with a `type: volume` mount failed. On .38 the reconciler
removed indeedhub-postgres/-minio for env drift and then could never
create their replacements, leaving the stack half-missing forever.
Named volumes now ride the spec's `volumes` field ({Name, Dest,
Options}). Also: the reconcile-failure log now prints the full anyhow
chain — `%e` showed only "create_container X" and hid the real error.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
||
|
|
0f21f598aa |
fix(security): FIPS mesh relay must not republish auth: local ports
Caught verifying the gate fixes on archi-dev-box: [fips0-ULA]:32838 answered HTTP 200 straight from nbxplorer with no credential. The catalog declares that port auth: local — host-local by intent, pinned to loopback, the gate deliberately keeps its hands off — but the mesh relay bridges a STATIC port list to 127.0.0.1, so it republished it to the whole mesh. Same bug class as the Tor onion gap: a transport that converges on the app loopback without consulting the declaration. PortMap now records declared-local ports and the relay withholds them (tearing down an existing bridge if a catalog refresh newly declares one), alongside the declared-gated withhold. Undeclared ports keep todays behaviour — silence is not an instruction in either direction. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
d2e4b00789 |
fix(security): gate classifies from the catalog overlay and releases withdrawn claims
Dev-box verification of the Tor/FIPS fixes caught a pre-existing split brain: the orchestrator publishes containers from the signed catalog's embedded manifests (origin-wins), but the gate classified ports from the stale disk manifests — so it externally bound nbxplorer 32838, a port the catalog declares auth: local and pins to loopback. Reachable behind a login, but reachable where it deliberately was not. - build_port_map now consults the catalog overlay first, via the same parse/validate/image-only filter the orchestrator uses (moved to app_catalog::catalog_manifest_overlay so the two cannot diverge again). - GatedPort carries . The gated set still includes undeclared Session-default ports for challenge/audit, but every action that REDIRECTS traffic — the torrc 127.0.0.2 repoint, the FIPS relay stand-down, the Tor-upstream bind — now keys on the declaration. - The sweep releases held claims whose port left the gated set, so a catalog refresh that withdraws a port (gated → local/none) takes effect without a daemon restart. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
ab2c8b6e96 |
fix(security): silence is not consent — undeclared ports are never acted on
Two live incidents on archi-dev-box today, one bug. Both times a safety
decision read an ABSENT manifest field as if it were a value, and a
node's installed manifests always lag the binary — so "absent" is the
state of essentially every port on every node.
1. Gating any `session` port regardless of `bind` published Bitcoin's
loopback-only RPC 8332 on the LAN, Tailscale and IPv6 within seconds
of deploy.
2. The `bind`-keyed replacement looked safe because it protected
`bind: 127.0.0.1` ports — but LND's gRPC 10009 and REST 18080 carry
an EMPTY bind, so they fell through. One container recreate from
pinning them to loopback and breaking Zeus and every remote wallet.
`auth` is now `Option<PortAuth>`, separating two questions that were
conflated:
* `auth_policy()` — what to CLASSIFY the port as. Undeclared reports as
Session, i.e. shows in the audit as something that should be behind
the gate. Reporting is always safe.
* `auth_is_declared()` — whether the daemon may ACT. Only an explicit
declaration authorises changing how a port is published.
Also reverts the daemon-side publish rewriting entirely. The node proved
it wrong twice over: the recreate path that actually ran was in
package::install, not podman_client, so the pin never fired; and even
`bind: 127.0.0.1` written directly into the node's manifest was
overridden by the signed catalog. Publishes are built in several places
and all of them already honour `bind`, so the migration belongs in the
catalog as data — not in daemon-side inference that can only ever cover
one path and guess wrong on the rest.
Tests: 75/75 container, incl. the LND wallet-port shape (`host: 10009`,
empty bind, no auth) asserted to be non-actionable.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
||
|
|
3716b6e9c3 |
fix(security): two gate bugs that would have made the rollout a no-op
Both found while setting up the on-node test, and both fail silently in the same direction — the gate reports success while protecting nothing, which is the exact failure the module was written to prevent. 1. Loopback-pinned ports were skipped entirely. `identity.rs` dropped any port whose manifest sets `bind: 127.0.0.1`, reasoning that a loopback publish is not externally reachable. But `listener.rs` requires loopback-pinning as the PRECONDITION for gating — while an app holds 0.0.0.0:<port> the kernel will not let the gate bind that port at all. So the two contradicted each other: pinning an app, the one action that lets the gate take over, was also what removed it from the gated set. Completing the entire migration would have gated nothing, and GateStatus would have reported zero unprotected ports while doing it. `bind` cannot carry this decision, because two unrelated intentions produce an identical loopback publish: Bitcoin's RPC 8332 is pinned so the LAN CANNOT reach it (fronting it would newly expose it on every host address, behind a login but exposed where it deliberately was not), whereas a migrated app is pinned precisely so the gate CAN. Inferring from `bind` breaks one or the other, so the intent is now declared: `PortAuth::Local` means the first case. The three ports that are host-local by intent (bitcoin-core/knots 8332, aiui 5180 — all already `bind: 127.0.0.1`) say so, and a loopback publish with `auth: session` stays gated. A test pins that property. 2. The port map was never refreshed. `AppGate::refresh()` existed, was documented as making catalog changes apply without a restart, and was called by nothing. The map was built once in `new()`, so an app installed while the daemon runs would never be gated — and would never appear in `unprotected` either, so the node would report itself fully enforced while serving a brand-new app to anyone who asked. The sweep now refreshes before classifying. Tests: 22/22 appgate, 73/73 archipelago-container. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
0de67ca6ae |
feat(security): app gate — authenticate every app port
Demo images / Build & push demo images (push) Successful in 4m18s
Reproduced again on this node today: with no session cookie, six app ports answered HTTP 200 with their real UIs (18083 LND, 8334, 8175 Fedimint Guardian, 8336 FIPS Mesh, 8090, 7777), all bound 0.0.0.0 and so served on every host address. Same bug class as the /lnd-connect-info and /bitcoin-rpc/ leaks closed in v1.7.120, but across every app. LAN, Tailscale, Tor and the FIPS mesh all converge on 127.0.0.1:<port>, so this is one gate rather than four. It lives in the daemon rather than a per-app sidecar (umbrel's app_proxy model): rootless, no extra container per app, and it can reuse machinery that already exists. It invents no authentication policy. verify_password, TOTP secret decryption, verify_code with used-step replay protection, the session store, and — importantly — the SAME LoginRateLimiter instance as the JSON-RPC path, so an attacker cannot get a fresh budget of password guesses by moving to an app port. Only the transport differs, an HTML form instead of JSON-RPC, because a browser being sent to an app cannot speak JSON-RPC. 2FA comes for free: a session still pending its TOTP step fails validate(), so the gate rejects it without knowing what a second factor is. Details worth keeping: - 401, not a redirect. A redirect to a login page is indistinguishable from the app itself redirecting, and machine clients would follow it and parse HTML as their API response. - Cookie and Authorization are stripped before proxying. The app has no use for the node session and must never be able to log or forward it. - The challenge page names and pictures the app being opened, so the visitor can confirm what they are authenticating to. - device_tokens grew `apps: Option<Vec<String>>` and verify_for_app for machine clients. None = node-wide, which every existing companion token is; migrating them by guessing a scope would silently revoke access nobody asked to revoke. An empty list is rejected rather than minted, since it reads as unrestricted while authorising nothing. The rollout is necessarily per-app and the gate is built to say so. A container publishing 0.0.0.0:<port> claims every host address, so the gate cannot bind that port until the app is pinned to bind: 127.0.0.1 and recreated — gate-first is impossible, and all-at-once would recreate every container on a node simultaneously. Every port it cannot claim is logged at warn each sweep and recorded in GateStatus::unprotected, surfaced by security.app-gate-status. The failure mode being designed against is a gate that binds nothing, logs at debug, and reports success while every app stays exactly as open as before — worse than no gate, because it stops anyone looking. Same reasoning that ruled out an nft drop-in, whose absence is a silent no-op. Not yet done: pinning the 39 gated ports to loopback, repointing HiddenServicePort at the gate, and on-node verification. Tests: 21/21 appgate, workspace builds clean. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |