merge(13): bring main forward — unblocks the crate's test build

The lane merged main at 0c4826f8, one commit before 0de67ca6 added
auth/auth_rationale to PortMapping's test constructors in prod_orchestrator.rs.
That left the lane unable to compile ANY test in the archipelago crate, which
is why 13-05 could not observe its 13 tests pass (window 19). Not a defect in
this phase's work — just staleness.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

# Conflicts:
#	core/archipelago/src/main.rs
This commit is contained in:
archipelago
2026-08-04 01:50:39 -04:00
25 changed files with 2094 additions and 35 deletions
+124 -6
View File
@@ -156,13 +156,86 @@ The gate is mostly assembly, not invention:
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: **NOT YET VERIFIED**
#### Research — StartOS: **DROPPED** (operator, 2026-08-03)
Their public docs cover the *addressing* model (per-service `.onion` and `.local`
addresses, an explicit "make public" opt-in for clearnet) but do not state whether a
universal auth layer sits in front of service interfaces, and the source could not be
read from this box (`gh` is not installed, and raw GitHub paths 404'd). **Do not assume
they delegate auth to each service — read `Start9Labs/start-os` before designing.**
"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
@@ -289,6 +362,51 @@ The whole update *pipeline* is built and is already independent of OTA:
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 apps**`bitcoin-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