Compare commits

...
Author SHA1 Message Date
archipelagoandClaude Opus 5 e20d7a14fb docs(whats-new): add the v1.7.121-alpha block to the in-app modal
Demo images / Build & push demo images (push) Successful in 3m41s
The release gate requires every CHANGELOG version to have a matching
block in Settings > What's New. Generated by scripts/sync-whats-new.py.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-04 01:22:16 -04:00
archipelagoandClaude Opus 5 1929f6a870 style: rustfmt the appgate, federation and manifest changes
The release gate runs cargo fmt --check and these were hand-written.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-04 01:00:29 -04:00
archipelagoandClaude Opus 5 9abf9072a3 docs(changelog): curate v1.7.121-alpha release notes
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-04 00:51:46 -04:00
archipelagoandClaude Opus 5 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>
2026-08-04 00:36:43 -04:00
archipelagoandClaude Opus 5 edc9a172e9 fix(mesh): federated peers are messageable without meeting over LoRa first
Peering a node was not enough to message it — you had to be in radio
range once before chat worked, which defeats the point of federating.

`send_message` chose its transport from the attached radio:

    let use_typed_envelope =
        archy && matches!(device_type, Meshcore | Reticulum);

Only the typed path knows about FIPS/Tor. Everything else fell through to
`peer_dest_prefix`, which resolves an over-the-air ROUTING key — so on a
node running Meshtastic, or with no radio at all, sending to a federated
peer failed. It only worked once a LoRa advert had created a radio twin
for the same archipelago identity, which is precisely the "connect on
LoRa first" the operator hit.

Federation contacts are reachable off-radio by definition — that is what
`upsert_federation_peer` records with `reachable: true` — so the
transport choice must not depend on which radio is plugged in. A
federation-synthetic contact id now always takes the typed path.

This loses no radio-first behaviour: `send_typed_wire` already prefers a
REACHABLE radio twin when the payload fits the frame, and only then falls
back to FIPS and Tor. The fix routes federation contacts INTO that logic
rather than around it.

Test pins the predicate across every device type, including the two that
failed (Meshtastic, Unknown), and asserts ordinary radio contacts and
stock clients still route exactly as before.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-04 00:35:02 -04:00
archipelagoandClaude Opus 5 719446c05f fix(companion): stop the endless rebuild loop on *-ui companions
Observed live on archi-dev-box: archy-fedimint-ui rebuilt 45 times in 10
minutes, every ~35s, indefinitely. bitcoin-ui, lnd-ui and electrs-ui were
all one reconcile away from the same loop.

`context_is_newer_than_image` decides to rebuild when the build context's
newest mtime is later than `podman image inspect .Created`. The rebuild
that follows is a full layer-cache hit, so podman reuses the identical
image and leaves .Created untouched — the condition that triggered the
rebuild is still true afterwards. The check cannot converge: it rebuilds
on every reconcile tick forever, burning CPU and churning the container.

It bites after any deploy that refreshes /opt/archipelago/docker/*, which
makes the contexts newer than the shipped images — so this is fleet-wide
on every OTA, not local to one node.

Fix: stamp the context mtime that was built into an image label and
compare against that instead. A label is part of the image config, so a
cache-hit build with a new value still produces a new image — the thing
being tested does change, and the comparison settles after exactly one
rebuild. Verified against real podman before writing it: two cache-hit
builds with different label values produced distinct image IDs
(6cfdbc9bcd3e vs a9b10eb9a558), each carrying its stamp; the indexed
inspect format was checked against an image with real labels, and a
missing label prints empty (handled, along with "<no value>").

Images built before this carry no label and fall back to .Created, so
behaviour is unchanged for them and each self-heals on its first
reconcile after upgrade — nodes fix themselves rather than needing the
manual `podman build --no-cache` pass this needed by hand.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-03 18:55:28 -04:00
archipelagoandClaude Opus 5 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>
2026-08-03 18:41:29 -04:00
archipelagoandClaude Opus 5 cc9e19589c fix(release): refuse to commit an unsigned OTA manifest
Every cycle has needed a manual check that releases/manifest.json got
signed, because the script would happily commit and tag one that hadn't.

The signing step is conditional: with no TTY and no
RELEASE_MASTER_MNEMONIC it prints a warning and falls through. The commit
at step 7 then ran regardless, so the release commit — and its tag —
carried an unsigned manifest.

publish-release-assets.sh already refuses to ship one, but that backstop
arrives a step too late. Nodes fetch releases/manifest.json straight from
branch `main` (the same URLs this script prints for verification), so the
COMMIT is what exposes it to the fleet, not the publish. By the time
publishing is refused, the unsigned manifest is already on main and nodes
are already declining to auto-apply.

So the same gate now runs before the commit: presence of a signature,
signed_by matching the release root, and `ceremony verify` for the crypto.
A release commit carrying a manifest no node will accept has no valid use,
so this refuses to create one rather than leave a tag that has to be
re-cut. The earlier warning is corrected too — it promised the run would
continue, which is no longer true.

Verified the predicate against three manifests: signed -> allow, signature
stripped -> refuse, signed_by swapped to another DID -> refuse.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-03 17:55:02 -04:00
archipelagoandClaude Opus 5 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>
2026-08-03 16:46:23 -04:00
archipelagoandClaude Opus 5 63d0183dd2 fix(ui): stop the dashboard cards leaving a backdrop-filter seam
A vertical line crossing both dashboard cards, appearing at random on
hover and hard to catch deliberately.

Diagnosed from the screenshot rather than by reproduction. Decoding it
and scanning column by column found a lone brightness step at CSS x=633
that never returns — every legitimate container edge in the page shows
up as a PAIR of steps 2px apart (the card borders at CSS 255, 288, 850,
875, 1437), so an unpaired one is not a border. Sampling by region
placed it inside the cards and nowhere else: 10/13 rows inside My Apps,
11/11 inside Wallet, 2/10 in the gap between them, 2/13 above them. Same
screen x in both cards, which means the boundary lives in screen space
and cuts whatever backdrop-filter surface it crosses.

style.css already neutralises backdrop-filter for the shared glass
classes inside the dashboard's animated perspective/scroll containers,
because Chromium/Brave mis-rasterise it there — that block was written
for the black-rectangle corruption. `.home-card-shell` declares its own
`backdrop-filter: blur(18px)` in Home.vue and was never added to the
list, so it was the only unmitigated blur surface on the dashboard.
That is exactly the set of pixels the seam appears in. A hover repaint
re-rasterises part of the backdrop, and the refreshed half meets the
stale half at the damage boundary.

Adding it to the existing list also makes the shell consistent with the
tiles beside it: its fill is already rgba(0,0,0,0.65), the same as
.glass-card, which renders unblurred here.

The list is hand-maintained, which is how this shipped — a component
declaring backdrop-filter in its own <style> is simply not covered and
nothing fails. So the fix comes with a test that parses Home.vue for
locally-declared backdrop-filter rules and asserts each is in the
mitigation list. Verified it catches the real bug: reverting the
one-line fix makes it fail naming `.home-card-shell`.

Tests: 3/3 new, vue-tsc clean, mitigation confirmed in the built CSS.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-03 16:45:32 -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
+11
View File
@@ -1,5 +1,16 @@
# Changelog
## v1.7.121-alpha (2026-08-04)
- **Making another node "Trusted" now asks for your node password.** Trust was being handed out by machines rather than by you: any node able to reach yours could join and mark itself Trusted, because the check proved only that the caller owned the key it had just presented — never that you had approved it. Trust also spread on its own, since every peer a Trusted node advertised was added as Trusted too, so one grant quietly propagated across the whole federation. Uninvited joins are now capped at Observer, advertised peers arrive as Observers, and raising anyone to Trusted — whether by generating an invite or by changing the dropdown on a node — requires your password. Lowering trust deliberately does not, because the safe action must never be the inconvenient one. Existing peers are left exactly as they are rather than silently demoted, and each one now records how its trust was granted so you can review them.
- **Nodes you have peered with can be messaged straight away.** Peering was not enough: you also had to be within LoRa radio range of the other node once before chat would work. The node picked how to send a message based on which radio was plugged in, and only one of those paths knew how to reach a peer over the mesh's internet transports — so on a node with a different radio, or no radio at all, messaging a peer you had just federated with simply failed until a radio contact happened to appear. Peered nodes are reachable without radio by definition, so that choice no longer depends on the hardware. Radio is still preferred when the other node is actually in range and the message fits.
- The dashboard no longer flickers a vertical line across its cards. A rendering seam appeared at random while moving the mouse, because the two large cards used a background-blur effect that this system already disables everywhere else on the dashboard — that browser mis-draws it inside the dashboard's animated container, and these two cards had been missed when the workaround was written. Diagnosed from a single screenshot rather than by trying to reproduce it.
- The Lightning screen will actually update from now on. Its image was set to "latest", and the container system will not re-fetch a label it already holds, so nodes kept the same Lightning screen forever no matter how many updates shipped. A separate copy of the same setting used only by brand-new installs also described the screen incorrectly, so fresh installs got a screen that never answered.
- Apps that provide their own screens stop rebuilding themselves in a loop. On this system's own node one of them rebuilt every thirty-five seconds indefinitely, burning processor time and restarting the app each round. The node decided a rebuild was needed by comparing file dates against the image's creation date, but a rebuild that changes nothing reuses the existing image and leaves that date untouched — so the condition that triggered the rebuild was still true afterwards, forever. Nodes taking this update repair themselves the first time they check.
- Groundwork you can see but that does not change access yet: the node can now tell you which of its app ports answer without a login, and every port that is deliberately open — Bitcoin's peer connections for syncing the chain, Lightning's wallet connections, the Electrum wallet protocol — now has to state in writing why it is safe, so the list of exceptions is something you can read rather than something you have to discover. The login gate that will sit in front of the rest is built and proven working end to end on a real node, but it is not yet closing any ports; that arrives with the signed app catalog that tells each app to hand its address over.
- Releases can no longer ship an unsigned update file. Signing was skippable, and when it was skipped the release was still committed and tagged — producing an update that every node correctly refuses to install. It had been caught by hand every cycle; now the release simply stops.
- Known gaps, disclosed rather than buried: the 5x real-node lifecycle gate was not run for this release. App ports other than the deliberate exceptions above are still reachable without a login — the gate reports them, and closing them needs the next signed catalog. Three voice-assistant ports are open without authentication and should not be; the correct fix puts them on a private network with the assistant instead, which needs testing on a node that runs both. Two nodes on the fleet still share SSH host keys (detection shipped, rotation remains a deliberate operator decision).
## v1.7.120-alpha (2026-08-02)
- **Security, and the reason to take this update: two ports on your node handed anyone who could reach them complete control of your money, with no password.** The Lightning app's port answered a plain web request with the LND admin macaroon, the TLS certificate and the node's onion address — everything needed to drain the wallet remotely, and the onion meant an attacker kept that ability even after losing access to your network. The Bitcoin app's port reached Bitcoin Core's control interface using credentials the node itself supplied on the caller's behalf, with a wallet loaded. Anything on your home network, your Tailscale network or the mesh could use either one. Both now require you to be logged in. If your node has been reachable by anyone you do not fully trust, treat the Lightning macaroon and the Bitcoin RPC password as known to them.
+1
View File
@@ -28,6 +28,7 @@ app:
container: 80
protocol: tcp
bind: 127.0.0.1 # Only accessible via nginx proxy, not externally
auth: local
health_check:
type: http
+1
View File
@@ -85,6 +85,7 @@ app:
container: 8332
protocol: tcp
bind: 127.0.0.1
auth: local
- host: 8333
container: 8333
protocol: tcp
+1
View File
@@ -85,6 +85,7 @@ app:
container: 8332
protocol: tcp
bind: 127.0.0.1
auth: local
- host: 8333
container: 8333
protocol: tcp
+59
View File
@@ -0,0 +1,59 @@
//! `security.app-gate-status` — what the app gate is actually enforcing.
//!
//! The gate rolls out per app (an app must be pinned to loopback before the
//! gate can claim its port — see `appgate::listener`), so for a while every
//! node is partially protected. "Partially" is only safe if it is *visible*:
//! this is the RPC that lets the UI say which app ports are still reachable
//! without a credential, instead of the operator having to port-scan their
//! own node to find out.
use anyhow::Result;
use super::RpcHandler;
impl RpcHandler {
pub(in crate::api::rpc) async fn handle_app_gate_status(&self) -> Result<serde_json::Value> {
let status = crate::appgate::listener::shared_status();
let status = status.read().await.clone();
let port_map = self.app_gate.port_map().await;
// Exemptions are reported alongside, and with their manifest
// rationale, because "which ports are open and why" is the actual
// question — a list of unprotected ports without the deliberate ones
// next to it invites someone to "fix" LND's gRPC port and break every
// remote wallet.
let exempt: Vec<serde_json::Value> = port_map
.exempt_ports()
.iter()
.map(|e| {
serde_json::json!({
"port": e.port,
"app_id": e.app_id,
"protocol": e.protocol,
"rationale": e.rationale,
})
})
.collect();
let gated: Vec<serde_json::Value> = port_map
.gated_ports()
.map(|g| {
serde_json::json!({
"port": g.port,
"app_id": g.app_id,
"app_name": g.app_name,
})
})
.collect();
Ok(serde_json::json!({
// The headline. False means this node still has app ports that
// answer without authentication.
"fully_enforced": status.is_fully_enforced(),
"claimed": status.claimed,
"unprotected": status.unprotected,
"gated": gated,
"exempt": exempt,
}))
}
}
@@ -462,6 +462,7 @@ impl RpcHandler {
"server.set-location" => self.handle_server_set_location(params).await,
// System monitoring
"security.app-gate-status" => self.handle_app_gate_status().await,
"system.get-hostname" => self.handle_system_get_hostname().await,
"system.stats" => self.handle_system_stats().await,
"system.processes" => self.handle_system_processes().await,
+14
View File
@@ -1,4 +1,5 @@
mod analytics;
mod appgate;
mod ark;
mod auth;
mod backup_rpc;
@@ -87,6 +88,11 @@ pub struct RpcHandler {
port_allocator: Arc<tokio::sync::Mutex<PortAllocator>>,
pub session_store: SessionStore,
login_rate_limiter: LoginRateLimiter,
/// Authentication in front of every app port. Built here rather than in
/// `server.rs` so it shares this handler's session store and login rate
/// limiter — an attacker must not get a fresh budget of password guesses
/// by moving from the dashboard to an app port.
pub(crate) app_gate: Arc<crate::appgate::AppGate>,
endpoint_rate_limiter: EndpointRateLimiter,
response_cache: ResponseCache,
mesh_service: Arc<tokio::sync::RwLock<Option<crate::mesh::MeshService>>>,
@@ -151,6 +157,13 @@ impl RpcHandler {
});
}
let app_gate = Arc::new(crate::appgate::AppGate::new(
session_store.clone(),
auth_manager.clone(),
login_rate_limiter.clone(),
config.data_dir.clone(),
));
Ok(Self {
config,
auth_manager,
@@ -161,6 +174,7 @@ impl RpcHandler {
port_allocator,
session_store,
login_rate_limiter,
app_gate,
endpoint_rate_limiter,
response_cache: ResponseCache::new(5),
mesh_service: Arc::new(tokio::sync::RwLock::new(None)),
+330
View File
@@ -0,0 +1,330 @@
//! Which app is behind a given host port, and may it be reached without
//! authenticating?
//!
//! The gate has to answer both questions for every inbound connection: the
//! first to decide whether to challenge at all, the second so the login page
//! can name and picture what the visitor is trying to open ("you are logging
//! in to reach Immich"), which is what makes the challenge legible instead of
//! alarming.
//!
//! Both answers come from the installed manifests rather than a generated
//! table, so a catalog refresh that adds or repoints an app is reflected
//! without a daemon restart — the same reason `app_port_v6_relay_loop`
//! rescans instead of snapshotting once.
use archipelago_container::manifest::{AppManifest, PortAuth};
use std::collections::HashMap;
use std::path::PathBuf;
/// An app port the gate is responsible for.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct GatedPort {
pub port: u16,
pub app_id: String,
/// Display name for the login page. Falls back to the id when a manifest
/// omits `name`.
pub app_name: String,
/// Manifest-declared icon path (`metadata.icon`), when present.
pub icon: Option<String>,
}
/// A port deliberately left unauthenticated, and the manifest's stated reason.
///
/// Carried around rather than discarded because "which ports are open and
/// why" is the question an operator actually asks, and the answer should be
/// one RPC call rather than an audit of 56 YAML files.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ExemptPort {
pub port: u16,
pub app_id: String,
pub rationale: String,
/// UDP ports are listed for completeness. The gate is TCP-only, so it
/// could not touch them even if they were marked `session`.
pub protocol: String,
}
/// Everything the gate knows about the node's published surface.
#[derive(Debug, Clone, Default)]
pub struct PortMap {
gated: HashMap<u16, GatedPort>,
exempt: Vec<ExemptPort>,
}
impl PortMap {
/// The app behind `port`, if the gate is responsible for it.
pub fn gated(&self, port: u16) -> Option<&GatedPort> {
self.gated.get(&port)
}
pub fn gated_ports(&self) -> impl Iterator<Item = &GatedPort> {
self.gated.values()
}
pub fn exempt_ports(&self) -> &[ExemptPort] {
&self.exempt
}
pub fn is_empty(&self) -> bool {
self.gated.is_empty() && self.exempt.is_empty()
}
}
/// Directories searched for installed manifests, most specific first.
///
/// Mirrors `api::rpc::package::runtime::manifest_apps_dirs` deliberately: the
/// gate must classify exactly the manifests the orchestrator installs from,
/// or a port could be gated here and published from a different declaration
/// there.
fn apps_dirs() -> Vec<PathBuf> {
let mut dirs = Vec::new();
if let Ok(manifest_dir) = std::env::var("CARGO_MANIFEST_DIR") {
dirs.push(PathBuf::from(manifest_dir).join("../../apps"));
}
dirs.extend([
PathBuf::from("apps"),
PathBuf::from("/opt/archipelago/apps"),
PathBuf::from("/opt/archipelago/web-ui/archipelago-runtime/apps"),
]);
dirs
}
/// Read `metadata.icon` out of the manifest's untyped extension bag.
fn manifest_icon(manifest: &AppManifest) -> Option<String> {
manifest
.app
.extensions
.get("metadata")?
.get("icon")?
.as_str()
.map(str::to_string)
}
/// Classify every published port across all installed manifests.
///
/// The first directory that yields a manifest for an app id wins, so a node's
/// `/opt/archipelago/apps` copy shadows a repo checkout rather than merging
/// with it — otherwise a stale checked-out manifest could re-open a port the
/// installed one gates.
pub fn build_port_map() -> PortMap {
let mut map = PortMap::default();
let mut seen_apps: HashMap<String, PathBuf> = HashMap::new();
for dir in apps_dirs() {
let Ok(entries) = std::fs::read_dir(&dir) else {
continue;
};
for entry in entries.flatten() {
let path = entry.path().join("manifest.yml");
let Ok(contents) = std::fs::read_to_string(&path) else {
continue;
};
let Ok(manifest) = AppManifest::parse(&contents) else {
// A manifest that does not parse is not installable either,
// so skipping it cannot open a port that the orchestrator
// would have published.
continue;
};
let app_id = manifest.app.id.clone();
if seen_apps.contains_key(&app_id) {
continue;
}
seen_apps.insert(app_id.clone(), path);
let icon = manifest_icon(&manifest);
let app_name = if manifest.app.name.trim().is_empty() {
app_id.clone()
} else {
manifest.app.name.clone()
};
for port in &manifest.app.ports {
let protocol = if port.protocol.is_empty() {
"tcp"
} else {
port.protocol.as_str()
};
match port.auth_policy() {
PortAuth::None => map.exempt.push(ExemptPort {
port: port.host,
app_id: app_id.clone(),
rationale: port
.auth_rationale
.clone()
.unwrap_or_else(|| "(no rationale recorded)".to_string()),
protocol: protocol.to_string(),
}),
// Declared host-local. Not gated and not reported as
// exposed, because it is neither — see PortAuth::Local
// for why this cannot be inferred from `bind`.
PortAuth::Local => {}
// Explicit opt-in: the app is on loopback and the daemon
// owns the external addresses. This is the ONLY way a
// port gets bound by the gate, regardless of `bind`.
PortAuth::Gated => {
map.gated.insert(
port.host,
GatedPort {
port: port.host,
app_id: app_id.clone(),
app_name: app_name.clone(),
icon: icon.clone(),
},
);
}
PortAuth::Session => {
// UDP cannot carry an HTTP challenge. Such a port has
// no business defaulting into the gated set where it
// would look protected without being protectable —
// surface it as an unrationalised exemption instead,
// which is honest and shows up in the audit list.
if protocol != "tcp" {
map.exempt.push(ExemptPort {
port: port.host,
app_id: app_id.clone(),
rationale: format!(
"{protocol} cannot carry an HTTP challenge; declare auth: none \
with a rationale to record why this is safe"
),
protocol: protocol.to_string(),
});
continue;
}
// A loopback publish is skipped, and this is the
// safety property of the whole module: the gate must
// never be the reason a port becomes reachable
// somewhere it was not. `session` is the DEFAULT, so
// it is what every un-migrated manifest carries —
// and a node's installed manifests always lag the
// repo. Binding those externally published Bitcoin
// RPC across the LAN within seconds of deploy
// (archi-dev-box 2026-08-03). Taking over a port is
// opt-in only: `auth: gated`, shipped in the same
// manifest edit as the loopback pin.
if port
.bind
.parse::<std::net::IpAddr>()
.is_ok_and(|ip| ip.is_loopback())
{
continue;
}
map.gated.insert(
port.host,
GatedPort {
port: port.host,
app_id: app_id.clone(),
app_name: app_name.clone(),
icon: icon.clone(),
},
);
}
}
}
}
}
map.exempt.sort_by_key(|e| e.port);
map
}
#[cfg(test)]
mod tests {
use super::*;
/// The corpus this runs against is the real `apps/` tree, so these assert
/// on properties rather than exact contents — the set of apps changes,
/// the invariants must not.
#[test]
fn real_manifests_classify_into_both_sets() {
let map = build_port_map();
assert!(!map.is_empty(), "no manifests found — apps dir missing?");
assert!(
map.gated_ports().count() > 20,
"expected most published ports to be gated, got {}",
map.gated_ports().count()
);
assert!(!map.exempt_ports().is_empty());
}
#[test]
fn every_exemption_carries_a_reason() {
for exempt in build_port_map().exempt_ports() {
assert!(
!exempt.rationale.trim().is_empty(),
"port {} ({}) is exempt with no rationale",
exempt.port,
exempt.app_id
);
}
}
/// Protocol ports that wallets dial directly must never end up gated —
/// this is the constraint that decided the design (Zeus and electrum
/// clients keep working untouched).
#[test]
fn wallet_protocol_ports_are_not_gated() {
let map = build_port_map();
for port in [10009, 18080, 9735, 50001] {
assert!(
map.gated(port).is_none(),
"port {port} must stay ungated — remote wallets cannot hold a session"
);
}
}
/// Bitcoin's RPC is host-local by intent (`auth: local`), so the gate
/// must neither gate it nor report it as exposed — fronting it would
/// newly publish it on every host address, behind a login but reachable
/// where it deliberately was not.
#[test]
fn host_local_ports_are_neither_gated_nor_reported() {
let map = build_port_map();
assert!(map.gated(8332).is_none(), "bitcoin RPC must not be gated");
assert!(
!map.exempt_ports().iter().any(|e| e.port == 8332),
"a host-local port is not an unauthenticated exposure"
);
}
/// THE safety property. A `session` port pinned to loopback must NOT be
/// gated, because gating means binding external addresses — the one
/// action that can make a port reachable where it was not.
///
/// This is not hypothetical. `session` is the default, so it is what
/// every un-migrated manifest carries, and a node's installed manifests
/// always lag the repo. An earlier revision gated these regardless of
/// `bind`, and within seconds of deploying to archi-dev-box the daemon
/// had published Bitcoin's loopback-only RPC 8332 on the LAN, Tailscale
/// and IPv6 addresses. Taking over a port must be opt-in.
#[test]
fn a_loopback_pinned_session_port_is_never_gated() {
let map = build_port_map();
// aiui and bitcoin RPC are both loopback-pinned in the shipped tree.
for port in [5180, 8332] {
assert!(
map.gated(port).is_none(),
"port {port} is loopback-pinned; gating it would newly expose it"
);
}
}
/// The migration end state: `auth: gated` opts a loopback-pinned port
/// into daemon ownership. Without this the rollout could never complete.
#[test]
fn an_explicitly_gated_loopback_port_is_gated() {
use archipelago_container::manifest::{AppManifest, PortAuth as PA};
let yaml = "app:\n id: pinned\n name: Pinned\n version: 1.0.0\n container:\n image: x:y\n ports:\n - host: 9911\n container: 80\n bind: 127.0.0.1\n auth: gated\n";
let m = AppManifest::parse(yaml).expect("parses");
assert_eq!(m.app.ports[0].auth, Some(PA::Gated));
assert_eq!(m.app.ports[0].bind, "127.0.0.1");
}
/// An app UI that was reachable with no credential in the 2026-08-03
/// reproduction must now resolve to a gated port with a display name.
#[test]
fn reproduced_open_ports_are_now_gated() {
let map = build_port_map();
let strfry = map.gated(8090).expect("strfry :8090 must be gated");
assert_eq!(strfry.app_id, "strfry");
assert!(!strfry.app_name.is_empty());
}
}
+333
View File
@@ -0,0 +1,333 @@
//! Binding the gate in front of apps, and telling the truth when it cannot.
//!
//! # The ordering problem
//!
//! 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 — the kernel refuses the overlap. So the gate can
//! only stand in front of an app whose own publish has been pinned to
//! loopback (`bind: 127.0.0.1` in its manifest, which
//! `PortMapping::bind` has supported all along).
//!
//! That makes the rollout necessarily two-step, per app: pin the publish,
//! recreate the container, and the gate claims the external addresses. Doing
//! it the other way round — gate first — is not possible, and doing it in one
//! step for every app at once would recreate every container on the node
//! simultaneously.
//!
//! # Why the failure has to be loud
//!
//! The dangerous version of this module is the one that tries to bind, fails
//! because the app still holds the port, logs at debug, and moves on. The
//! node would then be running "the app gate" while every app remained exactly
//! as open as before — a security control that reports success and does
//! nothing, which is worse than no control at all because it stops anyone
//! looking.
//!
//! So an unclaimable port is recorded in [`GateStatus::unprotected`] and
//! logged at warn on every sweep. The same reasoning killed the nft-drop-in
//! design: `/etc/fips/fips.nft` is provisioned out-of-band and its absence is
//! a silent no-op, so a gate shipped that way would be absent on every node
//! without the hardening baseline and nobody would know.
use super::identity::GatedPort;
use super::AppGate;
use std::collections::HashMap;
use std::net::{IpAddr, SocketAddr};
use std::sync::Arc;
use tokio::net::TcpListener;
use tokio::sync::RwLock;
use tracing::{debug, info, warn};
/// How often the sweep re-runs. Matches `app_port_v6_relay_loop`: addresses
/// come and go (DHCP, Tailscale up/down, the fips0 ULA appearing late) and
/// apps are installed while the daemon runs.
const SWEEP_INTERVAL: std::time::Duration = std::time::Duration::from_secs(60);
/// A port the gate should own but could not claim, and why.
#[derive(Debug, Clone, serde::Serialize)]
pub struct UnprotectedPort {
pub port: u16,
pub app_id: String,
pub app_name: String,
/// Human-readable cause, e.g. that the app still publishes on all
/// interfaces.
pub reason: String,
}
/// What the gate is actually enforcing right now.
#[derive(Debug, Clone, Default, serde::Serialize)]
pub struct GateStatus {
/// (port, address) pairs the gate holds.
pub claimed: Vec<(u16, String)>,
/// Ports that should be gated but are not. **Non-empty means the node
/// has unauthenticated app surface.**
pub unprotected: Vec<UnprotectedPort>,
}
impl GateStatus {
pub fn is_fully_enforced(&self) -> bool {
self.unprotected.is_empty()
}
}
/// Every non-loopback address currently on this host.
///
/// Shells out to `ip` rather than pulling in a `getifaddrs` binding: the
/// codebase already resolves addresses this way (`host_ip`), the result is
/// re-derived every sweep so a stale parse self-corrects, and a failure here
/// degrades to "claim nothing this round" rather than to a wrong claim.
async fn host_addresses() -> Vec<IpAddr> {
let Ok(out) = tokio::process::Command::new("ip")
.args(["-o", "addr", "show"])
.output()
.await
else {
return Vec::new();
};
let text = String::from_utf8_lossy(&out.stdout);
let mut addrs = Vec::new();
for line in text.lines() {
let mut fields = line.split_whitespace();
// `1: lo inet 127.0.0.1/8 scope host lo`
let Some(family) = fields.clone().nth(2) else {
continue;
};
if family != "inet" && family != "inet6" {
continue;
}
let Some(cidr) = fields.nth(3) else { continue };
let Some(addr) = cidr.split('/').next() else {
continue;
};
// Strip a zone index (`fe80::1%eth0`) — link-local addresses need a
// scope to bind and are not how anyone reaches an app anyway.
let addr = addr.split('%').next().unwrap_or(addr);
let Ok(ip) = addr.parse::<IpAddr>() else {
continue;
};
if ip.is_loopback() || ip.is_unspecified() {
continue;
}
if let IpAddr::V6(v6) = ip {
// Link-local v6 requires a scope id we do not carry.
if (v6.segments()[0] & 0xffc0) == 0xfe80 {
continue;
}
}
addrs.push(ip);
}
addrs.sort();
addrs.dedup();
addrs
}
/// Process-wide gate status, so any RPC handler can report what the gate is
/// actually enforcing without threading a handle through every caller.
///
/// A single shared cell rather than a value returned from `run`: "is my node
/// actually protected?" has to be answerable from the RPC layer, and the
/// listener that knows the answer runs in a detached task.
pub fn shared_status() -> Arc<RwLock<GateStatus>> {
static STATUS: std::sync::OnceLock<Arc<RwLock<GateStatus>>> = std::sync::OnceLock::new();
STATUS
.get_or_init(|| Arc::new(RwLock::new(GateStatus::default())))
.clone()
}
/// Run the gate. Returns only on shutdown.
pub async fn run(
gate: Arc<AppGate>,
status: Arc<RwLock<GateStatus>>,
mut shutdown_rx: tokio::sync::watch::Receiver<bool>,
) {
// (port, addr) pairs already served, so a sweep does not rebind what it
// already holds.
let mut held: HashMap<(u16, IpAddr), ()> = HashMap::new();
let mut interval = tokio::time::interval(SWEEP_INTERVAL);
interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
loop {
tokio::select! {
_ = interval.tick() => {
sweep(&gate, &status, &mut held, &shutdown_rx).await;
}
_ = shutdown_rx.changed() => return,
}
}
}
async fn sweep(
gate: &Arc<AppGate>,
status: &Arc<RwLock<GateStatus>>,
held: &mut HashMap<(u16, IpAddr), ()>,
shutdown_rx: &tokio::sync::watch::Receiver<bool>,
) {
// Re-read the manifests every sweep rather than trusting the map built
// at construction. An app installed while the daemon is running would
// otherwise never be gated until the next restart — and it would not
// appear in `unprotected` either, so the node would report itself fully
// enforced while serving a brand-new app to anyone who asked.
gate.refresh().await;
let port_map = gate.port_map().await;
let addresses = host_addresses().await;
if addresses.is_empty() {
debug!("app gate: no external addresses yet");
return;
}
let mut claimed = Vec::new();
let mut unprotected = Vec::new();
for app in port_map.gated_ports() {
// Nothing is listening on this port, so there is no app to protect
// and binding would steal the port from an install that has not
// happened yet. The relay loop learned this the hard way: binding a
// port for an app that is not installed makes its later install hit
// "address already in use", and the install's port-free step then
// kills the daemon holding it.
if !app_is_listening(app.port).await {
continue;
}
let mut claimed_any = false;
let mut blocked = false;
for &addr in &addresses {
let key = (app.port, addr);
if held.contains_key(&key) {
claimed.push((app.port, addr.to_string()));
claimed_any = true;
continue;
}
match TcpListener::bind(SocketAddr::new(addr, app.port)).await {
Ok(listener) => {
held.insert(key, ());
claimed.push((app.port, addr.to_string()));
claimed_any = true;
info!(
port = app.port, %addr, app = %app.app_id,
"app gate claimed an app port"
);
spawn_accept_loop(listener, gate.clone(), app.clone(), shutdown_rx.clone());
}
// Almost always the app itself holding 0.0.0.0:<port>.
Err(_) => blocked = true,
}
}
if blocked && !claimed_any {
warn!(
port = app.port, app = %app.app_id,
"APP GATE CANNOT PROTECT THIS PORT — the app still publishes on all \
interfaces. Pin its manifest port to bind: 127.0.0.1 and recreate the \
container, or it stays reachable without authentication."
);
unprotected.push(UnprotectedPort {
port: app.port,
app_id: app.app_id.clone(),
app_name: app.app_name.clone(),
reason: "app publishes on all interfaces; manifest port needs bind: 127.0.0.1"
.to_string(),
});
}
}
claimed.sort();
unprotected.sort_by_key(|u| u.port);
let mut guard = status.write().await;
guard.claimed = claimed;
guard.unprotected = unprotected;
}
/// Is anything answering on loopback for this port?
async fn app_is_listening(port: u16) -> bool {
tokio::time::timeout(
std::time::Duration::from_millis(300),
tokio::net::TcpStream::connect(("127.0.0.1", port)),
)
.await
.ok()
.and_then(|r| r.ok())
.is_some()
}
fn spawn_accept_loop(
listener: TcpListener,
gate: Arc<AppGate>,
app: GatedPort,
mut shutdown_rx: tokio::sync::watch::Receiver<bool>,
) {
tokio::spawn(async move {
loop {
tokio::select! {
accepted = listener.accept() => {
let Ok((stream, peer)) = accepted else { break };
let gate = gate.clone();
let app = app.clone();
tokio::spawn(async move {
let service = hyper::service::service_fn(move |req| {
let gate = gate.clone();
let app = app.clone();
async move {
Ok::<_, std::convert::Infallible>(
gate.handle(req, &app, peer.ip()).await,
)
}
});
let _ = hyper::server::conn::Http::new()
// Same slowloris guard as the main listener: an
// unauthenticated caller must not be able to hold
// a connection open by never sending headers.
.http1_header_read_timeout(std::time::Duration::from_secs(30))
.serve_connection(stream, service)
.with_upgrades()
.await;
});
}
_ = shutdown_rx.changed() => break,
}
}
});
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn host_addresses_excludes_loopback() {
for addr in host_addresses().await {
assert!(!addr.is_loopback(), "{addr} is loopback");
assert!(!addr.is_unspecified());
}
}
#[tokio::test]
async fn app_is_listening_is_false_for_a_dead_port() {
// Bind and immediately drop, so the port is known-free.
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let port = listener.local_addr().unwrap().port();
drop(listener);
assert!(!app_is_listening(port).await);
}
#[tokio::test]
async fn app_is_listening_is_true_for_a_live_port() {
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let port = listener.local_addr().unwrap().port();
assert!(app_is_listening(port).await);
}
#[test]
fn a_status_with_unprotected_ports_is_not_fully_enforced() {
let mut status = GateStatus::default();
assert!(status.is_fully_enforced());
status.unprotected.push(UnprotectedPort {
port: 8090,
app_id: "strfry".into(),
app_name: "Strfry".into(),
reason: "test".into(),
});
assert!(!status.is_fully_enforced());
}
}
+723
View File
@@ -0,0 +1,723 @@
//! The app gate — authentication in front of every app port.
//!
//! # Why this exists
//!
//! Reproduced on a live node 2026-08-03: with no session cookie at all, over
//! the Tailscale address, six app ports answered `HTTP 200` with their real
//! UIs. `ss -tlnp` showed them bound `0.0.0.0`, so the same pages were served
//! on the LAN address, the FIPS mesh address, and through each app's onion.
//! This is the same bug class as the `/lnd-connect-info` and `/bitcoin-rpc/`
//! leaks closed in v1.7.120, but across every app rather than two endpoints.
//!
//! # Why one gate covers four transports
//!
//! LAN, Tailscale, Tor and the FIPS mesh all converge on
//! `127.0.0.1:<app_port>` — the container publishes there, the mesh relay
//! forwards there, and `HiddenServicePort` points there. Authorising at that
//! convergence point is one gate rather than four, which is the only reason
//! this is tractable at all.
//!
//! # Why not umbrel's sidecar proxy
//!
//! umbrelOS gives every app an `app_proxy` container that owns the published
//! port. That works, but it costs a container per app and a second service to
//! hold the shared secret. Here the daemon already terminates HTTP, already
//! owns the session store, and already runs a relay loop for the mesh, so the
//! gate is assembly rather than new infrastructure.
//!
//! # What it does NOT do
//!
//! It does not invent authentication policy. Password verification, TOTP
//! decryption and step replay protection, session lifetime, and rate limiting
//! are the same primitives the JSON-RPC login path uses. Only the transport
//! differs — an HTML form instead of JSON-RPC — because a browser being
//! redirected to an app cannot speak JSON-RPC.
pub mod identity;
pub mod listener;
use crate::auth::AuthManager;
use crate::rate_limit::LoginRateLimiter;
use crate::session::SessionStore;
use hyper::{header, Body, HeaderMap, Method, Request, Response, StatusCode};
use identity::{GatedPort, PortMap};
use std::net::IpAddr;
use std::path::PathBuf;
use std::sync::Arc;
use tokio::sync::RwLock;
/// Paths the gate serves itself rather than proxying. Namespaced so an app
/// that happens to have its own `/login` is unaffected.
const GATE_PREFIX: &str = "/__archipelago-gate/";
/// Result of examining a request's credentials.
#[derive(Debug, PartialEq, Eq)]
pub enum Authorization {
/// Proxy it through.
Allow,
/// Serve the login page.
Challenge,
}
pub struct AppGate {
sessions: SessionStore,
auth: AuthManager,
limiter: LoginRateLimiter,
data_dir: PathBuf,
port_map: Arc<RwLock<PortMap>>,
}
impl AppGate {
pub fn new(
sessions: SessionStore,
auth: AuthManager,
limiter: LoginRateLimiter,
data_dir: PathBuf,
) -> Self {
Self {
sessions,
auth,
limiter,
data_dir,
port_map: Arc::new(RwLock::new(identity::build_port_map())),
}
}
/// Re-read the manifests. Called on catalog refresh so a newly installed
/// app is gated without a daemon restart.
pub async fn refresh(&self) {
*self.port_map.write().await = identity::build_port_map();
}
pub async fn port_map(&self) -> PortMap {
self.port_map.read().await.clone()
}
/// Does this request carry a credential good for `app_id`?
///
/// Two accepted forms, deliberately no others:
///
/// * the node session cookie — and because a session still pending its
/// TOTP step fails `validate()`, **2FA is honoured here for free**. The
/// gate never sees a TOTP code on a proxied request and never needs to.
/// * an app-scoped bearer token, for machine clients that speak HTTP but
/// cannot hold a cookie or complete an interactive login (Home
/// Assistant reaching an app's API is the motivating case).
pub async fn authorize(&self, headers: &HeaderMap, app_id: &str) -> Authorization {
if let Some(token) = crate::session::extract_session_cookie(headers) {
if self.sessions.validate(&token).await {
return Authorization::Allow;
}
}
if let Some(token) = bearer_token(headers) {
if crate::device_tokens::verify_for_app(&self.data_dir, &token, app_id)
.await
.is_some()
{
return Authorization::Allow;
}
}
Authorization::Challenge
}
/// Handle one inbound request on a gated port.
pub async fn handle(
&self,
req: Request<Body>,
app: &GatedPort,
client_ip: IpAddr,
) -> Response<Body> {
let path = req.uri().path().to_string();
if let Some(action) = path.strip_prefix(GATE_PREFIX) {
return self.handle_gate_action(req, app, action, client_ip).await;
}
match self.authorize(req.headers(), &app.app_id).await {
Authorization::Allow => proxy_to_app(req, app.port).await,
// 401 rather than 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 if it were their API
// response. The status says "you are not authenticated" in a way
// every client understands, and browsers still render the body.
Authorization::Challenge => login_page(app, None, StatusCode::UNAUTHORIZED),
}
}
/// The gate's own endpoints: the login form target and the TOTP step.
async fn handle_gate_action(
&self,
req: Request<Body>,
app: &GatedPort,
action: &str,
client_ip: IpAddr,
) -> Response<Body> {
if req.method() != Method::POST {
return login_page(app, None, StatusCode::OK);
}
// Captured before the body is consumed. The pending-2FA session
// rides the cookie rather than a hidden form field so the token
// never appears in the HTML, in a `view-source`, or in a screenshot
// of the second-factor page.
let pending = crate::session::extract_session_cookie(req.headers());
// Same limiter instance as the JSON-RPC login path, so an attacker
// cannot get a fresh budget of guesses simply by moving to an app
// port.
if !self.limiter.check(client_ip).await {
return login_page(
app,
Some("Too many attempts. Wait a minute and try again."),
StatusCode::TOO_MANY_REQUESTS,
);
}
let form = match read_form(req).await {
Some(form) => form,
None => return login_page(app, Some("Malformed request."), StatusCode::BAD_REQUEST),
};
match action {
"login" => self.do_login(app, &form, client_ip).await,
"totp" => self.do_totp(app, &form, pending, client_ip).await,
_ => not_found(),
}
}
async fn do_login(&self, app: &GatedPort, form: &Form, client_ip: IpAddr) -> Response<Body> {
let password = field(form, "password").unwrap_or_default();
match self.auth.verify_password(&password).await {
Ok(true) => {}
_ => {
self.limiter.record_failure(client_ip).await;
return login_page(app, Some("Incorrect password."), StatusCode::UNAUTHORIZED);
}
}
// 2FA, if configured. The secret is encrypted with the password, so
// this is the only moment it can be decrypted — exactly as in the
// JSON-RPC path. A pending session cannot pass `authorize`, so a
// half-finished login grants nothing.
if self.auth.is_totp_enabled().await.unwrap_or(false) {
if let Ok(Some(totp_data)) = self.auth.get_totp_data().await {
if let Ok(secret) = crate::totp::decrypt_secret(&totp_data, &password) {
let pending = self.sessions.create_pending(secret).await;
let mut resp = totp_page(app, None, StatusCode::OK);
set_session_cookie(&mut resp, &pending);
return resp;
}
}
// TOTP is on but its data is unreadable. Refuse: falling through
// to a full session would silently downgrade the node's second
// factor to nothing.
return login_page(
app,
Some("Two-factor data could not be read. Sign in from the dashboard."),
StatusCode::INTERNAL_SERVER_ERROR,
);
}
let token = self.sessions.create().await;
let mut resp = redirect_to_app();
set_session_cookie(&mut resp, &token);
resp
}
async fn do_totp(
&self,
app: &GatedPort,
form: &Form,
pending: Option<String>,
client_ip: IpAddr,
) -> Response<Body> {
let code = field(form, "code").unwrap_or_default();
let Some(pending) = pending.filter(|s| !s.is_empty()) else {
return login_page(app, Some("Session expired."), StatusCode::UNAUTHORIZED);
};
let Some(secret) = self.sessions.get_pending_secret(&pending).await else {
return login_page(
app,
Some("Session expired. Start again."),
StatusCode::UNAUTHORIZED,
);
};
let totp_data = self.auth.get_totp_data().await.ok().flatten();
let used_steps = totp_data
.as_ref()
.map(|d| d.used_steps.clone())
.unwrap_or_default();
match crate::totp::verify_code(&secret, &code, &used_steps) {
Ok(Some(step)) => {
// Record the step so the same code cannot be replayed — the
// JSON-RPC path does this and skipping it here would make the
// gate the weaker of the two doors.
if let Some(mut data) = totp_data {
data.used_steps.push(step);
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs() as i64)
.unwrap_or(0);
let cutoff = (now / 30) - 10;
data.used_steps.retain(|s| *s > cutoff);
let _ = self.auth.update_totp(data).await;
}
match self.sessions.upgrade_to_full(&pending).await {
Some(full) => {
let mut resp = redirect_to_app();
set_session_cookie(&mut resp, &full);
resp
}
None => login_page(app, Some("Session expired."), StatusCode::UNAUTHORIZED),
}
}
_ => {
self.limiter.record_failure(client_ip).await;
let mut resp = totp_page(app, Some("Incorrect code."), StatusCode::UNAUTHORIZED);
set_session_cookie(&mut resp, &pending);
resp
}
}
}
}
// ---------------------------------------------------------------------------
// Request helpers
// ---------------------------------------------------------------------------
fn bearer_token(headers: &HeaderMap) -> Option<String> {
let value = headers.get(header::AUTHORIZATION)?.to_str().ok()?;
let token = value
.strip_prefix("Bearer ")
.or_else(|| value.strip_prefix("bearer "))?;
let token = token.trim();
(!token.is_empty()).then(|| token.to_string())
}
type Form = std::collections::HashMap<String, String>;
/// Free function rather than a trait method: `HashMap` has an inherent `get`
/// that would win method resolution and silently return `Option<&String>`.
fn field(form: &Form, key: &str) -> Option<String> {
form.get(key).cloned()
}
/// Read an `application/x-www-form-urlencoded` body.
///
/// Capped: an unauthenticated caller must not be able to make the daemon
/// buffer arbitrary bytes, and no legitimate login form approaches this.
const MAX_FORM_BYTES: usize = 8 * 1024;
async fn read_form(req: Request<Body>) -> Option<Form> {
let bytes = hyper::body::to_bytes(req.into_body()).await.ok()?;
if bytes.len() > MAX_FORM_BYTES {
return None;
}
let text = std::str::from_utf8(&bytes).ok()?;
let mut form = Form::new();
for pair in text.split('&') {
let Some((k, v)) = pair.split_once('=') else {
continue;
};
form.insert(percent_decode(k), percent_decode(v));
}
Some(form)
}
fn percent_decode(input: &str) -> String {
let bytes = input.replace('+', " ").into_bytes();
let mut out = Vec::with_capacity(bytes.len());
let mut i = 0;
while i < bytes.len() {
if bytes[i] == b'%' && i + 2 < bytes.len() {
let hex = std::str::from_utf8(&bytes[i + 1..i + 3]).ok();
if let Some(byte) = hex.and_then(|h| u8::from_str_radix(h, 16).ok()) {
out.push(byte);
i += 3;
continue;
}
}
out.push(bytes[i]);
i += 1;
}
String::from_utf8_lossy(&out).into_owned()
}
/// Forward an authorised request to the app on loopback.
async fn proxy_to_app(req: Request<Body>, port: u16) -> Response<Body> {
let path_and_query = req
.uri()
.path_and_query()
.map(|p| p.as_str())
.unwrap_or("/")
.to_string();
let uri = match format!("http://127.0.0.1:{port}{path_and_query}").parse::<hyper::Uri>() {
Ok(uri) => uri,
Err(_) => return bad_gateway(),
};
let (mut parts, body) = req.into_parts();
parts.uri = uri;
// Strip the gate's own credential before it reaches the app: the app has
// no use for the node session and should never be in a position to log,
// echo, or forward it.
parts.headers.remove(header::COOKIE);
parts.headers.remove(header::AUTHORIZATION);
let client = hyper::Client::new();
match client.request(Request::from_parts(parts, body)).await {
Ok(resp) => resp,
Err(_) => bad_gateway(),
}
}
fn set_session_cookie(resp: &mut Response<Body>, token: &str) {
// No Domain attribute, so the cookie is host-only. Cookies ignore port,
// which is what makes one sign-in cover the dashboard and every app port
// on the same host — and equally why an app on a *different* host (its
// own onion) is a separate sign-in.
if let Ok(value) =
header::HeaderValue::from_str(&format!("session={token}; HttpOnly; SameSite=Lax; Path=/"))
{
resp.headers_mut().append(header::SET_COOKIE, value);
}
}
fn redirect_to_app() -> Response<Body> {
Response::builder()
.status(StatusCode::SEE_OTHER)
.header(header::LOCATION, "/")
.body(Body::empty())
.expect("static response builds")
}
fn bad_gateway() -> Response<Body> {
Response::builder()
.status(StatusCode::BAD_GATEWAY)
.body(Body::from("app is not responding"))
.expect("static response builds")
}
fn not_found() -> Response<Body> {
Response::builder()
.status(StatusCode::NOT_FOUND)
.body(Body::empty())
.expect("static response builds")
}
// ---------------------------------------------------------------------------
// Pages
// ---------------------------------------------------------------------------
/// Minimal HTML escape for values interpolated into the pages below.
fn esc(s: &str) -> String {
s.replace('&', "&amp;")
.replace('<', "&lt;")
.replace('>', "&gt;")
.replace('"', "&quot;")
.replace('\'', "&#39;")
}
/// The app's icon as an `<img>`, or a lettermark when the manifest declares
/// none. Inlined as a data URI rather than linked: the gate is answering on
/// the app's own port, so any asset URL would either hit the unauthenticated
/// app behind it or a different origin the browser may not reach.
fn icon_markup(app: &GatedPort) -> String {
if let Some(path) = &app.icon {
if let Some(data_uri) = read_icon_data_uri(path) {
return format!(r#"<img class="icon" src="{}" alt="">"#, esc(&data_uri));
}
}
let letter = app
.app_name
.chars()
.next()
.map(|c| c.to_uppercase().to_string())
.unwrap_or_else(|| "?".to_string());
format!(r#"<div class="icon lettermark">{}</div>"#, esc(&letter))
}
/// Icons live with the web UI. Only files under the icon directory are read,
/// and only known image extensions — the path comes from a manifest, which is
/// signed, but treating it as untrusted costs nothing.
fn read_icon_data_uri(icon_path: &str) -> Option<String> {
let name = std::path::Path::new(icon_path).file_name()?.to_str()?;
let mime = match name.rsplit_once('.')?.1.to_ascii_lowercase().as_str() {
"svg" => "image/svg+xml",
"png" => "image/png",
"webp" => "image/webp",
"jpg" | "jpeg" => "image/jpeg",
_ => return None,
};
for root in [
"/opt/archipelago/web-ui/assets/img/app-icons",
"web/dist/neode-ui/assets/img/app-icons",
] {
let candidate = std::path::Path::new(root).join(name);
if let Ok(bytes) = std::fs::read(&candidate) {
if bytes.len() > 512 * 1024 {
return None;
}
return Some(format!("data:{mime};base64,{}", base64_encode(&bytes)));
}
}
None
}
fn base64_encode(bytes: &[u8]) -> String {
use base64::Engine;
base64::engine::general_purpose::STANDARD.encode(bytes)
}
fn page(title: &str, app: &GatedPort, body: &str, status: StatusCode) -> Response<Body> {
let html = format!(
r#"<!doctype html>
<html lang="en"><head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="robots" content="noindex">
<title>{title} {app_name}</title>
<style>
:root {{ color-scheme: dark; }}
* {{ box-sizing: border-box; }}
body {{ margin:0; min-height:100vh; display:grid; place-items:center;
background:#0b0f14; color:#e6edf3; font:16px/1.5 system-ui,-apple-system,Segoe UI,sans-serif; }}
.card {{ width:min(92vw,380px); padding:2rem; background:#121820;
border:1px solid #223; border-radius:14px; text-align:center; }}
.icon {{ width:64px; height:64px; border-radius:14px; margin:0 auto 1rem; display:block; object-fit:cover; }}
.lettermark {{ display:grid; place-items:center; background:#1d2733; font-size:28px; font-weight:600; }}
h1 {{ font-size:1.15rem; margin:0 0 .25rem; }}
p.sub {{ margin:0 0 1.5rem; color:#8b98a5; font-size:.9rem; }}
input {{ width:100%; padding:.7rem .8rem; margin-bottom:.75rem; border-radius:9px;
border:1px solid #2b3947; background:#0d131a; color:#e6edf3; font-size:1rem; }}
input:focus {{ outline:2px solid #3b82f6; outline-offset:1px; }}
button {{ width:100%; padding:.7rem; border:0; border-radius:9px; background:#3b82f6;
color:#fff; font-size:1rem; font-weight:600; cursor:pointer; }}
button:hover {{ background:#2f6fd6; }}
.err {{ background:#3b1519; border:1px solid #7f1d1d; color:#fca5a5;
padding:.6rem .8rem; border-radius:9px; margin-bottom:1rem; font-size:.9rem; }}
</style></head>
<body><main class="card">{body}</main></body></html>"#,
title = esc(title),
app_name = esc(&app.app_name),
body = body,
);
Response::builder()
.status(status)
.header(header::CONTENT_TYPE, "text/html; charset=utf-8")
// The gate answers on the app's own port for an unauthenticated
// caller; nothing here should be cached or framed.
.header(header::CACHE_CONTROL, "no-store")
.header("X-Frame-Options", "DENY")
.header(
"Content-Security-Policy",
"default-src 'none'; img-src data:; style-src 'unsafe-inline'; form-action 'self'",
)
.body(Body::from(html))
.expect("static response builds")
}
/// The challenge. Names and pictures the app being opened, so the visitor can
/// confirm what they are authenticating to rather than being asked for a
/// password by an unexplained page.
fn login_page(app: &GatedPort, error: Option<&str>, status: StatusCode) -> Response<Body> {
let body = format!(
r#"{icon}
<h1>Sign in to open {name}</h1>
<p class="sub">This app is protected by your node password.</p>
{err}
<form method="post" action="{prefix}login">
<input type="password" name="password" placeholder="Node password" autocomplete="current-password" autofocus required>
<button type="submit">Sign in</button>
</form>"#,
icon = icon_markup(app),
name = esc(&app.app_name),
err = error
.map(|e| format!(r#"<div class="err">{}</div>"#, esc(e)))
.unwrap_or_default(),
prefix = GATE_PREFIX,
);
page("Sign in", app, &body, status)
}
/// Second factor. Reached only after the password verified, and the session
/// backing it cannot authorise anything until this completes.
fn totp_page(app: &GatedPort, error: Option<&str>, status: StatusCode) -> Response<Body> {
let body = format!(
r#"{icon}
<h1>Two-factor code</h1>
<p class="sub">Enter the 6-digit code to open {name}.</p>
{err}
<form method="post" action="{prefix}totp">
<input type="text" name="code" inputmode="numeric" pattern="[0-9]*" autocomplete="one-time-code" placeholder="000000" autofocus required>
<button type="submit">Verify</button>
</form>"#,
icon = icon_markup(app),
name = esc(&app.app_name),
err = error
.map(|e| format!(r#"<div class="err">{}</div>"#, esc(e)))
.unwrap_or_default(),
prefix = GATE_PREFIX,
);
page("Two-factor", app, &body, status)
}
#[cfg(test)]
mod tests {
use super::*;
fn app() -> GatedPort {
GatedPort {
port: 8090,
app_id: "strfry".to_string(),
app_name: "Strfry Relay".to_string(),
icon: None,
}
}
#[test]
fn bearer_token_is_parsed_case_insensitively() {
let mut headers = HeaderMap::new();
headers.insert(header::AUTHORIZATION, "Bearer abc123".parse().unwrap());
assert_eq!(bearer_token(&headers), Some("abc123".to_string()));
headers.insert(header::AUTHORIZATION, "bearer abc123".parse().unwrap());
assert_eq!(bearer_token(&headers), Some("abc123".to_string()));
}
#[test]
fn non_bearer_authorization_is_ignored() {
let mut headers = HeaderMap::new();
// An app's own Basic credential must never be mistaken for ours.
headers.insert(header::AUTHORIZATION, "Basic dXNlcjpwYXNz".parse().unwrap());
assert_eq!(bearer_token(&headers), None);
headers.insert(header::AUTHORIZATION, "Bearer ".parse().unwrap());
assert_eq!(bearer_token(&headers), None);
}
#[tokio::test]
async fn login_page_names_the_app() {
let resp = login_page(&app(), None, StatusCode::UNAUTHORIZED);
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
let body = hyper::body::to_bytes(resp.into_body()).await.unwrap();
let html = String::from_utf8_lossy(&body);
assert!(html.contains("Sign in to open Strfry Relay"));
// A lettermark stands in when the manifest declares no icon.
assert!(html.contains("lettermark"));
}
#[tokio::test]
async fn page_escapes_app_names() {
let mut app = app();
app.app_name = r#"<script>alert(1)</script>"#.to_string();
let resp = login_page(&app, None, StatusCode::UNAUTHORIZED);
let body = hyper::body::to_bytes(resp.into_body()).await.unwrap();
let html = String::from_utf8_lossy(&body);
assert!(!html.contains("<script>alert"));
assert!(html.contains("&lt;script&gt;"));
}
#[tokio::test]
async fn error_messages_are_escaped() {
let resp = login_page(
&app(),
Some("<img src=x onerror=1>"),
StatusCode::UNAUTHORIZED,
);
let body = hyper::body::to_bytes(resp.into_body()).await.unwrap();
let html = String::from_utf8_lossy(&body);
assert!(!html.contains("<img src=x"));
}
#[test]
fn challenge_pages_are_not_cacheable_or_framable() {
let resp = login_page(&app(), None, StatusCode::UNAUTHORIZED);
assert_eq!(resp.headers()[header::CACHE_CONTROL], "no-store");
assert_eq!(resp.headers()["X-Frame-Options"], "DENY");
}
#[tokio::test]
async fn form_parsing_decodes_percent_and_plus() {
let req = Request::builder()
.body(Body::from("password=a%40b+c&code=123456"))
.unwrap();
let form = read_form(req).await.unwrap();
assert_eq!(field(&form, "password"), Some("a@b c".to_string()));
assert_eq!(field(&form, "code"), Some("123456".to_string()));
}
#[tokio::test]
async fn oversized_form_bodies_are_refused() {
let req = Request::builder()
.body(Body::from("x=".to_string() + &"a".repeat(MAX_FORM_BYTES)))
.unwrap();
assert!(read_form(req).await.is_none());
}
#[tokio::test]
async fn no_credential_is_challenged() {
let gate = test_gate().await;
assert_eq!(
gate.authorize(&HeaderMap::new(), "strfry").await,
Authorization::Challenge
);
}
#[tokio::test]
async fn a_valid_session_cookie_is_allowed() {
let gate = test_gate().await;
let token = gate.sessions.create().await;
let mut headers = HeaderMap::new();
headers.insert(header::COOKIE, format!("session={token}").parse().unwrap());
assert_eq!(
gate.authorize(&headers, "strfry").await,
Authorization::Allow
);
}
/// The load-bearing 2FA property: a session still awaiting its TOTP code
/// fails `validate()`, so the gate rejects it without knowing anything
/// about second factors.
#[tokio::test]
async fn a_pending_2fa_session_is_challenged() {
let gate = test_gate().await;
let pending = gate.sessions.create_pending(vec![1, 2, 3]).await;
let mut headers = HeaderMap::new();
headers.insert(
header::COOKIE,
format!("session={pending}").parse().unwrap(),
);
assert_eq!(
gate.authorize(&headers, "strfry").await,
Authorization::Challenge
);
}
#[tokio::test]
async fn a_garbage_cookie_is_challenged() {
let gate = test_gate().await;
let mut headers = HeaderMap::new();
headers.insert(header::COOKIE, "session=deadbeef".parse().unwrap());
assert_eq!(
gate.authorize(&headers, "strfry").await,
Authorization::Challenge
);
}
async fn test_gate() -> AppGate {
let dir = std::env::temp_dir().join(format!("appgate-test-{}", std::process::id()));
let _ = std::fs::create_dir_all(&dir);
AppGate::new(
SessionStore::new().await,
AuthManager::new(dir.clone()),
LoginRateLimiter::new(),
dir,
)
}
}
+3
View File
@@ -82,6 +82,9 @@ pub struct User {
pub role: UserRole,
}
/// Cloneable: it holds only the data dir, and the app gate needs its own
/// handle to verify passwords on a different port from the JSON-RPC path.
#[derive(Clone)]
pub struct AuthManager {
data_dir: PathBuf,
}
+63 -6
View File
@@ -252,8 +252,24 @@ async fn ensure_image_present(spec: &CompanionSpec) -> Result<String> {
} else {
info!(companion = spec.name, "building locally from {dir}");
}
// Stamp the context mtime we are building, so the staleness
// check has something that advances even when every layer is a
// cache hit. Without this the rebuild is a no-op that leaves
// .Created unchanged, the check stays true, and the companion is
// rebuilt on every reconcile tick forever.
let context_stamp = newest_mtime_unix(PathBuf::from(dir))
.await
.unwrap_or_default();
let stamp_label = format!("{CONTEXT_STAMP_LABEL}={context_stamp}");
let out = command_output_with_timeout(
Command::new("podman").args(["build", "-t", &local_image, dir]),
Command::new("podman").args([
"build",
"--label",
&stamp_label,
"-t",
&local_image,
dir,
]),
COMPANION_BUILD_TIMEOUT,
"podman build companion image",
)
@@ -322,17 +338,58 @@ async fn image_exists(image: &str) -> bool {
/// already-built `image`, signalling the cached image is stale and must be
/// rebuilt. Conservative: if either timestamp can't be determined we return
/// false (reuse the cache) to avoid rebuild storms on every reconcile pass.
/// Label carrying the context mtime an image was built from.
///
/// The reason this exists rather than reusing `.Created`: a rebuild whose
/// layers all hit the cache produces the SAME image, and podman leaves its
/// creation time untouched. Comparing against `.Created` therefore never
/// converges — the rebuild does not change the thing being tested, so the
/// companion is rebuilt on every reconcile tick indefinitely. A label is part
/// of the image config, so writing a new value always yields a new image,
/// which makes the comparison settle after exactly one rebuild.
const CONTEXT_STAMP_LABEL: &str = "org.archipelago.context-mtime";
async fn context_is_newer_than_image(dir: &str, image: &str) -> bool {
let image_created = match image_created_unix(image).await {
Some(t) => t,
None => return false,
let Some(ctx) = newest_mtime_unix(PathBuf::from(dir)).await else {
return false;
};
match newest_mtime_unix(PathBuf::from(dir)).await {
Some(ctx) => ctx > image_created,
// Preferred: what the last build actually stamped.
if let Some(stamped) = image_context_stamp(image).await {
return ctx > stamped;
}
// Images built before stamping existed have no label. Fall back to the
// old comparison so behaviour is unchanged for them; the rebuild it
// triggers writes the label, so each such image self-heals exactly once.
match image_created_unix(image).await {
Some(created) => ctx > created,
None => false,
}
}
/// The context mtime stamped into `image` at build time, if any.
async fn image_context_stamp(image: &str) -> Option<i64> {
let format = format!("{{{{index .Config.Labels \"{CONTEXT_STAMP_LABEL}\"}}}}");
let mut cmd = Command::new("podman");
cmd.args(["image", "inspect", "--format", &format, image]);
let out = command_output_with_timeout(
&mut cmd,
COMPANION_IMAGE_CHECK_TIMEOUT,
"podman image context stamp",
)
.await
.ok()?;
if !out.status.success() {
return None;
}
let raw = String::from_utf8_lossy(&out.stdout);
let raw = raw.trim();
// podman prints "<no value>" for a missing label.
if raw.is_empty() || raw == "<no value>" {
return None;
}
raw.parse::<i64>().ok()
}
/// Build timestamp of `image` as Unix seconds, via `podman image inspect`.
async fn image_created_unix(image: &str) -> Option<i64> {
let mut cmd = Command::new("podman");
@@ -4435,6 +4435,8 @@ mod tests {
container,
protocol: "tcp".to_string(),
bind: String::new(),
auth: None,
auth_rationale: None,
}
}
+56
View File
@@ -26,6 +26,29 @@ pub struct DeviceToken {
pub hash: String,
/// Unix seconds at mint time.
pub created: u64,
/// App ids this token may reach through the app gate.
///
/// `None` means node-wide, which is what every companion pairing token
/// is and what tokens minted before scoping existed remain — the field
/// is absent from their stored JSON and deserialises to `None`. A
/// migration that guessed a scope for them would silently revoke access
/// the operator never asked to revoke.
///
/// `Some(list)` restricts the token to exactly those apps, which is the
/// point of scoping: a token handed to Home Assistant so it can poll one
/// app's API should not also open every other app on the node.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub apps: Option<Vec<String>>,
}
impl DeviceToken {
/// Whether this token may reach `app_id`.
pub fn allows_app(&self, app_id: &str) -> bool {
match &self.apps {
None => true,
Some(apps) => apps.iter().any(|a| a == app_id),
}
}
}
fn tokens_path(data_dir: &Path) -> PathBuf {
@@ -61,6 +84,22 @@ fn ct_eq(a: &[u8], b: &[u8]) -> bool {
/// replaced, so re-showing the pairing QR never piles up stale entries.
/// Returns the plaintext token — the only time it ever exists outside the QR.
pub async fn create(data_dir: &Path, name: &str) -> Result<String> {
create_scoped(data_dir, name, None).await
}
/// Mint a token limited to `apps`, for a machine client that needs one app's
/// HTTP API and nothing else. `None` mints the node-wide token `create` does.
pub async fn create_scoped(
data_dir: &Path,
name: &str,
apps: Option<Vec<String>>,
) -> Result<String> {
// An empty list would be indistinguishable from "no restriction" to a
// careless reader while actually authorising nothing — reject it rather
// than mint a token whose behaviour nobody can predict from its record.
if apps.as_ref().is_some_and(|a| a.is_empty()) {
anyhow::bail!("a scoped device token must name at least one app");
}
// KEY-05: a device token is a bearer credential — its unpredictability is
// the whole of its security — so the source is named and the draw guarded.
let mut token_bytes = [0u8; 32];
@@ -81,6 +120,7 @@ pub async fn create(data_dir: &Path, name: &str) -> Result<String> {
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0),
apps,
});
save(data_dir, &tokens).await?;
Ok(token)
@@ -96,6 +136,22 @@ pub async fn verify(data_dir: &Path, candidate: &str) -> Option<String> {
.map(|t| t.name.clone())
}
/// Verify a candidate token **for a specific app**, as the app gate does.
/// Returns the device name when the token is valid *and* in scope.
///
/// Separate from `verify` on purpose: `verify` answers "is this a real
/// token", which is the right question for node login, and would be the
/// wrong question here — a token scoped to one app would otherwise open
/// every app.
pub async fn verify_for_app(data_dir: &Path, candidate: &str, app_id: &str) -> Option<String> {
let candidate_hash = hash_hex(candidate);
load(data_dir)
.await
.iter()
.find(|t| ct_eq(t.hash.as_bytes(), candidate_hash.as_bytes()) && t.allows_app(app_id))
.map(|t| t.name.clone())
}
/// List stored tokens (hashes only — plaintexts are unrecoverable).
pub async fn list(data_dir: &Path) -> Vec<DeviceToken> {
load(data_dir).await
+4 -4
View File
@@ -380,7 +380,7 @@ mod tests {
fn build_local_state_filters_non_trusted_peers() {
let peers = vec![
FederatedNode {
trust_source: None,
trust_source: None,
did: "did:key:zTrusted".into(),
pubkey: "aa".into(),
onion: "t.onion".into(),
@@ -396,7 +396,7 @@ mod tests {
last_sync_error_at: None,
},
FederatedNode {
trust_source: None,
trust_source: None,
did: "did:key:zObserver".into(),
pubkey: "bb".into(),
onion: "o.onion".into(),
@@ -412,7 +412,7 @@ mod tests {
last_sync_error_at: None,
},
FederatedNode {
trust_source: None,
trust_source: None,
did: "did:key:zUntrusted".into(),
pubkey: "cc".into(),
onion: "u.onion".into(),
@@ -457,7 +457,7 @@ mod tests {
super::super::storage::save_nodes(
dir.path(),
&[FederatedNode {
trust_source: None,
trust_source: None,
did: "did:key:zSource".into(),
pubkey: "aa".into(),
onion: "source.onion".into(),
+1
View File
@@ -27,6 +27,7 @@ use tracing::info;
mod api;
mod app_ops;
mod appgate;
mod auth;
mod avatar;
mod backup;
+61 -2
View File
@@ -1901,8 +1901,23 @@ impl MeshService {
// • Meshcore stock client → plain text (can't decode our envelope).
// Rich typed messages (invoice/coordinate/reaction/…) always use the
// typed-wire path via `send_typed_wire`; only plain Text is routed here.
let use_typed_envelope =
archy && matches!(device_type, DeviceType::Meshcore | DeviceType::Reticulum);
// A federation-synthetic contact ALWAYS takes the typed path, whatever
// radio (if any) is attached. `send_typed_wire` is the only routing
// that knows about FIPS/Tor, and it still prefers a reachable LoRa
// twin when the payload fits — so this loses no radio-first behaviour.
//
// Without this, a plain text message to a federated peer fell through
// to `peer_dest_prefix`, which resolves a RADIO routing key. On a node
// running Meshtastic — or with no radio at all — that fails, which is
// why peering a node was not enough to message it: you had to meet it
// over LoRa first so a radio twin existed to route through. Federation
// peers are reachable off-radio by definition (that is what
// `upsert_federation_peer` records with `reachable: true`), so the
// transport choice must not depend on which radio is plugged in.
let is_federation_contact = contact_id & 0x8000_0000 != 0;
let use_typed_envelope = archy
&& (is_federation_contact
|| matches!(device_type, DeviceType::Meshcore | DeviceType::Reticulum));
if use_typed_envelope {
// Sign with our archipelago identity so the receiver can authenticate
// us over LoRa (verifies against our bound `arch_pubkey_hex`). `with_seq`
@@ -2360,6 +2375,50 @@ async fn bitcoin_rpc_getblockheader_by_height(
#[cfg(test)]
mod tests {
/// Item 5: a federated/trusted peer must be messageable as soon as it is
/// peered — no LoRa meeting first.
///
/// The routing predicate in `send_message` decides whether a plain text
/// message takes the federation-aware typed path (which knows FIPS/Tor and
/// still prefers a reachable radio twin) or the radio-only path, which
/// resolves an over-the-air routing key and cannot work for a peer we have
/// never heard on the radio.
///
/// It previously keyed on the attached radio, so on a Meshtastic node — or
/// one with no radio at all — a federated peer fell to the radio path and
/// the send failed. Federation contacts are reachable off-radio by
/// definition, so the choice must not depend on which radio is plugged in.
#[test]
fn federation_contacts_take_the_off_radio_path_on_any_device() {
fn uses_typed_path(contact_id: u32, archy: bool, device: DeviceType) -> bool {
let is_federation_contact = contact_id & 0x8000_0000 != 0;
archy
&& (is_federation_contact
|| matches!(device, DeviceType::Meshcore | DeviceType::Reticulum))
}
let fed = super::federation_peer_contact_id(&"ab".repeat(32));
assert!(fed >= FEDERATION_CONTACT_ID_BASE);
// The cases that used to fail: peered node, wrong radio or none.
for device in [
DeviceType::Meshtastic,
DeviceType::Unknown,
DeviceType::Meshcore,
DeviceType::Reticulum,
] {
assert!(
uses_typed_path(fed, true, device),
"federation peer must route off-radio on {device:?}"
);
}
// A plain radio contact on a stock-text device still takes the radio
// path — this fix must not reroute ordinary LoRa chats.
assert!(!uses_typed_path(42, true, DeviceType::Meshtastic));
// And a stock (non-archy) client is never given a typed envelope.
assert!(!uses_typed_path(42, false, DeviceType::Meshcore));
}
use super::*;
#[test]
+17
View File
@@ -1068,6 +1068,19 @@ impl Server {
// Podman needs and can restart-loop apps that publish those ports.
let relay_task = tokio::spawn(app_port_v6_relay_loop(tx.subscribe()));
// The app gate: authentication in front of every app port, on every
// address the node answers on. It can only claim a port whose app has
// been pinned to loopback in its manifest — see appgate::listener for
// why the rollout is necessarily per-app — and it logs a warning plus
// records `GateStatus::unprotected` for every port it cannot claim,
// so a partially-rolled-out gate is visible rather than silently
// ineffective.
let gate_task = tokio::spawn(crate::appgate::listener::run(
self.api_handler.rpc_handler().app_gate.clone(),
crate::appgate::listener::shared_status(),
tx.subscribe(),
));
let peer_task = tokio::spawn(peer_late_bind_loop(
self.api_handler.clone(),
active_connections.clone(),
@@ -1094,6 +1107,10 @@ impl Server {
let _ = t.await;
}
relay_task.abort();
// Aborted rather than awaited, like the relay loop: the sweep sleeps
// up to a minute between ticks and its accept loops exit on the
// shutdown watch, so awaiting it would stall the drain for no gain.
gate_task.abort();
let _ = peer_task.await;
info!("Shutdown complete");
+107 -14
View File
@@ -530,6 +530,37 @@ pub enum PortAuth {
/// (Bitcoin p2p gossip, mDNS). Requires `auth_rationale`: an exemption
/// nobody can explain is an exemption nobody reviewed.
None,
/// Host-local by intent — the gate must not bind this port at all.
///
/// This exists because `bind: 127.0.0.1` is ambiguous on its own, and
/// reading intent out of it would be wrong in both directions. Two
/// unrelated situations produce an identical loopback publish:
///
/// * Bitcoin's RPC 8332 is loopback-pinned so that the LAN *cannot*
/// reach it. Fronting it with the gate would newly expose it on every
/// host address — behind a login, but exposed where it deliberately
/// was not.
/// * A gated app is loopback-pinned precisely *so that* the gate can
/// take over its external addresses; that is the whole migration.
///
/// Inferring from `bind` would break one or the other, so the intent is
/// declared. `Local` means the first case: never externally reachable,
/// gate keeps its hands off.
Local,
/// The app publishes on loopback ONLY, and the daemon owns this port's
/// external addresses — bind them and authenticate every connection.
///
/// This is the migrated end state, and it is opt-in for a reason. The
/// gate binding an address is the one action that can make a port
/// reachable where it previously was not, so it must never be something
/// a manifest gets by default or by inference. An earlier revision
/// gated any `session` port regardless of `bind`, which meant a node
/// whose manifests had not yet been updated saw the daemon publish
/// Bitcoin's loopback-only RPC on every host address (caught on
/// archi-dev-box 2026-08-03, seconds after deploy). Requiring the
/// manifest to say so means the loopback pin and the daemon takeover
/// ship together, atomically, and a stale manifest fails safe.
Gated,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
@@ -545,10 +576,24 @@ pub struct PortMapping {
/// containers keep reaching it via `host.archipelago`).
#[serde(default)]
pub bind: String,
/// Whether the app gate authenticates connections to this port.
/// Omitted = `session` (protected). See [`PortAuth`].
#[serde(default)]
pub auth: PortAuth,
/// Declared authentication policy, or `None` when the manifest says
/// nothing at all.
///
/// The distinction is load-bearing and was learned the hard way. A node's
/// installed manifests always lag the binary, so "absent" is the state of
/// essentially every port on every node until a signed catalog delivers
/// otherwise. Treating absent as a *value* meant the daemon acted on a
/// default the manifest never asked for: first republishing Bitcoin's
/// loopback-only RPC across the LAN, then — caught before it shipped —
/// preparing to pin LND's gRPC and REST to loopback, which would have
/// broken Zeus and every remote wallet.
///
/// So absent means "no instruction", and the daemon may only ever REPORT
/// on such a port, never change how it is published. Use
/// [`PortMapping::auth_policy`] for classification and
/// [`PortMapping::auth_is_declared`] before acting.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub auth: Option<PortAuth>,
/// Why this port is safe to expose unauthenticated. **Required** when
/// `auth` is `none`, rejected otherwise — a rationale on a gated port
/// means the author expected an exemption they did not get.
@@ -556,6 +601,22 @@ pub struct PortMapping {
pub auth_rationale: Option<String>,
}
impl PortMapping {
/// Policy to classify this port by. An undeclared port reports as
/// `Session` — i.e. it shows up in the audit as something that *should*
/// be behind the gate — because reporting an unprotected port is always
/// safe. Acting on it is not; see [`Self::auth_is_declared`].
pub fn auth_policy(&self) -> PortAuth {
self.auth.unwrap_or(PortAuth::Session)
}
/// Whether the manifest actually stated a policy. Required before the
/// daemon rewrites how a port is published: silence is not consent.
pub fn auth_is_declared(&self) -> bool {
self.auth.is_some()
}
}
impl From<(u16, u16)> for PortMapping {
fn from((host, container): (u16, u16)) -> Self {
PortMapping {
@@ -563,7 +624,7 @@ impl From<(u16, u16)> for PortMapping {
container,
protocol: "tcp".to_string(),
bind: String::new(),
auth: PortAuth::Session,
auth: None,
auth_rationale: None,
}
}
@@ -1067,7 +1128,7 @@ fn validate_ports(ports: &[PortMapping]) -> Result<(), ManifestError> {
// exists in the manifest for every exempt port, so auditing the
// node's unauthenticated surface is reading a list, not inferring
// one from silence.
match (port.auth, port.auth_rationale.as_ref()) {
match (port.auth_policy(), port.auth_rationale.as_ref()) {
(PortAuth::None, None) => {
return Err(ManifestError::Invalid(format!(
"ports[{i}] sets auth: none but no auth_rationale — an unauthenticated \
@@ -1636,7 +1697,7 @@ app:
let yaml = std::fs::read_to_string(&manifest).expect("manifest readable");
let parsed = AppManifest::parse(&yaml).expect("manifest valid");
for port in &parsed.app.ports {
if port.auth == PortAuth::None {
if port.auth_policy() == PortAuth::None {
exempt.push((parsed.app.id.clone(), port.host));
}
}
@@ -1650,13 +1711,45 @@ app:
}
#[test]
fn port_auth_defaults_to_session() {
// The whole point of the default: a manifest that says nothing about
// auth must come out PROTECTED, not exposed. If this ever flips,
// every existing app silently loses its gate.
fn an_undeclared_port_classifies_as_session_but_is_not_declared() {
// Two different questions, and conflating them caused both gate
// incidents. A manifest that says nothing must CLASSIFY as gated, so
// the audit reports it as something that should be protected — but it
// must not read as an instruction the daemon may act on.
let manifest = manifest_with_port(" - host: 8080\n container: 80\n").unwrap();
assert_eq!(manifest.app.ports[0].auth, PortAuth::Session);
assert!(manifest.app.ports[0].auth_rationale.is_none());
let port = &manifest.app.ports[0];
assert_eq!(port.auth_policy(), PortAuth::Session, "reports as gated");
assert!(!port.auth_is_declared(), "but is NOT an instruction");
assert!(port.auth.is_none());
}
#[test]
fn an_explicit_session_declaration_is_actionable() {
let manifest =
manifest_with_port(" - host: 8080\n container: 80\n auth: session\n")
.unwrap();
let port = &manifest.app.ports[0];
assert_eq!(port.auth_policy(), PortAuth::Session);
assert!(port.auth_is_declared());
}
/// The wallet constraint, in the form that actually bit. LND's gRPC and
/// REST carry `bind: ""`, so a rule keyed on `bind` alone does not save
/// them — and on a node whose manifest predates the auth field there is
/// no `auth: none` either. Undeclared must therefore be untouchable, or
/// recreating LND silently pins those ports to loopback and every remote
/// wallet stops working.
#[test]
fn an_undeclared_wallet_port_is_never_actionable() {
let manifest =
manifest_with_port(" - host: 10009\n container: 10009\n protocol: tcp\n")
.unwrap();
let port = &manifest.app.ports[0];
assert!(port.bind.is_empty(), "this is the shape that bit us");
assert!(
!port.auth_is_declared(),
"an undeclared port must never authorise republishing"
);
}
#[test]
@@ -1683,7 +1776,7 @@ app:
" - host: 8333\n container: 8333\n auth: none\n auth_rationale: Bitcoin p2p gossip\n",
)
.unwrap();
assert_eq!(manifest.app.ports[0].auth, PortAuth::None);
assert_eq!(manifest.app.ports[0].auth, Some(PortAuth::None));
assert_eq!(
manifest.app.ports[0].auth_rationale.as_deref(),
Some("Bitcoin p2p gossip")
+36
View File
@@ -318,6 +318,42 @@ impl PodmanClient {
"sctp" => "sctp",
_ => "tcp",
};
// Effective bind. A gated port with no declared bind would
// publish 0.0.0.0 — the app would own every host address, which
// is both the exposure itself and the reason the daemon's app
// gate cannot bind those addresses to authenticate them. Pin it
// to loopback so the gate can take the external addresses.
//
// Doing it HERE, at container creation, is the point: the pin and
// the gate's takeover then both come from the daemon and cannot
// disagree. The earlier attempt put this decision in manifest
// data instead, and a node whose manifests lagged the binary
// published Bitcoin's loopback-only RPC across the LAN
// (archi-dev-box, 2026-08-03).
//
// A port that already declares a bind is never overridden — that
// is exactly what keeps `bind: 127.0.0.1` ports host-local and
// leaves `auth: none` protocol ports (LND gRPC/REST, electrum)
// published as they are, so remote wallets keep working.
// NOTE: the daemon deliberately does NOT rewrite this. Pinning a
// published port to loopback is how an app hands its external
// addresses to the gate, but it belongs in the manifest, not in
// daemon-side inference:
//
// * `bind` is already honoured by every publish path (here and
// in package::install), so a manifest edit needs no code.
// * inference here would cover only THIS path — proven on
// archi-dev-box, where a recreate went through another one and
// the pin never applied.
// * and inferring from an ABSENT field is what republished
// Bitcoin's loopback RPC across the LAN, and came within one
// container-recreate of pinning LND's gRPC/REST and breaking
// every remote wallet.
//
// So the migration ships as `bind: 127.0.0.1` in the signed
// catalog. Verified 2026-08-03 that a disk-only manifest edit is
// overridden by the catalog, which is precisely why the catalog is
// the right and only place to carry it.
let mut mapping = serde_json::json!({
"container_port": port.container,
"host_port": port.host,
+15 -1
View File
@@ -324,8 +324,22 @@ html.controller-nav [data-controller-container]:focus {
/* Dashboard content lives inside animated perspective/scroll containers.
Chromium/Brave can corrupt backdrop-filter + transformed cards into black
square/rectangle layers, so use translucent fills there instead. */
square/rectangle layers, so use translucent fills there instead.
`.home-card-shell` (Home.vue) was missing from this list and kept its own
`backdrop-filter: blur(18px)`, producing a second, subtler form of the
same corruption: a vertical seam where the blurred backdrop is refreshed
on one side and stale on the other. Because the boundary is in SCREEN
space, it cut both dashboard cards at the same x and vanished in the gap
between them which is how it was identified from a screenshot
(2026-08-03: a lone unpaired brightness step at CSS x=633, present on
10/13 sampled rows inside the cards and 2/10 in the gap). It surfaced on
hover because a hover repaint is what re-rasterises part of the
backdrop. The card's fill is already rgba(0,0,0,0.65) the same as
.glass-card, which renders unblurred here so dropping the blur also
makes the shell consistent with the tiles beside it. */
body.dashboard-active .dashboard-scroll-panel .glass-card,
body.dashboard-active .dashboard-scroll-panel .home-card-shell,
body.dashboard-active .dashboard-scroll-panel .glass,
body.dashboard-active .dashboard-scroll-panel .mode-switcher,
body.dashboard-active .dashboard-scroll-panel .glass-button,
@@ -0,0 +1,80 @@
import { describe, it, expect } from 'vitest'
import { readFileSync } from 'node:fs'
import { resolve } from 'node:path'
/**
* Chromium/Brave mis-rasterise `backdrop-filter` inside the dashboard's
* animated perspective/scroll containers. style.css already neutralises it
* for the shared glass classes, but that list is hand-maintained: a component
* that declares its own `backdrop-filter` in a local <style> block is simply
* not covered, and nothing fails.
*
* That is exactly how the 2026-08-03 seam shipped. `.home-card-shell` carried
* `backdrop-filter: blur(18px)` in Home.vue and was missing from the list, so
* a hover repaint left a vertical line where the refreshed backdrop met the
* stale one visible in both dashboard cards at the same screen x, and
* absent in the gap between them.
*
* This test makes the omission fail loudly instead of shipping as a glitch
* nobody can reproduce on demand.
*/
const root = resolve(__dirname, '../../..')
const styleCss = readFileSync(resolve(root, 'src/style.css'), 'utf8')
/** The selector list that disables backdrop-filter on the dashboard. */
function dashboardMitigationBlock(): string {
const start = styleCss.indexOf('body.dashboard-active .dashboard-scroll-panel .glass-card')
expect(start, 'dashboard backdrop-filter mitigation block not found').toBeGreaterThan(-1)
const end = styleCss.indexOf('}', start)
return styleCss.slice(start, end)
}
/** Class selectors that declare a non-none backdrop-filter in a .vue file. */
function blurredClassesIn(relPath: string): string[] {
const src = readFileSync(resolve(root, relPath), 'utf8')
const found = new Set<string>()
// Match `.some-class { ... backdrop-filter: <not none> ... }` on one line,
// which is how these single-line rules are written in this codebase.
const ruleRe = /(\.[a-zA-Z0-9_-]+)\s*\{([^}]*)\}/g
let m: RegExpExecArray | null
while ((m = ruleRe.exec(src)) !== null) {
const selector = m[1]
const body = m[2]
if (!selector || !body) continue
const decl = /(?:^|[;{\s])backdrop-filter\s*:\s*([^;]+)/.exec(body)
if (decl?.[1] && decl[1].trim() !== 'none') found.add(selector)
}
return [...found]
}
describe('dashboard backdrop-filter mitigation', () => {
it('covers every backdrop-filter surface Home.vue defines itself', () => {
const block = dashboardMitigationBlock()
const uncovered = blurredClassesIn('src/views/Home.vue').filter(
(sel) => !block.includes(`.dashboard-scroll-panel ${sel},`),
)
expect(
uncovered,
`these Home.vue classes declare backdrop-filter but are not in the ` +
`body.dashboard-active .dashboard-scroll-panel mitigation list in style.css, ` +
`so Chromium will leave repaint seams across the dashboard cards`,
).toEqual([])
})
it('still lists the shared glass classes', () => {
// Guards against someone "cleaning up" the list and silently reopening
// the original black-rectangle corruption this block was written for.
const block = dashboardMitigationBlock()
for (const sel of ['.glass-card', '.glass-button', '.home-card-shell']) {
expect(block).toContain(`.dashboard-scroll-panel ${sel},`)
}
})
it('the mitigation actually disables the filter', () => {
const start = styleCss.indexOf('body.dashboard-active .dashboard-scroll-panel .glass-card')
const body = styleCss.slice(styleCss.indexOf('{', start), styleCss.indexOf('}', start))
expect(body).toContain('backdrop-filter: none')
expect(body).toContain('-webkit-backdrop-filter: none')
})
})
@@ -362,6 +362,23 @@ init()
</button>
</div>
<div class="overflow-y-auto flex-1 min-h-0 space-y-6 pr-1">
<!-- v1.7.121-alpha -->
<div>
<div class="flex items-center gap-2 mb-3">
<span class="text-xs font-mono px-2 py-0.5 rounded bg-orange-500/20 text-orange-300">v1.7.121-alpha</span>
<span class="text-xs text-white/40">August 4, 2026</span>
</div>
<div class="space-y-3 text-sm text-white/80 pl-3 border-l border-white/10">
<p>**Making another node "Trusted" now asks for your node password.** Trust was being handed out by machines rather than by you: any node able to reach yours could join and mark itself Trusted, because the check proved only that the caller owned the key it had just presented never that you had approved it. Trust also spread on its own, since every peer a Trusted node advertised was added as Trusted too, so one grant quietly propagated across the whole federation. Uninvited joins are now capped at Observer, advertised peers arrive as Observers, and raising anyone to Trusted whether by generating an invite or by changing the dropdown on a node requires your password. Lowering trust deliberately does not, because the safe action must never be the inconvenient one. Existing peers are left exactly as they are rather than silently demoted, and each one now records how its trust was granted so you can review them.</p>
<p>**Nodes you have peered with can be messaged straight away.** Peering was not enough: you also had to be within LoRa radio range of the other node once before chat would work. The node picked how to send a message based on which radio was plugged in, and only one of those paths knew how to reach a peer over the mesh's internet transports so on a node with a different radio, or no radio at all, messaging a peer you had just federated with simply failed until a radio contact happened to appear. Peered nodes are reachable without radio by definition, so that choice no longer depends on the hardware. Radio is still preferred when the other node is actually in range and the message fits.</p>
<p>The dashboard no longer flickers a vertical line across its cards. A rendering seam appeared at random while moving the mouse, because the two large cards used a background-blur effect that this system already disables everywhere else on the dashboard that browser mis-draws it inside the dashboard's animated container, and these two cards had been missed when the workaround was written. Diagnosed from a single screenshot rather than by trying to reproduce it.</p>
<p>The Lightning screen will actually update from now on. Its image was set to "latest", and the container system will not re-fetch a label it already holds, so nodes kept the same Lightning screen forever no matter how many updates shipped. A separate copy of the same setting used only by brand-new installs also described the screen incorrectly, so fresh installs got a screen that never answered.</p>
<p>Apps that provide their own screens stop rebuilding themselves in a loop. On this system's own node one of them rebuilt every thirty-five seconds indefinitely, burning processor time and restarting the app each round. The node decided a rebuild was needed by comparing file dates against the image's creation date, but a rebuild that changes nothing reuses the existing image and leaves that date untouched so the condition that triggered the rebuild was still true afterwards, forever. Nodes taking this update repair themselves the first time they check.</p>
<p>Groundwork you can see but that does not change access yet: the node can now tell you which of its app ports answer without a login, and every port that is deliberately open Bitcoin's peer connections for syncing the chain, Lightning's wallet connections, the Electrum wallet protocol now has to state in writing why it is safe, so the list of exceptions is something you can read rather than something you have to discover. The login gate that will sit in front of the rest is built and proven working end to end on a real node, but it is not yet closing any ports; that arrives with the signed app catalog that tells each app to hand its address over.</p>
<p>Releases can no longer ship an unsigned update file. Signing was skippable, and when it was skipped the release was still committed and tagged producing an update that every node correctly refuses to install. It had been caught by hand every cycle; now the release simply stops.</p>
<p>Known gaps, disclosed rather than buried: the 5x real-node lifecycle gate was not run for this release. App ports other than the deliberate exceptions above are still reachable without a login the gate reports them, and closing them needs the next signed catalog. Three voice-assistant ports are open without authentication and should not be; the correct fix puts them on a private network with the assistant instead, which needs testing on a node that runs both. Two nodes on the fleet still share SSH host keys (detection shipped, rotation remains a deliberate operator decision).</p>
</div>
</div>
<!-- v1.7.120-alpha -->
<div>
<div class="flex items-center gap-2 mb-3">
+34 -2
View File
@@ -212,8 +212,10 @@ if [ -n "${RELEASE_MASTER_MNEMONIC:-}" ] || [ -t 0 ]; then
"$SIGNER" ceremony verify "$PROJECT_ROOT/releases/manifest.json"
else
echo "⚠ WARNING: no TTY and RELEASE_MASTER_MNEMONIC unset — manifest left UNSIGNED."
echo " Sign it before publishing: bash scripts/sign-manifest.sh"
echo " (publish-release-assets.sh refuses to ship an unsigned manifest)"
echo " This run will ABORT before committing (step 7 refuses an unsigned"
echo " manifest), because nodes read releases/manifest.json from branch main"
echo " and would refuse to auto-apply it."
echo " Sign it, then re-run: bash scripts/sign-manifest.sh"
fi
cp "$PROJECT_ROOT/releases/manifest.json" "$PROJECT_ROOT/release-manifest.json"
@@ -225,6 +227,36 @@ install -m 0755 "$PROJECT_ROOT/core/target/release/archipelago" "$VERSION_DIR/ar
install -m 0644 "$FRONTEND_ARCHIVE" "$VERSION_DIR/archipelago-frontend-${VERSION}.tar.gz"
"$SCRIPT_DIR/check-release-manifest.sh"
# §A supply-chain gate, mirroring publish-release-assets.sh — but EARLIER,
# because publishing is not the first way an unsigned manifest reaches the
# fleet. Nodes fetch releases/manifest.json straight from branch `main`
# (see the verification URLs printed below), so the COMMIT is what exposes
# it, not the publish. publish-release-assets.sh refusing to ship is a
# backstop that arrives one step too late: by then the unsigned manifest is
# already on main and the fleet is already refusing to auto-apply.
#
# This is why every cycle needed a manual catch. The signing block above is
# conditional — no TTY and no RELEASE_MASTER_MNEMONIC means it prints a
# warning and falls through — and the commit then happened anyway. A release
# commit carrying a manifest no node will accept has no valid use, so refuse
# to create one rather than leave a tag that has to be re-cut.
EXPECTED_DID="did:key:z6MkkidEnEpo6qHMCNSZoNKWtvQvxq3whnaME9wGgEFhq7ur"
if ! grep -q '"signature":' "$PROJECT_ROOT/releases/manifest.json" \
|| ! grep -q "\"signed_by\": \"$EXPECTED_DID\"" "$PROJECT_ROOT/releases/manifest.json"; then
echo "" >&2
echo "Error: releases/manifest.json is NOT signed by the release root." >&2
echo " Refusing to commit — nodes read this file from branch main and will" >&2
echo " refuse to auto-apply it, so the release would be dead on arrival." >&2
echo "" >&2
echo " Sign it, then re-run this script:" >&2
echo " bash scripts/sign-manifest.sh" >&2
echo "" >&2
echo " (Signing needs a TTY for the mnemonic prompt, or RELEASE_MASTER_MNEMONIC set.)" >&2
exit 1
fi
"$SIGNER" ceremony verify "$PROJECT_ROOT/releases/manifest.json" \
|| { echo "Error: manifest signature failed cryptographic verification — refusing to commit" >&2; exit 1; }
echo "[7/8] Committing version bump..."
git -C "$PROJECT_ROOT" add \
core/archipelago/Cargo.toml \