Files
archy/.planning/RELEASE-1.7.121-TASKS.md
T

32 KiB
Raw Blame History

Release 1.7.121 — task list

Opened 2026-08-03, immediately after v1.7.120-alpha shipped. Everything the operator has asked for since, plus the items v1.7.120 deliberately left open. Ordered by severity.

Status key: DONE (committed) · READY (written, not yet committed/tested) · OPEN (not started) · BLOCKED (needs an operator decision)


P0 — Security

1. App ports are reachable with no login, on every transport — OPEN

"if I'm logged out I can reach every app port on tailscale and LAN, this can not be allowed… it must present the login to access the app with an app icon of what you're accessing to confirm, and 2FA if present" — operator, 2026-08-03

  • Applies to Tailscale, LAN, Tor, FIPS alike, and to "ssh access to that port or whatever".
  • Required behaviour: an unauthenticated request to any app port serves a login page naming and showing the icon of the app being accessed, then honours 2FA when set.
  • Research first: how umbrelOS and StartOS gate app access (operator asked explicitly). Both are open source — getumbrel/umbrel and Start9Labs/start-os. Do not guess at their model; read it.
  • This is the same class as the v1.7.120 /lnd-connect-info + /bitcoin-rpc/ leaks, but fleet-wide across every app port rather than two endpoints. Those two were closed by moving authorisation to the resource; this needs a general gate.
  • Scope note: fips/app_ports.rs holds the mesh allowlist; is_peer_allowed_path in server.rs holds the peer HTTP allowlist. Neither currently authenticates app ports.

Research — umbrelOS (verified from their docs/source, 2026-08-03)

umbrelOS solves this architecturally, not per-app: the app's own port is never published. Each app gets a sidecar app_proxy container that owns the published port and forwards to the app on the internal network.

  • containers/app-proxy is described as "a transparent HTTP proxy to add authentication to Umbrel apps"every HTTP request and WebSocket upgrade passes through it and has its session token checked.
  • Tokens come from a separate app-auth service; the proxy talks to it over a local port (default 2000) with a shared secret (UMBREL_AUTH_SECRET). Two JWTs exist: an API token in localStorage ({loggedIn: true}) for the dashboard's own API, and a proxy token in an HttpOnly cookie ({proxyToken: true}) for app access. Both HS256, 7-day expiry.
  • Unauthenticated requests are redirected to the login screen.
  • Per-app escape hatches, all env vars on the proxy: PROXY_AUTH_ADD (bool, default true — so apps are protected unless opted out), PROXY_AUTH_WHITELIST (paths exempt, e.g. /public/*), PROXY_AUTH_BLACKLIST (paths that must be authed, e.g. /admin/*).
  • Known friction worth designing around: apps with their own login (Frigate, and the PROXY_AUTH_ADD=false tracker issue) end up double-authenticating, and non-browser API clients (Home Assistant hitting an app's API) break because they have no cookie. Any gate we build needs a story for machine clients, not just browsers.

The lesson for us: the reason umbrel doesn't have this bug class is that there is no unauthenticated path to bind to in the first place. Our apps publish their own ports directly, so a gate bolted onto one transport leaves the others open — which is exactly the shape of the /lnd-connect-info + /bitcoin-rpc/ leaks. The fix likely has to move the port binding, not just add a check.

Reproduced ON archi-dev-box, 2026-08-03 — baseline before the fix

No session cookie, over the Tailscale IP 100.69.68.39:

port 18083  HTTP 200  LND - Archipelago
port 8334   HTTP 200
port 8175   HTTP 200  Fedimint Guardian - Archipelago
port 8336   HTTP 200  FIPS Mesh
port 8090   HTTP 200
port 7777   HTTP 200

ss -tlnp confirms these are bound 0.0.0.0, so the same responses are served on the LAN IP and every other host address. Re-run this exact loop after the fix: every one must become the login page, and the ports listed as protocol exemptions (item 1b) must be the only ones still answering.

Exposure map — how app ports are reachable TODAY (verified in source, 2026-08-03)

All four transports converge on 127.0.0.1:<app_port>. This is the whole reason the fix is tractable: it is one gate, not four.

Transport Path to the app Code
LAN / Tailscale container publishes the port on the host (--network host, so 0.0.0.0:<port>) — reachable on every host IP scripts/container-specs.sh, first-boot-containers.sh
FIPS mesh daemon binds [fips0-ULA]:<port> and raw-TCP-forwards to 127.0.0.1:<port> server.rs:1130 app_port_v6_relay_loop
FIPS firewall tcp dport { …APP_LAUNCH_PORTS… } accept drop-in opens them all fips/config.rs:274, fips/app_ports.rs
Tor HiddenServicePort 80 127.0.0.1:<local_port> per service api/rpc/tor/mod.rs:243

Design decision (operator, 2026-08-03)

Gate app UIs + bearer tokens; protocol ports exempt. HTTP app UIs get the login gate (app name + icon, 2FA honoured). Protocol ports (LND gRPC 10009 + REST, electrum 50002, bitcoin p2p 8333) stay open but MUST be declared auth: none with a rationale in the manifest, so the exceptions are a grep rather than a discovery — see item 1b. Per-app long-lived bearer tokens cover machine clients that speak HTTP (Home Assistant). Zeus and electrum wallets keep working untouched — that was the deciding constraint.

The gate lives in the daemon, not a per-app sidecar container (umbrel's app_proxy model): rootless, no extra containers per app, one place to update, and it can reuse the existing app_port_v6_relay_loop rather than fight it.

⚠️ Trap found while designing — an nft-only gate FAILS OPEN

The obvious implementation is an nft redirect of inbound app-port traffic to the gate. But /etc/fips/fips.nft is provisioned out-of-band and fips/config.rs:290 treats its absence as a no-op (if try_exists("/etc/fips/fips.nft")). A gate shipped as a fips.d drop-in would therefore be silently absent on every node without the hardening baseline — i.e. it fails open, which is exactly the failure class this item exists to close.

Two viable shapes, both fail-closed:

  • (a) Apps bind loopback only, daemon owns every external bind. Airtight, the true umbrel model, but requires touching each app's own listen config (nginx.conf etc.). Note you cannot half-do this: while an app holds 0.0.0.0:<port>, the daemon cannot bind <lan-ip>:<port> at all.
  • (b) Daemon owns a dedicated archipelago-appgate nft table with its own default-deny + redirect, independent of whether fips.nft exists, and refuses to start / alarms loudly if it cannot install it. Non-invasive to apps.

Enabler found — PortMapping.bind already does half of (a)

core/container/src/manifest.rs:518PortMapping has a bind field, documented as "Host address to bind the publish to. Empty = all interfaces (0.0.0.0). Set 127.0.0.1 to keep a port host-local". So for bridge apps that declare ports:, going loopback-only is a manifest edit, not app surgery, and the daemon can then own the external bind. That is most of the catalog.

The exception is host-networked apps (security.network_policy: hostlnd-ui, bitcoin-ui, electrs-ui): host networking bypasses port mapping entirely, so bind has no effect and ports: is deliberately empty. Those bind whatever their internal nginx binds. We build those images ourselves, so the fix is a listen 127.0.0.1:<port>; change in each docker/*-ui/nginx.conf — still no third-party surgery.

Watch the rootless trap documented at manifest.rs:532: a publish bound to an address the host cannot actually bind crash-loops the whole unit (took bitcoin down fleet-wide on .228, 2026-07-09). Loopback binds are explicitly always accepted without probing, so this direction is safe.

Tor needs separate handling either way: the onion connects from localhost, so a redirect that exempts loopback will not catch it. HiddenServicePort must be repointed at the gate, and since that mapping loses the original destination port, each app needs its own gate port (or an HTTP-level Host mapping).

Primitives that already exist — do NOT build these from scratch

The gate is mostly assembly, not invention:

Need Existing API
Read the session cookie off a request session::extract_session_cookie(&HeaderMap) -> Option<String> (session.rs:479)
Validate a session SessionStore::validate(&token) -> bool (session.rs:194)
Honour 2FA Already modelled: create_pending(totp_secret) (:176) + upgrade_to_full (:247). A session still pending 2FA fails validate(), so the gate gets 2FA for free by calling validate — no TOTP code in the gate itself
Machine-client bearer tokens device_tokens::create/verify (device_tokens.rs:63/:90) — long-lived, minted from an authenticated session, only the SHA-256 persisted, plaintext returned once. Built for the companion pairing QR; needs per-app scoping added for this use
Rate limiting device_tokens verification already rides auth.login's limiter

So the new code is: the listener/redirect, the app-identification step (which app is this port?), the login page render (app name + icon), and per-app scoping on device_tokens.

Research — StartOS: DROPPED (operator, 2026-08-03)

"don't need the startOS research we decided on a approach already." The umbrelOS read plus the design decision above settled it; no further prior-art work.

1b. Manifest declaration of unauthenticated ports — DONE (0c4826f8, pushed)

PortMapping grew auth (session | none, defaulting to session) and auth_rationale. The default is the protected one, so exposure is now something a manifest has to ask for rather than something it gets by saying nothing.

Validation is two-sided: auth: none without a rationale is rejected, and a rationale without auth: none is rejected — that combination means the author wrote an exemption and did not get one, and shipping it silently would leave them believing otherwise.

17 ports across 12 apps are exempt, each with its reason: Lightning p2p (BOLT-8 noise), LND gRPC 10009 / REST 18080 and CLN gRPC 9835 (macaroon / mutual TLS — this is what keeps Zeus and remote wallets working), Bitcoin p2p 8333, electrum 50001, the three Wyoming voice ports, git-over-SSH 2222, and the UDP discovery protocols (mDNS 5353, SSDP 1900, STUN 3478). The other 39 published ports now default to gated.

Bitcoin RPC 8332 is deliberately not exempted: it is already bind: 127.0.0.1, so the gate never sees it, and claiming an exemption it does not need would put a meaningless line in the audit list. If that bind is ever dropped it fails closed.

Two corpus tests pin this: every shipped manifest must parse, and the exempt set is frozen at 17 so the node's unauthenticated surface cannot grow by accident.

1c. The gate itself — IN PROGRESS

core/archipelago/src/appgate/identity.rs (port → app id/name/icon, gated vs exempt, re-read from manifests so a catalog refresh applies without a restart), mod.rs (authorize + login page + TOTP step + reverse proxy), listener.rs (binds the external addresses, sweeps every 60s).

Design points worth not re-deriving:

  • It invents no auth policy. verify_password, totp::decrypt_secret, verify_code + used-step replay protection, SessionStore::create/create_pending/ upgrade_to_full, and the same LoginRateLimiter instance as the JSON-RPC path. Only the transport differs (HTML form vs JSON-RPC), because a browser being redirected to an app cannot speak JSON-RPC. Sharing the limiter matters: otherwise an attacker gets a fresh budget of password guesses by moving to an app port.
  • 2FA is free. A session still pending its TOTP step fails validate(), so the gate rejects it without knowing anything about second factors.
  • Cookies ignore port. The session cookie is host-only with no Domain, so one sign-in covers the dashboard and every app port on the same host. The corollary is that an app reached on a different host — its own onion — is a separate sign-in.
  • 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.
  • The gate strips Cookie and Authorization before proxying. The app has no use for the node session and must never be in a position to log or forward it.
  • Machine clients: device_tokens grew apps: Option<Vec<String>> and verify_for_app. None = node-wide (what every existing companion token is — migrating them by guessing a scope would silently revoke access nobody asked to revoke); Some(list) restricts to those apps. An empty list is rejected rather than minted, since it would read as "unrestricted" while authorising nothing.

⚠️ The ordering constraint that shapes the rollout

A published container port is bound 0.0.0.0:<port>, which claims every host address. While the app holds that, the gate cannot bind <lan-ip>:<port> at all. So the gate can only stand in front of an app whose publish has been pinned to loopback (bind: 127.0.0.1) and whose container has been recreated. Gate-first is not possible; all-apps-at-once would recreate every container on the node simultaneously.

Therefore the rollout is per app, and the gate is built to be honest about being partially deployed: a port it cannot claim is logged at warn every sweep and recorded in GateStatus::unprotected. The failure mode this exists to prevent 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 killed the nft drop-in: /etc/fips/fips.nft is provisioned out-of-band and its absence is a silent no-op.)

Still open on this item: pin the 39 gated ports to loopback app-by-app, repoint HiddenServicePort at the gate (Tor connects from loopback, so a loopback-exempt redirect will not catch it, and the mapping loses the original destination port), gate the FIPS relay path, surface GateStatus in the UI, and verify on a real node.

2. Filebrowser ships an insecure default login — OPEN

  • Change the default credential without breaking the dashboard's Cloud view, which authenticates to filebrowser on the user's behalf.
  • Related prior art: FED-07 rotated the shipped Fedimint gateway credential and had to recreate the running container for it to take effect (06e0e695) — the same trap applies here.

3. Federation trust escalation — DONE (c0cfc72a, pushed)

Two independent fail-open paths granted Trusted without any operator decision:

  • federation.peer-joined is unauthenticated (middleware no-session list) and peer-reachable on /rpc/v1. Its ed25519 check verifies the caller against the pubkey the caller supplied, so it proves key possession, never authorisation. A join with no invite_token fell through to TrustLevel::Trusted.min(claimed_trust), and claimed_trust defaults to Trusted — so anyone able to reach the node could self-grant Trusted. Now capped at Observer.
  • merge_transitive_peers added every peer advertised by a Trusted source as Trusted, making trust viral across the whole federation graph. Now Observer — which is what NodeStateSnapshot.federated_peers' own doc comment always said it should be ("adds them as Observers on her side… doesn't auto-promote to Trusted"). The code contradicted its own spec.
  • Added FederatedNode.trust_source (invite | uninvited-join | transitive-merge | manual, None = pre-existing/unknown) so existing grants are auditable. Per operator decision: existing peers are left alone, not auto-demoted.
  • trust_source is now surfaced in federation.list-nodes (as an explicit null when unknown, not omitted — "recorded before this was tracked" is the population that needs review, so the UI must be able to tell it apart from a field it didn't read) and rendered under the trust dropdown in the node detail modal as "Granted via:".

3b. Granting Trusted must require the node password — DONE (uncommitted at time of writing)

"to make someone trusted must require the node password to generate the code or change in the modal dropdown when you click a node" — operator, 2026-08-03

Re-authentication on privilege escalation. Both entry points are covered:

  • Minting a Trusted invite (federation.invite) — gated on the resolved level, which matters because "Link Your Nodes" sends no trust_level at all and falls through to the Trusted default. The invite is a bearer grant of Trusted to whoever redeems it, so minting it is the escalation. Observer invites are untouched.
  • Changing a node's level in the UI dropdown (federation.set-trust) — gated only when the peer is not already Trusted, so the dropdown re-emitting its own value doesn't demand a password for a no-op.

Demotion is NOT gated: making something less privileged must never be harder than leaving it, or the safe action becomes the inconvenient one. The operator path stamps TrustSource::Manual; set_trust_level grew an Option<TrustSource> so automatic adjustments (the discovery-handshake demotion safety net) pass None and leave the recorded provenance alone rather than laundering an uninvited-join peer into looking operator-approved.

Wiring: the backend is the sole authority on what counts as an escalation — it returns a PASSWORD_REQUIRED: prefixed error, and the UI prompts and retries only on that. The frontend never pre-judges, so the rule lives in exactly one place. TrustPasswordModal.vue (modelled on RotateDidModal.vue) serves both flows. NodeDetailModal's select now snaps back to the node's real level on change, because a cancelled or failed promotion would otherwise leave the dropdown displaying a level the node never accepted.

Follow-up, deliberately not done here: federation.join also grants Trusted (when redeeming someone else's Trusted invite) with no re-auth. It is an explicit operator paste rather than a UI toggle, and was outside the two entry points specified — but it is the third way a node reaches Trusted and should be reviewed.


P1 — Correctness the operator hit directly

4. LND UI never updates over OTA — DONE (5088aef5, pushed)

  • LND_UI_IMAGE was lnd-ui:latest while BITCOIN_UI_IMAGE was pinned to 1.7.119-alpha. Podman will not re-pull a tag it already holds locally, so nodes kept a stale lnd-ui forever. Now pinned to 1.7.119-alpha.
  • scripts/first-boot-containers.sh declared lnd-ui as bridge -p 18083:80. That is the third copy of the declaration the UI agent already corrected in scripts/container-specs.sh and apps/lnd-ui/manifest.yml — so fresh installs still produced the reproduced HTTP 000. Now --network host, ports empty.
  • Root cause worth fixing separately: the same container spec is declared in three places.

5. Federated/peered nodes must message without a LoRa hop first — OPEN

"make it so federated/peered nodes can message without needing to connect on Lora first once connected"

  • Investigate the split contact model (radio contact vs federation peer) — there is prior art in memory: project_archy_lora_e2e_rootcause ("split contact model; don't touch federation") and mesh::seed_federation_peers_into_mesh / upsert_federation_peer, which already mirror federation peers into the mesh table.
  • Likely the gap is addressing/route selection rather than transport availability.

6. In-app app updates, independent of OTA — OPEN

"we need app update to see updates in the registry, whether UI or not… show the update mechanism in the app… a modal and update now / cancel… same in the detail page… the update button should show 'see update' and a different graphic for just ui, app, or both together. All pushed through the signed-catalog flow." … "This has to show independent of OTA updates as a separate pipeline, I think we've done a lot of work on it."

  • Operator says much of this already exists — research the codebase before building. Known groundwork: the signed catalog (releases/app-catalog.json, sign-catalog.sh), catalog→manifest runtime reload, package.update RPC, check-app-catalog-drift.py, and scripts/image-versions.sh pinning.

What already exists (verified in source, 2026-08-03) — the operator was right

The whole update pipeline is built and is already independent of OTA:

  • package.check-updates (api/rpc/package/update.rs:180) refreshes the signed catalog and hot-reloads manifests when it changed — no daemon restart, no OTA involved.
  • package.update (spawn_package_update), package.versions, package.set-config version pinning, and execute_update (stop → pull → remove → recreate → verify).
  • Version awareness: app_catalog::catalog_versions(app_id) vs installed_version() (api/rpc/package/set_config.rs:46).
  • Frontend: AppCard.vue already renders an Update button off pkg['available-update'] (:48, :128) and emits update.

What is actually MISSING (this is the real scope of item 6)

  1. The UI-vs-app-vs-both distinction does not exist. available-update is a single version string — nothing classifies whether the change is the app image, its *-ui image, or both. This is the core of the operator's ask ("a different graphic for just ui, app, or both together") and needs a backend change, not just an icon. ⚠️ Compounding factor: per reference_app_ui_delivery_model, *-ui apps are not in the signed catalog at all — so "is there a UI update" cannot be answered from the catalog today. That gap has to be closed first or the UI half is unanswerable.
  2. The modal (Update now / Cancel) — the card currently updates on click, no confirm.
  3. The detail-page affordance — same treatment as the card.
  4. Button copy: "See update" rather than "Update".

6b. Multiversion for ALL apps + upstream release discovery — OPEN (operator, 2026-08-03)

"we also need a way to provide multiversion support for all apps and it automatically pulls the latest versions from the source app repository, safely, and the user can choose to update so we aren't always updating manually"

Verified 2026-08-03: the schema and runtime already exist

This is much less work than it sounds, because the multiversion machinery built for Bitcoin generalises as data rather than code:

  • releases/app-catalog.json entries already support a versions[] array of {version, image, default?, deprecated?}.
  • Runtime is complete: catalog_versions(app_id), catalog_default_version, catalog_image_for_version, package.versions, version pinning through package.set-config, and available_update_for_app falling back to the image-versions.sh baseline pin.

It is populated for 2 of 66 appsbitcoin-core (9 versions) and bitcoin-knots (5). Every other app carries a single version. So "multiversion for all apps" is primarily a catalog-generation and image-mirroring job, not new runtime plumbing.

What has to be built

  1. Populate versions[] fleet-wide. Extend scripts/generate-app-catalog.py to emit a version list per app instead of a single pin. Needs a per-app policy for how many historical versions to carry and which is default (Bitcoin's list shows the shape, including deprecated: true for old-but-installable).
  2. Mirror the images. A version in the catalog that is not in our registry is a broken promise — package.update would pull and fail. Use the existing skopeo path (feedback_skopeo_source_selection: prefer .160, concurrency ≤ 6).
  3. An upstream release-watcher. 48 manifests already carry a repo: URL under metadata, so there is something to poll (GitHub releases / registry tags). It runs off-node, as part of catalog generation.
  4. Keep the signed catalog as the trust boundary. This is the whole of "safely": the watcher proposes versions, the offline signing ceremony admits them, and nodes only ever install what the signed catalog carries. A node must never pull straight from an upstream repo — that would put an unsigned third party inside the supply chain, which is exactly what the signed-registry model exists to prevent.
  5. The user chooses. Discovery must never auto-apply. package.check-updates already refreshes and hot-reloads without touching the running containers, so "a new version exists" and "install it" stay separate — which is also what item 6's modal is for.

Sequencing note: 6b's step 1 and item 6's UI-vs-app classification want the same thing — *-ui images represented in the catalog. Doing that once unblocks both.


P2 — Carried over from v1.7.120

7. create-release.sh commits the manifest BEFORE signing — OPEN

Release commit always carries an unsigned manifest; nodes fetch it from branch main and refuse to auto-apply. Caught manually this cycle. Fix the ordering so it cannot ship.

8. gitea-vps2 remote is dead, and is the same server as gitea-aiOPEN

Stored token fails auth. source.archipelago-foundation.org == 146.59.87.168, so git push gitea-ai already publishes to the "primary" OTA host. Ties into the existing "migrate VPS2 IP to domain" todo.

9. Fleet SSH host-key rotation — BLOCKED (operator decision)

archipelago-1, archy-x250-beta, archipelago share all three SSH host keys; two also share a TLS private key. Detection shipped; rotation deliberately not performed.

10. 5× lifecycle gate — OPEN

Not run for v1.7.120 (disclosed in its changelog). Needs repeated reboots of a live node.

11. prod_orchestrator.rs:3181 unreachable code — OPEN

bitcoin_host() returns unconditionally at :3171, so the podman container-name lookup below is dead on every path. Pre-existing; spotted in the v1.7.120 build warnings.


Notes for whoever picks this up

  • A separate agent is doing AIUI planning with GSD — do not touch AIUI.
  • AIUI must always be built VITE_BASE_PATH=/aiui/ (see the memory note); a hand-built bundle renders a black page.
  • Verify security claims on the node, not from the source. v1.7.120's headline bug was a fix that shipped in the binary and silently never reached the running container.

STATUS 2026-08-04 — what shipped in 1.7.121 and what did not

Shipped (committed + pushed)

Item Commit Verified
3. Federation trust escalation c0cfc72a 42/42 federation tests
3b. Trusted requires node password 24ce8b39 44/44 + 79/79 + vue-tsc
4. lnd-ui OTA pin + host networking 5088aef5
1b. Manifest auth: declarations 0c4826f8 73/73, all 56 manifests parse
1c. App gate (engine + audit) 0de67ca6 23/23 appgate
Dashboard backdrop-filter seam 63d0183d 3/3, live on archi-dev-box
7. Release refuses unsigned manifest cc9e1958 dry-run: signed/stripped/wrong-signer
Gate safety model (Option<PortAuth>) ab2c8b6e 75/75 incl. LND wallet-port case
Companion rebuild-loop 719446c0 podman behaviour proven first
5. Federated peers messageable edc9a172 predicate pinned across device types

The two gate incidents — read before touching the gate again

Both were ONE mistake: a safety decision read an ABSENT manifest field as a value. A node's installed manifests always lag the binary, so "absent" is the normal state, and the daemon acted on instructions no manifest ever gave.

  1. Gating any session port regardless of bind published Bitcoin's loopback-only RPC 8332 on the LAN/Tailscale/IPv6 within seconds of deploy.
  2. The bind-keyed replacement looked safe (it protected bind: 127.0.0.1) but LND's gRPC 10009 / REST 18080 carry an EMPTY bind — one container recreate from pinning them to loopback and breaking Zeus and every remote wallet.

Now structural: auth_policy() classifies (undeclared → reported as unprotected, always safe), auth_is_declared() gates action (undeclared → never acted on). Silence is not consent.

Proven on the node, empirically, not by reasoning

  • Gate challenge → login → proxy works end to end over LAN and Tailscale.
  • Daemon-side publish rewriting was removed. Publishes are built in several places (podman_client, package::install, stacks); patching one covered one — the strfry recreate went through another and the pin never fired.
  • Disk manifest edits do not apply to catalog-covered apps. Even bind: 127.0.0.1 written into the node's strfry manifest was overridden by the signed catalog. The catalog re-sign is REQUIRED; there is no shortcut.
  • A loopback-bound host port is unreachable from a pasta container, so loopback-pinning the Wyoming ports would break Home Assistant voice.

Open for 1.7.122

  1. Catalog re-signbind: 127.0.0.1 + auth: session on the ~39 gated UI ports. This is what turns the gate from auditing into enforcing. Nothing in code can substitute for it.
  2. Release-root rotation — branch rotate-release-root, key did:key:z6Mkfu5LT…DLWT / 1578adcc…4418, validated as a real curve point. Sign the rotation release with the OLD key; only the release after it uses the new one. Re-sign the catalog too.
  3. Wyoming voice ports (10200/10300/10400) — unauthenticated, and by the operator's policy they should not be. Correct fix is co-locating Home Assistant with the pine services on one container network so nothing is published; needs a node running both.
  4. Item 2 filebrowser default login. Items 6/6b app updates + multiversion (versions[] already exists, populated for 2 of 66 apps).
  5. cargo-test-weekly times out at its 1500s cap on a loaded box — raise the cap or split the stage; it is not a code failure.

RESUME HERE — next session

Landed this session (both pushed):

  • c0cfc72a federation trust escalation (items 3) — 42/42 federation tests green
  • 5088aef5 lnd-ui OTA pin + host networking (item 4), and this task file

v1.7.120-alpha is SHIPPED — signed, published, assets verified live. Do not re-cut it.

Start with item 3b (password gate) — groundwork already located

Everything needed to implement it, so the next session does not re-search:

  • The helper to use: self.auth_manager.verify_password(password).await? — returns bool. Existing callers to copy the shape from: api/rpc/node.rs:176, api/rpc/totp.rs:18 / :66 / :121.
  • Entry point A — minting a Trusted invite: handle_federation_invite, api/rpc/federation/handlers.rs:58. It reads trust_level from params and defaults to TrustLevel::Trusted at :72. Gate only when the resolved level is Trusted; leave Observer invites unchanged.
  • Entry point B — the UI dropdown: handle_federation_set_trust, api/rpc/federation/handlers.rs:326, dispatched as "federation.set-trust" (api/rpc/dispatcher.rs:353). Its parse is at :342.
  • Rule: gate PROMOTION to Trusted only. Demotion must stay ungated — making something less privileged must never be harder than leaving it.
  • Set TrustSource::Manual on the operator path so the audit trail distinguishes a deliberate grant from the capped automatic ones.
  • Frontend will need the password prompt in both places (invite modal, node dropdown).

Then item 1 (app ports unauthenticated) — the big one

Start with the research the operator explicitly asked for: how umbrelOS (getumbrel/umbrel) and StartOS (Start9Labs/start-os) gate app access. Read their model rather than inventing one. Only then design the gate.

Give this a fresh session with real context — it is the largest item here and is the same bug class as the /lnd-connect-info + /bitcoin-rpc/ leaks fixed in v1.7.120, but across every app port and every transport.

Working notes

  • A separate agent is doing AIUI planning with GSD — do not touch AIUI.
  • The shared tree has concurrent agents: stage by explicit path, never git add -A.
  • Verify security claims on the node, not from source. v1.7.120's headline bug was a fix that shipped in the binary and silently never reached the running container.
  • A piped command's exit code is the pipe's, not the script's — redirect to a log file and read the content.