Compare commits
65
Commits
@@ -27,6 +27,216 @@ Status key: **DONE** (committed) · **READY** (written, not yet committed/tested
|
||||
- Scope note: `fips/app_ports.rs` holds the mesh allowlist; `is_peer_allowed_path` in
|
||||
`server.rs` holds the peer HTTP allowlist. Neither currently authenticates app ports.
|
||||
|
||||
#### Research — umbrelOS (verified from their docs/source, 2026-08-03)
|
||||
|
||||
umbrelOS solves this **architecturally, not per-app**: the app's own port is never
|
||||
published. Each app gets a sidecar `app_proxy` container that owns the published port and
|
||||
forwards to the app on the internal network.
|
||||
|
||||
- `containers/app-proxy` is described as *"a transparent HTTP proxy to add authentication
|
||||
to Umbrel apps"* — **every** HTTP request and WebSocket upgrade passes through it and
|
||||
has its session token checked.
|
||||
- Tokens come from a separate `app-auth` service; the proxy talks to it over a local port
|
||||
(default 2000) with a shared secret (`UMBREL_AUTH_SECRET`). Two JWTs exist: an **API
|
||||
token** in localStorage (`{loggedIn: true}`) for the dashboard's own API, and a
|
||||
**proxy token** in an **HttpOnly cookie** (`{proxyToken: true}`) for app access. Both
|
||||
HS256, 7-day expiry.
|
||||
- Unauthenticated requests are redirected to the login screen.
|
||||
- Per-app escape hatches, all env vars on the proxy: `PROXY_AUTH_ADD` (bool, **default
|
||||
true** — so apps are protected unless opted out), `PROXY_AUTH_WHITELIST` (paths exempt,
|
||||
e.g. `/public/*`), `PROXY_AUTH_BLACKLIST` (paths that must be authed, e.g. `/admin/*`).
|
||||
- Known friction worth designing around: apps with their own login (Frigate, and the
|
||||
`PROXY_AUTH_ADD=false` tracker issue) end up double-authenticating, and non-browser API
|
||||
clients (Home Assistant hitting an app's API) break because they have no cookie. Any
|
||||
gate we build needs a story for machine clients, not just browsers.
|
||||
|
||||
**The lesson for us:** the reason umbrel doesn't have this bug class is that there is no
|
||||
unauthenticated path to bind to in the first place. Our apps publish their own ports
|
||||
directly, so a gate bolted onto one transport leaves the others open — which is exactly
|
||||
the shape of the `/lnd-connect-info` + `/bitcoin-rpc/` leaks. The fix likely has to move
|
||||
the port binding, not just add a check.
|
||||
|
||||
#### Reproduced ON archi-dev-box, 2026-08-03 — baseline before the fix
|
||||
|
||||
No session cookie, over the Tailscale IP `100.69.68.39`:
|
||||
|
||||
```
|
||||
port 18083 HTTP 200 LND - Archipelago
|
||||
port 8334 HTTP 200
|
||||
port 8175 HTTP 200 Fedimint Guardian - Archipelago
|
||||
port 8336 HTTP 200 FIPS Mesh
|
||||
port 8090 HTTP 200
|
||||
port 7777 HTTP 200
|
||||
```
|
||||
|
||||
`ss -tlnp` confirms these are bound `0.0.0.0`, so the same responses are served on the LAN
|
||||
IP and every other host address. Re-run this exact loop after the fix: every one must
|
||||
become the login page, and the ports listed as protocol exemptions (item 1b) must be the
|
||||
*only* ones still answering.
|
||||
|
||||
#### Exposure map — how app ports are reachable TODAY (verified in source, 2026-08-03)
|
||||
|
||||
All four transports converge on `127.0.0.1:<app_port>`. This is the whole reason the fix
|
||||
is tractable: it is **one gate, not four**.
|
||||
|
||||
| Transport | Path to the app | Code |
|
||||
|---|---|---|
|
||||
| LAN / Tailscale | container publishes the port on the host (`--network host`, so `0.0.0.0:<port>`) — reachable on *every* host IP | `scripts/container-specs.sh`, `first-boot-containers.sh` |
|
||||
| FIPS mesh | daemon binds `[fips0-ULA]:<port>` and raw-TCP-forwards to `127.0.0.1:<port>` | `server.rs:1130` `app_port_v6_relay_loop` |
|
||||
| FIPS firewall | `tcp dport { …APP_LAUNCH_PORTS… } accept` drop-in opens them all | `fips/config.rs:274`, `fips/app_ports.rs` |
|
||||
| Tor | `HiddenServicePort 80 127.0.0.1:<local_port>` per service | `api/rpc/tor/mod.rs:243` |
|
||||
|
||||
#### Design decision (operator, 2026-08-03)
|
||||
|
||||
**Gate app UIs + bearer tokens; protocol ports exempt.** HTTP app UIs get the login gate
|
||||
(app name + icon, 2FA honoured). Protocol ports (LND gRPC 10009 + REST, electrum 50002,
|
||||
bitcoin p2p 8333) stay open but MUST be declared `auth: none` with a rationale in the
|
||||
manifest, so the exceptions are a grep rather than a discovery — see item 1b. Per-app
|
||||
long-lived bearer tokens cover machine clients that speak HTTP (Home Assistant). **Zeus
|
||||
and electrum wallets keep working untouched** — that was the deciding constraint.
|
||||
|
||||
The gate lives in the **daemon**, not a per-app sidecar container (umbrel's `app_proxy`
|
||||
model): rootless, no extra containers per app, one place to update, and it can reuse the
|
||||
existing `app_port_v6_relay_loop` rather than fight it.
|
||||
|
||||
#### ⚠️ Trap found while designing — an nft-only gate FAILS OPEN
|
||||
|
||||
The obvious implementation is an nft redirect of inbound app-port traffic to the gate.
|
||||
But `/etc/fips/fips.nft` is **provisioned out-of-band** and `fips/config.rs:290` treats its
|
||||
absence as a no-op (`if try_exists("/etc/fips/fips.nft")`). A gate shipped as a `fips.d`
|
||||
drop-in would therefore be **silently absent on every node without the hardening
|
||||
baseline** — i.e. it fails open, which is exactly the failure class this item exists to
|
||||
close.
|
||||
|
||||
Two viable shapes, both fail-closed:
|
||||
- **(a) Apps bind loopback only**, daemon owns every external bind. Airtight, the true
|
||||
umbrel model, but requires touching each app's own listen config (nginx.conf etc.).
|
||||
Note you *cannot* half-do this: while an app holds `0.0.0.0:<port>`, the daemon cannot
|
||||
bind `<lan-ip>:<port>` at all.
|
||||
- **(b) Daemon owns a dedicated `archipelago-appgate` nft table** with its own
|
||||
default-deny + redirect, independent of whether `fips.nft` exists, and refuses to start
|
||||
/ alarms loudly if it cannot install it. Non-invasive to apps.
|
||||
|
||||
#### Enabler found — `PortMapping.bind` already does half of (a)
|
||||
|
||||
`core/container/src/manifest.rs:518` — `PortMapping` has a `bind` field, documented as
|
||||
*"Host address to bind the publish to. Empty = all interfaces (0.0.0.0). Set `127.0.0.1`
|
||||
to keep a port host-local"*. So for **bridge apps that declare `ports:`**, going
|
||||
loopback-only is a **manifest edit, not app surgery**, and the daemon can then own the
|
||||
external bind. That is most of the catalog.
|
||||
|
||||
The exception is **host-networked apps** (`security.network_policy: host` — `lnd-ui`,
|
||||
`bitcoin-ui`, `electrs-ui`): host networking bypasses port mapping entirely, so `bind` has
|
||||
no effect and `ports:` is deliberately empty. Those bind whatever their internal nginx
|
||||
binds. We build those images ourselves, so the fix is a `listen 127.0.0.1:<port>;` change
|
||||
in each `docker/*-ui/nginx.conf` — still no third-party surgery.
|
||||
|
||||
Watch the rootless trap documented at `manifest.rs:532`: a publish bound to an address the
|
||||
host cannot actually bind crash-loops the whole unit (took bitcoin down fleet-wide on .228,
|
||||
2026-07-09). Loopback binds are explicitly always accepted without probing, so this
|
||||
direction is safe.
|
||||
|
||||
**Tor needs separate handling either way**: the onion connects *from* localhost, so a
|
||||
redirect that exempts loopback will not catch it. `HiddenServicePort` must be repointed at
|
||||
the gate, and since that mapping loses the original destination port, each app needs its
|
||||
own gate port (or an HTTP-level Host mapping).
|
||||
|
||||
#### Primitives that already exist — do NOT build these from scratch
|
||||
|
||||
The gate is mostly assembly, not invention:
|
||||
|
||||
| Need | Existing API |
|
||||
|---|---|
|
||||
| Read the session cookie off a request | `session::extract_session_cookie(&HeaderMap) -> Option<String>` (`session.rs:479`) |
|
||||
| Validate a session | `SessionStore::validate(&token) -> bool` (`session.rs:194`) |
|
||||
| **Honour 2FA** | Already modelled: `create_pending(totp_secret)` (`:176`) + `upgrade_to_full` (`:247`). A session still pending 2FA **fails `validate()`**, so the gate gets 2FA for free by calling `validate` — no TOTP code in the gate itself |
|
||||
| **Machine-client bearer tokens** | `device_tokens::create/verify` (`device_tokens.rs:63/:90`) — long-lived, minted from an authenticated session, only the SHA-256 persisted, plaintext returned once. Built for the companion pairing QR; needs **per-app scoping** added for this use |
|
||||
| Rate limiting | `device_tokens` verification already rides `auth.login`'s limiter |
|
||||
|
||||
So the new code is: the listener/redirect, the app-identification step (which app is this port?),
|
||||
the login page render (app name + icon), and per-app scoping on `device_tokens`.
|
||||
|
||||
#### Research — StartOS: **DROPPED** (operator, 2026-08-03)
|
||||
|
||||
"don't need the startOS research we decided on a approach already." The umbrelOS read
|
||||
plus the design decision above settled it; no further prior-art work.
|
||||
|
||||
### 1b. Manifest declaration of unauthenticated ports — **DONE** (`0c4826f8`, pushed)
|
||||
|
||||
`PortMapping` grew `auth` (`session` | `none`, defaulting to **`session`**) and
|
||||
`auth_rationale`. The default is the protected one, so exposure is now something a
|
||||
manifest has to ask for rather than something it gets by saying nothing.
|
||||
|
||||
Validation is two-sided: `auth: none` without a rationale is rejected, **and** a
|
||||
rationale without `auth: none` is rejected — that combination means the author wrote an
|
||||
exemption and did not get one, and shipping it silently would leave them believing
|
||||
otherwise.
|
||||
|
||||
**17 ports across 12 apps are exempt**, each with its reason: Lightning p2p (BOLT-8
|
||||
noise), LND gRPC 10009 / REST 18080 and CLN gRPC 9835 (macaroon / mutual TLS — this is
|
||||
what keeps Zeus and remote wallets working), Bitcoin p2p 8333, electrum 50001, the three
|
||||
Wyoming voice ports, git-over-SSH 2222, and the UDP discovery protocols (mDNS 5353,
|
||||
SSDP 1900, STUN 3478). **The other 39 published ports now default to gated.**
|
||||
|
||||
Bitcoin RPC 8332 is deliberately *not* exempted: it is already `bind: 127.0.0.1`, so the
|
||||
gate never sees it, and claiming an exemption it does not need would put a meaningless
|
||||
line in the audit list. If that bind is ever dropped it fails closed.
|
||||
|
||||
Two corpus tests pin this: every shipped manifest must parse, and the exempt set is
|
||||
frozen at 17 so the node's unauthenticated surface cannot grow by accident.
|
||||
|
||||
### 1c. The gate itself — **IN PROGRESS**
|
||||
|
||||
`core/archipelago/src/appgate/` — `identity.rs` (port → app id/name/icon, gated vs
|
||||
exempt, re-read from manifests so a catalog refresh applies without a restart),
|
||||
`mod.rs` (authorize + login page + TOTP step + reverse proxy), `listener.rs` (binds the
|
||||
external addresses, sweeps every 60s).
|
||||
|
||||
Design points worth not re-deriving:
|
||||
|
||||
- **It invents no auth policy.** `verify_password`, `totp::decrypt_secret`,
|
||||
`verify_code` + used-step replay protection, `SessionStore::create/create_pending/
|
||||
upgrade_to_full`, and the *same* `LoginRateLimiter` instance as the JSON-RPC path.
|
||||
Only the transport differs (HTML form vs JSON-RPC), because a browser being redirected
|
||||
to an app cannot speak JSON-RPC. Sharing the limiter matters: otherwise an attacker
|
||||
gets a fresh budget of password guesses by moving to an app port.
|
||||
- **2FA is free.** A session still pending its TOTP step fails `validate()`, so the gate
|
||||
rejects it without knowing anything about second factors.
|
||||
- **Cookies ignore port.** The session cookie is host-only with no `Domain`, so one
|
||||
sign-in covers the dashboard and every app port on the same host. The corollary is
|
||||
that an app reached on a *different* host — its own onion — is a separate sign-in.
|
||||
- **401, not a redirect.** A redirect to a login page is indistinguishable from the app
|
||||
itself redirecting, and machine clients would follow it and parse HTML as their API
|
||||
response.
|
||||
- **The gate strips `Cookie` and `Authorization` before proxying.** The app has no use
|
||||
for the node session and must never be in a position to log or forward it.
|
||||
- **Machine clients**: `device_tokens` grew `apps: Option<Vec<String>>` and
|
||||
`verify_for_app`. `None` = node-wide (what every existing companion token is —
|
||||
migrating them by guessing a scope would silently revoke access nobody asked to
|
||||
revoke); `Some(list)` restricts to those apps. An empty list is rejected rather than
|
||||
minted, since it would read as "unrestricted" while authorising nothing.
|
||||
|
||||
#### ⚠️ The ordering constraint that shapes the rollout
|
||||
|
||||
A published container port is bound `0.0.0.0:<port>`, which claims **every** host
|
||||
address. While the app holds that, the gate **cannot** bind `<lan-ip>:<port>` at all.
|
||||
So the gate can only stand in front of an app whose publish has been pinned to loopback
|
||||
(`bind: 127.0.0.1`) and whose container has been recreated. Gate-first is not possible;
|
||||
all-apps-at-once would recreate every container on the node simultaneously.
|
||||
|
||||
Therefore the rollout is **per app**, and the gate is built to be honest about being
|
||||
partially deployed: a port it cannot claim is logged at **warn** every sweep and recorded
|
||||
in `GateStatus::unprotected`. The failure mode this exists to prevent is a gate that
|
||||
binds nothing, logs at debug, and reports success while every app stays exactly as open
|
||||
as before — worse than no gate, because it stops anyone looking. (Same reasoning that
|
||||
killed the nft drop-in: `/etc/fips/fips.nft` is provisioned out-of-band and its absence
|
||||
is a silent no-op.)
|
||||
|
||||
**Still open on this item:** pin the 39 gated ports to loopback app-by-app, repoint
|
||||
`HiddenServicePort` at the gate (Tor connects *from* loopback, so a loopback-exempt
|
||||
redirect will not catch it, and the mapping loses the original destination port), gate
|
||||
the FIPS relay path, surface `GateStatus` in the UI, and verify on a real node.
|
||||
|
||||
### 2. Filebrowser ships an insecure default login — **OPEN**
|
||||
- Change the default credential **without breaking the dashboard's Cloud view**, which
|
||||
authenticates to filebrowser on the user's behalf.
|
||||
@@ -51,23 +261,44 @@ Two independent fail-open paths granted `Trusted` without any operator decision:
|
||||
- Added `FederatedNode.trust_source` (`invite` | `uninvited-join` | `transitive-merge` |
|
||||
`manual`, `None` = pre-existing/unknown) so existing grants are **auditable**. Per
|
||||
operator decision: existing peers are **left alone, not auto-demoted**.
|
||||
- **Still to do:** surface `trust_source` in `federation.list-nodes` + the UI so the
|
||||
operator can actually review the `None`/`uninvited-join` population.
|
||||
- `trust_source` is now **surfaced** in `federation.list-nodes` (as an explicit `null`
|
||||
when unknown, not omitted — "recorded before this was tracked" is the population that
|
||||
needs review, so the UI must be able to tell it apart from a field it didn't read) and
|
||||
rendered under the trust dropdown in the node detail modal as "Granted via:".
|
||||
|
||||
### 3b. Granting Trusted must require the node password — **OPEN**
|
||||
### 3b. Granting Trusted must require the node password — **DONE** (uncommitted at time of writing)
|
||||
> "to make someone trusted must require the node password to generate the code or change
|
||||
> in the modal dropdown when you click a node" — operator, 2026-08-03
|
||||
|
||||
Re-authentication on privilege escalation. Two entry points, both must be covered:
|
||||
Re-authentication on privilege escalation. Both entry points are covered:
|
||||
|
||||
- **Minting a Trusted invite** (`federation.invite` with `trust_level: "trusted"`) —
|
||||
"Link Your Nodes" mints Trusted today with no re-auth.
|
||||
- **Changing a node's level in the UI dropdown** (`federation.set-trust-level` /
|
||||
`handlers.rs:342`) — promoting Observer → Trusted.
|
||||
- **Minting a Trusted invite** (`federation.invite`) — gated on the **resolved** level,
|
||||
which matters because "Link Your Nodes" sends no `trust_level` at all and falls through
|
||||
to the `Trusted` default. The invite is a bearer grant of Trusted to whoever redeems
|
||||
it, so minting it *is* the escalation. Observer invites are untouched.
|
||||
- **Changing a node's level in the UI dropdown** (`federation.set-trust`) — gated only
|
||||
when the peer is **not already** Trusted, so the dropdown re-emitting its own value
|
||||
doesn't demand a password for a no-op.
|
||||
|
||||
Demotion must NOT require the password: making something less privileged should never be
|
||||
harder than leaving it. Grant `TrustSource::Manual` on the operator path so the audit
|
||||
trail distinguishes it from the capped automatic ones.
|
||||
Demotion is NOT gated: making something less privileged must never be harder than leaving
|
||||
it, or the safe action becomes the inconvenient one. The operator path stamps
|
||||
`TrustSource::Manual`; `set_trust_level` grew an `Option<TrustSource>` so automatic
|
||||
adjustments (the discovery-handshake demotion safety net) pass `None` and leave the
|
||||
recorded provenance alone rather than laundering an `uninvited-join` peer into looking
|
||||
operator-approved.
|
||||
|
||||
Wiring: the backend is the sole authority on what counts as an escalation — it returns a
|
||||
`PASSWORD_REQUIRED:` prefixed error, and the UI prompts and retries only on that. The
|
||||
frontend never pre-judges, so the rule lives in exactly one place.
|
||||
`TrustPasswordModal.vue` (modelled on `RotateDidModal.vue`) serves both flows.
|
||||
`NodeDetailModal`'s select now snaps back to the node's real level on change, because a
|
||||
cancelled or failed promotion would otherwise leave the dropdown displaying a level the
|
||||
node never accepted.
|
||||
|
||||
**Follow-up, deliberately not done here:** `federation.join` also grants Trusted (when
|
||||
redeeming someone else's Trusted invite) with no re-auth. It is an explicit operator
|
||||
paste rather than a UI toggle, and was outside the two entry points specified — but it is
|
||||
the third way a node reaches Trusted and should be reviewed.
|
||||
|
||||
---
|
||||
|
||||
@@ -104,8 +335,77 @@ trail distinguishes it from the capped automatic ones.
|
||||
Known groundwork: the signed catalog (`releases/app-catalog.json`, `sign-catalog.sh`),
|
||||
catalog→manifest runtime reload, `package.update` RPC, `check-app-catalog-drift.py`,
|
||||
and `scripts/image-versions.sh` pinning.
|
||||
- Needs: registry-version awareness per app, a diff of what changed (UI vs app vs both),
|
||||
the modal + detail-page affordance, and distinct iconography for the three cases.
|
||||
|
||||
#### What already exists (verified in source, 2026-08-03) — the operator was right
|
||||
|
||||
The whole update *pipeline* is built and is already independent of OTA:
|
||||
|
||||
- `package.check-updates` (`api/rpc/package/update.rs:180`) refreshes the signed catalog
|
||||
and hot-reloads manifests when it changed — no daemon restart, no OTA involved.
|
||||
- `package.update` (`spawn_package_update`), `package.versions`, `package.set-config`
|
||||
version pinning, and `execute_update` (stop → pull → remove → recreate → verify).
|
||||
- Version awareness: `app_catalog::catalog_versions(app_id)` vs `installed_version()`
|
||||
(`api/rpc/package/set_config.rs:46`).
|
||||
- Frontend: `AppCard.vue` already renders an Update button off `pkg['available-update']`
|
||||
(`:48`, `:128`) and emits `update`.
|
||||
|
||||
#### What is actually MISSING (this is the real scope of item 6)
|
||||
|
||||
1. **The UI-vs-app-vs-both distinction does not exist.** `available-update` is a single
|
||||
version string — nothing classifies whether the change is the app image, its `*-ui`
|
||||
image, or both. This is the core of the operator's ask ("a different graphic for just
|
||||
ui, app, or both together") and needs a backend change, not just an icon.
|
||||
⚠️ Compounding factor: per `reference_app_ui_delivery_model`, `*-ui` apps are **not in
|
||||
the signed catalog** at all — so "is there a UI update" cannot be answered from the
|
||||
catalog today. That gap has to be closed first or the UI half is unanswerable.
|
||||
2. **The modal** (Update now / Cancel) — the card currently updates on click, no confirm.
|
||||
3. **The detail-page affordance** — same treatment as the card.
|
||||
4. **Button copy**: "See update" rather than "Update".
|
||||
|
||||
### 6b. Multiversion for ALL apps + upstream release discovery — **OPEN** (operator, 2026-08-03)
|
||||
> "we also need a way to provide multiversion support for all apps and it automatically
|
||||
> pulls the latest versions from the source app repository, safely, and the user can
|
||||
> choose to update so we aren't always updating manually"
|
||||
|
||||
#### Verified 2026-08-03: the schema and runtime already exist
|
||||
|
||||
This is much less work than it sounds, because the multiversion machinery built for
|
||||
Bitcoin generalises as data rather than code:
|
||||
|
||||
- `releases/app-catalog.json` entries already support a `versions[]` array of
|
||||
`{version, image, default?, deprecated?}`.
|
||||
- Runtime is complete: `catalog_versions(app_id)`, `catalog_default_version`,
|
||||
`catalog_image_for_version`, `package.versions`, version pinning through
|
||||
`package.set-config`, and `available_update_for_app` falling back to the
|
||||
`image-versions.sh` baseline pin.
|
||||
|
||||
**It is populated for 2 of 66 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.
|
||||
|
||||
---
|
||||
|
||||
@@ -143,6 +443,70 @@ below is dead on every path. Pre-existing; spotted in the v1.7.120 build warning
|
||||
|
||||
---
|
||||
|
||||
## STATUS 2026-08-04 — what shipped in 1.7.121 and what did not
|
||||
|
||||
### Shipped (committed + pushed)
|
||||
|
||||
| Item | Commit | Verified |
|
||||
|---|---|---|
|
||||
| 3. Federation trust escalation | `c0cfc72a` | 42/42 federation tests |
|
||||
| 3b. Trusted requires node password | `24ce8b39` | 44/44 + 79/79 + vue-tsc |
|
||||
| 4. lnd-ui OTA pin + host networking | `5088aef5` | — |
|
||||
| 1b. Manifest `auth:` declarations | `0c4826f8` | 73/73, all 56 manifests parse |
|
||||
| 1c. App gate (engine + audit) | `0de67ca6` | 23/23 appgate |
|
||||
| Dashboard backdrop-filter seam | `63d0183d` | 3/3, **live on archi-dev-box** |
|
||||
| 7. Release refuses unsigned manifest | `cc9e1958` | dry-run: signed/stripped/wrong-signer |
|
||||
| Gate safety model (`Option<PortAuth>`) | `ab2c8b6e` | 75/75 incl. LND wallet-port case |
|
||||
| Companion rebuild-loop | `719446c0` | podman behaviour proven first |
|
||||
| 5. Federated peers messageable | `edc9a172` | predicate pinned across device types |
|
||||
|
||||
### The two gate incidents — read before touching the gate again
|
||||
|
||||
Both were ONE mistake: a safety decision read an ABSENT manifest field as a
|
||||
value. A node's installed manifests always lag the binary, so "absent" is the
|
||||
normal state, and the daemon acted on instructions no manifest ever gave.
|
||||
|
||||
1. Gating any `session` port regardless of `bind` **published Bitcoin's
|
||||
loopback-only RPC 8332 on the LAN/Tailscale/IPv6** within seconds of deploy.
|
||||
2. The `bind`-keyed replacement looked safe (it protected `bind: 127.0.0.1`)
|
||||
but LND's gRPC 10009 / REST 18080 carry an EMPTY bind — one container
|
||||
recreate from pinning them to loopback and **breaking Zeus and every remote
|
||||
wallet**.
|
||||
|
||||
Now structural: `auth_policy()` classifies (undeclared → reported as
|
||||
unprotected, always safe), `auth_is_declared()` gates action (undeclared →
|
||||
never acted on). **Silence is not consent.**
|
||||
|
||||
### Proven on the node, empirically, not by reasoning
|
||||
|
||||
- Gate challenge → login → proxy works end to end over LAN and Tailscale.
|
||||
- **Daemon-side publish rewriting was removed.** Publishes are built in several
|
||||
places (`podman_client`, `package::install`, `stacks`); patching one covered
|
||||
one — the strfry recreate went through another and the pin never fired.
|
||||
- **Disk manifest edits do not apply to catalog-covered apps.** Even
|
||||
`bind: 127.0.0.1` written into the node's strfry manifest was overridden by
|
||||
the signed catalog. The catalog re-sign is REQUIRED; there is no shortcut.
|
||||
- A loopback-bound host port is **unreachable** from a pasta container, so
|
||||
loopback-pinning the Wyoming ports would break Home Assistant voice.
|
||||
|
||||
### Open for 1.7.122
|
||||
|
||||
1. **Catalog re-sign** — `bind: 127.0.0.1` + `auth: session` on the ~39 gated
|
||||
UI ports. This is what turns the gate from auditing into enforcing. Nothing
|
||||
in code can substitute for it.
|
||||
2. **Release-root rotation** — branch `rotate-release-root`, key
|
||||
`did:key:z6Mkfu5LT…DLWT` / `1578adcc…4418`, validated as a real curve point.
|
||||
**Sign the rotation release with the OLD key**; only the release after it
|
||||
uses the new one. Re-sign the catalog too.
|
||||
3. **Wyoming voice ports** (10200/10300/10400) — unauthenticated, and by the
|
||||
operator's policy they should not be. Correct fix is co-locating Home
|
||||
Assistant with the pine services on one container network so nothing is
|
||||
published; needs a node running both.
|
||||
4. **Item 2** filebrowser default login. **Items 6/6b** app updates +
|
||||
multiversion (`versions[]` already exists, populated for 2 of 66 apps).
|
||||
5. **`cargo-test-weekly` times out** at its 1500s cap on a loaded box — raise
|
||||
the cap or split the stage; it is not a code failure.
|
||||
|
||||
## RESUME HERE — next session
|
||||
|
||||
**Landed this session (both pushed):**
|
||||
|
||||
+10
-10
@@ -2,13 +2,13 @@
|
||||
gsd_state_version: 1.0
|
||||
milestone: v1.8.0
|
||||
milestone_name: milestone
|
||||
current_phase: 13
|
||||
current_phase_name: aiui-functional-conversational-node-control-and-content-surf
|
||||
current_phase: 09
|
||||
current_phase_name: BotFights Platform Upgrade
|
||||
status: executing
|
||||
stopped_at: v1.7.120-alpha SHIPPED; 1.7.121 queue open — see .planning/RELEASE-1.7.121-TASKS.md (12 items, RESUME HERE section at the end)
|
||||
last_updated: "2026-08-03T15:28:57.184Z"
|
||||
last_activity: 2026-08-03
|
||||
last_activity_desc: Phase 13 execution started
|
||||
last_updated: "2026-08-03T15:15:58.798Z"
|
||||
last_activity: 2026-07-31
|
||||
last_activity_desc: Phase 02 complete, transitioned to Phase 09
|
||||
progress:
|
||||
total_phases: 13
|
||||
completed_phases: 2
|
||||
@@ -24,14 +24,14 @@ progress:
|
||||
See: .planning/PROJECT.md (updated 2026-07-29)
|
||||
|
||||
**Core value:** A third-party developer can publish an app via the signed/decentralized registry and a user can install it on their node — manifest-driven, rootless, secure, robust.
|
||||
**Current focus:** Phase 13 — aiui-functional-conversational-node-control-and-content-surf
|
||||
**Current focus:** Phase 02 — ui-performance
|
||||
|
||||
## Current Position
|
||||
|
||||
Phase: 13 (aiui-functional-conversational-node-control-and-content-surf) — EXECUTING
|
||||
Plan: 1 of 15
|
||||
Status: Executing Phase 13
|
||||
Last activity: 2026-08-03 — Phase 13 execution started
|
||||
Phase: 09 — BotFights Platform Upgrade
|
||||
Plan: Not started
|
||||
Status: Ready to execute
|
||||
Last activity: 2026-07-31 — Phase 02 complete, transitioned to Phase 09
|
||||
|
||||
Progress: [█████░░░░░] 54%
|
||||
|
||||
|
||||
@@ -1,5 +1,61 @@
|
||||
# Changelog
|
||||
|
||||
## v1.7.125-alpha (2026-08-06)
|
||||
|
||||
- **The Lightning, Bitcoin, Electrum and mesh screens work again behind the login gate.** Since the gate went up, those screens loaded their frame and then showed every number as unreachable. The gate was deliberately hiding your login from the apps it protects — right for third-party apps, wrong for the node's own screens, which need that login to fetch your data. The gate now removes only its own credential, and the node's own screens explicitly receive yours. The same mistake was also quietly signing you out of apps with their own logins — Vaultwarden, Nextcloud, Gitea — on every single request; that stops too.
|
||||
- **IndeeHub heals itself.** Three separate faults fixed: its database helper was recreated with permissions too tight to read its own files (it had crashed and restarted roughly ten thousand times on one node); on another node two of its seven parts could never be recreated at all because of how the node asked for their storage — it would remove the old part and then fail to build its replacement, leaving the app half-missing forever; and a regenerated password could lock the app out of a database that keeps the original. The storage fault fixes the same trap for every future multi-part app.
|
||||
- **A missing piece of a running app now gets put back automatically.** If one container of a multi-part app disappears while its siblings are still running, the node treats that as a hole to repair rather than a choice to respect, and rebuilds the missing piece. An app you actually uninstalled stays uninstalled.
|
||||
- **Send and Receive open clean every time.** Whatever you typed last — an address, an amount, and above all an armed "send all funds" toggle — no longer quietly carries over into the next payment. Choosing "send all funds" also shows the amount being swept instead of a confusing 0.
|
||||
- **A sweep that cannot happen now says why.** Trying to sweep a balance that is below Bitcoin's dust minimum (about 546 sats) or not yet confirmed used to fail with "check server logs"; it now explains that no transaction can be built from those coins.
|
||||
- **The camera scanner option no longer vanishes on desktop.** Browsers only allow the live camera on secure (HTTPS) pages, and the scan window silently hid the camera choice on plain connections — which read as "the scanner is gone". The option now stays visible and explains itself, and the photo and paste routes always work. The companion app's built-in scanner is untouched.
|
||||
- **App data folders can no longer be "repaired" into a state the app cannot use.** When the node fixed a folder's ownership through its fallback path, it wrote the container's raw user number instead of the translated one, so the fix reported success while the app still could not open its own files — one node's BotFights restarted every ten seconds over exactly this. The translation is now applied.
|
||||
- Also: the app login page uses the Archipelago mark and stays centred on phones with the keyboard open, app icons in the install window are no longer cropped, and when the node fails to build a container it now records the actual reason instead of a one-line stub that hid the cause of the IndeeHub fault for days.
|
||||
- Known gaps, disclosed rather than buried: three voice-assistant ports remain open without authentication. Non-browser clients — phone apps for Vaultwarden, Home Assistant or Jellyfin, and git over the web — meet the login page and need an access token. The 5x real-node lifecycle gate was not run for this release.
|
||||
|
||||
## v1.7.124-alpha (2026-08-05)
|
||||
|
||||
- **The most important fix in this release: some nodes were left switched off by their own update, and could not switch themselves back on.** The node replaces its program and then exits, expecting the system to start it again — but nodes installed from older images carried a setting that only restarts the program if it *crashes*. A clean, deliberate exit looked like success, so nothing restarted it, and the node sat dead showing "server starting" with nothing able to start it. One of ours was down for over two hours this way, and three of four checked had the same setting waiting to bite. Your node now repairs that setting itself the first time it starts, so it survives every future update.
|
||||
- **Portainer opens again.** Its screen reported the app as not responding because the app was quietly refusing to start: nodes have been running Portainer 2.39.1, their stored data was written by that version, and the app list pinned a version from two years earlier — so when the container was rebuilt it landed on the old one, which will not read newer data. The correct version is now pinned, older installs upgrade cleanly, and no data was touched.
|
||||
- **Bitcoin starts reliably again.** A leftover settings file in the Bitcoin folder — one the node itself kept rewriting and Bitcoin no longer reads — is treated as fatal by Bitcoin, so affected nodes restarted every few seconds forever. The node no longer writes that file, removes stale copies, and treats any that remain as harmless.
|
||||
- **Every app screen opens from My Apps again.** The login gate refused to be displayed inside another page at all, which is exactly how My Apps opens an app, so protected apps appeared broken. It now allows only your own node to display it, and refuses everyone else — a distinction the old setting could not express.
|
||||
- **The app login screen now looks like the node's own.** Same rotating artwork, the same panel, the Archipelago mark, and the app's real icon shown as a tile the way My Apps shows it, instead of a plain box with a letter.
|
||||
- **The Mesh screen uses wide displays properly.** On very large screens it stacked all five panels on top of each other, clipping three of the headings to a sliver and squeezing the map into a letterbox — more screen producing a worse view. It now shows one panel at a time, filling the space, with the map running edge to edge.
|
||||
- **You can choose how long you stay signed in.** Settings → Account now offers an inactivity timeout and a hard limit, plus an option to re-enter your password before sending funds. TV and kiosk screens are never signed out for sitting idle, because there is nobody there to sign them back in.
|
||||
- Updates now come from `source.archipelago-foundation.org` rather than a bare address, with the old one kept as an automatic fallback for nodes whose clock or name lookup is off. Also included: clearer wallet errors from ecash mints, and mesh peers reconnecting via their last known address before falling back to the wider network.
|
||||
- Known gaps, disclosed rather than buried: three voice-assistant ports remain open without authentication; the correct fix puts them on a private network with the assistant. Non-browser clients — phone apps for Vaultwarden, Home Assistant or Jellyfin, and git over the web — meet the login page and need an access token. The 5x real-node lifecycle gate was not run for this release.
|
||||
|
||||
## v1.7.123-alpha (2026-08-05)
|
||||
|
||||
- **Five more screens on your node were readable by anyone who could reach it, and the previous release's own check said they were fine.** The Bitcoin, Lightning, Electrum, FIPS mesh and Fedimint Guardian screens each answered on their port with no login. They were missed because they work differently from ordinary apps: they run directly on the node's network rather than behind its container plumbing, so there was no address to pin and their descriptions listed no port at all — and the node builds its list of what to protect from exactly those descriptions. It therefore neither protected them nor listed them as unprotected. A check that reports success while five screens are open is worse than no check, and this was found by scanning the node from another machine rather than asking the node about itself.
|
||||
- **What was actually readable was the page, not your money.** Every request on those ports that could have returned a credential — the Lightning connection details, the wallet passthrough, container logs, and every node command — already required a login and still refused without one. The Lightning macaroon fix from v1.7.120 was verified directly rather than assumed. What leaked was the screen itself: layout and code, no wallet data, no keys.
|
||||
- All five now serve only to the node itself, with the login gate in front of them, exactly like the twenty app screens closed in the previous release.
|
||||
- **Every port on the node now has a stated policy — there are no undecided ones left.** Eleven ports previously had no instruction either way and stayed open by default. The BotFights arena, the router screen and the Pine voice screen now require the node password. The ones that genuinely cannot take a login page stay open with a written reason: Fedimint's guardian and gateway connections (federation members authenticate to the federation), NetBird's management and dashboard ports (your VPN devices carry their own credentials and cannot hold a browser session, and its dashboard needs its own certificate), Pine's secure listener, and the Lightning REST port, which wallets reach with a macaroon exactly as before.
|
||||
- Fresh installs are covered too, not just existing nodes. The five screens are delivered as prebuilt images, so a newly flashed node would have come up open even after this fix. All five were rebuilt, published, and then pulled back and inspected to confirm the fix is really inside them.
|
||||
- Two delivery faults fixed alongside, either of which would have silently undone the above: two of the five screens were reaching nodes through no update path at all, so edits to them never arrived; and a fourth copy of the Bitcoin screen's configuration was being rewritten on every health check, which would have re-opened that port after everything else was corrected.
|
||||
- Known gaps, disclosed rather than buried: non-browser clients — phone apps for Vaultwarden, Home Assistant or Jellyfin, and git over the web — meet the login page and need an access token. Three voice-assistant ports remain open without authentication; the correct fix puts them on a private network with the assistant. The 5x real-node lifecycle gate was not run for this release.
|
||||
|
||||
## v1.7.122-alpha (2026-08-04)
|
||||
|
||||
- **Your apps now ask for your node password before they open — over your home network, Tailscale, the mesh and Tor alike.** Until now anyone who could reach your node could open Immich, Nextcloud, Vaultwarden, Jellyfin, Grafana and the rest simply by typing the address and port, with no login at all. Twenty app screens now sit behind the same login you use for the node, showing you which app you are opening, and honouring two-factor if you have it switched on. Logging in at an app address logs you into the dashboard too, so it is one password, not one per app. This completes the groundwork disclosed in v1.7.121.
|
||||
- **The things that must stay open stayed open.** Zeus and other remote wallets still reach your Lightning node directly, Electrum wallets still connect, and Bitcoin still talks to its peers — those connections carry their own proof of identity and a login page would simply break them. Every one of these seventeen exceptions now has to state in writing why it is safe to leave open, so the list is something you can read rather than something you have to discover.
|
||||
- **A private address on your node was answering the mesh without a password.** One app's port was marked as being for this machine only, and the part of the node that carries mesh traffic did not know that — it forwarded requests from the whole mesh straight to it. Found while verifying the work above on a real node, not in testing. That path now refuses anything marked machine-only, and the app is reachable only from the node itself, as intended.
|
||||
- **Tor addresses no longer skip the login.** An app published as a .onion address was handed straight to the app, because a Tor visitor carries no session cookie to check. The login gate now takes those addresses first, closing the last of the four routes that went around it.
|
||||
- Nodes fix themselves after this update. Apps installed before this system used its current container setup kept their old wide-open address even after the signed list told them to move, and each would otherwise have needed hand-holding on every node. Your node now notices the difference and rebuilds those apps itself, keeping their data, within about half a minute of starting. Verified by putting a node back into the old state deliberately and watching it repair.
|
||||
- The node had been reading two different sets of instructions about its own apps — the signed list it downloads, and older copies on disk — which is how a port meant to stay private was briefly opened on a test node. Both now come from the signed list, and a port withdrawn from the login gate is released without needing a restart.
|
||||
- **The key that signs these updates has been replaced.** The previous signing key was exposed where it should not have been, so it is treated as compromised and this release installs its replacement. This update is the last one signed with the old key, by necessity — it is the one that teaches your node the new one.
|
||||
- Known gaps, disclosed rather than buried: eleven app ports still have no stated policy — BotFights, the Fedimint gateway, NetBird, the voice assistant's own screens and the router screen — and remain reachable without a login until each is decided deliberately; the node reports them rather than guessing, because guessing at an unstated setting caused both incidents behind this work. Three voice-assistant ports are still open without authentication; the correct fix puts them on a private network with the assistant. Non-browser clients — phone apps for Vaultwarden, Home Assistant or Jellyfin, and git over the web — will now meet the login page and need an access token. The 5x real-node lifecycle gate was not run for this release.
|
||||
|
||||
## 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.
|
||||
|
||||
@@ -442,7 +442,7 @@
|
||||
"author": "Portainer",
|
||||
"category": "development",
|
||||
"tier": "optional",
|
||||
"dockerImage": "146.59.87.168:3000/lfg2025/portainer:2.19.4",
|
||||
"dockerImage": "146.59.87.168:3000/lfg2025/portainer:2.39.1",
|
||||
"repoUrl": "https://github.com/portainer/portainer",
|
||||
"containerConfig": {
|
||||
"ports": [
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -26,6 +26,8 @@ app:
|
||||
- host: 4080
|
||||
container: 8080
|
||||
protocol: tcp
|
||||
bind: 127.0.0.1
|
||||
auth: gated
|
||||
|
||||
environment:
|
||||
- FRONTEND_HTTP_PORT=8080
|
||||
|
||||
@@ -33,6 +33,8 @@ app:
|
||||
- host: 32838
|
||||
container: 32838
|
||||
protocol: tcp
|
||||
bind: 127.0.0.1
|
||||
auth: local
|
||||
|
||||
volumes:
|
||||
- type: bind
|
||||
|
||||
@@ -51,6 +51,8 @@ app:
|
||||
- host: 3535
|
||||
container: 3535
|
||||
protocol: tcp
|
||||
bind: 127.0.0.1
|
||||
auth: local
|
||||
|
||||
volumes:
|
||||
# Holds the wallet DB, mnemonic and auth token. ARK funds are recoverable
|
||||
|
||||
@@ -38,6 +38,9 @@ app:
|
||||
RPC_CONF="/tmp/rpc.conf";
|
||||
umask 077;
|
||||
{ echo "rpcuser=$RPC_USER"; echo "rpcpassword=$RPC_PASS"; } > "$RPC_CONF";
|
||||
if [ -f /home/bitcoin/.bitcoin/bitcoin.conf ]; then
|
||||
echo "archipelago: ignoring legacy datadir bitcoin.conf; RPC config comes from $RPC_CONF" >&2;
|
||||
fi;
|
||||
RPC_TXRELAY_AUTH="$(printenv BITCOIN_RPC_TXRELAY_RPCAUTH || true)";
|
||||
DISK_GB_VALUE="$(printenv DISK_GB || true)";
|
||||
RPC_HEADROOM="-rpcthreads=16 -rpcworkqueue=256";
|
||||
@@ -46,9 +49,9 @@ app:
|
||||
RPC_TXRELAY_FLAGS="$RPC_TXRELAY_FLAGS -rpcauth=$RPC_TXRELAY_AUTH -rpcwhitelist=txrelay:sendrawtransaction,submitpackage,testmempoolaccept,getmempoolinfo,getrawmempool,getmempoolentry,getnetworkinfo,getblockchaininfo,getblockcount,getblockhash,getblock,getblockheader,getrawtransaction,gettxout,gettxspendingprevout,decoderawtransaction,decodescript,estimatesmartfee,uptime,ping,getconnectioncount,getpeerinfo,getindexinfo,getdeploymentinfo,getchaintips";
|
||||
fi;
|
||||
if [ "${DISK_GB_VALUE:-0}" -lt 1000 ]; then
|
||||
exec "$BITCOIND" -datadir=/home/bitcoin/.bitcoin -conf="$RPC_CONF" -printtoconsole=0 -server=1 -prune=550 -rpcallowip=0.0.0.0/0 -rpcbind=0.0.0.0:8332 -listen=1 -bind=0.0.0.0:8333 -dbcache=1024 -par=0 -maxconnections=125 $RPC_HEADROOM $RPC_TXRELAY_FLAGS;
|
||||
exec "$BITCOIND" -datadir=/home/bitcoin/.bitcoin -conf="$RPC_CONF" -allowignoredconf=1 -printtoconsole=0 -server=1 -prune=550 -rpcallowip=0.0.0.0/0 -rpcbind=0.0.0.0:8332 -listen=1 -bind=0.0.0.0:8333 -dbcache=1024 -par=0 -maxconnections=125 $RPC_HEADROOM $RPC_TXRELAY_FLAGS;
|
||||
else
|
||||
exec "$BITCOIND" -datadir=/home/bitcoin/.bitcoin -conf="$RPC_CONF" -printtoconsole=0 -server=1 -txindex=1 -rpcallowip=0.0.0.0/0 -rpcbind=0.0.0.0:8332 -listen=1 -bind=0.0.0.0:8333 -dbcache=4096 -par=0 -maxconnections=125 $RPC_HEADROOM $RPC_TXRELAY_FLAGS;
|
||||
exec "$BITCOIND" -datadir=/home/bitcoin/.bitcoin -conf="$RPC_CONF" -allowignoredconf=1 -printtoconsole=0 -server=1 -txindex=1 -rpcallowip=0.0.0.0/0 -rpcbind=0.0.0.0:8332 -listen=1 -bind=0.0.0.0:8333 -dbcache=4096 -par=0 -maxconnections=125 $RPC_HEADROOM $RPC_TXRELAY_FLAGS;
|
||||
fi
|
||||
derived_env:
|
||||
- key: DISK_GB
|
||||
@@ -85,9 +88,13 @@ app:
|
||||
container: 8332
|
||||
protocol: tcp
|
||||
bind: 127.0.0.1
|
||||
auth: local
|
||||
- host: 8333
|
||||
container: 8333
|
||||
protocol: tcp
|
||||
auth: none
|
||||
auth_rationale: >-
|
||||
Bitcoin p2p gossip. Peers are anonymous by design and speak the Bitcoin wire protocol, not HTTP.
|
||||
|
||||
volumes:
|
||||
- type: bind
|
||||
|
||||
@@ -38,6 +38,9 @@ app:
|
||||
RPC_CONF="/tmp/rpc.conf";
|
||||
umask 077;
|
||||
{ echo "rpcuser=$RPC_USER"; echo "rpcpassword=$RPC_PASS"; } > "$RPC_CONF";
|
||||
if [ -f /home/bitcoin/.bitcoin/bitcoin.conf ]; then
|
||||
echo "archipelago: ignoring legacy datadir bitcoin.conf; RPC config comes from $RPC_CONF" >&2;
|
||||
fi;
|
||||
RPC_TXRELAY_AUTH="$(printenv BITCOIN_RPC_TXRELAY_RPCAUTH || true)";
|
||||
DISK_GB_VALUE="$(printenv DISK_GB || true)";
|
||||
RPC_HEADROOM="-rpcthreads=16 -rpcworkqueue=256";
|
||||
@@ -46,9 +49,9 @@ app:
|
||||
RPC_TXRELAY_FLAGS="$RPC_TXRELAY_FLAGS -rpcauth=$RPC_TXRELAY_AUTH -rpcwhitelist=txrelay:sendrawtransaction,submitpackage,testmempoolaccept,getmempoolinfo,getrawmempool,getmempoolentry,getnetworkinfo,getblockchaininfo,getblockcount,getblockhash,getblock,getblockheader,getrawtransaction,gettxout,gettxspendingprevout,decoderawtransaction,decodescript,estimatesmartfee,uptime,ping,getconnectioncount,getpeerinfo,getindexinfo,getdeploymentinfo,getchaintips";
|
||||
fi;
|
||||
if [ "${DISK_GB_VALUE:-0}" -lt 1000 ]; then
|
||||
exec "$BITCOIND" -datadir=/home/bitcoin/.bitcoin -conf="$RPC_CONF" -printtoconsole=0 -server=1 -prune=550 -rpcallowip=0.0.0.0/0 -rpcbind=0.0.0.0:8332 -listen=1 -bind=0.0.0.0:8333 -dbcache=2048 -par=0 -maxconnections=125 $RPC_HEADROOM $RPC_TXRELAY_FLAGS;
|
||||
exec "$BITCOIND" -datadir=/home/bitcoin/.bitcoin -conf="$RPC_CONF" -allowignoredconf=1 -printtoconsole=0 -server=1 -prune=550 -rpcallowip=0.0.0.0/0 -rpcbind=0.0.0.0:8332 -listen=1 -bind=0.0.0.0:8333 -dbcache=2048 -par=0 -maxconnections=125 $RPC_HEADROOM $RPC_TXRELAY_FLAGS;
|
||||
else
|
||||
exec "$BITCOIND" -datadir=/home/bitcoin/.bitcoin -conf="$RPC_CONF" -printtoconsole=0 -server=1 -txindex=1 -rpcallowip=0.0.0.0/0 -rpcbind=0.0.0.0:8332 -listen=1 -bind=0.0.0.0:8333 -dbcache=4096 -par=0 -maxconnections=125 $RPC_HEADROOM $RPC_TXRELAY_FLAGS;
|
||||
exec "$BITCOIND" -datadir=/home/bitcoin/.bitcoin -conf="$RPC_CONF" -allowignoredconf=1 -printtoconsole=0 -server=1 -txindex=1 -rpcallowip=0.0.0.0/0 -rpcbind=0.0.0.0:8332 -listen=1 -bind=0.0.0.0:8333 -dbcache=4096 -par=0 -maxconnections=125 $RPC_HEADROOM $RPC_TXRELAY_FLAGS;
|
||||
fi
|
||||
derived_env:
|
||||
- key: DISK_GB
|
||||
@@ -85,9 +88,13 @@ app:
|
||||
container: 8332
|
||||
protocol: tcp
|
||||
bind: 127.0.0.1
|
||||
auth: local
|
||||
- host: 8333
|
||||
container: 8333
|
||||
protocol: tcp
|
||||
auth: none
|
||||
auth_rationale: >-
|
||||
Bitcoin p2p gossip. Peers are anonymous by design and speak the Bitcoin wire protocol, not HTTP.
|
||||
|
||||
volumes:
|
||||
- type: bind
|
||||
|
||||
@@ -31,7 +31,22 @@ app:
|
||||
# proxies to 127.0.0.1:8332 which is where the bitcoin backend binds
|
||||
# its RPC. `ports:` is intentionally empty because host networking
|
||||
# bypasses port mapping.
|
||||
ports: []
|
||||
# Declared so the APP GATE can see this port. Host networking means Podman
|
||||
# publishes nothing (quadlet skips PublishPort in host mode), so `bind:` here
|
||||
# is a statement of where the container's own nginx listens — 127.0.0.1 —
|
||||
# not a publish instruction. Without this declaration the gate had no idea
|
||||
# the port existed: it was neither protected nor listed as unprotected, and
|
||||
# served the Bitcoin screen unauthenticated on every interface.
|
||||
ports:
|
||||
- host: 8334
|
||||
container: 8334
|
||||
protocol: tcp
|
||||
bind: 127.0.0.1
|
||||
auth: gated
|
||||
# First-party companion UI: its nginx forwards the node session cookie
|
||||
# to the daemon's authenticated endpoints; without passthrough the gate
|
||||
# strips it and every data call 401s while the page shell renders.
|
||||
session_passthrough: true
|
||||
|
||||
volumes:
|
||||
# Bind-mount the rendered nginx.conf read-only. The prod orchestrator
|
||||
|
||||
@@ -62,6 +62,8 @@ app:
|
||||
- host: 9100
|
||||
container: 9100
|
||||
protocol: tcp # Web UI + API
|
||||
bind: 127.0.0.1
|
||||
auth: gated
|
||||
|
||||
volumes:
|
||||
# A bare relative source (was "botfights-data", no leading slash) is
|
||||
|
||||
@@ -45,6 +45,8 @@ app:
|
||||
- host: 23000
|
||||
container: 49392
|
||||
protocol: tcp
|
||||
bind: 127.0.0.1
|
||||
auth: gated
|
||||
|
||||
volumes:
|
||||
- type: bind
|
||||
|
||||
@@ -31,9 +31,15 @@ app:
|
||||
- host: 9736
|
||||
container: 9735
|
||||
protocol: tcp # P2P (using 9736 to avoid conflict with LND)
|
||||
auth: none
|
||||
auth_rationale: >-
|
||||
Lightning p2p. The BOLT-8 noise handshake authenticates and encrypts the channel itself.
|
||||
- host: 9835
|
||||
container: 9835
|
||||
protocol: tcp # gRPC
|
||||
auth: none
|
||||
auth_rationale: >-
|
||||
Core Lightning gRPC, authenticated by mutual TLS client certificates.
|
||||
|
||||
volumes:
|
||||
- type: bind
|
||||
|
||||
@@ -30,6 +30,8 @@ app:
|
||||
- host: 8088
|
||||
container: 8080
|
||||
protocol: tcp # Web UI
|
||||
bind: 127.0.0.1
|
||||
auth: gated
|
||||
|
||||
volumes:
|
||||
- type: bind
|
||||
|
||||
@@ -23,7 +23,22 @@ app:
|
||||
network_policy: host
|
||||
|
||||
# Host networking: nginx listens on 50002 directly on the host IP.
|
||||
ports: []
|
||||
# Declared so the APP GATE can see this port. Host networking means Podman
|
||||
# publishes nothing (quadlet skips PublishPort in host mode), so `bind:` here
|
||||
# is a statement of where the container's own nginx listens — 127.0.0.1 —
|
||||
# not a publish instruction. Without this declaration the gate had no idea
|
||||
# the port existed: it was neither protected nor listed as unprotected, and
|
||||
# served the Electrs screen unauthenticated on every interface.
|
||||
ports:
|
||||
- host: 50002
|
||||
container: 50002
|
||||
protocol: tcp
|
||||
bind: 127.0.0.1
|
||||
auth: gated
|
||||
# First-party companion UI: its nginx forwards the node session cookie
|
||||
# to the daemon's authenticated endpoints; without passthrough the gate
|
||||
# strips it and every data call 401s while the page shell renders.
|
||||
session_passthrough: true
|
||||
|
||||
volumes: []
|
||||
|
||||
|
||||
@@ -45,6 +45,9 @@ app:
|
||||
- host: 50001
|
||||
container: 50001
|
||||
protocol: tcp
|
||||
auth: none
|
||||
auth_rationale: >-
|
||||
Electrum wire protocol over TCP. Electrum wallets speak it directly and cannot hold a session cookie.
|
||||
|
||||
volumes:
|
||||
- type: bind
|
||||
|
||||
@@ -66,6 +66,8 @@ app:
|
||||
- host: 8178
|
||||
container: 8080
|
||||
protocol: tcp
|
||||
bind: 127.0.0.1
|
||||
auth: local
|
||||
|
||||
volumes:
|
||||
# Same dir the first-boot bundled path uses + where the wallet bridge reads
|
||||
|
||||
@@ -60,9 +60,17 @@ app:
|
||||
- host: 8176
|
||||
container: 8176
|
||||
protocol: tcp
|
||||
auth: none
|
||||
auth_rationale: >-
|
||||
Fedimint gateway API, protected by its own bcrypt password (--bcrypt-password-hash)
|
||||
and reached by federation peers and clients that cannot hold a browser session.
|
||||
- host: 9737
|
||||
container: 9737
|
||||
protocol: tcp
|
||||
auth: none
|
||||
auth_rationale: >-
|
||||
LDK Lightning p2p for the gateway. The BOLT-8 noise handshake authenticates and
|
||||
encrypts the connection itself.
|
||||
|
||||
volumes:
|
||||
- type: bind
|
||||
|
||||
@@ -50,14 +50,31 @@ app:
|
||||
- host: 8173
|
||||
container: 8173
|
||||
protocol: tcp
|
||||
auth: none
|
||||
auth_rationale: >-
|
||||
Fedimint guardian consensus. Other guardians speak the federation's own
|
||||
authenticated protocol here; a login page would break consensus.
|
||||
- host: 8174
|
||||
container: 8174
|
||||
protocol: tcp
|
||||
auth: none
|
||||
auth_rationale: >-
|
||||
Fedimint guardian API for federation clients, which authenticate to the
|
||||
federation itself and cannot hold a browser session.
|
||||
# Public launch port 8175 is owned by archy-fedimint-ui, which serves a
|
||||
# wait page while Bitcoin syncs and proxies here after fedimintd starts.
|
||||
# 8175 is NOT declared here. It is served by the archy-fedimint-ui
|
||||
# companion, a different container, and declaring it on this app made the
|
||||
# orchestrator try to publish 8175 from fedimintd — colliding with the
|
||||
# companion that already holds it, so start_container failed forever and
|
||||
# fedimint crash-looped (100.82.34.38, 2026-08-05). The companion's nginx
|
||||
# is pinned to 127.0.0.1, which is what actually closes that port; the
|
||||
# gate reports it rather than fronting it.
|
||||
- host: 8177
|
||||
container: 8175
|
||||
protocol: tcp
|
||||
bind: 127.0.0.1
|
||||
auth: local
|
||||
|
||||
volumes:
|
||||
- type: bind
|
||||
|
||||
@@ -27,6 +27,8 @@ app:
|
||||
- host: 8083
|
||||
container: 80
|
||||
protocol: tcp
|
||||
bind: 127.0.0.1
|
||||
auth: gated
|
||||
|
||||
volumes:
|
||||
- type: bind
|
||||
|
||||
@@ -27,7 +27,22 @@ app:
|
||||
# Host networking: nginx listens on 8336 directly on the host IP and
|
||||
# proxies to 127.0.0.1:5678 (the archipelago RPC). `ports:` is
|
||||
# intentionally empty because host networking bypasses port mapping.
|
||||
ports: []
|
||||
# Declared so the APP GATE can see this port. Host networking means Podman
|
||||
# publishes nothing (quadlet skips PublishPort in host mode), so `bind:` here
|
||||
# is a statement of where the container's own nginx listens — 127.0.0.1 —
|
||||
# not a publish instruction. Without this declaration the gate had no idea
|
||||
# the port existed: it was neither protected nor listed as unprotected, and
|
||||
# served the FIPS mesh screen unauthenticated on every interface.
|
||||
ports:
|
||||
- host: 8336
|
||||
container: 8336
|
||||
protocol: tcp
|
||||
bind: 127.0.0.1
|
||||
auth: gated
|
||||
# First-party companion UI: its nginx forwards the node session cookie
|
||||
# to the daemon's authenticated endpoints; without passthrough the gate
|
||||
# strips it and every data call 401s while the page shell renders.
|
||||
session_passthrough: true
|
||||
|
||||
volumes: []
|
||||
|
||||
|
||||
@@ -26,9 +26,14 @@ app:
|
||||
- host: 3001
|
||||
container: 3000
|
||||
protocol: tcp
|
||||
bind: 127.0.0.1
|
||||
auth: gated
|
||||
- host: 2222
|
||||
container: 22
|
||||
protocol: tcp
|
||||
auth: none
|
||||
auth_rationale: >-
|
||||
Git over SSH, authenticated by the user's own SSH keypair. Not HTTP, so the gate cannot serve a login page here.
|
||||
|
||||
volumes:
|
||||
- type: bind
|
||||
|
||||
@@ -31,6 +31,8 @@ app:
|
||||
- host: 3000
|
||||
container: 3000
|
||||
protocol: tcp # Web UI
|
||||
bind: 127.0.0.1
|
||||
auth: gated
|
||||
|
||||
volumes:
|
||||
- type: bind
|
||||
|
||||
@@ -30,6 +30,8 @@ app:
|
||||
- host: 8123
|
||||
container: 8123
|
||||
protocol: tcp # Web UI
|
||||
bind: 127.0.0.1
|
||||
auth: gated
|
||||
|
||||
volumes:
|
||||
- type: bind
|
||||
|
||||
@@ -44,6 +44,8 @@ app:
|
||||
- host: 2283
|
||||
container: 2283
|
||||
protocol: tcp
|
||||
bind: 127.0.0.1
|
||||
auth: gated
|
||||
|
||||
volumes:
|
||||
- type: bind
|
||||
|
||||
@@ -22,7 +22,13 @@ app:
|
||||
memory_limit: 256Mi
|
||||
|
||||
security:
|
||||
capabilities: [SETGID, SETUID]
|
||||
# The alpine entrypoint runs as container-root, `find`s /data to chown
|
||||
# anything not owned by the redis user, then su-execs to it. Under the
|
||||
# orchestrator's --cap-drop=ALL, root cannot traverse the 0700
|
||||
# appendonlydir owned by uid 999 without DAC_OVERRIDE (observed
|
||||
# crash-looping ~4k restarts on archi-dev-box) — CHOWN is what the find's
|
||||
# -exec chown needs on adopted legacy data.
|
||||
capabilities: [CHOWN, DAC_OVERRIDE, SETGID, SETUID]
|
||||
readonly_root: false
|
||||
network_policy: isolated
|
||||
|
||||
|
||||
@@ -38,6 +38,8 @@ app:
|
||||
- host: 7778
|
||||
container: 7777
|
||||
protocol: tcp # Web UI. Port 7777 on the host is reserved for the Nostr relay.
|
||||
bind: 127.0.0.1
|
||||
auth: gated
|
||||
|
||||
# Writable scratch the baked nginx needs; matches the legacy installer's
|
||||
# --tmpfs /run + /var/cache/nginx.
|
||||
|
||||
@@ -25,6 +25,8 @@ app:
|
||||
- host: 8096
|
||||
container: 8096
|
||||
protocol: tcp
|
||||
bind: 127.0.0.1
|
||||
auth: gated
|
||||
|
||||
volumes:
|
||||
- type: bind
|
||||
|
||||
@@ -32,12 +32,23 @@ app:
|
||||
- host: 9738
|
||||
container: 9735
|
||||
protocol: tcp # P2P
|
||||
auth: none
|
||||
auth_rationale: >-
|
||||
Lightning p2p. The BOLT-8 noise handshake authenticates and encrypts the channel itself.
|
||||
- host: 10010
|
||||
container: 10009
|
||||
protocol: tcp # gRPC
|
||||
auth: none
|
||||
auth_rationale: >-
|
||||
LND gRPC, authenticated by macaroon over TLS. Remote wallets depend on reaching this directly.
|
||||
# Mirrors lnd's 18080 exemption — same LND REST API, same macaroon auth.
|
||||
- host: 8091
|
||||
container: 8080
|
||||
protocol: tcp # REST/Web UI
|
||||
auth: none
|
||||
auth_rationale: >-
|
||||
LND REST, authenticated by macaroon over TLS. A browser login page would break
|
||||
Zeus and every non-browser wallet client, exactly as for lnd's 18080.
|
||||
|
||||
volumes:
|
||||
- type: bind
|
||||
|
||||
@@ -35,7 +35,22 @@ app:
|
||||
# port to a container port where nothing listens. scripts/container-specs.sh
|
||||
# carried the identical mistake and was fixed alongside this; recreating from
|
||||
# it on archi-dev-box left :18083 refusing connections.
|
||||
ports: []
|
||||
# Declared so the APP GATE can see this port. Host networking means Podman
|
||||
# publishes nothing (quadlet skips PublishPort in host mode), so `bind:` here
|
||||
# is a statement of where the container's own nginx listens — 127.0.0.1 —
|
||||
# not a publish instruction. Without this declaration the gate had no idea
|
||||
# the port existed: it was neither protected nor listed as unprotected, and
|
||||
# served the LND screen unauthenticated on every interface.
|
||||
ports:
|
||||
- host: 18083
|
||||
container: 18083
|
||||
protocol: tcp
|
||||
bind: 127.0.0.1
|
||||
auth: gated
|
||||
# First-party companion UI: its nginx forwards the node session cookie
|
||||
# to the daemon's authenticated endpoints; without passthrough the gate
|
||||
# strips it and every data call 401s while the page shell renders.
|
||||
session_passthrough: true
|
||||
|
||||
volumes: []
|
||||
|
||||
|
||||
@@ -38,12 +38,21 @@ app:
|
||||
- host: 9735
|
||||
container: 9735
|
||||
protocol: tcp
|
||||
auth: none
|
||||
auth_rationale: >-
|
||||
Lightning p2p. The BOLT-8 noise handshake authenticates and encrypts the channel itself.
|
||||
- host: 10009
|
||||
container: 10009
|
||||
protocol: tcp
|
||||
auth: none
|
||||
auth_rationale: >-
|
||||
LND gRPC, authenticated by macaroon over TLS. Zeus and other remote wallets depend on reaching this directly.
|
||||
- host: 18080
|
||||
container: 8080
|
||||
protocol: tcp
|
||||
auth: none
|
||||
auth_rationale: >-
|
||||
LND REST, authenticated by macaroon over TLS. A browser login page would break Zeus and every non-browser wallet client.
|
||||
|
||||
volumes:
|
||||
- type: bind
|
||||
|
||||
@@ -42,6 +42,8 @@ app:
|
||||
- host: 8999
|
||||
container: 8999
|
||||
protocol: tcp
|
||||
bind: 127.0.0.1
|
||||
auth: local
|
||||
|
||||
volumes:
|
||||
- type: bind
|
||||
|
||||
@@ -33,6 +33,8 @@ app:
|
||||
- host: 4080
|
||||
container: 8080 # mempool-frontend nginx listens on 8080 (FRONTEND_HTTP_PORT=8080)
|
||||
protocol: tcp # Web UI
|
||||
bind: 127.0.0.1
|
||||
auth: gated
|
||||
|
||||
volumes:
|
||||
- type: bind
|
||||
|
||||
@@ -30,6 +30,8 @@ app:
|
||||
- host: 8089
|
||||
container: 8080
|
||||
protocol: tcp # Web UI
|
||||
bind: 127.0.0.1
|
||||
auth: gated
|
||||
|
||||
volumes:
|
||||
- type: bind
|
||||
|
||||
@@ -48,9 +48,17 @@ app:
|
||||
- host: 8086
|
||||
container: 80
|
||||
protocol: tcp # management API + embedded OIDC issuer (/oauth2)
|
||||
auth: none
|
||||
auth_rationale: >-
|
||||
NetBird management API and its OIDC issuer. Enrolled devices authenticate
|
||||
themselves with setup keys and JWTs, and they cannot hold a browser session —
|
||||
a login page here would disconnect every VPN client on the network.
|
||||
- host: 3478
|
||||
container: 3478
|
||||
protocol: udp # STUN — must be UDP; tcp here breaks relay discovery
|
||||
auth: none
|
||||
auth_rationale: >-
|
||||
STUN over UDP for NAT traversal; it must answer unauthenticated probes to do its job at all.
|
||||
|
||||
volumes:
|
||||
- type: bind
|
||||
|
||||
@@ -44,6 +44,11 @@ app:
|
||||
- host: 8087
|
||||
container: 443
|
||||
protocol: tcp
|
||||
auth: none
|
||||
auth_rationale: >-
|
||||
NetBird dashboard over TLS, with its own login. The gate speaks plain HTTP,
|
||||
so fronting this port would break the secure context the dashboard requires
|
||||
(issue #15) and the certificate clients pin.
|
||||
|
||||
volumes:
|
||||
- type: bind
|
||||
|
||||
@@ -25,6 +25,8 @@ app:
|
||||
- host: 8085
|
||||
container: 80
|
||||
protocol: tcp
|
||||
bind: 127.0.0.1
|
||||
auth: gated
|
||||
|
||||
volumes:
|
||||
- type: bind
|
||||
|
||||
@@ -31,6 +31,8 @@ app:
|
||||
- host: 18081
|
||||
container: 8080
|
||||
protocol: tcp # HTTP/WebSocket
|
||||
bind: 127.0.0.1
|
||||
auth: gated
|
||||
|
||||
volumes:
|
||||
- type: bind
|
||||
|
||||
@@ -24,6 +24,8 @@ app:
|
||||
- host: 2342
|
||||
container: 2342
|
||||
protocol: tcp
|
||||
bind: 127.0.0.1
|
||||
auth: gated
|
||||
|
||||
volumes:
|
||||
- type: bind
|
||||
|
||||
@@ -40,6 +40,9 @@ app:
|
||||
- host: 10400
|
||||
container: 10400
|
||||
protocol: tcp
|
||||
auth: none
|
||||
auth_rationale: >-
|
||||
Wyoming voice protocol, a binary local-only stream consumed by Home Assistant; not HTTP and not browser-reachable.
|
||||
|
||||
volumes:
|
||||
- type: bind
|
||||
|
||||
@@ -40,6 +40,9 @@ app:
|
||||
- host: 10200
|
||||
container: 10200
|
||||
protocol: tcp
|
||||
auth: none
|
||||
auth_rationale: >-
|
||||
Wyoming voice protocol, a binary local-only stream consumed by Home Assistant; not HTTP and not browser-reachable.
|
||||
|
||||
volumes:
|
||||
- type: bind
|
||||
|
||||
@@ -48,6 +48,9 @@ app:
|
||||
- host: 10300
|
||||
container: 10300
|
||||
protocol: tcp
|
||||
auth: none
|
||||
auth_rationale: >-
|
||||
Wyoming voice protocol, a binary local-only stream consumed by Home Assistant; not HTTP and not browser-reachable.
|
||||
|
||||
volumes:
|
||||
- type: bind
|
||||
|
||||
@@ -53,9 +53,16 @@ app:
|
||||
- host: 10380
|
||||
container: 80
|
||||
protocol: tcp
|
||||
bind: 127.0.0.1
|
||||
auth: gated
|
||||
- host: 10381
|
||||
container: 443
|
||||
protocol: tcp
|
||||
auth: none
|
||||
auth_rationale: >-
|
||||
Pine's TLS listener. The gate speaks plain HTTP, so fronting this port would
|
||||
break the secure context navigator.bluetooth needs for WiFi provisioning.
|
||||
The plain-HTTP entry point (10380) is gated, and it is what the UI opens.
|
||||
|
||||
volumes:
|
||||
- type: bind
|
||||
|
||||
@@ -6,7 +6,7 @@ app:
|
||||
category: development
|
||||
|
||||
container:
|
||||
image: 146.59.87.168:3000/lfg2025/portainer:2.19.4
|
||||
image: 146.59.87.168:3000/lfg2025/portainer:2.39.1
|
||||
pull_policy: if-not-present
|
||||
data_uid: "1000:1000"
|
||||
|
||||
@@ -27,6 +27,8 @@ app:
|
||||
- host: 9000
|
||||
container: 9000
|
||||
protocol: tcp
|
||||
bind: 127.0.0.1
|
||||
auth: gated
|
||||
|
||||
volumes:
|
||||
- type: bind
|
||||
|
||||
@@ -30,12 +30,20 @@ app:
|
||||
- host: 8084
|
||||
container: 8080
|
||||
protocol: tcp # Web UI
|
||||
bind: 127.0.0.1
|
||||
auth: gated
|
||||
- host: 5353
|
||||
container: 5353
|
||||
protocol: udp # mDNS/Bonjour
|
||||
auth: none
|
||||
auth_rationale: >-
|
||||
mDNS is UDP multicast service discovery; gating it would break .local name resolution for every device on the LAN.
|
||||
- host: 1900
|
||||
container: 1900
|
||||
protocol: udp # SSDP
|
||||
auth: none
|
||||
auth_rationale: >-
|
||||
SSDP/UPnP discovery is UDP multicast — there is no HTTP request to gate and no client that could hold a session.
|
||||
|
||||
volumes:
|
||||
- type: bind
|
||||
|
||||
@@ -29,6 +29,8 @@ app:
|
||||
- host: 8888
|
||||
container: 8080
|
||||
protocol: tcp # Web UI
|
||||
bind: 127.0.0.1
|
||||
auth: gated
|
||||
|
||||
volumes:
|
||||
- type: bind
|
||||
|
||||
@@ -29,6 +29,8 @@ app:
|
||||
- host: 8090
|
||||
container: 7777
|
||||
protocol: tcp # HTTP/WebSocket (strfry listens on 7777)
|
||||
bind: 127.0.0.1
|
||||
auth: gated
|
||||
|
||||
volumes:
|
||||
- type: bind
|
||||
|
||||
@@ -26,6 +26,8 @@ app:
|
||||
- host: 3002
|
||||
container: 3001
|
||||
protocol: tcp
|
||||
bind: 127.0.0.1
|
||||
auth: gated
|
||||
|
||||
volumes:
|
||||
- type: bind
|
||||
|
||||
@@ -25,6 +25,8 @@ app:
|
||||
- host: 8082
|
||||
container: 80
|
||||
protocol: tcp
|
||||
bind: 127.0.0.1
|
||||
auth: gated
|
||||
|
||||
volumes:
|
||||
- type: bind
|
||||
|
||||
Generated
+1
-1
@@ -104,7 +104,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "archipelago"
|
||||
version = "1.7.120-alpha"
|
||||
version = "1.7.125-alpha"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"archipelago-container",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "archipelago"
|
||||
version = "1.7.120-alpha"
|
||||
version = "1.7.125-alpha"
|
||||
edition = "2021"
|
||||
description = "Archipelago Bitcoin Node OS - Native backend"
|
||||
authors = ["Archipelago Team"]
|
||||
|
||||
@@ -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,
|
||||
}))
|
||||
}
|
||||
}
|
||||
@@ -1,81 +0,0 @@
|
||||
//! `assistant.*` RPC surface (D-01/D-02) — the front door onto the shared
|
||||
//! assistant service in `crate::assistant`. Every later `assistant.*`
|
||||
//! method (13-05's `list-tools`/`grants-*`, 13-08's `confirm-tool`, 13-10's
|
||||
//! `history`) is added inside this file; `dispatcher.rs` registers exactly
|
||||
//! one guarded arm for the whole `assistant.` prefix (see
|
||||
//! `grep -c 'starts_with("assistant.")' dispatcher.rs` == 1), never a new
|
||||
//! per-method literal arm.
|
||||
|
||||
use super::RpcHandler;
|
||||
use anyhow::Result;
|
||||
use std::sync::Arc;
|
||||
|
||||
impl RpcHandler {
|
||||
/// Prefix sub-dispatcher for `assistant.*`. Reached only after the
|
||||
/// caller has already passed the session-cookie + CSRF +
|
||||
/// `role.can_access()` gate in `api/rpc/mod.rs:264-330` — no bespoke
|
||||
/// auth here (asserted by
|
||||
/// `assistant::loop_::tests::assistant_methods_require_session`, which
|
||||
/// confirms `assistant.*` is absent from `UNAUTHENTICATED_METHODS`).
|
||||
pub(in crate::api::rpc) async fn handle_assistant(
|
||||
self: &Arc<Self>,
|
||||
method: &str,
|
||||
params: Option<serde_json::Value>,
|
||||
session_token: &Option<String>,
|
||||
) -> Result<serde_json::Value> {
|
||||
match method {
|
||||
"assistant.chat" => self.handle_assistant_chat(params, session_token).await,
|
||||
other => anyhow::bail!("no such assistant method: {other}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// assistant.chat — a single chat turn from the authenticated local
|
||||
/// operator. Params: `{ "text": string }`. Returns `{ "text": string }`.
|
||||
async fn handle_assistant_chat(
|
||||
self: &Arc<Self>,
|
||||
params: Option<serde_json::Value>,
|
||||
session_token: &Option<String>,
|
||||
) -> Result<serde_json::Value> {
|
||||
let text = params
|
||||
.as_ref()
|
||||
.and_then(|p| p.get("text"))
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.to_string())
|
||||
.ok_or_else(|| anyhow::anyhow!("text is required"))?;
|
||||
|
||||
// The caller's authenticated session identifies this LocalOperator —
|
||||
// authority is resolved node-side from CallerScope, never from
|
||||
// anything the browser or the model asserts about itself.
|
||||
let session_id = session_token.clone().unwrap_or_default();
|
||||
let caller = crate::assistant::CallerScope::LocalOperator { session_id };
|
||||
|
||||
let answer = crate::assistant::chat(Arc::clone(self), caller, text).await?;
|
||||
Ok(serde_json::json!({ "text": answer }))
|
||||
}
|
||||
|
||||
/// Internal-only bridge: executes a curated assistant tool against the
|
||||
/// SAME `RpcHandler` method every authenticated RPC caller dispatches
|
||||
/// through (never an AI-only backdoor). NOT itself an RPC method — only
|
||||
/// `assistant::loop_::execute_tool` calls this, and only for tool names
|
||||
/// present in the curated D-06 registry.
|
||||
///
|
||||
/// Rust module privacy is what requires this thin bridge:
|
||||
/// `handle_system_disk_status` is `pub(in crate::api::rpc)`, so
|
||||
/// `crate::assistant` (outside that module subtree) cannot call it
|
||||
/// directly. This function lives inside `api::rpc` so it CAN call the
|
||||
/// private handler, and re-exposes only the one curated method name a
|
||||
/// tool call is allowed to reach — not the general RPC surface.
|
||||
pub(crate) async fn assistant_dispatch_tool(&self, method: &str) -> Result<serde_json::Value> {
|
||||
match method {
|
||||
"system.disk-status" => self.handle_system_disk_status().await,
|
||||
other => anyhow::bail!("assistant_dispatch_tool: no such handler for {other}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Read-only `data_dir` accessor for `crate::assistant`, which lives
|
||||
/// outside `api::rpc`'s module tree and so cannot read the private
|
||||
/// `config` field directly. Minimal, `pub(crate)`, no behavior change.
|
||||
pub(crate) fn data_dir(&self) -> &std::path::Path {
|
||||
&self.config.data_dir
|
||||
}
|
||||
}
|
||||
@@ -444,14 +444,6 @@ impl RpcHandler {
|
||||
"mesh.deadman-checkin" => self.handle_mesh_deadman_checkin().await,
|
||||
"mesh.assistant-status" => self.handle_mesh_assistant_status().await,
|
||||
"mesh.assistant-configure" => self.handle_mesh_assistant_configure(params).await,
|
||||
// Phase 13 (D-01/D-02): the whole `assistant.*` surface lives in
|
||||
// assistant_chat.rs, not as new arms here — this is the ONLY
|
||||
// dispatcher.rs registration point for it. Every later
|
||||
// assistant.* method (13-05, 13-08, 13-10) is added inside
|
||||
// assistant_chat.rs's own match, never as a new arm in this file.
|
||||
m if m.starts_with("assistant.") => {
|
||||
self.handle_assistant(m, params, session_token).await
|
||||
}
|
||||
"mesh.schedule-message" => self.handle_mesh_schedule_message(params).await,
|
||||
"mesh.list-scheduled" => self.handle_mesh_list_scheduled().await,
|
||||
"mesh.cancel-scheduled" => self.handle_mesh_cancel_scheduled(params).await,
|
||||
@@ -470,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,
|
||||
@@ -479,6 +472,8 @@ impl RpcHandler {
|
||||
"system.disk-cleanup" => self.handle_system_disk_cleanup().await,
|
||||
"system.reboot" => self.handle_system_reboot(params).await,
|
||||
"system.factory-reset" => self.handle_system_factory_reset(params).await,
|
||||
"auth.session-policy.get" => self.handle_session_policy_get().await,
|
||||
"auth.session-policy.set" => self.handle_session_policy_set(params).await,
|
||||
"system.settings.get" => self.handle_system_settings_get(params).await,
|
||||
"system.settings.set" => self.handle_system_settings_set(params).await,
|
||||
"system.kiosk-display.get" => self.handle_system_kiosk_display_get().await,
|
||||
|
||||
@@ -51,10 +51,48 @@ impl RpcHandler {
|
||||
}
|
||||
}
|
||||
|
||||
/// Error prefix the frontend keys on to know it should prompt for the node
|
||||
/// password and retry, rather than surface the message as a dead end.
|
||||
pub(in crate::api::rpc) const PASSWORD_REQUIRED_PREFIX: &str = "PASSWORD_REQUIRED";
|
||||
|
||||
impl RpcHandler {
|
||||
/// Re-authenticate the operator before granting `Trusted`.
|
||||
///
|
||||
/// A Trusted peer can read node state, be deployed to, and is exempt from
|
||||
/// the `!= Untrusted` gates federation/DWN/messaging use — so granting it
|
||||
/// is a privilege escalation and must cost a fresh proof that the person
|
||||
/// at the keyboard is the operator, not merely that a session cookie
|
||||
/// exists. This is the same reasoning as `node.rotate-identity` and 2FA
|
||||
/// setup, both of which already re-verify.
|
||||
///
|
||||
/// Only ever called on the way UP. Demotion stays ungated: making
|
||||
/// something less privileged must never be harder than leaving it alone,
|
||||
/// or the safe action becomes the inconvenient one.
|
||||
async fn verify_operator_password(&self, params: Option<&serde_json::Value>) -> Result<()> {
|
||||
let password = params
|
||||
.and_then(|p| p.get("password"))
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("");
|
||||
|
||||
if password.is_empty() {
|
||||
anyhow::bail!("{PASSWORD_REQUIRED_PREFIX}: node password required to grant Trusted");
|
||||
}
|
||||
|
||||
if !self.auth_manager.verify_password(password).await? {
|
||||
anyhow::bail!("Password verification failed");
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// federation.invite — Generate an invite code containing our DID + onion for a peer.
|
||||
/// Optional param `trust_level`: "trusted" (default, "Link Your Nodes") or
|
||||
/// "observer" ("Invite a Peer") — the level BOTH sides assign for this invite.
|
||||
///
|
||||
/// Minting a **Trusted** invite requires the node password (param
|
||||
/// `password`): the invite is a bearer grant of Trusted to whoever
|
||||
/// redeems it, so it is the escalation, not the later redemption.
|
||||
/// Observer invites are unchanged.
|
||||
pub(in crate::api::rpc) async fn handle_federation_invite(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
@@ -71,6 +109,13 @@ impl RpcHandler {
|
||||
.transpose()?
|
||||
.unwrap_or(TrustLevel::Trusted);
|
||||
|
||||
// Note this covers the DEFAULT too: "Link Your Nodes" sends no
|
||||
// `trust_level` and lands on Trusted above, so the gate must key off
|
||||
// the resolved level rather than an explicit request for Trusted.
|
||||
if trust_level == TrustLevel::Trusted {
|
||||
self.verify_operator_password(params.as_ref()).await?;
|
||||
}
|
||||
|
||||
let (data, _) = self.state_manager.get_snapshot().await;
|
||||
let did = identity::did_key_from_pubkey_hex(&data.server_info.pubkey)?;
|
||||
let onion = data.server_info.tor_address.clone().unwrap_or_default();
|
||||
@@ -272,6 +317,15 @@ impl RpcHandler {
|
||||
if let Some(at) = &n.last_sync_error_at {
|
||||
obj["last_sync_error_at"] = serde_json::json!(at);
|
||||
}
|
||||
// How this peer's trust level came to be. Emitted as an
|
||||
// explicit null when unknown rather than omitted: "recorded
|
||||
// before provenance was tracked" is the population the
|
||||
// operator most needs to review, so the UI must be able to
|
||||
// distinguish it from a field it simply didn't read.
|
||||
obj["trust_source"] = match &n.trust_source {
|
||||
Some(src) => serde_json::to_value(src).unwrap_or(serde_json::Value::Null),
|
||||
None => serde_json::Value::Null,
|
||||
};
|
||||
obj
|
||||
})
|
||||
.collect();
|
||||
@@ -323,6 +377,10 @@ impl RpcHandler {
|
||||
}
|
||||
|
||||
/// federation.set-trust — Change trust level for a federated node.
|
||||
///
|
||||
/// Promoting a node TO `Trusted` requires the node password (param
|
||||
/// `password`). Demotion and no-op re-sets do not: see
|
||||
/// `verify_operator_password` for why the gate is one-directional.
|
||||
pub(in crate::api::rpc) async fn handle_federation_set_trust(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
@@ -348,7 +406,32 @@ impl RpcHandler {
|
||||
),
|
||||
};
|
||||
|
||||
federation::set_trust_level(&self.config.data_dir, did, trust).await?;
|
||||
// Gate the ESCALATION only. Comparing against the node's current level
|
||||
// means a re-set of an already-Trusted peer (the dropdown re-emitting
|
||||
// its own value) doesn't pointlessly demand a password, while every
|
||||
// path that actually raises a peer to Trusted does.
|
||||
if trust == TrustLevel::Trusted {
|
||||
let already_trusted = federation::load_nodes(&self.config.data_dir)
|
||||
.await?
|
||||
.iter()
|
||||
.any(|n| n.did == did && n.trust_level == TrustLevel::Trusted);
|
||||
if !already_trusted {
|
||||
self.verify_operator_password(Some(¶ms)).await?;
|
||||
}
|
||||
}
|
||||
|
||||
// Stamp Manual: this is the one path where a human chose the level, so
|
||||
// an audit of `trust_source` can tell it apart from the automatic
|
||||
// grants that `UninvitedJoin` / `TransitiveMerge` mark.
|
||||
federation::set_trust_level(
|
||||
&self.config.data_dir,
|
||||
did,
|
||||
trust,
|
||||
Some(federation::TrustSource::Manual),
|
||||
)
|
||||
.await?;
|
||||
|
||||
info!(did = %did, trust = %trust, "Operator set federation trust level");
|
||||
|
||||
Ok(serde_json::json!({
|
||||
"updated": true,
|
||||
|
||||
@@ -350,10 +350,14 @@ impl RpcHandler {
|
||||
// lands on Observer; keep this explicit demotion as a
|
||||
// safety net for legacy Trusted-only invite codes — the
|
||||
// discovery flow should never auto-trust.
|
||||
// `None` source: this is an automatic safety-net
|
||||
// demotion, not an operator decision, so it must
|
||||
// not overwrite how the peer actually got here.
|
||||
let _ = crate::federation::set_trust_level(
|
||||
&self.config.data_dir,
|
||||
&node.did,
|
||||
crate::federation::TrustLevel::Observer,
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
|
||||
|
||||
@@ -192,6 +192,19 @@ impl RpcHandler {
|
||||
.get("message")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("Unknown error");
|
||||
// LND's sweep refusal reads like a debug dump ("insufficient
|
||||
// input to create sweep tx: input_sum=0 BTC, output_sum=…").
|
||||
// input_sum=0 with a tiny output means the wallet's coins are
|
||||
// unconfirmed or below Bitcoin's dust minimum — say that
|
||||
// (framework-pt sweep of 92 sats, 2026-08-06).
|
||||
if msg.contains("insufficient input to create sweep tx") {
|
||||
return Err(anyhow::anyhow!(
|
||||
"Failed to send: your on-chain balance is too small or still \
|
||||
unconfirmed to sweep. Bitcoin cannot build a transaction from \
|
||||
coins below the dust minimum (~546 sats) or from funds that \
|
||||
have not confirmed yet. (LND: {msg})"
|
||||
));
|
||||
}
|
||||
return Err(anyhow::anyhow!("Failed to send: {}", msg));
|
||||
}
|
||||
|
||||
|
||||
@@ -2,13 +2,7 @@ use crate::session::SessionStore;
|
||||
use std::net::IpAddr;
|
||||
|
||||
/// Methods that do not require a valid session cookie.
|
||||
///
|
||||
/// `pub(crate)` (not just `pub(super)`) so `crate::assistant`'s test suite
|
||||
/// can assert directly against the live list that the assistant RPC prefix
|
||||
/// is never added to it (Phase-10 hard constraint) — see the re-export in
|
||||
/// `api/rpc/mod.rs`. Read-visibility only; the list's contents and every
|
||||
/// other visibility in this module are unchanged.
|
||||
pub(crate) const UNAUTHENTICATED_METHODS: &[&str] = &[
|
||||
pub(super) const UNAUTHENTICATED_METHODS: &[&str] = &[
|
||||
"auth.login",
|
||||
"auth.login.totp",
|
||||
"auth.login.backup",
|
||||
@@ -85,6 +79,11 @@ pub(super) fn sanitize_error_message(msg: &str) -> String {
|
||||
// them in the first place (ecash send, 2026-07-22).
|
||||
"Insufficient balance",
|
||||
"Insufficient funds",
|
||||
// On-chain send/sweep refusals from LND ("Failed to send: your
|
||||
// on-chain balance is too small or still unconfirmed to sweep…").
|
||||
// Masking sent the operator to journalctl again (framework-pt
|
||||
// sweep, 2026-08-06) — same lesson as the two above.
|
||||
"Failed to send",
|
||||
// Lightning payment failures carry LND's reason ("invoice expired.
|
||||
// Valid until …", "no route", …) — the user can act on every one of
|
||||
// them, and masking sent the operator to journalctl (invoice-expired
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
mod analytics;
|
||||
mod appgate;
|
||||
mod ark;
|
||||
mod assistant_chat;
|
||||
mod auth;
|
||||
mod backup_rpc;
|
||||
mod bitcoin;
|
||||
@@ -60,15 +60,9 @@ use std::sync::Arc;
|
||||
use tracing::{debug, error};
|
||||
|
||||
pub use middleware::PeerAddr;
|
||||
// Re-exported `pub(crate)` (not just imported) so `crate::assistant`'s test
|
||||
// suite can assert directly against the live list that `assistant.*` is
|
||||
// never added to it — the Phase-10 hard constraint this crate must hold.
|
||||
// The list's *contents* are unchanged; only its read-visibility widens from
|
||||
// "this module" to "this crate".
|
||||
pub(crate) use middleware::UNAUTHENTICATED_METHODS;
|
||||
use middleware::{
|
||||
derive_csrf_token, extract_client_ip, extract_cookie, sanitize_error_message,
|
||||
CACHEABLE_METHODS,
|
||||
CACHEABLE_METHODS, UNAUTHENTICATED_METHODS,
|
||||
};
|
||||
use response::{cookie_header, json_response, ResponseCache, RpcError, RpcRequest, RpcResponse};
|
||||
|
||||
@@ -94,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>>>,
|
||||
@@ -158,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,
|
||||
@@ -168,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)),
|
||||
|
||||
@@ -307,19 +307,24 @@ impl RpcHandler {
|
||||
let deps = self.gate_install_deps(package_id).await?;
|
||||
check_bitcoin_pruning_compatibility(package_id).await?;
|
||||
log_optional_dep_info(package_id, &deps);
|
||||
let repaired_bitcoin_conf =
|
||||
if matches!(package_id, "bitcoin" | "bitcoin-core" | "bitcoin-knots") {
|
||||
// Materialise the RPC password file before any install path
|
||||
// runs. The orchestrator path resolves secret_env from
|
||||
// /var/lib/archipelago/secrets/bitcoin-rpc-password at start
|
||||
// time; if the file is missing, bitcoind exits within ms.
|
||||
// bitcoin_rpc_credentials() generates + persists on first
|
||||
// call (OnceCell-cached), so this is idempotent.
|
||||
let _ = crate::bitcoin_rpc::bitcoin_rpc_credentials().await;
|
||||
ensure_bitcoin_rpc_config().await?
|
||||
} else {
|
||||
false
|
||||
};
|
||||
if matches!(package_id, "bitcoin" | "bitcoin-core" | "bitcoin-knots") {
|
||||
// Materialise the RPC password file before any install path
|
||||
// runs. The orchestrator path resolves secret_env from
|
||||
// /var/lib/archipelago/secrets/bitcoin-rpc-password at start
|
||||
// time; if the file is missing, bitcoind exits within ms.
|
||||
// bitcoin_rpc_credentials() generates + persists on first
|
||||
// call (OnceCell-cached), so this is idempotent.
|
||||
let _ = crate::bitcoin_rpc::bitcoin_rpc_credentials().await;
|
||||
// A stale datadir bitcoin.conf from an older install conflicts
|
||||
// with the container's -conf=/tmp/rpc.conf launch (see
|
||||
// apps/bitcoin-core & bitcoin-knots manifest.yml) and makes
|
||||
// Bitcoin Core refuse to start at all. Clear it before
|
||||
// (re)install. Unlike the old bind-setting "repair" this was
|
||||
// replacing, it never requires restarting an already-running
|
||||
// container — bitcoind doesn't read this file, so removing it
|
||||
// changes nothing at runtime.
|
||||
remove_stale_bitcoin_conf().await?;
|
||||
}
|
||||
|
||||
// For orchestrator-managed apps, skip the legacy "container exists →
|
||||
// adopt + return" probe entirely. The orchestrator's own install path
|
||||
@@ -389,37 +394,7 @@ impl RpcHandler {
|
||||
.trim()
|
||||
.to_string();
|
||||
|
||||
if state == "running" && repaired_bitcoin_conf {
|
||||
info!(
|
||||
"Restarting existing container {} after bitcoin.conf RPC repair",
|
||||
package_id
|
||||
);
|
||||
let restart_output = tokio::process::Command::new("podman")
|
||||
.args(["restart", package_id])
|
||||
.output()
|
||||
.await
|
||||
.context(
|
||||
"Failed to restart existing container after bitcoin.conf repair",
|
||||
)?;
|
||||
if !restart_output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&restart_output.stderr);
|
||||
install_log(&format!(
|
||||
"INSTALL ADOPT FAIL: {} - restart after RPC repair failed: {}",
|
||||
package_id, stderr
|
||||
))
|
||||
.await;
|
||||
return Err(anyhow::anyhow!(
|
||||
"Container {} exists but failed to restart after RPC repair: {}",
|
||||
package_id,
|
||||
stderr
|
||||
));
|
||||
}
|
||||
let _ = tokio::process::Command::new("podman")
|
||||
.args(["restart", "archy-bitcoin-ui"])
|
||||
.output()
|
||||
.await;
|
||||
wait_for_adopted_container(package_id, package_id).await?;
|
||||
} else if state != "running" {
|
||||
if state != "running" {
|
||||
// Start the stopped/exited container
|
||||
info!("Starting existing container {} (was {})", package_id, state);
|
||||
let start_output = tokio::process::Command::new("podman")
|
||||
@@ -715,9 +690,13 @@ impl RpcHandler {
|
||||
}
|
||||
}
|
||||
|
||||
// Pre-install: write config files BEFORE chown (dir is still owned by archipelago user)
|
||||
// Pre-install: clear a stale datadir bitcoin.conf BEFORE chown (dir is
|
||||
// still owned by archipelago user). bitcoind is launched with
|
||||
// -conf=/tmp/rpc.conf (see apps/bitcoin-core & bitcoin-knots
|
||||
// manifest.yml) and never reads a datadir bitcoin.conf — if one
|
||||
// exists, Bitcoin Core's own safety check refuses to start at all.
|
||||
if matches!(package_id, "bitcoin" | "bitcoin-core" | "bitcoin-knots") {
|
||||
self.write_bitcoin_conf(&rpc_user, &rpc_pass).await?;
|
||||
remove_stale_bitcoin_conf().await?;
|
||||
}
|
||||
|
||||
if package_id == "lnd" {
|
||||
@@ -1435,101 +1414,13 @@ impl RpcHandler {
|
||||
}
|
||||
}
|
||||
|
||||
/// Write bitcoin.conf with rpcauth (salted HMAC hash, no plaintext password).
|
||||
async fn write_bitcoin_conf(&self, rpc_user: &str, rpc_pass: &str) -> Result<()> {
|
||||
let bitcoin_dir = "/var/lib/archipelago/bitcoin";
|
||||
let conf_path = format!("{}/bitcoin.conf", bitcoin_dir);
|
||||
|
||||
// Idempotent: once bitcoin-knots (or a prior install) has started,
|
||||
// the data dir is chowned into the container's user namespace
|
||||
// (e.g. UID 100100 on the host) with 700 perms — the archipelago
|
||||
// daemon can no longer stat or write there. Treat any non-NotFound
|
||||
// error on the conf as "conf already provisioned by the container
|
||||
// user" and skip. Matches the lnd.conf behavior below.
|
||||
match tokio::fs::metadata(&conf_path).await {
|
||||
Ok(_) => {
|
||||
ensure_bitcoin_rpc_config().await?;
|
||||
info!("bitcoin.conf already exists, ensured Bitcoin RPC config");
|
||||
return Ok(());
|
||||
}
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
|
||||
Err(_) => {
|
||||
ensure_bitcoin_rpc_config().await?;
|
||||
info!("bitcoin.conf path inaccessible, ensured Bitcoin RPC config via host helper");
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
|
||||
use hmac::{Hmac, Mac};
|
||||
use sha2::Sha256;
|
||||
// KEY-05: the salt is half of the stored `rpcauth=` credential line, so
|
||||
// source named and draw guarded.
|
||||
let mut salt_bytes = [0u8; 16];
|
||||
crate::entropy::draw_key_bytes(&mut rand::rngs::OsRng, &mut salt_bytes).map_err(|e| {
|
||||
anyhow::anyhow!("Refusing to build an rpcauth line from degenerate salt entropy: {e}")
|
||||
})?;
|
||||
let salt_hex = hex::encode(salt_bytes);
|
||||
let mut mac = Hmac::<Sha256>::new_from_slice(salt_hex.as_bytes())
|
||||
.expect("HMAC accepts any key length");
|
||||
mac.update(rpc_pass.as_bytes());
|
||||
let hash_hex = hex::encode(mac.finalize().into_bytes());
|
||||
let rpcauth_line = format!("rpcauth={}:{}${}", rpc_user, salt_hex, hash_hex);
|
||||
|
||||
// Default to full archive — operators with 2TB+ drives shouldn't be
|
||||
// silently pruned down to 550 MB. Users who want a pruned node can
|
||||
// set `prune=N` in bitcoin.conf themselves after install.
|
||||
//
|
||||
// printtoconsole=0: bitcoind already writes debug.log in the datadir
|
||||
// (self-shrunk on restart); duplicating it to stdout pushed every IBD
|
||||
// "UpdateTip" line through conmon into journald (>1 GB/day). Deep
|
||||
// debugging uses /var/lib/archipelago/bitcoin/debug.log.
|
||||
// rpcbind=0.0.0.0 is REQUIRED inside a container: with rpcallowip set
|
||||
// but no rpcbind, bitcoind binds RPC to 127.0.0.1 in the container
|
||||
// netns only — LND / the Bitcoin UI dialing bitcoin-knots:8332 over
|
||||
// the bridge get connection refused (fresh-install LND crash-loop +
|
||||
// bitcoin-rpc 502, seen on the 1.7.99 ISO). The port publish stays
|
||||
// 127.0.0.1-only on the host, so exposure is unchanged.
|
||||
// Prune sized to the data volume. A full archive needs ~810 GB and
|
||||
// grows; silently writing an unpruned config onto a small disk fills
|
||||
// it mid-IBD (framework node 2026-07-14: unpruned mainnet on a 205 GB
|
||||
// volume). Volumes with real archival headroom (≥1.2 TB) stay full
|
||||
// archive; smaller ones get prune = 25% of the volume, clamped to
|
||||
// [550 MB, 100 GB], leaving room for LND/apps sharing the disk.
|
||||
let prune_line = match bitcoin_data_volume_gb().await {
|
||||
Some(total_gb) if total_gb > 0 && total_gb < 1200 => {
|
||||
let prune_mb = ((total_gb as f64 * 0.25 * 1024.0) as u64).clamp(550, 100_000);
|
||||
info!(
|
||||
volume_gb = total_gb,
|
||||
prune_mb, "Data volume below archival size — enabling sized bitcoin prune"
|
||||
);
|
||||
format!("prune={}\n", prune_mb)
|
||||
}
|
||||
_ => String::new(),
|
||||
};
|
||||
|
||||
let bitcoin_conf = format!(
|
||||
"\
|
||||
# rpcauth: salted hash only - no plaintext password in config or CLI\n\
|
||||
{}\n\
|
||||
server=1\n\
|
||||
rpcbind=0.0.0.0\n\
|
||||
rpcallowip=0.0.0.0/0\n\
|
||||
listen=1\n\
|
||||
rpcthreads=16\n\
|
||||
rpcworkqueue=256\n\
|
||||
printtoconsole=0\n\
|
||||
{}",
|
||||
rpcauth_line, prune_line
|
||||
);
|
||||
tokio::fs::create_dir_all(bitcoin_dir)
|
||||
.await
|
||||
.context("Failed to create bitcoin data directory")?;
|
||||
tokio::fs::write(&conf_path, bitcoin_conf)
|
||||
.await
|
||||
.context("Failed to write bitcoin.conf")?;
|
||||
info!("Created bitcoin.conf with rpcauth (no plaintext credentials)");
|
||||
Ok(())
|
||||
}
|
||||
// write_bitcoin_conf removed: bitcoind is launched with -conf=/tmp/rpc.conf
|
||||
// (see apps/bitcoin-core & bitcoin-knots manifest.yml, commit a597c1d9)
|
||||
// and never reads a datadir bitcoin.conf. Writing one here created a
|
||||
// fatal "-conf vs default bitcoin.conf" conflict on every subsequent
|
||||
// start (Bitcoin Core's own datadir-conflict safety check). See
|
||||
// `remove_stale_bitcoin_conf` below, which replaces both this and
|
||||
// `ensure_bitcoin_rpc_config`.
|
||||
|
||||
/// Write LND config file with Bitcoin RPC credentials.
|
||||
async fn write_lnd_conf(&self, rpc_user: &str, rpc_pass: &str) -> Result<()> {
|
||||
@@ -2624,28 +2515,12 @@ async fn wait_for_adopted_container(package_id: &str, container_name: &str) -> R
|
||||
))
|
||||
}
|
||||
|
||||
/// Total size (GB) of the filesystem holding the bitcoin data dir, via
|
||||
/// `df -k`. None when df fails (containers, exotic mounts) — callers treat
|
||||
/// unknown as "don't prune" to preserve archival defaults on big iron.
|
||||
async fn bitcoin_data_volume_gb() -> Option<u64> {
|
||||
let target = if std::path::Path::new("/var/lib/archipelago").exists() {
|
||||
"/var/lib/archipelago"
|
||||
} else {
|
||||
"/"
|
||||
};
|
||||
let output = tokio::process::Command::new("df")
|
||||
.args(["-k", target])
|
||||
.output()
|
||||
.await
|
||||
.ok()?;
|
||||
if !output.status.success() {
|
||||
return None;
|
||||
}
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
let line = stdout.lines().nth(1)?;
|
||||
let kb: u64 = line.split_whitespace().nth(1)?.parse().ok()?;
|
||||
Some(kb / 1024 / 1024)
|
||||
}
|
||||
// bitcoin_data_volume_gb removed with write_bitcoin_conf: it only fed that
|
||||
// function's volume-aware `prune=` line, which bitcoind never read either
|
||||
// (see remove_stale_bitcoin_conf). The manifest's shell entrypoint already
|
||||
// computes DISK_GB_VALUE and hardcodes -prune=550 on small volumes — a
|
||||
// real volume-aware prune fix belongs there, not in a conf file nothing
|
||||
// reads. Tracked as follow-up in bitcoin-conf-crash-patch.md.
|
||||
|
||||
/// One-shot probe: does bitcoind answer an authenticated getblockchaininfo?
|
||||
/// Works during IBD (the call answers with progress while syncing). Goes via
|
||||
@@ -2723,52 +2598,36 @@ async fn wait_for_bitcoin_rpc_gate(package_id: &str) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn ensure_bitcoin_rpc_config() -> Result<bool> {
|
||||
/// bitcoind reads only `/tmp/rpc.conf` + CLI args at container start (see
|
||||
/// apps/bitcoin-core & bitcoin-knots manifest.yml, commit a597c1d9) — it
|
||||
/// never reads a datadir bitcoin.conf. A leftover file from an older install
|
||||
/// (or a manual edit) makes Bitcoin Core's own datadir-conflict safety check
|
||||
/// refuse to start ("-conf=... vs default bitcoin.conf"). Remove it — via
|
||||
/// the same host-privileged path the old writer/repairer used, since the
|
||||
/// dir may already be chowned into the container's UID namespace by a
|
||||
/// previous start — instead of "repairing" it into existence.
|
||||
async fn remove_stale_bitcoin_conf() -> Result<bool> {
|
||||
let script = r#"
|
||||
set -eu
|
||||
conf=/var/lib/archipelago/bitcoin/bitcoin.conf
|
||||
[ -f "$conf" ] || exit 0
|
||||
changed=0
|
||||
tmp=$(mktemp)
|
||||
awk -F= '
|
||||
/^(server|txindex|rpcbind|rpcallowip|rpcport|listen|bind|dbcache|rpcthreads|rpcworkqueue)=/ {
|
||||
if (seen[$1]++) next
|
||||
}
|
||||
{ print }
|
||||
' "$conf" > "$tmp"
|
||||
if ! cmp -s "$conf" "$tmp"; then
|
||||
cat "$tmp" > "$conf"
|
||||
changed=1
|
||||
fi
|
||||
rm -f "$tmp"
|
||||
ensure_line() {
|
||||
line="$1"
|
||||
key="${line%%=*}"
|
||||
if ! grep -q "^${key}=" "$conf"; then
|
||||
printf '%s\n' "$line" >> "$conf"
|
||||
changed=1
|
||||
fi
|
||||
}
|
||||
ensure_line server=1
|
||||
ensure_line rpcbind=0.0.0.0
|
||||
ensure_line rpcallowip=0.0.0.0/0
|
||||
ensure_line listen=1
|
||||
ensure_line rpcthreads=16
|
||||
ensure_line rpcworkqueue=256
|
||||
[ "$changed" -eq 0 ] && exit 0
|
||||
mv "$conf" "$conf.disabled-$(date +%s)"
|
||||
exit 2
|
||||
"#;
|
||||
let status = host_sudo(&["sh", "-lc", script])
|
||||
.await
|
||||
.context("ensure bitcoin.conf RPC bind settings")?;
|
||||
.context("remove stale bitcoin.conf")?;
|
||||
match status.code() {
|
||||
Some(0) => Ok(false),
|
||||
Some(2) => {
|
||||
install_log("INSTALL REPAIR: bitcoin.conf RPC bind settings added").await;
|
||||
install_log(
|
||||
"INSTALL REPAIR: removed stale bitcoin.conf (conflicts with -conf=/tmp/rpc.conf launch)",
|
||||
)
|
||||
.await;
|
||||
Ok(true)
|
||||
}
|
||||
_ => Err(anyhow::anyhow!(
|
||||
"bitcoin.conf RPC repair helper exited with {}",
|
||||
"bitcoin.conf removal helper exited with {}",
|
||||
status
|
||||
)),
|
||||
}
|
||||
|
||||
@@ -1011,6 +1011,59 @@ impl RpcHandler {
|
||||
}
|
||||
}
|
||||
|
||||
/// auth.session-policy.get — how long a login lasts on this node.
|
||||
pub(in crate::api::rpc) async fn handle_session_policy_get(&self) -> Result<serde_json::Value> {
|
||||
let policy = crate::settings::session_policy::load(&self.config.data_dir).await;
|
||||
Ok(serde_json::json!({
|
||||
"idle_timeout_secs": policy.idle_timeout_secs,
|
||||
"absolute_timeout_secs": policy.absolute_timeout_secs,
|
||||
"reauth_for_funds": policy.reauth_for_funds,
|
||||
}))
|
||||
}
|
||||
|
||||
/// auth.session-policy.set — change it.
|
||||
///
|
||||
/// Values are clamped rather than rejected: the caller learns what was
|
||||
/// actually stored from the reply, which is friendlier than an error and
|
||||
/// makes the bounds discoverable. Fields are individually optional so the
|
||||
/// UI can change one control without having to send the others back.
|
||||
pub(in crate::api::rpc) async fn handle_session_policy_set(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
let params = params.unwrap_or(serde_json::json!({}));
|
||||
let current = crate::settings::session_policy::load(&self.config.data_dir).await;
|
||||
let policy = crate::settings::session_policy::SessionPolicy {
|
||||
idle_timeout_secs: params
|
||||
.get("idle_timeout_secs")
|
||||
.and_then(|v| v.as_u64())
|
||||
.unwrap_or(current.idle_timeout_secs),
|
||||
absolute_timeout_secs: match params.get("absolute_timeout_secs") {
|
||||
// Explicit null means "no absolute cap", which is different
|
||||
// from the field being absent (leave it as it is).
|
||||
Some(serde_json::Value::Null) => None,
|
||||
Some(v) => v.as_u64().or(current.absolute_timeout_secs),
|
||||
None => current.absolute_timeout_secs,
|
||||
},
|
||||
reauth_for_funds: params
|
||||
.get("reauth_for_funds")
|
||||
.and_then(|v| v.as_bool())
|
||||
.unwrap_or(current.reauth_for_funds),
|
||||
};
|
||||
let saved = crate::settings::session_policy::save(&self.config.data_dir, policy).await?;
|
||||
tracing::info!(
|
||||
idle = saved.idle_timeout_secs,
|
||||
absolute = ?saved.absolute_timeout_secs,
|
||||
reauth_for_funds = saved.reauth_for_funds,
|
||||
"session policy updated"
|
||||
);
|
||||
Ok(serde_json::json!({
|
||||
"idle_timeout_secs": saved.idle_timeout_secs,
|
||||
"absolute_timeout_secs": saved.absolute_timeout_secs,
|
||||
"reauth_for_funds": saved.reauth_for_funds,
|
||||
}))
|
||||
}
|
||||
|
||||
/// system.settings.set — Write a settings value
|
||||
pub(in crate::api::rpc) async fn handle_system_settings_set(
|
||||
&self,
|
||||
|
||||
@@ -222,6 +222,19 @@ pub(in crate::api::rpc) async fn regenerate_torrc(config: &ServicesConfig) -> Re
|
||||
lines.push("# ControlPort disabled for security".to_string());
|
||||
lines.push(String::new());
|
||||
|
||||
// Ports whose manifests declare `auth: gated` forward to the gate's own
|
||||
// loopback (127.0.0.2, where the app-gate listener binds — see
|
||||
// `appgate::listener::GATE_TOR_UPSTREAM`) instead of the app's 127.0.0.1.
|
||||
// Tor carries no session cookie, so an onion pointed at the app is an
|
||||
// unauthenticated bypass of the gate. Declared-gated ports only: an
|
||||
// undeclared port keeps today's target, because absence of the field is
|
||||
// not an instruction (the v1.7.121 incident rule).
|
||||
let gated_ports: std::collections::HashSet<u16> = crate::appgate::identity::build_port_map()
|
||||
.gated_ports()
|
||||
.filter(|g| g.declared)
|
||||
.map(|g| g.port)
|
||||
.collect();
|
||||
|
||||
for svc in &config.services {
|
||||
if !svc.enabled {
|
||||
continue;
|
||||
@@ -240,7 +253,7 @@ pub(in crate::api::rpc) async fn regenerate_torrc(config: &ServicesConfig) -> Re
|
||||
lines.push("HiddenServicePort 10009 127.0.0.1:10009".to_string());
|
||||
}
|
||||
} else {
|
||||
lines.push(format!("HiddenServicePort 80 127.0.0.1:{}", svc.local_port));
|
||||
lines.push(app_hidden_service_port_line(svc.local_port, &gated_ports));
|
||||
}
|
||||
|
||||
lines.push(String::new());
|
||||
@@ -248,6 +261,24 @@ pub(in crate::api::rpc) async fn regenerate_torrc(config: &ServicesConfig) -> Re
|
||||
|
||||
let content = lines.join("\n");
|
||||
let staging = "/var/lib/archipelago/tor-config/torrc.staged";
|
||||
write_staged_torrc(&content, staging).await
|
||||
}
|
||||
|
||||
/// The `HiddenServicePort` line for an HTTP app onion. Gated ports forward to
|
||||
/// the gate's Tor upstream; everything else to the app itself.
|
||||
fn app_hidden_service_port_line(
|
||||
local_port: u16,
|
||||
gated_ports: &std::collections::HashSet<u16>,
|
||||
) -> String {
|
||||
let upstream = if gated_ports.contains(&local_port) {
|
||||
crate::appgate::listener::GATE_TOR_UPSTREAM.to_string()
|
||||
} else {
|
||||
"127.0.0.1".to_string()
|
||||
};
|
||||
format!("HiddenServicePort 80 {}:{}", upstream, local_port)
|
||||
}
|
||||
|
||||
async fn write_staged_torrc(content: &str, staging: &str) -> Result<()> {
|
||||
let config_dir = Path::new(staging)
|
||||
.parent()
|
||||
.unwrap_or_else(|| Path::new("/var/lib/archipelago/tor-config"));
|
||||
@@ -256,14 +287,37 @@ pub(in crate::api::rpc) async fn regenerate_torrc(config: &ServicesConfig) -> Re
|
||||
.await
|
||||
.context("Failed to write staged torrc")?;
|
||||
|
||||
debug!(
|
||||
"Staged torrc with {} enabled services",
|
||||
config.services.iter().filter(|s| s.enabled).count()
|
||||
);
|
||||
debug!("Staged torrc ({} bytes)", content.len());
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod torrc_tests {
|
||||
use super::app_hidden_service_port_line;
|
||||
use std::collections::HashSet;
|
||||
|
||||
#[test]
|
||||
fn gated_port_forwards_to_the_gate_not_the_app() {
|
||||
let gated: HashSet<u16> = [8082u16].into_iter().collect();
|
||||
assert_eq!(
|
||||
app_hidden_service_port_line(8082, &gated),
|
||||
"HiddenServicePort 80 127.0.0.2:8082"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn undeclared_port_keeps_the_app_loopback_target() {
|
||||
// Absence of `auth: gated` is not an instruction — the onion keeps
|
||||
// pointing at the app, exactly as before this change.
|
||||
let gated: HashSet<u16> = [8082u16].into_iter().collect();
|
||||
assert_eq!(
|
||||
app_hidden_service_port_line(9100, &gated),
|
||||
"HiddenServicePort 80 127.0.0.1:9100"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Hostname Sync ───────────────────────────────────────────────
|
||||
|
||||
pub(in crate::api::rpc) async fn sync_single_hostname(name: &str, address: &str) {
|
||||
|
||||
@@ -75,7 +75,8 @@ pub fn address_caching_dependents(package_id: &str) -> &'static [&'static str] {
|
||||
/// The package whose lifecycle lock covers `app_id`: the stack package when
|
||||
/// `app_id` is a member (RPC ops on "mempool" hold the "mempool" lock while
|
||||
/// they drive archy-mempool-web), otherwise the app itself.
|
||||
fn owning_package(app_id: &str) -> &str {
|
||||
/// Also consulted by the reconciler's absent-stack-member recovery.
|
||||
pub fn owning_package(app_id: &str) -> &str {
|
||||
const STACKS: &[&str] = &[
|
||||
"immich",
|
||||
"indeedhub",
|
||||
|
||||
@@ -0,0 +1,477 @@
|
||||
//! 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>,
|
||||
/// True only when the manifest says `auth: gated` in so many words.
|
||||
///
|
||||
/// The gated set deliberately also carries undeclared Session-default
|
||||
/// ports (so the gate challenges them wherever it can already stand, and
|
||||
/// the audit reports them). But everything that CHANGES where traffic
|
||||
/// goes — the torrc repoint to 127.0.0.2, the FIPS relay stand-down, the
|
||||
/// Tor-upstream bind — must key on this flag: acting on an undeclared
|
||||
/// port is the v1.7.121 incident class, whatever the action.
|
||||
pub declared: bool,
|
||||
/// Manifest opt-in (`session_passthrough: true` on the port): forward the
|
||||
/// node session cookie to the app on authorised requests. First-party
|
||||
/// companion UIs proxy that cookie to the daemon's authenticated
|
||||
/// endpoints; for every other app the gate strips its own credential.
|
||||
pub session_passthrough: bool,
|
||||
}
|
||||
|
||||
/// 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>,
|
||||
local: std::collections::HashSet<u16>,
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
/// Declared `auth: local` — host-local by intent, so NOTHING may make it
|
||||
/// externally reachable.
|
||||
///
|
||||
/// The gate honours this by keeping its hands off, but it is not the only
|
||||
/// thing that can publish a port: the FIPS mesh relay bridges the fips0
|
||||
/// ULA to `127.0.0.1` for a static port list, and it forwarded nbxplorer
|
||||
/// 32838 — declared `local` and pinned to loopback — to the mesh
|
||||
/// unauthenticated (archi-dev-box 2026-08-04). Anything that republishes
|
||||
/// a loopback port must consult this set first.
|
||||
pub fn is_declared_local(&self, port: u16) -> bool {
|
||||
self.local.contains(&port)
|
||||
}
|
||||
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.gated.is_empty() && self.exempt.is_empty() && self.local.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 signed catalog's embedded manifests are consulted FIRST, because they
|
||||
/// are what the orchestrator actually publishes containers from
|
||||
/// (origin-wins; see `app_catalog::catalog_manifest_overlay`). Classifying
|
||||
/// from disk alone made the gate act on policy the node was no longer
|
||||
/// running: the catalog declared nbxplorer `auth: local` and pinned it to
|
||||
/// loopback, the stale disk manifest declared nothing, and the gate
|
||||
/// externally bound a deliberately host-local port (archi-dev-box
|
||||
/// 2026-08-04).
|
||||
///
|
||||
/// After the catalog, 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: std::collections::HashSet<String> = std::collections::HashSet::new();
|
||||
|
||||
for (app_id, value) in crate::container::app_catalog::catalog_manifest_values() {
|
||||
let Some(manifest) =
|
||||
crate::container::app_catalog::catalog_manifest_overlay(&app_id, value)
|
||||
else {
|
||||
// Unparseable/invalid/build-source → the orchestrator falls back
|
||||
// to disk for this app, so classification must too.
|
||||
continue;
|
||||
};
|
||||
if seen_apps.insert(app_id) {
|
||||
classify_manifest(&manifest, &mut map);
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
};
|
||||
if seen_apps.insert(manifest.app.id.clone()) {
|
||||
classify_manifest(&manifest, &mut map);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
map.exempt.sort_by_key(|e| e.port);
|
||||
map
|
||||
}
|
||||
|
||||
/// Classify one manifest's ports into the map. Split from [`build_port_map`]
|
||||
/// so the catalog-overlay pass and the disk pass cannot diverge.
|
||||
fn classify_manifest(manifest: &AppManifest, map: &mut PortMap) {
|
||||
let app_id = manifest.app.id.clone();
|
||||
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`. Recorded so
|
||||
// the mesh relay (and any future republisher) can refuse to
|
||||
// expose it.
|
||||
PortAuth::Local => {
|
||||
map.local.insert(port.host);
|
||||
}
|
||||
// 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(),
|
||||
declared: true,
|
||||
session_passthrough: port.session_passthrough,
|
||||
},
|
||||
);
|
||||
}
|
||||
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(),
|
||||
declared: false,
|
||||
// An undeclared port never gets the node session —
|
||||
// passthrough is an explicit manifest opt-in only.
|
||||
session_passthrough: false,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[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
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn manifest(yaml: &str) -> AppManifest {
|
||||
AppManifest::parse(yaml).expect("test manifest must parse")
|
||||
}
|
||||
|
||||
const BASE: &str = r#"
|
||||
app:
|
||||
id: testapp
|
||||
name: Test App
|
||||
version: "1.0"
|
||||
container:
|
||||
image: example.org/testapp:1.0
|
||||
"#;
|
||||
|
||||
/// `auth: gated` is the only classification allowed to redirect traffic —
|
||||
/// torrc repoints, relay stand-down, and the 127.0.0.2 bind all key on
|
||||
/// `declared`. An undeclared Session port is challenged and audited but
|
||||
/// must never be `declared`.
|
||||
#[test]
|
||||
fn declared_tracks_the_manifest_not_the_default() {
|
||||
let mut map = PortMap::default();
|
||||
classify_manifest(
|
||||
&manifest(&format!(
|
||||
"{BASE} ports:\n - host: 8090\n container: 7777\n protocol: tcp\n bind: 127.0.0.1\n auth: gated\n"
|
||||
)),
|
||||
&mut map,
|
||||
);
|
||||
assert!(map.gated(8090).expect("gated").declared);
|
||||
|
||||
let mut map = PortMap::default();
|
||||
classify_manifest(
|
||||
&manifest(&format!(
|
||||
"{BASE} ports:\n - host: 9100\n container: 9100\n protocol: tcp\n"
|
||||
)),
|
||||
&mut map,
|
||||
);
|
||||
let undeclared = map.gated(9100).expect("session default is challenged");
|
||||
assert!(
|
||||
!undeclared.declared,
|
||||
"an absent auth field must never read as an instruction"
|
||||
);
|
||||
}
|
||||
|
||||
/// `auth: local` keeps the gate's hands off entirely — the port is
|
||||
/// neither gated nor exempt-reported — but it IS recorded, so the mesh
|
||||
/// relay can refuse to republish a deliberately host-local port.
|
||||
#[test]
|
||||
fn local_ports_are_untouched_but_recorded() {
|
||||
let mut map = PortMap::default();
|
||||
classify_manifest(
|
||||
&manifest(&format!(
|
||||
"{BASE} ports:\n - host: 32838\n container: 32838\n protocol: tcp\n bind: 127.0.0.1\n auth: local\n"
|
||||
)),
|
||||
&mut map,
|
||||
);
|
||||
assert!(map.gated(32838).is_none());
|
||||
assert!(map.exempt_ports().is_empty());
|
||||
assert!(
|
||||
map.is_declared_local(32838),
|
||||
"the mesh relay needs this to refuse bridging a host-local port"
|
||||
);
|
||||
assert!(!map.is_declared_local(3000));
|
||||
}
|
||||
|
||||
/// The real corpus: every port the FIPS relay can bridge must be safe to
|
||||
/// bridge. A port that is declared `local` (host-local by intent) or
|
||||
/// declared `gated` (the app gate owns its external addresses) must be
|
||||
/// withheld by the relay — this asserts the two sets the relay consults
|
||||
/// actually classify the live manifests, so a future manifest edit that
|
||||
/// re-opens one is caught here rather than on a node.
|
||||
#[test]
|
||||
fn relay_port_list_respects_local_and_gated_declarations() {
|
||||
let map = build_port_map();
|
||||
let relay_would_expose: Vec<u16> = crate::fips::app_ports::APP_LAUNCH_PORTS
|
||||
.iter()
|
||||
.copied()
|
||||
.filter(|p| map.is_declared_local(*p))
|
||||
.collect();
|
||||
assert!(
|
||||
!relay_would_expose.is_empty(),
|
||||
"expected the corpus to contain at least one local port in the relay list \
|
||||
(32838/8999) — if this fails the guard is untested, not unnecessary"
|
||||
);
|
||||
}
|
||||
|
||||
/// 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());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,399 @@
|
||||
//! 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);
|
||||
|
||||
/// The gate's own loopback address, distinct from the app's `127.0.0.1`.
|
||||
///
|
||||
/// Tor cannot present a session cookie, so `HiddenServicePort → 127.0.0.1`
|
||||
/// reaches the app around the gate. Instead torrc forwards gated ports to
|
||||
/// this address (`api/rpc/tor`), where the gate — not the app — listens. A
|
||||
/// second loopback address rather than a second port number, so no app needs
|
||||
/// a port it did not declare.
|
||||
pub const GATE_TOR_UPSTREAM: IpAddr = IpAddr::V4(std::net::Ipv4Addr::new(127, 0, 0, 2));
|
||||
|
||||
/// 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. The accept-loop handle is kept so a claim can be
|
||||
// RELEASED when its port leaves the gated set — a catalog refresh
|
||||
// declaring a port `local`/`none` must make the gate let go without a
|
||||
// daemon restart, or the stale bind keeps republishing a port the
|
||||
// catalog just withdrew (nbxplorer 32838, archi-dev-box 2026-08-04).
|
||||
let mut held: HashMap<(u16, IpAddr), tokio::task::JoinHandle<()>> = 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), tokio::task::JoinHandle<()>>,
|
||||
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;
|
||||
|
||||
// Release claims whose port left the gated set (or whose Tor-upstream
|
||||
// claim lost its declaration). Aborting the accept loop drops the
|
||||
// listener, freeing the address for whoever now legitimately owns it —
|
||||
// the app itself, or nobody.
|
||||
held.retain(|(port, addr), handle| {
|
||||
let keep = match port_map.gated(*port) {
|
||||
None => false,
|
||||
Some(app) => *addr != GATE_TOR_UPSTREAM || app.declared,
|
||||
};
|
||||
if !keep {
|
||||
handle.abort();
|
||||
info!(port, %addr, "app gate released a claim: port is no longer gated here");
|
||||
}
|
||||
keep
|
||||
});
|
||||
|
||||
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;
|
||||
// External addresses first, then the gate's Tor upstream. 127.0.0.2
|
||||
// deliberately does NOT count toward `claimed_any`: the warning below
|
||||
// is about external exposure, and a port whose only claim is the Tor
|
||||
// loopback is still wide open on the LAN.
|
||||
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) => {
|
||||
let handle =
|
||||
spawn_accept_loop(listener, gate.clone(), app.clone(), shutdown_rx.clone());
|
||||
held.insert(key, handle);
|
||||
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"
|
||||
);
|
||||
}
|
||||
// Almost always the app itself holding 0.0.0.0:<port>.
|
||||
Err(_) => blocked = true,
|
||||
}
|
||||
}
|
||||
// The Tor upstream is bound for DECLARED gated ports only: torrc only
|
||||
// repoints an onion at 127.0.0.2 for a declared port, and standing a
|
||||
// challenge on an undeclared port's would-be upstream would change
|
||||
// where its traffic goes on nothing but a default.
|
||||
if app.declared {
|
||||
let tor_key = (app.port, GATE_TOR_UPSTREAM);
|
||||
if held.contains_key(&tor_key) {
|
||||
claimed.push((app.port, GATE_TOR_UPSTREAM.to_string()));
|
||||
} else {
|
||||
match TcpListener::bind(SocketAddr::new(GATE_TOR_UPSTREAM, app.port)).await {
|
||||
Ok(listener) => {
|
||||
let handle = spawn_accept_loop(
|
||||
listener,
|
||||
gate.clone(),
|
||||
app.clone(),
|
||||
shutdown_rx.clone(),
|
||||
);
|
||||
held.insert(tor_key, handle);
|
||||
claimed.push((app.port, GATE_TOR_UPSTREAM.to_string()));
|
||||
info!(
|
||||
port = app.port, app = %app.app_id,
|
||||
"app gate claimed the Tor upstream (127.0.0.2)"
|
||||
);
|
||||
}
|
||||
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()
|
||||
}
|
||||
|
||||
/// Returns the accept-loop task handle so the sweep can release the claim
|
||||
/// (abort → listener drops → address freed) when the port leaves the gated
|
||||
/// set. In-flight connections finish on their own tasks.
|
||||
fn spawn_accept_loop(
|
||||
listener: TcpListener,
|
||||
gate: Arc<AppGate>,
|
||||
app: GatedPort,
|
||||
mut shutdown_rx: tokio::sync::watch::Receiver<bool>,
|
||||
) -> tokio::task::JoinHandle<()> {
|
||||
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());
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,207 +0,0 @@
|
||||
//! The Claude leg of the D-04 backend chain — Anthropic Messages API with
|
||||
//! `tools`/`tool_use`/`tool_result`. Modeled on
|
||||
//! `mesh/listener/assist.rs::call_claude`'s HTTP client construction and
|
||||
//! `api/rpc/mesh/assistant.rs`'s key-path convention, but NOT extended
|
||||
//! in place: this is a new, tool-calling-capable request/response shape,
|
||||
//! and its constants are new (AI-SPEC §3 Pitfall 6 — the mesh constants are
|
||||
//! airtime-tuned for LoRa and must not be reused here).
|
||||
|
||||
use std::path::PathBuf;
|
||||
use std::time::Duration;
|
||||
|
||||
use anyhow::Result;
|
||||
use async_trait::async_trait;
|
||||
use serde_json::{json, Value};
|
||||
|
||||
use super::{Backend, BackendTurn};
|
||||
use crate::assistant::tools::{ChatMessage, Role, ToolCall, ToolDef};
|
||||
|
||||
const CLAUDE_URL: &str = "https://api.anthropic.com/v1/messages";
|
||||
/// Kept in sync with `mesh/listener/assist.rs::CLAUDE_DEFAULT_MODEL` —
|
||||
/// cheap and already proven fast enough; D-07 makes backend choice a
|
||||
/// privacy/cost decision, not a capability-need one, so there is no reason
|
||||
/// to default to a stronger model here.
|
||||
const CLAUDE_MODEL: &str = "claude-haiku-4-5-20251001";
|
||||
/// New, separate constant for the AIUI path's multi-turn tool loop (which
|
||||
/// may include a network round trip) — NOT `assist.rs`'s `OLLAMA_TIMEOUT`
|
||||
/// (60s, LoRa-airtime-tuned).
|
||||
const ASSISTANT_HTTP_TIMEOUT: Duration = Duration::from_secs(180);
|
||||
/// Raised from mesh's `512` — `tool_use` content blocks and multi-turn
|
||||
/// reasoning need more headroom. Never left unbounded.
|
||||
const ASSISTANT_MAX_TOKENS: u32 = 2048;
|
||||
|
||||
pub struct ClaudeBackend {
|
||||
data_dir: PathBuf,
|
||||
}
|
||||
|
||||
impl ClaudeBackend {
|
||||
pub fn new(data_dir: PathBuf) -> Self {
|
||||
Self { data_dir }
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Backend for ClaudeBackend {
|
||||
async fn send(
|
||||
&self,
|
||||
system: &str,
|
||||
tools: &[ToolDef],
|
||||
history: &[ChatMessage],
|
||||
) -> Result<BackendTurn> {
|
||||
// SAME key path `api/rpc/mesh/assistant.rs` probes — do not
|
||||
// introduce a second key location (D-01, one key ledger).
|
||||
let key = tokio::fs::read_to_string(self.data_dir.join("secrets/claude-api-key"))
|
||||
.await
|
||||
.map_err(|_| anyhow::anyhow!("Claude API key not configured on this node"))?;
|
||||
let key = key.trim();
|
||||
if key.is_empty() {
|
||||
anyhow::bail!("Claude API key is empty");
|
||||
}
|
||||
|
||||
let messages: Vec<Value> = history.iter().filter_map(message_to_wire).collect();
|
||||
|
||||
let claude_tools: Vec<Value> = tools
|
||||
.iter()
|
||||
.map(|t| {
|
||||
json!({
|
||||
"name": t.name,
|
||||
"description": t.description,
|
||||
"input_schema": t.parameters,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
|
||||
let mut body = json!({
|
||||
"model": CLAUDE_MODEL,
|
||||
"max_tokens": ASSISTANT_MAX_TOKENS,
|
||||
"system": system,
|
||||
"messages": messages,
|
||||
"stream": false,
|
||||
});
|
||||
if !claude_tools.is_empty() {
|
||||
body["tools"] = json!(claude_tools);
|
||||
// AI-SPEC §3 Pitfall 5: every tool_use.id from one assistant
|
||||
// turn needs a matching tool_result before the next request.
|
||||
// Disabling parallel tool use sidesteps that bookkeeping —
|
||||
// D-06's tools are one deliberate action at a time anyway.
|
||||
body["tool_choice"] = json!({"type": "auto", "disable_parallel_tool_use": true});
|
||||
}
|
||||
|
||||
let client = reqwest::Client::builder()
|
||||
.timeout(ASSISTANT_HTTP_TIMEOUT)
|
||||
.build()?;
|
||||
let resp = client
|
||||
.post(CLAUDE_URL)
|
||||
.header("x-api-key", key)
|
||||
.header("anthropic-version", "2023-06-01")
|
||||
.header("content-type", "application/json")
|
||||
.json(&body)
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
if !resp.status().is_success() {
|
||||
let status = resp.status();
|
||||
let txt = resp.text().await.unwrap_or_default();
|
||||
anyhow::bail!(
|
||||
"Claude API HTTP {}: {}",
|
||||
status,
|
||||
txt.chars().take(180).collect::<String>()
|
||||
);
|
||||
}
|
||||
|
||||
let json: Value = resp.json().await?;
|
||||
let blocks = json
|
||||
.get("content")
|
||||
.and_then(|c| c.as_array())
|
||||
.cloned()
|
||||
.unwrap_or_default();
|
||||
|
||||
let mut tool_calls = Vec::new();
|
||||
let mut text = String::new();
|
||||
for block in &blocks {
|
||||
match block.get("type").and_then(|t| t.as_str()) {
|
||||
Some("tool_use") => {
|
||||
let id = block
|
||||
.get("id")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or_default()
|
||||
.to_string();
|
||||
let name = block
|
||||
.get("name")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or_default()
|
||||
.to_string();
|
||||
let arguments = block.get("input").cloned().unwrap_or_else(|| json!({}));
|
||||
tool_calls.push(ToolCall {
|
||||
id,
|
||||
name,
|
||||
arguments,
|
||||
});
|
||||
}
|
||||
Some("text") => {
|
||||
if let Some(t) = block.get("text").and_then(|v| v.as_str()) {
|
||||
text.push_str(t);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
if !tool_calls.is_empty() {
|
||||
Ok(BackendTurn::ToolCalls(tool_calls))
|
||||
} else {
|
||||
Ok(BackendTurn::Text(text))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Map one internal `ChatMessage` onto an Anthropic Messages API turn.
|
||||
/// `Role::System` returns `None` — the system prompt is sent via the
|
||||
/// top-level `system` field, not as a message in the array.
|
||||
fn message_to_wire(msg: &ChatMessage) -> Option<Value> {
|
||||
match msg.role {
|
||||
Role::System => None,
|
||||
Role::User => Some(json!({
|
||||
"role": "user",
|
||||
"content": msg.text.clone().unwrap_or_default(),
|
||||
})),
|
||||
Role::Assistant => {
|
||||
if !msg.tool_calls.is_empty() {
|
||||
let blocks: Vec<Value> = msg
|
||||
.tool_calls
|
||||
.iter()
|
||||
.map(|c| {
|
||||
json!({
|
||||
"type": "tool_use",
|
||||
"id": c.id,
|
||||
"name": c.name,
|
||||
"input": c.arguments,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
Some(json!({"role": "assistant", "content": blocks}))
|
||||
} else {
|
||||
Some(json!({
|
||||
"role": "assistant",
|
||||
"content": msg.text.clone().unwrap_or_default(),
|
||||
}))
|
||||
}
|
||||
}
|
||||
Role::Tool => {
|
||||
let blocks: Vec<Value> = msg
|
||||
.tool_results
|
||||
.iter()
|
||||
.map(|r| {
|
||||
json!({
|
||||
"type": "tool_result",
|
||||
"tool_use_id": r.call_id,
|
||||
"content": r.content,
|
||||
"is_error": r.is_error,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
// Anthropic's tool_result blocks travel back as a "user" turn.
|
||||
Some(json!({"role": "user", "content": blocks}))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,35 +0,0 @@
|
||||
//! The `Backend` trait — the wire-format-agnostic seam every model backend
|
||||
//! (Ollama, Claude, Routstr) implements once. The loop and every tool are
|
||||
//! written against this trait only; wire-format differences live entirely
|
||||
//! inside each adapter.
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
use anyhow::Result;
|
||||
use async_trait::async_trait;
|
||||
|
||||
use super::tools::{ChatMessage, ToolCall, ToolDef};
|
||||
|
||||
pub mod claude;
|
||||
#[cfg(test)]
|
||||
pub mod scripted;
|
||||
|
||||
pub enum BackendTurn {
|
||||
Text(String),
|
||||
ToolCalls(Vec<ToolCall>),
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
pub trait Backend: Send + Sync {
|
||||
async fn send(&self, system: &str, tools: &[ToolDef], history: &[ChatMessage]) -> Result<BackendTurn>;
|
||||
}
|
||||
|
||||
/// D-04's backend chain: local Ollama first (node data never leaves the
|
||||
/// node when a local model is available), then Claude, then Routstr. Only
|
||||
/// the Claude leg is implemented in this tracer — `backends/ollama.rs`
|
||||
/// (13-10) and `backends/routstr.rs` (13-13) slot in ahead of and behind it
|
||||
/// without changing the `Backend` trait; that is the architectural
|
||||
/// commitment this tracer proves.
|
||||
pub fn select_backend(data_dir: &Path) -> Box<dyn Backend> {
|
||||
Box::new(claude::ClaudeBackend::new(data_dir.to_path_buf()))
|
||||
}
|
||||
@@ -1,44 +0,0 @@
|
||||
//! Test-only backend that replays a canned sequence of turns. Never
|
||||
//! compiles into the shipped binary — gated by `#![cfg(test)]` here AND by
|
||||
//! `#[cfg(test)] pub mod scripted;` in `backends/mod.rs`.
|
||||
|
||||
#![cfg(test)]
|
||||
|
||||
use std::sync::Mutex;
|
||||
|
||||
use anyhow::Result;
|
||||
use async_trait::async_trait;
|
||||
|
||||
use super::{Backend, BackendTurn};
|
||||
use crate::assistant::tools::{ChatMessage, ToolDef};
|
||||
|
||||
pub struct ScriptedBackend {
|
||||
turns: Mutex<Vec<BackendTurn>>,
|
||||
}
|
||||
|
||||
impl ScriptedBackend {
|
||||
/// `turns` are consumed in the order given — the first call to `send()`
|
||||
/// returns `turns[0]`, the second `turns[1]`, and so on.
|
||||
pub fn new(turns: Vec<BackendTurn>) -> Self {
|
||||
let mut turns = turns;
|
||||
turns.reverse();
|
||||
Self {
|
||||
turns: Mutex::new(turns),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Backend for ScriptedBackend {
|
||||
async fn send(
|
||||
&self,
|
||||
_system: &str,
|
||||
_tools: &[ToolDef],
|
||||
_history: &[ChatMessage],
|
||||
) -> Result<BackendTurn> {
|
||||
let mut turns = self.turns.lock().expect("ScriptedBackend mutex poisoned");
|
||||
turns
|
||||
.pop()
|
||||
.ok_or_else(|| anyhow::anyhow!("ScriptedBackend exhausted — no more turns queued"))
|
||||
}
|
||||
}
|
||||
@@ -1,237 +0,0 @@
|
||||
//! The multi-turn tool-calling loop (D-01/D-02). No analog exists elsewhere
|
||||
//! in this codebase — this is the first tool-calling agent loop ever
|
||||
//! written here (confirmed by 13-RESEARCH.md/13-AI-SPEC.md); built directly
|
||||
//! from `13-AI-SPEC.md` §3/§4's sketch.
|
||||
//!
|
||||
//! Concurrency discipline inherited from `mesh/listener/assist.rs`'s own
|
||||
//! doc comment ("Spawned off the radio loop so it never blocks"): never
|
||||
//! hold a shared lock across a `.await` that can block for human-response
|
||||
//! time. `execute_tool` below holds no lock at all in this tracer — there
|
||||
//! is nothing yet to hold one across (13-08's confirm gate is what
|
||||
//! introduces that discipline requirement for real).
|
||||
|
||||
use anyhow::Result;
|
||||
|
||||
use super::backends::{Backend, BackendTurn};
|
||||
use super::tools::{ChatMessage, Role, ToolCall, ToolResult};
|
||||
use super::tools::ToolDef;
|
||||
use super::ToolExecCtx;
|
||||
|
||||
/// Hard stop — a looping model must never spin unbounded (D-05).
|
||||
pub const MAX_TURNS: usize = 8;
|
||||
|
||||
pub async fn run_loop(
|
||||
backend: &dyn Backend,
|
||||
system: &str,
|
||||
tools: &[ToolDef],
|
||||
mut history: Vec<ChatMessage>,
|
||||
ctx: &ToolExecCtx,
|
||||
) -> Result<String> {
|
||||
for _ in 0..MAX_TURNS {
|
||||
match backend.send(system, tools, &history).await? {
|
||||
BackendTurn::Text(answer) => return Ok(answer),
|
||||
BackendTurn::ToolCalls(calls) => {
|
||||
history.push(ChatMessage {
|
||||
role: Role::Assistant,
|
||||
text: None,
|
||||
tool_calls: calls.clone(),
|
||||
tool_results: vec![],
|
||||
});
|
||||
let mut results = Vec::with_capacity(calls.len());
|
||||
for call in &calls {
|
||||
results.push(execute_tool(call, ctx).await);
|
||||
}
|
||||
history.push(ChatMessage {
|
||||
role: Role::Tool,
|
||||
text: None,
|
||||
tool_calls: vec![],
|
||||
tool_results: results,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
anyhow::bail!("assistant loop exceeded MAX_TURNS without a final answer — stopping, not looping forever")
|
||||
}
|
||||
|
||||
/// The single choke point every tool call passes through, regardless of
|
||||
/// which backend produced it. Enforces, in order: D-06 (curated allowlist —
|
||||
/// unknown names are refused, never silently ignored), D-16 (default-closed
|
||||
/// category grants — re-checked here even though the system prompt already
|
||||
/// omits ungranted tools; never trust that as the only enforcement layer),
|
||||
/// schema validation (never coerce, never guess), and D-07 (every
|
||||
/// destructive tool suspends for confirmation — 13-08 fills that branch in;
|
||||
/// there are no destructive tools registered yet, so it is unreachable
|
||||
/// today).
|
||||
async fn execute_tool(call: &ToolCall, ctx: &ToolExecCtx) -> ToolResult {
|
||||
let Some(tool) = ctx.registry.get(&call.name) else {
|
||||
return ToolResult {
|
||||
call_id: call.id.clone(),
|
||||
is_error: true,
|
||||
content: format!("no such tool: {}", call.name),
|
||||
};
|
||||
};
|
||||
|
||||
if !ctx.caller.granted_categories().contains(&tool.category) {
|
||||
return ToolResult {
|
||||
call_id: call.id.clone(),
|
||||
is_error: true,
|
||||
content: "not permitted — this category is not granted".to_string(),
|
||||
};
|
||||
}
|
||||
|
||||
if let Err(e) = tool.validate(&call.arguments) {
|
||||
return ToolResult {
|
||||
call_id: call.id.clone(),
|
||||
is_error: true,
|
||||
content: format!("invalid arguments: {e}"),
|
||||
};
|
||||
}
|
||||
|
||||
if tool.destructive {
|
||||
return ToolResult {
|
||||
call_id: call.id.clone(),
|
||||
is_error: true,
|
||||
content: "destructive tool execution is not yet implemented".to_string(),
|
||||
};
|
||||
}
|
||||
|
||||
match call.name.as_str() {
|
||||
// Dispatches to the SAME RpcHandler method every other authenticated
|
||||
// caller uses (no AI-only backdoor) — see `assistant_dispatch_tool`
|
||||
// in `api/rpc/assistant_chat.rs` for why this bridge exists.
|
||||
"system_disk_status" => match ctx
|
||||
.handler
|
||||
.assistant_dispatch_tool("system.disk-status")
|
||||
.await
|
||||
{
|
||||
Ok(v) => ToolResult {
|
||||
call_id: call.id.clone(),
|
||||
is_error: false,
|
||||
content: v.to_string(),
|
||||
},
|
||||
Err(e) => ToolResult {
|
||||
call_id: call.id.clone(),
|
||||
is_error: true,
|
||||
content: format!("tool execution failed: {e}"),
|
||||
},
|
||||
},
|
||||
other => ToolResult {
|
||||
call_id: call.id.clone(),
|
||||
is_error: true,
|
||||
content: format!("no execution wired for tool: {other}"),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::assistant::backends::scripted::ScriptedBackend;
|
||||
use crate::assistant::tools::{registry, system_disk_status_tool};
|
||||
use crate::assistant::{CallerScope, PermissionCategory};
|
||||
use crate::api::rpc::RpcHandler;
|
||||
use serde_json::json;
|
||||
use std::sync::Arc;
|
||||
|
||||
/// A minimal but real `RpcHandler` for tests: a fresh temp `data_dir`
|
||||
/// (no `/var/lib/archipelago` writes), no orchestrator (container RPCs
|
||||
/// aren't exercised here), matching the doc comment on `orchestrator`
|
||||
/// that this is exactly why the field is `Option`.
|
||||
async fn test_rpc_handler() -> (Arc<RpcHandler>, tempfile::TempDir) {
|
||||
let tmp = tempfile::tempdir().expect("tempdir");
|
||||
let mut config = crate::config::Config::default();
|
||||
config.data_dir = tmp.path().to_path_buf();
|
||||
let state_manager = Arc::new(crate::state::StateManager::new());
|
||||
let metrics_store = Arc::new(crate::monitoring::MetricsStore::new());
|
||||
let session_store =
|
||||
crate::session::SessionStore::new_for_tests(tmp.path().join("sessions.json"));
|
||||
let handler = RpcHandler::new(
|
||||
config,
|
||||
state_manager,
|
||||
metrics_store,
|
||||
session_store,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.expect("RpcHandler::new");
|
||||
(Arc::new(handler), tmp)
|
||||
}
|
||||
|
||||
fn local_operator_ctx(handler: Arc<RpcHandler>) -> ToolExecCtx {
|
||||
ToolExecCtx {
|
||||
registry: registry(),
|
||||
caller: CallerScope::LocalOperator {
|
||||
session_id: "test-session".to_string(),
|
||||
},
|
||||
handler,
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn disk_status_tool_executes() {
|
||||
let (handler, _tmp) = test_rpc_handler().await;
|
||||
|
||||
// The real figures the tool path returns must match what the SAME
|
||||
// handler returns when dispatched directly — proving `execute_tool`
|
||||
// is not a parallel, AI-only code path.
|
||||
let direct = handler
|
||||
.assistant_dispatch_tool("system.disk-status")
|
||||
.await
|
||||
.expect("direct dispatch");
|
||||
|
||||
let ctx = local_operator_ctx(handler.clone());
|
||||
let call = ToolCall {
|
||||
id: "call-1".to_string(),
|
||||
name: "system_disk_status".to_string(),
|
||||
arguments: json!({}),
|
||||
};
|
||||
let result = execute_tool(&call, &ctx).await;
|
||||
assert!(!result.is_error, "tool call errored: {}", result.content);
|
||||
assert_eq!(result.content, direct.to_string());
|
||||
assert!(result.content.contains("total_bytes"));
|
||||
|
||||
// Exercise the whole loop: a ScriptedBackend that names the tool,
|
||||
// then answers — proving the real figures reached the final answer
|
||||
// path (the answer itself is the second scripted turn, matching
|
||||
// AI-SPEC's run_loop shape; the tool result that fed into it is
|
||||
// asserted above).
|
||||
let backend = ScriptedBackend::new(vec![
|
||||
BackendTurn::ToolCalls(vec![call.clone()]),
|
||||
BackendTurn::Text("Disk space report generated.".to_string()),
|
||||
]);
|
||||
let tools_list = vec![system_disk_status_tool()];
|
||||
let answer = run_loop(&backend, "system prompt", &tools_list, vec![], &ctx)
|
||||
.await
|
||||
.expect("run_loop");
|
||||
assert_eq!(answer, "Disk space report generated.");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn unknown_tool_is_refused_not_ignored() {
|
||||
let (handler, _tmp) = test_rpc_handler().await;
|
||||
let ctx = local_operator_ctx(handler);
|
||||
let call = ToolCall {
|
||||
id: "call-1".to_string(),
|
||||
name: "delete_everything".to_string(),
|
||||
arguments: json!({}),
|
||||
};
|
||||
let result = execute_tool(&call, &ctx).await;
|
||||
assert!(result.is_error);
|
||||
assert!(result.content.contains("no such tool"), "{}", result.content);
|
||||
}
|
||||
|
||||
/// Phase-10 hard constraint: `assistant.*` must never be reachable
|
||||
/// unauthenticated. Asserted directly against the live list, not
|
||||
/// assumed.
|
||||
#[test]
|
||||
fn assistant_methods_require_session() {
|
||||
let has_assistant_method = crate::api::rpc::UNAUTHENTICATED_METHODS
|
||||
.iter()
|
||||
.any(|m| m.starts_with("assistant."));
|
||||
assert!(
|
||||
!has_assistant_method,
|
||||
"assistant.* must never be added to UNAUTHENTICATED_METHODS (Phase-10 hard constraint)"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,125 +0,0 @@
|
||||
//! D-02: "one assistant, many front doors." A shared assistant service —
|
||||
//! one curated tool registry, one backend selector, one place the model key
|
||||
//! lives — used today by AIUI chat (`CallerScope::LocalOperator`) and, by
|
||||
//! design, extensible to mesh/LoRa callers (`CallerScope::Mesh`) and later
|
||||
//! Pine voice without recreating a second, divergent security model.
|
||||
//!
|
||||
//! This is the tracer slice for Phase 13 (D-01, D-02, D-06): a typed
|
||||
//! question reaches exactly one curated, read-only tool
|
||||
//! (`tools::system_disk_status_tool`) via the Claude backend, dispatched
|
||||
//! through the SAME `handle_system_disk_status` RPC handler every other
|
||||
//! authenticated caller uses. See `13-01-PLAN.md` for the full spine.
|
||||
|
||||
pub mod backends;
|
||||
pub mod loop_;
|
||||
pub mod tools;
|
||||
|
||||
use std::collections::BTreeSet;
|
||||
use std::sync::Arc;
|
||||
|
||||
use anyhow::Result;
|
||||
|
||||
use crate::api::rpc::RpcHandler;
|
||||
|
||||
/// D-16's ten permission categories. All default-closed on a fresh node —
|
||||
/// nothing is shared with the model until deliberately granted.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
|
||||
pub enum PermissionCategory {
|
||||
Apps,
|
||||
System,
|
||||
Network,
|
||||
Wallet,
|
||||
Files,
|
||||
Media,
|
||||
Search,
|
||||
AiLocal,
|
||||
Notes,
|
||||
Bitcoin,
|
||||
}
|
||||
|
||||
/// D-02's promoted primary noun: a caller identity carrying the permission
|
||||
/// scope its tool calls resolve authority through. "A mesh peer" and "the
|
||||
/// local operator in AIUI" are two variants of it; Pine voice will be a
|
||||
/// third (not built in this phase — no `Voice` variant exists yet, by
|
||||
/// design, until that phase actually needs one).
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum CallerScope {
|
||||
/// A mesh/LoRa peer. Not exercised by this plan (mesh's existing
|
||||
/// `!ai` path is Q&A-only, per `mesh/listener/assist.rs`'s own doc
|
||||
/// comment) — the variant exists so the shape is right when a future
|
||||
/// plan wires mesh callers into the shared loop.
|
||||
Mesh { peer_id: String },
|
||||
/// The authenticated operator using AIUI, identified by their neode-ui
|
||||
/// session. This is the only variant this tracer's `assistant.chat`
|
||||
/// RPC constructs.
|
||||
LocalOperator { session_id: String },
|
||||
}
|
||||
|
||||
impl CallerScope {
|
||||
/// The sole source of tool authority `execute_tool` reads. No
|
||||
/// `execute_tool` branch may read a caller-specific field directly
|
||||
/// instead of going through this — that would reintroduce the
|
||||
/// mesh-only assumption D-02 exists to retire.
|
||||
pub fn granted_categories(&self) -> BTreeSet<PermissionCategory> {
|
||||
match self {
|
||||
// 13-05 replaces this hardcoded default with the persisted
|
||||
// D-16 default-closed grants store — a data-source change, not
|
||||
// an architectural one (per the plan's assumption-delta note).
|
||||
CallerScope::LocalOperator { .. } => {
|
||||
let mut set = BTreeSet::new();
|
||||
set.insert(PermissionCategory::System);
|
||||
set
|
||||
}
|
||||
// Intentionally conservative for this tracer: mesh has no
|
||||
// tool-calling caller path wired up yet (today's mesh `!ai` is
|
||||
// Q&A-only), so there is no real trusted_only/allowed_contacts
|
||||
// grant to resolve. A future plan that wires the Mesh variant
|
||||
// into the shared loop threads those existing per-caller
|
||||
// controls through here — this is explicitly NOT the place a
|
||||
// mesh-only field gets read directly by `execute_tool`.
|
||||
CallerScope::Mesh { .. } => BTreeSet::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Bundles what `execute_tool` needs regardless of which backend produced
|
||||
/// the tool call: the curated registry, the caller's resolved authority,
|
||||
/// and a handle back to the SAME `RpcHandler` every other authenticated
|
||||
/// caller dispatches through — never an AI-only backdoor.
|
||||
pub struct ToolExecCtx {
|
||||
pub registry: tools::ToolRegistry,
|
||||
pub caller: CallerScope,
|
||||
pub handler: Arc<RpcHandler>,
|
||||
}
|
||||
|
||||
/// Entry point: run one chat turn for `caller` through the shared loop.
|
||||
/// Builds the visible-tool set from the caller's granted categories only
|
||||
/// (D-16 — the model should never even see a tool it can't use), selects a
|
||||
/// backend (Claude only, in this tracer), and runs it to a final answer.
|
||||
pub async fn chat(handler: Arc<RpcHandler>, caller: CallerScope, user_text: String) -> Result<String> {
|
||||
let registry = tools::registry();
|
||||
let grants = caller.granted_categories();
|
||||
let visible_tools = registry.visible_to(&grants);
|
||||
|
||||
let backend = backends::select_backend(handler.data_dir());
|
||||
|
||||
let system_prompt = "You are the Archipelago node's operator-control assistant. \
|
||||
Only use the tools explicitly listed for this turn — never invent a tool name or call \
|
||||
one that isn't listed. Every write requires human confirmation you cannot bypass or \
|
||||
pre-approve on the user's behalf.";
|
||||
|
||||
let history = vec![tools::ChatMessage {
|
||||
role: tools::Role::User,
|
||||
text: Some(user_text),
|
||||
tool_calls: vec![],
|
||||
tool_results: vec![],
|
||||
}];
|
||||
|
||||
let ctx = ToolExecCtx {
|
||||
registry,
|
||||
caller,
|
||||
handler,
|
||||
};
|
||||
|
||||
loop_::run_loop(backend.as_ref(), system_prompt, &visible_tools, history, &ctx).await
|
||||
}
|
||||
@@ -1,170 +0,0 @@
|
||||
//! D-06: a curated, hand-written tool registry. Never derived from
|
||||
//! `api::rpc::dispatcher`'s method table — every capability the chat has is
|
||||
//! a deliberate decision recorded here, and the model never sees the full
|
||||
//! RPC surface. No `schemars` — that crate is absent from `Cargo.toml` and
|
||||
//! from 13-RESEARCH.md's Package Legitimacy Audit, so `parameters` below is
|
||||
//! a hand-written JSON Schema object literal instead.
|
||||
|
||||
use std::collections::{BTreeSet, HashMap};
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use serde::Deserialize;
|
||||
use serde_json::{json, Value};
|
||||
|
||||
use super::PermissionCategory;
|
||||
|
||||
/// The backend-agnostic in/out of a tool invocation — the same shape
|
||||
/// regardless of which adapter (Ollama/Claude/Routstr) produced it.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ToolCall {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub arguments: Value,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ToolResult {
|
||||
pub call_id: String,
|
||||
pub content: String,
|
||||
pub is_error: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Role {
|
||||
System,
|
||||
User,
|
||||
Assistant,
|
||||
Tool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ChatMessage {
|
||||
pub role: Role,
|
||||
/// Plain text, or (a future plan's) D-10-wrapped untrusted content.
|
||||
pub text: Option<String>,
|
||||
/// Assistant-authored tool calls made THIS turn (role: Assistant).
|
||||
pub tool_calls: Vec<ToolCall>,
|
||||
/// Tool results fed back THIS turn (role: Tool).
|
||||
pub tool_results: Vec<ToolResult>,
|
||||
}
|
||||
|
||||
/// D-06: one curated, hand-written tool. Never generated from the RPC
|
||||
/// dispatcher — the curated set IS the D-09 authority boundary.
|
||||
#[derive(Clone)]
|
||||
pub struct ToolDef {
|
||||
pub name: &'static str,
|
||||
pub description: &'static str,
|
||||
/// JSON Schema `{"type":"object","properties":{...},"required":[...]}`,
|
||||
/// hand-written and pinned adjacent to the args struct it must never
|
||||
/// drift from — see `disk_status_schema_round_trips_required_keys`.
|
||||
pub parameters: Value,
|
||||
pub category: PermissionCategory,
|
||||
/// D-07: true => confirm gate, no exceptions. There are no destructive
|
||||
/// tools in this tracer's registry; `execute_tool` refuses this branch
|
||||
/// with a not-yet-implemented error until 13-08 fills it in.
|
||||
pub destructive: bool,
|
||||
}
|
||||
|
||||
/// Args for `system_disk_status` — takes no parameters.
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct SystemDiskStatusArgs {}
|
||||
|
||||
impl ToolDef {
|
||||
/// Deserialize + validate model-produced arguments before ANY
|
||||
/// execution. Never coerce, never guess, never panic on a mismatch —
|
||||
/// refuse and let the caller turn the error into a tool result the
|
||||
/// model can recover from.
|
||||
///
|
||||
/// This tracer's registry has exactly one tool, so this is a direct
|
||||
/// deserialize; a future plan adding a second tool dispatches by
|
||||
/// `self.name` here before deserializing into that tool's own args type.
|
||||
pub fn validate(&self, raw: &Value) -> Result<SystemDiskStatusArgs> {
|
||||
serde_json::from_value(raw.clone())
|
||||
.context("tool arguments did not match the declared schema")
|
||||
}
|
||||
}
|
||||
|
||||
/// `system_disk_status` — category `System`, read-only. Reports free and
|
||||
/// total disk space on this node via the same `system.disk-status` handler
|
||||
/// every other authenticated caller uses.
|
||||
pub fn system_disk_status_tool() -> ToolDef {
|
||||
ToolDef {
|
||||
name: "system_disk_status",
|
||||
description: "Report free and total disk space on this Archipelago node.",
|
||||
parameters: json!({
|
||||
"type": "object",
|
||||
"properties": {},
|
||||
"required": [],
|
||||
}),
|
||||
category: PermissionCategory::System,
|
||||
destructive: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// D-06's curated allowlist, name-indexed.
|
||||
pub struct ToolRegistry {
|
||||
tools: HashMap<&'static str, ToolDef>,
|
||||
}
|
||||
|
||||
impl ToolRegistry {
|
||||
pub fn get(&self, name: &str) -> Option<&ToolDef> {
|
||||
self.tools.get(name)
|
||||
}
|
||||
|
||||
/// The subset of the registry visible to a caller with `grants`. D-16:
|
||||
/// an unconfigured node's system prompt should advertise close to zero
|
||||
/// tools — the model should never even see a tool it can't use.
|
||||
pub fn visible_to(&self, grants: &BTreeSet<PermissionCategory>) -> Vec<ToolDef> {
|
||||
self.tools
|
||||
.values()
|
||||
.filter(|t| grants.contains(&t.category))
|
||||
.cloned()
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
/// The curated D-06 registry. This tracer registers exactly one tool.
|
||||
pub fn registry() -> ToolRegistry {
|
||||
let mut tools = HashMap::new();
|
||||
let tool = system_disk_status_tool();
|
||||
tools.insert(tool.name, tool);
|
||||
ToolRegistry { tools }
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// The schema sent to the model and the struct used to deserialize its
|
||||
/// output must never silently drift apart. Round-trip the schema's
|
||||
/// declared `required` keys through the args struct.
|
||||
#[test]
|
||||
fn disk_status_schema_round_trips_required_keys() {
|
||||
let tool = system_disk_status_tool();
|
||||
let required = tool
|
||||
.parameters
|
||||
.get("required")
|
||||
.and_then(|r| r.as_array())
|
||||
.cloned()
|
||||
.unwrap_or_default();
|
||||
|
||||
let mut obj = serde_json::Map::new();
|
||||
for key in &required {
|
||||
if let Some(k) = key.as_str() {
|
||||
obj.insert(k.to_string(), Value::Null);
|
||||
}
|
||||
}
|
||||
let value = Value::Object(obj);
|
||||
let parsed: Result<SystemDiskStatusArgs, _> = serde_json::from_value(value);
|
||||
assert!(parsed.is_ok(), "schema/args struct drift: {:?}", parsed.err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn registry_visible_to_respects_grants() {
|
||||
let reg = registry();
|
||||
let mut grants = BTreeSet::new();
|
||||
assert!(reg.visible_to(&grants).is_empty());
|
||||
grants.insert(PermissionCategory::System);
|
||||
assert_eq!(reg.visible_to(&grants).len(), 1);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -154,9 +154,9 @@ pub async fn ensure_doctor_installed() {
|
||||
}
|
||||
match run_bitcoin_rpc_repair().await {
|
||||
Ok(true) => {
|
||||
info!("Repaired Bitcoin RPC bind settings; running Bitcoin containers left untouched")
|
||||
info!("Removed stale bitcoin.conf; running Bitcoin containers left untouched")
|
||||
}
|
||||
Ok(false) => debug!("Bitcoin RPC bind settings already usable"),
|
||||
Ok(false) => debug!("No stale bitcoin.conf found"),
|
||||
Err(e) => warn!("Bitcoin RPC repair failed (non-fatal): {:#}", e),
|
||||
}
|
||||
match run_apps_dir_repair().await {
|
||||
@@ -621,52 +621,30 @@ exit 2
|
||||
}
|
||||
|
||||
async fn run_bitcoin_rpc_repair() -> Result<bool> {
|
||||
// Older installs can have a container-owned bitcoin.conf with only rpcauth
|
||||
// and printtoconsole. Repair it at startup so OTA fixes existing nodes
|
||||
// without a manual uninstall/reinstall. Bind/port stay in the container
|
||||
// command line to avoid duplicate RPC endpoint definitions.
|
||||
// bitcoind is launched with -conf=/tmp/rpc.conf and never reads a
|
||||
// datadir bitcoin.conf (apps/bitcoin-core & bitcoin-knots manifest.yml,
|
||||
// commit a597c1d9 — bind/port live only on the container command line).
|
||||
// A leftover file from an older install makes Bitcoin Core's own
|
||||
// datadir-conflict safety check refuse to start on every subsequent
|
||||
// start. Remove it instead of "repairing" it into existence — this
|
||||
// previously wrote server=/rpcbind=/rpcallowip=/listen= into the file,
|
||||
// which is exactly what caused the conflict.
|
||||
let script = r#"
|
||||
set -eu
|
||||
conf=/var/lib/archipelago/bitcoin/bitcoin.conf
|
||||
[ -f "$conf" ] || exit 0
|
||||
changed=0
|
||||
ensure_line() {
|
||||
line="$1"
|
||||
key="${line%%=*}"
|
||||
if ! grep -q "^${key}=" "$conf"; then
|
||||
printf '%s\n' "$line" >> "$conf"
|
||||
changed=1
|
||||
fi
|
||||
}
|
||||
ensure_line server=1
|
||||
# rpcbind=0.0.0.0 is required inside the container: with rpcallowip set but
|
||||
# no rpcbind, bitcoind binds RPC to the container's loopback only and every
|
||||
# dial over the container network (LND, bitcoin-ui) is refused — the fresh-
|
||||
# install "LND took 5 attempts" / bitcoin-rpc 502 failure (host publish stays
|
||||
# 127.0.0.1-only, so exposure is unchanged).
|
||||
ensure_line rpcbind=0.0.0.0
|
||||
ensure_line rpcallowip=0.0.0.0/0
|
||||
ensure_line listen=1
|
||||
# Log-volume fix: printtoconsole=1 duplicated every log line (incl. per-block
|
||||
# IBD "UpdateTip" spam) into journald via conmon on top of the datadir
|
||||
# debug.log bitcoind already writes. Console off; debug.log stays (bitcoind
|
||||
# self-shrinks it on restart).
|
||||
if grep -q '^printtoconsole=1' "$conf"; then
|
||||
sed -i 's/^printtoconsole=1$/printtoconsole=0/' "$conf"
|
||||
changed=1
|
||||
fi
|
||||
[ "$changed" -eq 0 ] && exit 0
|
||||
mv "$conf" "$conf.disabled-$(date +%s)"
|
||||
exit 2
|
||||
"#;
|
||||
let status = host_sudo(&["sh", "-lc", script])
|
||||
.await
|
||||
.context("repair bitcoin.conf RPC bind settings")?;
|
||||
.context("remove stale bitcoin.conf RPC bind settings")?;
|
||||
match status.code() {
|
||||
Some(0) => Ok(false),
|
||||
// Do not restart Bitcoin from bootstrap. During IBD, an automatic
|
||||
// restart can cost hours of progress. The repaired file is only a
|
||||
// fallback for future starts; current containers keep their command-line
|
||||
// RPC args until an operator or update intentionally restarts them.
|
||||
// restart can cost hours of progress. Removing the stale file is
|
||||
// only a fallback for future starts; current containers keep their
|
||||
// command-line RPC args regardless.
|
||||
Some(2) => Ok(true),
|
||||
_ => {
|
||||
warn!("Bitcoin RPC repair helper exited with {}", status);
|
||||
@@ -1293,3 +1271,54 @@ mod tests {
|
||||
assert_ne!(outcome, PodmanHealOutcome::Healthy);
|
||||
}
|
||||
}
|
||||
|
||||
/// Repair this node's own systemd restart policy.
|
||||
///
|
||||
/// The in-process updater replaces the binary and then asks systemd to
|
||||
/// restart the service, treating `Restart=always` on the unit as its second
|
||||
/// net if that request is ever lost. On austin-sapien (2026-08-05) the unit
|
||||
/// was an old one carrying `Restart=on-failure`: the daemon exited cleanly
|
||||
/// (status 0), systemd read that as success, and the node sat dead for over
|
||||
/// two hours after a routine update — "server starting" in the UI, with
|
||||
/// nothing to start it.
|
||||
///
|
||||
/// A node cannot be relied on to fix this via `self-update.sh` (which does
|
||||
/// refresh units) because the in-process update path never runs it. So the
|
||||
/// daemon checks its own unit at boot: any node that starts even once ends
|
||||
/// up with a policy that survives the next update. Deliberately narrow —
|
||||
/// only the `Restart=` line is touched, so local edits elsewhere in the unit
|
||||
/// are preserved.
|
||||
pub async fn ensure_restart_policy() {
|
||||
const UNIT: &str = "/etc/systemd/system/archipelago.service";
|
||||
let Ok(body) = fs::read_to_string(UNIT).await else {
|
||||
return; // not a systemd install (container, dev box) — nothing to do
|
||||
};
|
||||
if !body.lines().any(|l| {
|
||||
let l = l.trim();
|
||||
l.starts_with("Restart=") && l != "Restart=always"
|
||||
}) {
|
||||
return; // already correct, or no Restart= line to repair
|
||||
}
|
||||
let patched: String = body
|
||||
.lines()
|
||||
.map(|l| {
|
||||
if l.trim().starts_with("Restart=") && l.trim() != "Restart=always" {
|
||||
"Restart=always"
|
||||
} else {
|
||||
l
|
||||
}
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
match write_root_if_needed(UNIT, &patched).await {
|
||||
Ok(true) => {
|
||||
tracing::warn!(
|
||||
"repaired archipelago.service Restart= policy to always — this node would \
|
||||
have stayed dead after an in-process update"
|
||||
);
|
||||
let _ = host_sudo(&["systemctl", "daemon-reload"]).await;
|
||||
}
|
||||
Ok(false) => {}
|
||||
Err(e) => tracing::warn!(error = %e, "could not repair archipelago.service restart policy"),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -216,6 +216,46 @@ pub fn catalog_manifest_values() -> Vec<(String, serde_json::Value)> {
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// A catalog-embedded manifest as the node actually applies it: parsed,
|
||||
/// id-checked, validated, and image-only (build-source manifests defer to
|
||||
/// disk). `None` = the caller must fall back to the disk manifest.
|
||||
///
|
||||
/// Shared between the orchestrator's load overlay and the app gate's port
|
||||
/// classification so both answer "which manifest governs this app?" from the
|
||||
/// same origin. They diverged once — the orchestrator published containers
|
||||
/// from the catalog while the gate classified from stale disk manifests, and
|
||||
/// the gate externally bound a port the catalog had declared `auth: local`
|
||||
/// (nbxplorer 32838, archi-dev-box 2026-08-04).
|
||||
pub fn catalog_manifest_overlay(
|
||||
app_id: &str,
|
||||
value: serde_json::Value,
|
||||
) -> Option<archipelago_container::manifest::AppManifest> {
|
||||
let m: archipelago_container::manifest::AppManifest = match serde_json::from_value(value) {
|
||||
Ok(m) => m,
|
||||
Err(e) => {
|
||||
tracing::warn!(app = %app_id, error = %e,
|
||||
"skipping unparseable catalog manifest; using disk fallback");
|
||||
return None;
|
||||
}
|
||||
};
|
||||
if m.app.id != app_id {
|
||||
tracing::warn!(catalog_id = %app_id, manifest_id = %m.app.id,
|
||||
"skipping catalog manifest: embedded app id mismatches catalog key");
|
||||
return None;
|
||||
}
|
||||
if let Err(e) = m.validate() {
|
||||
tracing::warn!(app = %app_id, error = %e,
|
||||
"skipping invalid catalog manifest; using disk fallback");
|
||||
return None;
|
||||
}
|
||||
if m.app.container.build.is_some() {
|
||||
tracing::debug!(app = %app_id,
|
||||
"catalog manifest has a build source; deferring to disk (phase 1 = image-only)");
|
||||
return None;
|
||||
}
|
||||
Some(m)
|
||||
}
|
||||
|
||||
/// The catalog's default/latest version string for an app (the top-level
|
||||
/// `version` field), if covered. Used to decide whether an install-time
|
||||
/// selection should pin (older) or track-latest (default).
|
||||
|
||||
@@ -293,6 +293,6 @@ mod tests {
|
||||
// Lock in the core shape so a bad template edit doesn't ship.
|
||||
assert!(TEMPLATE.contains("proxy_pass http://127.0.0.1:8332/"));
|
||||
assert!(TEMPLATE.contains("location /bitcoin-rpc/"));
|
||||
assert!(TEMPLATE.contains("listen 8334"));
|
||||
assert!(TEMPLATE.contains("listen 127.0.0.1:8334"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
server {
|
||||
listen 8334;
|
||||
# Loopback ONLY. This container is host-networked, so this nginx binds the
|
||||
# HOST's address directly — `listen 8334;` meant every interface, and the
|
||||
# app gate could never stand in front of it (there is no podman publish to
|
||||
# pin, and the manifest declared no port, so the gate neither protected it
|
||||
# nor reported it — it served this page to anyone who asked, on LAN,
|
||||
# Tailscale and the mesh alike). Binding loopback lets the daemon claim the
|
||||
# external addresses and authenticate them; see appgate::listener.
|
||||
listen 127.0.0.1:8334;
|
||||
server_name _;
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
@@ -214,10 +214,59 @@ pub async fn install_one(spec: &CompanionSpec) -> Result<()> {
|
||||
}
|
||||
// Start is idempotent — if already running, systemctl returns 0.
|
||||
quadlet::enable_now(&unit.service_name()).await?;
|
||||
|
||||
// A rebuilt image does NOT reach a container that is already running.
|
||||
// `ensure_image_present` rebuilds in place under the same tag, so the unit
|
||||
// body is byte-identical, `write_if_changed` reports no change, and
|
||||
// `enable_now` is a no-op on a running service — the container keeps the
|
||||
// old layers indefinitely. That is exactly how archi-dev-box kept serving
|
||||
// the LND, FIPS, Electrs and Guardian screens on 0.0.0.0 after v1.7.123
|
||||
// rebuilt every one of those images to bind loopback: the images were
|
||||
// correct on disk and the running containers were three days old
|
||||
// (2026-08-05). Compare image IDs and restart when they diverge.
|
||||
if let Some(running) = container_image_id(spec.name).await {
|
||||
if let Some(built) = image_id(&image).await {
|
||||
if running != built {
|
||||
info!(
|
||||
companion = spec.name,
|
||||
"running container uses a stale image; restarting onto the rebuilt one"
|
||||
);
|
||||
quadlet::restart_service(&unit.service_name()).await?;
|
||||
}
|
||||
}
|
||||
}
|
||||
info!(companion = spec.name, "companion started");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Image ID a container is actually running, or `None` when it does not exist.
|
||||
async fn container_image_id(name: &str) -> Option<String> {
|
||||
let out = tokio::process::Command::new("podman")
|
||||
.args(["inspect", name, "--format", "{{.Image}}"])
|
||||
.output()
|
||||
.await
|
||||
.ok()?;
|
||||
if !out.status.success() {
|
||||
return None;
|
||||
}
|
||||
let id = String::from_utf8_lossy(&out.stdout).trim().to_string();
|
||||
(!id.is_empty()).then_some(id)
|
||||
}
|
||||
|
||||
/// Current ID behind an image reference, or `None` when absent.
|
||||
async fn image_id(image_ref: &str) -> Option<String> {
|
||||
let out = tokio::process::Command::new("podman")
|
||||
.args(["image", "inspect", image_ref, "--format", "{{.Id}}"])
|
||||
.output()
|
||||
.await
|
||||
.ok()?;
|
||||
if !out.status.success() {
|
||||
return None;
|
||||
}
|
||||
let id = String::from_utf8_lossy(&out.stdout).trim().to_string();
|
||||
(!id.is_empty()).then_some(id)
|
||||
}
|
||||
|
||||
/// Build companion image locally if a Dockerfile exists, otherwise
|
||||
/// pull from the lfg2025 registry. Returns the image ref the quadlet
|
||||
/// should reference (`localhost/<base>:latest` for build, registry
|
||||
@@ -252,8 +301,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 +387,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");
|
||||
|
||||
@@ -104,6 +104,32 @@ fn dependency_manifests_required_by_active_apps<'a>(
|
||||
required
|
||||
}
|
||||
|
||||
/// Whether `app_id` is a member of a known multi-container stack that has at
|
||||
/// least one OTHER member with a live container (any state). A live sibling
|
||||
/// proves the stack is installed on this node, so an absent member is a hole
|
||||
/// to repair — while a stack with no containers at all stays untouched
|
||||
/// (uninstalled, or never installed here). Sibling app ids resolve to
|
||||
/// container names through the loaded-manifest map when available (immich's
|
||||
/// `immich-postgres` app id runs as container `immich_postgres`), falling
|
||||
/// back to the id itself.
|
||||
fn absent_stack_member_with_live_sibling(
|
||||
app_id: &str,
|
||||
present_containers: &HashSet<String>,
|
||||
container_name_by_app_id: &std::collections::HashMap<String, String>,
|
||||
) -> bool {
|
||||
let stack = crate::app_ops::owning_package(app_id);
|
||||
let members = crate::app_ops::stack_member_app_ids(stack);
|
||||
members.iter().any(|member| {
|
||||
*member != app_id
|
||||
&& present_containers.contains(
|
||||
container_name_by_app_id
|
||||
.get(*member)
|
||||
.map(String::as_str)
|
||||
.unwrap_or(member),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
fn manifest_dependency_app_ids(manifest: &AppManifest) -> Vec<String> {
|
||||
manifest
|
||||
.app
|
||||
@@ -246,10 +272,10 @@ fn build_fingerprint_stamp_path(data_dir: &Path, tag: &str) -> PathBuf {
|
||||
}
|
||||
|
||||
async fn chown_for_rootless_container(uid_gid: &str, path: &str) -> Result<()> {
|
||||
let uid = uid_gid
|
||||
let (uid, gid) = uid_gid
|
||||
.split_once(':')
|
||||
.and_then(|(uid, _)| uid.parse::<u32>().ok())
|
||||
.unwrap_or(0);
|
||||
.map(|(u, g)| (u.parse::<u32>().unwrap_or(0), g.parse::<u32>().unwrap_or(0)))
|
||||
.unwrap_or((0, 0));
|
||||
|
||||
if uid > 0 && uid < 100_000 {
|
||||
let output = tokio::process::Command::new("podman")
|
||||
@@ -262,9 +288,22 @@ async fn chown_for_rootless_container(uid_gid: &str, path: &str) -> Result<()> {
|
||||
}
|
||||
}
|
||||
|
||||
let status = host_sudo(&["chown", "-R", uid_gid, path])
|
||||
// Host-side fallback. A CONTAINER-namespace id must be translated into
|
||||
// the subuid range first: `sudo chown 999` writes literal host uid 999,
|
||||
// which maps to nobody inside the userns — the app then can't open its
|
||||
// own files while the chown reported success (botfights SQLITE_CANTOPEN
|
||||
// crash-loop, framework-pt 2026-08-06). Container uid N (N>=1) lives at
|
||||
// subuid_base + N - 1; the fleet provisions base 100000. uid 0 and
|
||||
// already-mapped ids (>=100000) pass through untouched.
|
||||
let host_uid_gid = if uid > 0 && uid < 100_000 {
|
||||
let map = |id: u32| if id == 0 { 1000 } else { 100_000 + id - 1 };
|
||||
format!("{}:{}", map(uid), map(gid))
|
||||
} else {
|
||||
uid_gid.to_string()
|
||||
};
|
||||
let status = host_sudo(&["chown", "-R", &host_uid_gid, path])
|
||||
.await
|
||||
.with_context(|| format!("sudo chown -R {uid_gid} {path}"))?;
|
||||
.with_context(|| format!("sudo chown -R {host_uid_gid} {path}"))?;
|
||||
if status.success() {
|
||||
return Ok(());
|
||||
}
|
||||
@@ -595,10 +634,20 @@ async fn wait_for_manifest_host_ports(
|
||||
/// `podman inspect --format '{{json .HostConfig.PortBindings}}'` emits, e.g.
|
||||
/// `{"8080/tcp":[{"HostIp":"","HostPort":"18080"}]}`. Returns true only when a
|
||||
/// manifest container-port is positively published to a *different* host port
|
||||
/// than the manifest now asks for. Absence of a binding is deliberately NOT
|
||||
/// treated as drift here — that case is handled by the host-port repair/restart
|
||||
/// path and by host-networked apps that publish nothing — so we never trigger a
|
||||
/// destructive recreate on a false positive.
|
||||
/// than the manifest now asks for — or, when the manifest DECLARES a bind
|
||||
/// address, to a different host address. Absence of a binding is deliberately
|
||||
/// NOT treated as drift here — that case is handled by the host-port
|
||||
/// repair/restart path and by host-networked apps that publish nothing — so we
|
||||
/// never trigger a destructive recreate on a false positive.
|
||||
///
|
||||
/// The bind comparison is what lets a node self-heal after a catalog refresh
|
||||
/// pins an app to loopback for the app gate: a legacy (pre-quadlet) container
|
||||
/// still publishing `0.0.0.0:P` against a manifest that now declares
|
||||
/// `bind: 127.0.0.1` is recreated to the declared state, exactly as
|
||||
/// `package.update` would. An EMPTY manifest bind means "no instruction" and
|
||||
/// never fires this — recreating a loopback-published container to wildcard on
|
||||
/// silence is precisely the v1.7.121 incident class (Bitcoin RPC republished
|
||||
/// on the LAN).
|
||||
fn host_port_bindings_drifted(
|
||||
port_bindings_json: &str,
|
||||
manifest_ports: &[archipelago_container::manifest::PortMapping],
|
||||
@@ -626,10 +675,26 @@ fn host_port_bindings_drifted(
|
||||
}
|
||||
let expected = port.host.to_string();
|
||||
let matches_expected = bindings.iter().any(|b| {
|
||||
b.get("HostPort")
|
||||
let host_port_ok = b
|
||||
.get("HostPort")
|
||||
.and_then(|h| h.as_str())
|
||||
.map(|h| h == expected)
|
||||
.unwrap_or(false)
|
||||
.unwrap_or(false);
|
||||
if !host_port_ok {
|
||||
return false;
|
||||
}
|
||||
// Only a DECLARED bind participates; podman reports a wildcard
|
||||
// publish as "" or "0.0.0.0".
|
||||
if port.bind.is_empty() {
|
||||
return true;
|
||||
}
|
||||
let actual_ip = b.get("HostIp").and_then(|h| h.as_str()).unwrap_or("");
|
||||
let actual = if actual_ip.is_empty() {
|
||||
"0.0.0.0"
|
||||
} else {
|
||||
actual_ip
|
||||
};
|
||||
actual == port.bind
|
||||
});
|
||||
if !matches_expected {
|
||||
return true;
|
||||
@@ -1157,30 +1222,7 @@ struct LoadedManifest {
|
||||
/// source (build contexts aren't registry-distributed yet — phase 1 is
|
||||
/// image-only). See `docs/registry-manifest-design.md`.
|
||||
fn catalog_manifest_to_overlay(app_id: &str, value: serde_json::Value) -> Option<AppManifest> {
|
||||
let m: AppManifest = match serde_json::from_value(value) {
|
||||
Ok(m) => m,
|
||||
Err(e) => {
|
||||
tracing::warn!(app = %app_id, error = %e,
|
||||
"skipping unparseable catalog manifest; using disk fallback");
|
||||
return None;
|
||||
}
|
||||
};
|
||||
if m.app.id != app_id {
|
||||
tracing::warn!(catalog_id = %app_id, manifest_id = %m.app.id,
|
||||
"skipping catalog manifest: embedded app id mismatches catalog key");
|
||||
return None;
|
||||
}
|
||||
if let Err(e) = m.validate() {
|
||||
tracing::warn!(app = %app_id, error = %e,
|
||||
"skipping invalid catalog manifest; using disk fallback");
|
||||
return None;
|
||||
}
|
||||
if m.app.container.build.is_some() {
|
||||
tracing::debug!(app = %app_id,
|
||||
"catalog manifest has a build source; deferring to disk (phase 1 = image-only)");
|
||||
return None;
|
||||
}
|
||||
Some(m)
|
||||
crate::container::app_catalog::catalog_manifest_overlay(app_id, value)
|
||||
}
|
||||
|
||||
struct OrchestratorState {
|
||||
@@ -1651,13 +1693,16 @@ impl ProdContainerOrchestrator {
|
||||
// app whose container vanished (e.g. a wedged teardown cleared by a
|
||||
// reboot) instead of leaving it down. See the immich .198 incident.
|
||||
let was_running = crate::crash_recovery::load_last_running_names(&self.data_dir).await;
|
||||
let manifests: Vec<LoadedManifest> = {
|
||||
let (manifests, container_name_by_app_id): (
|
||||
Vec<LoadedManifest>,
|
||||
std::collections::HashMap<String, String>,
|
||||
) = {
|
||||
let state = self.state.read().await;
|
||||
let dependency_required = dependency_manifests_required_by_active_apps(
|
||||
state.manifests.values().map(|lm| &lm.manifest),
|
||||
&user_stopped,
|
||||
);
|
||||
state
|
||||
let filtered = state
|
||||
.manifests
|
||||
.iter()
|
||||
.filter(|(app_id, _)| !state.disabled.contains(*app_id))
|
||||
@@ -1667,8 +1712,25 @@ impl ProdContainerOrchestrator {
|
||||
&& !user_stopped.contains(&compute_container_name(&lm.manifest)))
|
||||
})
|
||||
.map(|(_, lm)| lm.clone())
|
||||
.collect()
|
||||
.collect();
|
||||
// Unfiltered id→container-name map for the absent-stack-member
|
||||
// recovery below: a sibling may be excluded from this pass (e.g.
|
||||
// user-stopped) yet its live container still proves the stack is
|
||||
// installed.
|
||||
let names = state
|
||||
.manifests
|
||||
.iter()
|
||||
.map(|(id, lm)| (id.clone(), compute_container_name(&lm.manifest)))
|
||||
.collect();
|
||||
(filtered, names)
|
||||
};
|
||||
// Live container names (any state), for the same recovery check.
|
||||
let present_containers: std::collections::HashSet<String> = self
|
||||
.runtime
|
||||
.list_containers()
|
||||
.await
|
||||
.map(|cs| cs.into_iter().map(|c| c.name).collect())
|
||||
.unwrap_or_default();
|
||||
let mut report = ReconcileReport::default();
|
||||
let disk_gb = self.disk_gb().await;
|
||||
// Register every candidate before the (sequential, possibly slow)
|
||||
@@ -1735,7 +1797,20 @@ impl ProdContainerOrchestrator {
|
||||
Ok(ReconcileAction::Left(reason))
|
||||
if mode == ReconcileMode::ExistingOnly
|
||||
&& reason == "absent"
|
||||
&& was_running.contains(&compute_container_name(&lm.manifest)) =>
|
||||
&& (was_running.contains(&compute_container_name(&lm.manifest))
|
||||
// Absent STACK MEMBER whose siblings have live
|
||||
// containers: the stack is installed, so the
|
||||
// missing member is a hole, not a choice. The
|
||||
// was_running snapshot ages out after a few daemon
|
||||
// restarts, which left indeedhub-minio/-postgres
|
||||
// permanently absent on .38 (2026-08-06) — nginx
|
||||
// down on `host not found in upstream "minio"`
|
||||
// with nothing ever recreating the members.
|
||||
|| absent_stack_member_with_live_sibling(
|
||||
&app_id,
|
||||
&present_containers,
|
||||
&container_name_by_app_id,
|
||||
)) =>
|
||||
{
|
||||
tracing::warn!(
|
||||
app_id = %app_id,
|
||||
@@ -1751,7 +1826,10 @@ impl ProdContainerOrchestrator {
|
||||
}
|
||||
Ok(action) => report.record(&app_id, action),
|
||||
Err(e) => {
|
||||
tracing::error!(app_id = %app_id, error = %e, "reconcile failed");
|
||||
// `{:#}` prints the whole anyhow chain — `%e` alone showed
|
||||
// only the outer context ("create_container X") and hid
|
||||
// the actual libpod error for days.
|
||||
tracing::error!(app_id = %app_id, error = %format!("{e:#}"), "reconcile failed");
|
||||
report.failures.push((app_id, e.to_string()));
|
||||
}
|
||||
}
|
||||
@@ -4435,6 +4513,9 @@ mod tests {
|
||||
container,
|
||||
protocol: "tcp".to_string(),
|
||||
bind: String::new(),
|
||||
auth: None,
|
||||
auth_rationale: None,
|
||||
session_passthrough: false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4442,6 +4523,61 @@ mod tests {
|
||||
items.iter().map(|s| s.to_string()).collect()
|
||||
}
|
||||
|
||||
/// The .38 indeedhub incident class: an absent stack member must be
|
||||
/// recovered when its siblings have live containers (the stack is
|
||||
/// installed), and left alone when the whole stack is gone or the app
|
||||
/// is not a stack member at all.
|
||||
#[test]
|
||||
fn absent_stack_member_recovery_requires_a_live_sibling() {
|
||||
let present: HashSet<String> = ["indeedhub-redis", "indeedhub-relay", "indeedhub"]
|
||||
.iter()
|
||||
.map(|s| s.to_string())
|
||||
.collect();
|
||||
let names = std::collections::HashMap::new();
|
||||
// Missing members of a stack with live siblings → recover.
|
||||
assert!(absent_stack_member_with_live_sibling(
|
||||
"indeedhub-minio",
|
||||
&present,
|
||||
&names
|
||||
));
|
||||
assert!(absent_stack_member_with_live_sibling(
|
||||
"indeedhub-postgres",
|
||||
&present,
|
||||
&names
|
||||
));
|
||||
// Whole stack absent → NOT recovered (uninstalled stays uninstalled).
|
||||
let empty = HashSet::new();
|
||||
assert!(!absent_stack_member_with_live_sibling(
|
||||
"indeedhub-minio",
|
||||
&empty,
|
||||
&names
|
||||
));
|
||||
// Non-stack app → never.
|
||||
assert!(!absent_stack_member_with_live_sibling(
|
||||
"vaultwarden",
|
||||
&present,
|
||||
&names
|
||||
));
|
||||
// An app's OWN container being present proves nothing about siblings.
|
||||
let only_self: HashSet<String> = std::iter::once("indeedhub-minio".to_string()).collect();
|
||||
assert!(!absent_stack_member_with_live_sibling(
|
||||
"indeedhub-minio",
|
||||
&only_self,
|
||||
&names
|
||||
));
|
||||
// App-id → container-name mapping is honoured (immich_postgres runs
|
||||
// under an underscore name while its app id is hyphenated).
|
||||
let mut mapped = std::collections::HashMap::new();
|
||||
mapped.insert("immich-postgres".to_string(), "immich_postgres".to_string());
|
||||
let immich_present: HashSet<String> =
|
||||
std::iter::once("immich_postgres".to_string()).collect();
|
||||
assert!(absent_stack_member_with_live_sibling(
|
||||
"immich-redis",
|
||||
&immich_present,
|
||||
&mapped
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn command_drift_tolerates_quadlet_entrypoint_split() {
|
||||
// Quadlet writes Entrypoint=sh + Exec=-lc "<script>", so podman
|
||||
@@ -4567,6 +4703,76 @@ mod tests {
|
||||
));
|
||||
}
|
||||
|
||||
fn bound_port(
|
||||
host: u16,
|
||||
container: u16,
|
||||
bind: &str,
|
||||
) -> archipelago_container::manifest::PortMapping {
|
||||
archipelago_container::manifest::PortMapping {
|
||||
bind: bind.to_string(),
|
||||
..port(host, container)
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bind_drift_detected_when_declared_loopback_but_published_wildcard() {
|
||||
// The legacy-container case: a pre-quadlet container still publishes
|
||||
// 0.0.0.0 while the catalog-delivered manifest pins the app to
|
||||
// loopback for the app gate. Must recreate, or the port stays open on
|
||||
// every interface and the gate can never claim it.
|
||||
for wildcard in [r#""""#, r#""0.0.0.0""#] {
|
||||
let bindings = format!(r#"{{"80/tcp":[{{"HostIp":{wildcard},"HostPort":"8082"}}]}}"#);
|
||||
assert!(host_port_bindings_drifted(
|
||||
&bindings,
|
||||
&[bound_port(8082, 80, "127.0.0.1")]
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_bind_drift_when_declared_loopback_and_published_loopback() {
|
||||
let bindings = r#"{"80/tcp":[{"HostIp":"127.0.0.1","HostPort":"8082"}]}"#;
|
||||
assert!(!host_port_bindings_drifted(
|
||||
bindings,
|
||||
&[bound_port(8082, 80, "127.0.0.1")]
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_bind_drift_on_undeclared_bind() {
|
||||
// Silence is not consent (v1.7.121 incident class): an EMPTY manifest
|
||||
// bind must never recreate a loopback-published container to
|
||||
// wildcard — that is how Bitcoin's RPC got republished on the LAN.
|
||||
let bindings = r#"{"8332/tcp":[{"HostIp":"127.0.0.1","HostPort":"8332"}]}"#;
|
||||
assert!(!host_port_bindings_drifted(bindings, &[port(8332, 8332)]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn multi_bind_publish_satisfies_each_declared_entry() {
|
||||
// Same host/container pair listed twice (loopback + archy-net
|
||||
// gateway): both declared binds are present in the actual publish.
|
||||
let bindings = r#"{"8332/tcp":[
|
||||
{"HostIp":"127.0.0.1","HostPort":"8332"},
|
||||
{"HostIp":"10.89.0.1","HostPort":"8332"}
|
||||
]}"#;
|
||||
assert!(!host_port_bindings_drifted(
|
||||
bindings,
|
||||
&[
|
||||
bound_port(8332, 8332, "127.0.0.1"),
|
||||
bound_port(8332, 8332, "10.89.0.1")
|
||||
]
|
||||
));
|
||||
// And a wildcard-only publish drifts BOTH declared entries.
|
||||
let wildcard = r#"{"8332/tcp":[{"HostIp":"","HostPort":"8332"}]}"#;
|
||||
assert!(host_port_bindings_drifted(
|
||||
wildcard,
|
||||
&[
|
||||
bound_port(8332, 8332, "127.0.0.1"),
|
||||
bound_port(8332, 8332, "10.89.0.1")
|
||||
]
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_secret_error_names_the_secret() {
|
||||
use archipelago_container::manifest::SecretsProvider;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -5,7 +5,7 @@ use serde::{Deserialize, Serialize};
|
||||
use std::path::Path;
|
||||
use tokio::fs;
|
||||
|
||||
use super::types::{FederatedNode, FederationInvite, NodeStateSnapshot, TrustLevel};
|
||||
use super::types::{FederatedNode, FederationInvite, NodeStateSnapshot, TrustLevel, TrustSource};
|
||||
|
||||
pub(crate) const FEDERATION_DIR: &str = "federation";
|
||||
pub(crate) const NODES_FILE: &str = "nodes.json";
|
||||
@@ -392,10 +392,19 @@ async fn untombstone_did_inner(data_dir: &Path, did: &str) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Change a federated node's trust level, optionally recording HOW the change
|
||||
/// came about.
|
||||
///
|
||||
/// `source` is `Some(TrustSource::Manual)` on the operator RPC path so an
|
||||
/// audit of `trust_source` can tell a deliberate grant apart from the levels
|
||||
/// the automatic paths assign. Pass `None` for automatic adjustments that are
|
||||
/// not operator decisions (e.g. the discovery-handshake demotion safety net) —
|
||||
/// those must leave the recorded provenance alone rather than claim one.
|
||||
pub async fn set_trust_level(
|
||||
data_dir: &Path,
|
||||
did: &str,
|
||||
trust: TrustLevel,
|
||||
source: Option<TrustSource>,
|
||||
) -> Result<Vec<FederatedNode>> {
|
||||
let _guard = FEDERATION_STORE_LOCK.lock().await;
|
||||
let mut nodes = load_nodes_inner(data_dir).await?;
|
||||
@@ -404,6 +413,9 @@ pub async fn set_trust_level(
|
||||
.find(|n| n.did == did)
|
||||
.ok_or_else(|| anyhow::anyhow!("No federated node with DID {}", did))?;
|
||||
node.trust_level = trust;
|
||||
if let Some(source) = source {
|
||||
node.trust_source = Some(source);
|
||||
}
|
||||
save_nodes_inner(data_dir, &nodes).await?;
|
||||
Ok(nodes)
|
||||
}
|
||||
@@ -661,12 +673,55 @@ mod tests {
|
||||
add_node(dir.path(), make_node("did:key:z1", "a.onion"))
|
||||
.await
|
||||
.unwrap();
|
||||
let nodes = set_trust_level(dir.path(), "did:key:z1", TrustLevel::Observer)
|
||||
let nodes = set_trust_level(dir.path(), "did:key:z1", TrustLevel::Observer, None)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(nodes[0].trust_level, TrustLevel::Observer);
|
||||
}
|
||||
|
||||
/// The operator RPC path stamps `Manual`, so an audit of `trust_source`
|
||||
/// can separate a deliberate grant from the levels the automatic paths
|
||||
/// (`UninvitedJoin`, `TransitiveMerge`) assign on their own authority.
|
||||
#[tokio::test]
|
||||
async fn test_set_trust_level_records_manual_source() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
add_node(dir.path(), make_node("did:key:z1", "a.onion"))
|
||||
.await
|
||||
.unwrap();
|
||||
let nodes = set_trust_level(
|
||||
dir.path(),
|
||||
"did:key:z1",
|
||||
TrustLevel::Trusted,
|
||||
Some(TrustSource::Manual),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(nodes[0].trust_level, TrustLevel::Trusted);
|
||||
assert_eq!(nodes[0].trust_source, Some(TrustSource::Manual));
|
||||
}
|
||||
|
||||
/// An automatic adjustment must not claim a provenance it doesn't have:
|
||||
/// passing `None` leaves whatever was recorded before intact, so the
|
||||
/// discovery-handshake demotion can't launder an `UninvitedJoin` peer
|
||||
/// into looking operator-approved.
|
||||
#[tokio::test]
|
||||
async fn test_set_trust_level_none_source_preserves_provenance() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let mut node = make_node("did:key:z1", "a.onion");
|
||||
node.trust_source = Some(TrustSource::UninvitedJoin);
|
||||
add_node(dir.path(), node).await.unwrap();
|
||||
|
||||
let nodes = set_trust_level(dir.path(), "did:key:z1", TrustLevel::Observer, None)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(nodes[0].trust_level, TrustLevel::Observer);
|
||||
assert_eq!(
|
||||
nodes[0].trust_source,
|
||||
Some(TrustSource::UninvitedJoin),
|
||||
"an automatic level change must not rewrite how the peer got here"
|
||||
);
|
||||
}
|
||||
|
||||
/// The .198 v1.7.103 update-bricking race (see `update.rs`'s
|
||||
/// `UPDATE_OP_LOCK`) had the same shape as this test: two concurrent
|
||||
/// mutators sharing one on-disk file with no coordination. Here,
|
||||
@@ -695,7 +750,7 @@ mod tests {
|
||||
async move { add_node(&dir_a, make_node("did:key:zB", "b.onion")).await },
|
||||
);
|
||||
let trust_task = tokio::spawn(async move {
|
||||
set_trust_level(&dir_b, "did:key:zA", TrustLevel::Observer).await
|
||||
set_trust_level(&dir_b, "did:key:zA", TrustLevel::Observer, None).await
|
||||
});
|
||||
add_task.await.unwrap().unwrap();
|
||||
trust_task.await.unwrap().unwrap();
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -7,6 +7,6 @@
|
||||
|
||||
pub const APP_LAUNCH_PORTS: &[u16] = &[
|
||||
2283, 2342, 3000, 3001, 3002, 4080, 5180, 7778, 8080, 8081, 8082, 8083, 8084, 8085, 8087, 8088,
|
||||
8089, 8090, 8096, 8123, 8175, 8176, 8240, 8334, 8888, 8999, 9000, 9100, 10380, 11434, 18081,
|
||||
18083, 23000, 32838, 50002,
|
||||
8089, 8090, 8096, 8123, 8175, 8176, 8240, 8334, 8336, 8888, 8999, 9000, 9100, 10380, 11434,
|
||||
18081, 18083, 23000, 32838, 50002,
|
||||
];
|
||||
|
||||
@@ -0,0 +1,205 @@
|
||||
//! Last-known-good FIPS peer endpoints (A3.10).
|
||||
//!
|
||||
//! The LAN direct-peering tick (`anchors::lan_fips_anchors`) only helps peers
|
||||
//! we can currently see on the LAN. When a federation peer's LAN path is gone
|
||||
//! (renumbered network, remote site, mDNS blackout) the only route left is the
|
||||
//! anchor spanning tree — the exact hairpin RC2 calls out. But if we were EVER
|
||||
//! connected to that peer directly, the daemon knew a working endpoint for it
|
||||
//! (`fipsctl show peers` → `transport_addr`/`transport_type`, which covers
|
||||
//! LAN, Tailscale, and WAN endpoints alike). This module persists those
|
||||
//! npub-keyed endpoints and re-offers them as dial candidates when the live
|
||||
//! paths disappear: LAN → last-known-good → anchor tree.
|
||||
//!
|
||||
//! Persisted at `<data_dir>/fips-endpoints.json`. Entries are refreshed every
|
||||
//! time the peer is seen connected and dropped after `RETENTION` without a
|
||||
//! sighting, so a peer that genuinely moved doesn't get dialed at a stale
|
||||
//! address forever ( `fipsctl connect` to a dead address is harmless but not
|
||||
//! free).
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::path::Path;
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
use anyhow::Result;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tokio::fs;
|
||||
|
||||
use super::anchors::SeedAnchor;
|
||||
|
||||
const FILE_NAME: &str = "fips-endpoints.json";
|
||||
/// Forget endpoints not seen connected for this long (seconds) — 30 days.
|
||||
const RETENTION_SECS: u64 = 30 * 24 * 60 * 60;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub struct KnownEndpoint {
|
||||
/// "ip:port" as reported by the daemon (`transport_addr`).
|
||||
pub address: String,
|
||||
/// "udp" | "tcp" (`transport_type`).
|
||||
pub transport: String,
|
||||
/// Unix seconds of the last time this peer was seen connected here.
|
||||
pub last_ok_unix: u64,
|
||||
}
|
||||
|
||||
/// A currently-connected peer as parsed from `fipsctl show peers`.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ConnectedPeer {
|
||||
pub npub: String,
|
||||
pub address: String,
|
||||
pub transport: String,
|
||||
}
|
||||
|
||||
fn now_unix() -> u64 {
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map(|d| d.as_secs())
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
pub async fn load(data_dir: &Path) -> HashMap<String, KnownEndpoint> {
|
||||
let path = data_dir.join(FILE_NAME);
|
||||
match fs::read(&path).await {
|
||||
Ok(bytes) => serde_json::from_slice(&bytes).unwrap_or_default(),
|
||||
Err(_) => HashMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
async fn save(data_dir: &Path, map: &HashMap<String, KnownEndpoint>) -> Result<()> {
|
||||
let path = data_dir.join(FILE_NAME);
|
||||
let tmp = data_dir.join(format!("{FILE_NAME}.tmp"));
|
||||
fs::write(&tmp, serde_json::to_vec_pretty(map)?).await?;
|
||||
fs::rename(&tmp, &path).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Merge the currently-connected peers into the store (refreshing their
|
||||
/// timestamps), prune expired entries, persist, and return the updated map.
|
||||
/// Persistence failures are non-fatal — the in-memory result is still
|
||||
/// returned so this tick's fallback logic works.
|
||||
pub async fn record_connected(
|
||||
data_dir: &Path,
|
||||
connected: &[ConnectedPeer],
|
||||
) -> HashMap<String, KnownEndpoint> {
|
||||
let mut map = load(data_dir).await;
|
||||
let now = now_unix();
|
||||
let before = map.clone();
|
||||
for p in connected {
|
||||
if p.npub.is_empty() || p.address.is_empty() {
|
||||
continue;
|
||||
}
|
||||
map.insert(
|
||||
p.npub.clone(),
|
||||
KnownEndpoint {
|
||||
address: p.address.clone(),
|
||||
transport: p.transport.clone(),
|
||||
last_ok_unix: now,
|
||||
},
|
||||
);
|
||||
}
|
||||
map.retain(|_, e| now.saturating_sub(e.last_ok_unix) <= RETENTION_SECS);
|
||||
if map != before {
|
||||
if let Err(e) = save(data_dir, &map).await {
|
||||
tracing::debug!("fips endpoint store save failed (non-fatal): {e}");
|
||||
}
|
||||
}
|
||||
map
|
||||
}
|
||||
|
||||
/// Build fallback anchors for federation peers whose live paths are gone:
|
||||
/// every `wanted_npub` that is neither currently connected nor covered by a
|
||||
/// live LAN direct entry, but has a last-known-good endpoint, becomes a dial
|
||||
/// candidate. `fipsctl connect` is idempotent and failure-tolerant, so a
|
||||
/// stale candidate costs one failed dial, bounded by apply()'s per-connect
|
||||
/// timeout.
|
||||
pub fn fallback_anchors(
|
||||
known: &HashMap<String, KnownEndpoint>,
|
||||
wanted_npubs: &[String],
|
||||
connected_npubs: &[String],
|
||||
lan_direct: &[SeedAnchor],
|
||||
) -> Vec<SeedAnchor> {
|
||||
let mut out = Vec::new();
|
||||
for npub in wanted_npubs {
|
||||
if connected_npubs.iter().any(|c| c == npub) {
|
||||
continue;
|
||||
}
|
||||
if lan_direct.iter().any(|a| &a.npub == npub) {
|
||||
continue;
|
||||
}
|
||||
if let Some(e) = known.get(npub) {
|
||||
out.push(SeedAnchor {
|
||||
npub: npub.clone(),
|
||||
address: e.address.clone(),
|
||||
transport: e.transport.clone(),
|
||||
label: "last-known-good endpoint (direct FIPS)".to_string(),
|
||||
});
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn ep(addr: &str) -> KnownEndpoint {
|
||||
KnownEndpoint {
|
||||
address: addr.to_string(),
|
||||
transport: "udp".to_string(),
|
||||
last_ok_unix: now_unix(),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn record_and_reload_roundtrip() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let connected = vec![ConnectedPeer {
|
||||
npub: "npub1aaa".into(),
|
||||
address: "100.114.134.21:2121".into(),
|
||||
transport: "udp".into(),
|
||||
}];
|
||||
let map = record_connected(dir.path(), &connected).await;
|
||||
assert_eq!(map["npub1aaa"].address, "100.114.134.21:2121");
|
||||
let reloaded = load(dir.path()).await;
|
||||
assert_eq!(reloaded, map);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn expired_entries_are_pruned_on_record() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let mut stale = HashMap::new();
|
||||
stale.insert(
|
||||
"npub1old".to_string(),
|
||||
KnownEndpoint {
|
||||
address: "10.0.0.1:2121".into(),
|
||||
transport: "udp".into(),
|
||||
last_ok_unix: now_unix() - RETENTION_SECS - 60,
|
||||
},
|
||||
);
|
||||
save(dir.path(), &stale).await.unwrap();
|
||||
let map = record_connected(dir.path(), &[]).await;
|
||||
assert!(map.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fallback_skips_connected_and_lan_covered_peers() {
|
||||
let mut known = HashMap::new();
|
||||
known.insert("npub1gone".to_string(), ep("100.1.2.3:2121"));
|
||||
known.insert("npub1conn".to_string(), ep("100.1.2.4:2121"));
|
||||
known.insert("npub1lan".to_string(), ep("100.1.2.5:2121"));
|
||||
let wanted: Vec<String> = ["npub1gone", "npub1conn", "npub1lan", "npub1never"]
|
||||
.iter()
|
||||
.map(|s| s.to_string())
|
||||
.collect();
|
||||
let connected = vec!["npub1conn".to_string()];
|
||||
let lan = vec![SeedAnchor {
|
||||
npub: "npub1lan".into(),
|
||||
address: "192.168.63.198:2121".into(),
|
||||
transport: "udp".into(),
|
||||
label: "LAN".into(),
|
||||
}];
|
||||
let out = fallback_anchors(&known, &wanted, &connected, &lan);
|
||||
assert_eq!(out.len(), 1);
|
||||
assert_eq!(out[0].npub, "npub1gone");
|
||||
assert_eq!(out[0].address, "100.1.2.3:2121");
|
||||
// npub1never has no stored endpoint → nothing to dial.
|
||||
}
|
||||
}
|
||||
@@ -29,6 +29,7 @@ pub mod anchors;
|
||||
pub mod app_ports;
|
||||
pub mod config;
|
||||
pub mod dial;
|
||||
pub mod endpoints;
|
||||
pub mod iface;
|
||||
pub mod service;
|
||||
pub mod telemetry;
|
||||
|
||||
@@ -227,6 +227,52 @@ pub async fn peer_connectivity_summary(anchor_candidates: &[String]) -> (u32, bo
|
||||
(authenticated_peer_count, anchor_connected)
|
||||
}
|
||||
|
||||
/// Currently-connected peers with their live endpoints, from
|
||||
/// `fipsctl show peers` (`transport_addr`/`transport_type`). Feeds the
|
||||
/// last-known-good endpoint store (A3.10); empty on any failure.
|
||||
pub async fn connected_peer_endpoints() -> Vec<crate::fips::endpoints::ConnectedPeer> {
|
||||
let peers_json = match Command::new("sudo")
|
||||
.args(["-n", "fipsctl", "show", "peers"])
|
||||
.output()
|
||||
.await
|
||||
{
|
||||
Ok(o) if o.status.success() => o.stdout,
|
||||
_ => return Vec::new(),
|
||||
};
|
||||
let parsed: serde_json::Value = match serde_json::from_slice(&peers_json) {
|
||||
Ok(v) => v,
|
||||
Err(_) => return Vec::new(),
|
||||
};
|
||||
parsed
|
||||
.get("peers")
|
||||
.and_then(|p| p.as_array())
|
||||
.map(|peers| {
|
||||
peers
|
||||
.iter()
|
||||
.filter(|p| {
|
||||
p.get("connectivity")
|
||||
.and_then(|c| c.as_str())
|
||||
.map(|s| s == "connected")
|
||||
.unwrap_or(false)
|
||||
})
|
||||
.filter_map(|p| {
|
||||
let npub = p.get("npub").and_then(|n| n.as_str())?;
|
||||
let address = p.get("transport_addr").and_then(|a| a.as_str())?;
|
||||
let transport = p
|
||||
.get("transport_type")
|
||||
.and_then(|t| t.as_str())
|
||||
.unwrap_or("udp");
|
||||
Some(crate::fips::endpoints::ConnectedPeer {
|
||||
npub: npub.to_string(),
|
||||
address: address.to_string(),
|
||||
transport: transport.to_string(),
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Read the upstream daemon's public key at `/etc/fips/fips.pub` and return
|
||||
/// it as a bech32 npub. Returns `Ok(None)` if the file doesn't exist — used
|
||||
/// as a fallback on legacy/dev nodes where no seed-derived key exists.
|
||||
|
||||
@@ -27,7 +27,7 @@ use tracing::info;
|
||||
|
||||
mod api;
|
||||
mod app_ops;
|
||||
mod assistant;
|
||||
mod appgate;
|
||||
mod auth;
|
||||
mod avatar;
|
||||
mod backup;
|
||||
@@ -409,6 +409,11 @@ async fn main() -> Result<()> {
|
||||
// flags) on already-deployed nodes via OTA; no-op if the kiosk isn't installed.
|
||||
tokio::spawn(bootstrap::ensure_kiosk_hardened());
|
||||
|
||||
// Repair our own restart policy before anything else can need it: a node
|
||||
// whose unit still says Restart=on-failure stays dead after the next
|
||||
// in-process update, because the daemon exits cleanly to be restarted.
|
||||
tokio::spawn(bootstrap::ensure_restart_policy());
|
||||
|
||||
// HDMI audio: install the PipeWire stack + audio-router daemon on kiosk
|
||||
// nodes (older ISOs shipped no audio stack; the router also heals the
|
||||
// boot-time ELD race that leaves HDMI silently unavailable).
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -847,6 +847,39 @@ impl Server {
|
||||
if !direct.is_empty() {
|
||||
let _ = crate::fips::anchors::apply(&direct).await;
|
||||
}
|
||||
|
||||
// A3.10 — endpoint fallback for direct peering. Record
|
||||
// where currently-connected peers actually are (their
|
||||
// transport_addr covers LAN, Tailscale, and WAN alike),
|
||||
// then re-dial the last-known-good endpoint of every
|
||||
// federation peer whose live paths are gone: not
|
||||
// connected now, no LAN direct entry this tick. Escala-
|
||||
// tion order is LAN → last-known-good → anchor tree;
|
||||
// a stale candidate costs one bounded failed dial.
|
||||
let connected = crate::fips::service::connected_peer_endpoints().await;
|
||||
let known =
|
||||
crate::fips::endpoints::record_connected(&data_dir, &connected).await;
|
||||
let wanted: Vec<String> = reg
|
||||
.all_peers()
|
||||
.await
|
||||
.iter()
|
||||
.filter_map(|p| p.fips_npub.clone())
|
||||
.collect();
|
||||
let connected_npubs: Vec<String> =
|
||||
connected.iter().map(|c| c.npub.clone()).collect();
|
||||
let fallback = crate::fips::endpoints::fallback_anchors(
|
||||
&known,
|
||||
&wanted,
|
||||
&connected_npubs,
|
||||
&direct,
|
||||
);
|
||||
if !fallback.is_empty() {
|
||||
tracing::info!(
|
||||
count = fallback.len(),
|
||||
"dialing last-known-good endpoints for disconnected federation peers"
|
||||
);
|
||||
let _ = crate::fips::anchors::apply(&fallback).await;
|
||||
}
|
||||
}
|
||||
|
||||
let next = if daemon_restarting && fast_retries < MAX_FAST_RETRIES {
|
||||
@@ -1068,6 +1101,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 +1140,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");
|
||||
@@ -1128,16 +1178,52 @@ fn fips_app_relay_addr(ip: std::net::Ipv6Addr, port: u16) -> SocketAddr {
|
||||
/// without a daemon restart. Each relay binds to the fips0 ULA only and
|
||||
/// forwards raw TCP to the same port on IPv4 loopback.
|
||||
async fn app_port_v6_relay_loop(mut shutdown_rx: tokio::sync::watch::Receiver<bool>) {
|
||||
use std::collections::HashSet;
|
||||
let mut bridged: HashSet<u16> = HashSet::new();
|
||||
use std::collections::HashMap;
|
||||
let mut bridged: HashMap<u16, tokio::task::JoinHandle<()>> = HashMap::new();
|
||||
let mut interval = tokio::time::interval(std::time::Duration::from_secs(60));
|
||||
interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
|
||||
loop {
|
||||
tokio::select! {
|
||||
_ = interval.tick() => {
|
||||
let Some(fips_ip) = crate::fips::iface::fips0_ula() else { continue };
|
||||
// This relay is a raw unauthenticated forward from the mesh to
|
||||
// the app's loopback, so it must refuse two classes of port:
|
||||
//
|
||||
// * `auth: gated` — the app gate owns the fips0 ULA for these,
|
||||
// and bridging one would bypass the login page. Which of the
|
||||
// two won the bind used to be a race.
|
||||
// * `auth: local` — host-local BY INTENT. Bridging one makes a
|
||||
// port reachable from the whole mesh that was deliberately
|
||||
// never externally reachable: nbxplorer 32838 answered HTTP
|
||||
// 200 over the mesh with no credential (archi-dev-box
|
||||
// 2026-08-04) purely because it appeared in the static port
|
||||
// list below.
|
||||
//
|
||||
// Undeclared ports keep today's behaviour — silence is not an
|
||||
// instruction in either direction, and this relay predates the
|
||||
// declarations.
|
||||
let port_map = crate::appgate::identity::build_port_map();
|
||||
let gate_owned: std::collections::HashSet<u16> = port_map
|
||||
.gated_ports()
|
||||
.filter(|g| g.declared)
|
||||
.map(|g| g.port)
|
||||
.collect();
|
||||
for &port in crate::fips::app_ports::APP_LAUNCH_PORTS {
|
||||
if bridged.contains(&port) {
|
||||
let withhold = if gate_owned.contains(&port) {
|
||||
Some("port is now gate-owned")
|
||||
} else if port_map.is_declared_local(port) {
|
||||
Some("port is declared auth: local (host-local by intent)")
|
||||
} else {
|
||||
None
|
||||
};
|
||||
if let Some(reason) = withhold {
|
||||
if let Some(handle) = bridged.remove(&port) {
|
||||
handle.abort();
|
||||
info!(port, reason, "v6 relay released a bridge");
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if bridged.contains_key(&port) {
|
||||
continue;
|
||||
}
|
||||
// ONLY bridge a port that a running app already answers on
|
||||
@@ -1164,10 +1250,9 @@ async fn app_port_v6_relay_loop(mut shutdown_rx: tokio::sync::watch::Receiver<bo
|
||||
// EADDRINUSE = fipsd or another process already answers
|
||||
// on this mesh address/port, so stay out of the way.
|
||||
let Ok(listener) = bind_v6_only(addr) else { continue };
|
||||
bridged.insert(port);
|
||||
debug!("v6 relay bridging [{fips_ip}]:{port} -> 127.0.0.1:{port}");
|
||||
let mut rx = shutdown_rx.clone();
|
||||
tokio::spawn(async move {
|
||||
let handle = tokio::spawn(async move {
|
||||
loop {
|
||||
tokio::select! {
|
||||
accepted = listener.accept() => {
|
||||
@@ -1188,6 +1273,7 @@ async fn app_port_v6_relay_loop(mut shutdown_rx: tokio::sync::watch::Receiver<bo
|
||||
}
|
||||
}
|
||||
});
|
||||
bridged.insert(port, handle);
|
||||
}
|
||||
}
|
||||
_ = shutdown_rx.changed() => return,
|
||||
|
||||
@@ -40,12 +40,19 @@ struct Session {
|
||||
created_at: SystemTime,
|
||||
last_activity: SystemTime,
|
||||
session_type: SessionType,
|
||||
/// What kind of screen this login came from. A TV on the wall must not
|
||||
/// be signed out for sitting still — nobody is there to type a password
|
||||
/// back in — while a browser must be.
|
||||
device_class: crate::settings::session_policy::DeviceClass,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct SessionStore {
|
||||
sessions: Arc<RwLock<HashMap<[u8; 32], Session>>>,
|
||||
persist_path: PathBuf,
|
||||
/// Where the session policy lives. Held rather than looked up globally
|
||||
/// so tests can point at a temp dir.
|
||||
data_dir: PathBuf,
|
||||
}
|
||||
|
||||
/// On-disk representation of a persisted session (only Full sessions, no TOTP secrets).
|
||||
@@ -67,6 +74,7 @@ impl SessionStore {
|
||||
Self {
|
||||
sessions: Arc::new(RwLock::new(sessions)),
|
||||
persist_path,
|
||||
data_dir: PathBuf::from("/var/lib/archipelago"),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -75,9 +83,17 @@ impl SessionStore {
|
||||
/// machine's real /var/lib/archipelago/sessions.json.
|
||||
#[cfg(test)]
|
||||
pub fn new_for_tests(persist_path: PathBuf) -> Self {
|
||||
// data_dir shares the temp path's parent so a test that writes a
|
||||
// policy file is honoured, and one that doesn't gets the defaults
|
||||
// rather than the dev machine's real configuration.
|
||||
let data_dir = persist_path
|
||||
.parent()
|
||||
.map(PathBuf::from)
|
||||
.unwrap_or_else(|| PathBuf::from("."));
|
||||
Self {
|
||||
sessions: Arc::new(RwLock::new(HashMap::new())),
|
||||
persist_path,
|
||||
data_dir,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -120,6 +136,7 @@ impl SessionStore {
|
||||
created_at,
|
||||
last_activity,
|
||||
session_type: SessionType::Full,
|
||||
device_class: crate::settings::session_policy::DeviceClass::Browser,
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -160,6 +177,7 @@ impl SessionStore {
|
||||
created_at: now,
|
||||
last_activity: now,
|
||||
session_type: SessionType::Full,
|
||||
device_class: crate::settings::session_policy::DeviceClass::Browser,
|
||||
};
|
||||
|
||||
let mut sessions = self.sessions.write().await;
|
||||
@@ -184,6 +202,10 @@ impl SessionStore {
|
||||
totp_secret,
|
||||
attempts: 0,
|
||||
},
|
||||
// A half-finished login is always treated as a browser: it lives
|
||||
// for PENDING_SESSION_TTL either way, and a kiosk exemption on a
|
||||
// session that has not passed 2FA yet would be the wrong default.
|
||||
device_class: crate::settings::session_policy::DeviceClass::Browser,
|
||||
};
|
||||
self.sessions.write().await.insert(hash, session);
|
||||
token
|
||||
@@ -192,19 +214,23 @@ impl SessionStore {
|
||||
/// Validate a full session token. Returns true if the session exists and hasn't expired.
|
||||
/// Updates last_activity on successful validation (inactivity-based expiry).
|
||||
pub async fn validate(&self, token: &str) -> bool {
|
||||
let policy = self.policy().await;
|
||||
let hash = hash_token(token);
|
||||
let mut sessions = self.sessions.write().await;
|
||||
if let Some(session) = sessions.get_mut(&hash) {
|
||||
if !matches!(session.session_type, SessionType::Full) {
|
||||
return false;
|
||||
}
|
||||
if session
|
||||
let idle = session
|
||||
.last_activity
|
||||
.elapsed()
|
||||
.unwrap_or_default()
|
||||
.as_secs()
|
||||
>= FULL_SESSION_TTL
|
||||
{
|
||||
.as_secs();
|
||||
let age = session.created_at.elapsed().unwrap_or_default().as_secs();
|
||||
// Both limits, not just idleness: the dashboard polls, so an
|
||||
// idle timeout alone would never fire on an open tab. The
|
||||
// absolute cap is what actually guarantees a login ends.
|
||||
if policy.is_expired(session.device_class, age, idle) {
|
||||
sessions.remove(&hash);
|
||||
return false;
|
||||
}
|
||||
@@ -215,6 +241,13 @@ impl SessionStore {
|
||||
}
|
||||
}
|
||||
|
||||
/// The operator's session policy, re-read from disk rather than cached
|
||||
/// for the process lifetime so a change in Settings takes effect on the
|
||||
/// next request instead of the next restart.
|
||||
pub async fn policy(&self) -> crate::settings::session_policy::SessionPolicy {
|
||||
crate::settings::session_policy::load(&self.data_dir).await
|
||||
}
|
||||
|
||||
/// Get the TOTP secret from a pending session. Returns None if not a valid pending session.
|
||||
/// Increments the attempt counter.
|
||||
pub async fn get_pending_secret(&self, token: &str) -> Option<Vec<u8>> {
|
||||
@@ -259,6 +292,7 @@ impl SessionStore {
|
||||
created_at: now,
|
||||
last_activity: now,
|
||||
session_type: SessionType::Full,
|
||||
device_class: crate::settings::session_policy::DeviceClass::Browser,
|
||||
},
|
||||
);
|
||||
Self::save_to_disk(&sessions, &self.persist_path).await;
|
||||
@@ -300,6 +334,7 @@ impl SessionStore {
|
||||
created_at: now,
|
||||
last_activity: now,
|
||||
session_type: SessionType::Full,
|
||||
device_class: crate::settings::session_policy::DeviceClass::Browser,
|
||||
},
|
||||
);
|
||||
Self::save_to_disk(&sessions, &self.persist_path).await;
|
||||
|
||||
@@ -4,4 +4,5 @@
|
||||
//! call sites (deep in the transport / RPC / ingest stacks) don't need
|
||||
//! to thread a data_dir or Arc through the entire call graph.
|
||||
|
||||
pub mod session_policy;
|
||||
pub mod transport;
|
||||
|
||||
@@ -0,0 +1,200 @@
|
||||
//! How long a login lasts, and who gets to say so.
|
||||
//!
|
||||
//! # Why this is configurable rather than a constant
|
||||
//!
|
||||
//! There is no single correct session lifetime. The same node can be a
|
||||
//! wall-mounted TV in a living room that must never ask for a password
|
||||
//! mid-film, and a wallet holding real funds where PCI DSS-style guidance
|
||||
//! says fifteen minutes. Both are legitimate; the operator knows which one
|
||||
//! this node is and we do not.
|
||||
//!
|
||||
//! # The two tokens
|
||||
//!
|
||||
//! * **Session token** — short-lived, refreshed silently on every
|
||||
//! authenticated request. This is what the browser sends; if it leaks, it
|
||||
//! is useful only until [`SessionPolicy::idle_timeout_secs`] of silence.
|
||||
//! * **Login (remember) token** — long-lived, and its *only* power is to
|
||||
//! mint a fresh session token. Kept separate so raising the convenience
|
||||
//! knob does not put a 30-day bearer credential on every request.
|
||||
//!
|
||||
//! Raising the idle timeout therefore does not weaken the credential that
|
||||
//! actually travels; it only changes how long a quiet tab stays usable.
|
||||
//!
|
||||
//! # Why an absolute cap exists at all
|
||||
//!
|
||||
//! Idle timeout alone can be defeated by any page that polls — the
|
||||
//! dashboard polls constantly, so an idle timeout would never fire while a
|
||||
//! tab is open. The absolute cap is what guarantees a login eventually
|
||||
//! ends, which is the property an auditor actually asks about.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::path::Path;
|
||||
|
||||
const FILE_PATH: &str = "settings/session_policy.json";
|
||||
|
||||
/// Bounds. A setting that can be made meaningless is not a setting, and one
|
||||
/// that can lock the operator out of their own node is a footgun.
|
||||
const MIN_IDLE_SECS: u64 = 60;
|
||||
const MAX_IDLE_SECS: u64 = 90 * 24 * 3600;
|
||||
const MIN_ABSOLUTE_SECS: u64 = 300;
|
||||
const MAX_ABSOLUTE_SECS: u64 = 365 * 24 * 3600;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "kebab-case")]
|
||||
pub enum DeviceClass {
|
||||
/// Ordinary browser on a phone or laptop. Policy applies as configured.
|
||||
Browser,
|
||||
/// A screen nobody logs into — a wall-mounted dashboard or TV. Being
|
||||
/// signed out mid-view is the failure mode here, not a stale session:
|
||||
/// the device is physically in the home, and there is no keyboard to
|
||||
/// re-authenticate with. Exempt from the idle timeout, still subject to
|
||||
/// the absolute cap so a stolen box does not stay authenticated forever.
|
||||
Kiosk,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct SessionPolicy {
|
||||
/// Silence after which a session token stops validating.
|
||||
pub idle_timeout_secs: u64,
|
||||
/// Hard ceiling from login, regardless of activity. `None` = no cap.
|
||||
pub absolute_timeout_secs: Option<u64>,
|
||||
/// Re-prompt for the password before actions that move money, however
|
||||
/// fresh the session is. Independent of the timeouts on purpose: it is
|
||||
/// the control that matters when funds are involved, and it costs the
|
||||
/// operator nothing the rest of the time.
|
||||
pub reauth_for_funds: bool,
|
||||
}
|
||||
|
||||
impl Default for SessionPolicy {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
// A day of silence, matching the previous hard-coded constant so
|
||||
// existing nodes see no behaviour change until someone chooses.
|
||||
idle_timeout_secs: 86_400,
|
||||
// 30 days, aligned with the login token's own lifetime: a
|
||||
// session that outlived the token which could refresh it would
|
||||
// be an oddity.
|
||||
absolute_timeout_secs: Some(30 * 24 * 3600),
|
||||
reauth_for_funds: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl SessionPolicy {
|
||||
/// Clamp to the supported range. Applied on load as well as on save, so
|
||||
/// a hand-edited file cannot disable expiry by writing `0`.
|
||||
pub fn sanitized(mut self) -> Self {
|
||||
self.idle_timeout_secs = self.idle_timeout_secs.clamp(MIN_IDLE_SECS, MAX_IDLE_SECS);
|
||||
self.absolute_timeout_secs = self
|
||||
.absolute_timeout_secs
|
||||
.map(|v| v.clamp(MIN_ABSOLUTE_SECS, MAX_ABSOLUTE_SECS))
|
||||
// An absolute cap below the idle timeout would expire sessions
|
||||
// while they are still active, which reads as random logouts.
|
||||
.map(|v| v.max(self.idle_timeout_secs));
|
||||
self
|
||||
}
|
||||
|
||||
/// Idle timeout for a given device, or `None` when idleness is not a
|
||||
/// reason to expire (kiosk screens).
|
||||
pub fn idle_timeout_for(&self, class: DeviceClass) -> Option<u64> {
|
||||
match class {
|
||||
DeviceClass::Browser => Some(self.idle_timeout_secs),
|
||||
DeviceClass::Kiosk => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Has a session expired? `age` is time since login, `idle` since last
|
||||
/// use. Both are checked because either alone is insufficient: idle
|
||||
/// never fires on a polling dashboard, and absolute alone leaves a
|
||||
/// forgotten tab usable for a month.
|
||||
pub fn is_expired(&self, class: DeviceClass, age_secs: u64, idle_secs: u64) -> bool {
|
||||
if let Some(limit) = self.absolute_timeout_secs {
|
||||
if age_secs >= limit {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
match self.idle_timeout_for(class) {
|
||||
Some(limit) => idle_secs >= limit,
|
||||
None => false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn load(data_dir: &Path) -> SessionPolicy {
|
||||
let path = data_dir.join(FILE_PATH);
|
||||
match tokio::fs::read(&path).await {
|
||||
Ok(bytes) => serde_json::from_slice::<SessionPolicy>(&bytes)
|
||||
.map(SessionPolicy::sanitized)
|
||||
.unwrap_or_else(|e| {
|
||||
tracing::warn!(error = %e, "session policy unreadable; using defaults");
|
||||
SessionPolicy::default()
|
||||
}),
|
||||
Err(_) => SessionPolicy::default(),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn save(data_dir: &Path, policy: SessionPolicy) -> anyhow::Result<SessionPolicy> {
|
||||
let policy = policy.sanitized();
|
||||
let path = data_dir.join(FILE_PATH);
|
||||
if let Some(parent) = path.parent() {
|
||||
tokio::fs::create_dir_all(parent).await?;
|
||||
}
|
||||
let tmp = path.with_extension("json.tmp");
|
||||
tokio::fs::write(&tmp, serde_json::to_vec_pretty(&policy)?).await?;
|
||||
tokio::fs::rename(&tmp, &path).await?;
|
||||
Ok(policy)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn defaults_match_the_previous_hardcoded_behaviour() {
|
||||
let p = SessionPolicy::default();
|
||||
assert_eq!(p.idle_timeout_secs, 86_400);
|
||||
assert!(p.reauth_for_funds);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn expiry_cannot_be_disabled_by_hand_editing_the_file() {
|
||||
let p = SessionPolicy {
|
||||
idle_timeout_secs: 0,
|
||||
absolute_timeout_secs: Some(0),
|
||||
reauth_for_funds: false,
|
||||
}
|
||||
.sanitized();
|
||||
assert!(p.idle_timeout_secs >= MIN_IDLE_SECS);
|
||||
assert!(p.absolute_timeout_secs.unwrap() >= MIN_ABSOLUTE_SECS);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn absolute_cap_is_never_shorter_than_idle() {
|
||||
// Otherwise a session dies while actively in use, which the operator
|
||||
// experiences as being logged out at random.
|
||||
let p = SessionPolicy {
|
||||
idle_timeout_secs: 7 * 24 * 3600,
|
||||
absolute_timeout_secs: Some(3600),
|
||||
reauth_for_funds: true,
|
||||
}
|
||||
.sanitized();
|
||||
assert_eq!(p.absolute_timeout_secs.unwrap(), p.idle_timeout_secs);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_kiosk_never_expires_from_idleness_but_still_has_a_ceiling() {
|
||||
let p = SessionPolicy::default();
|
||||
let a_week = 7 * 24 * 3600;
|
||||
assert!(!p.is_expired(DeviceClass::Kiosk, 60, a_week));
|
||||
assert!(p.is_expired(DeviceClass::Browser, 60, a_week));
|
||||
// The absolute cap still applies to the TV.
|
||||
assert!(p.is_expired(DeviceClass::Kiosk, 31 * 24 * 3600, 0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_polling_dashboard_still_eventually_expires() {
|
||||
// idle never grows because the page polls; only the cap saves us.
|
||||
let p = SessionPolicy::default();
|
||||
assert!(p.is_expired(DeviceClass::Browser, 30 * 24 * 3600, 0));
|
||||
}
|
||||
}
|
||||
@@ -16,13 +16,28 @@ use ed25519_dalek::VerifyingKey;
|
||||
|
||||
/// Hex of the pinned Ed25519 release-root public key (32 bytes / 64 hex chars).
|
||||
///
|
||||
/// Pinned 2026-07-02 from the release-root signing ceremony
|
||||
/// (signer did:key:z6MkkidEnEpo6qHMCNSZoNKWtvQvxq3whnaME9wGgEFhq7ur). The
|
||||
/// ROTATED 2026-08-04 to did:key:z6Mkfu5LT8d4DjETtrkATvHh9Dvcbnr7zBCUwfau8Sw7DLWT.
|
||||
///
|
||||
/// The previous root (z6Mkkid…q7ur, pinned 2026-07-02) was exposed in a chat
|
||||
/// transcript and is treated as compromised.
|
||||
///
|
||||
/// Rotation is ORDERING-CRITICAL. Nodes pin the OLD key, so the release that
|
||||
/// carries this change must itself be signed with the OLD key — that is the
|
||||
/// only signature a node running the previous binary will accept. Only the
|
||||
/// release AFTER it may be signed with the new key. Signing the rotation
|
||||
/// release with the new key makes every node reject it and ends OTA
|
||||
/// fleet-wide, recoverable only by touching each node by hand.
|
||||
///
|
||||
/// Verified before pinning: this hex and the did:key above are the same
|
||||
/// keypair (the did:key encodes exactly these 32 bytes), checked with a
|
||||
/// decoder round-tripped against the previous known-good pair. An earlier
|
||||
/// candidate hex was rejected because it did not match the stated DID.
|
||||
/// The
|
||||
/// corresponding mnemonic is held offline by the publisher — see
|
||||
/// `docs/workstream-b-signing-runbook.md`. Regenerate/verify with:
|
||||
/// `RELEASE_MASTER_MNEMONIC=… archipelago ceremony pubkey`.
|
||||
pub const RELEASE_ROOT_PUBKEY_HEX: Option<&str> =
|
||||
Some("5d15cbee8a108f7dd288c02d29a1d9d71f198acc99186aad8008b4f28d469951");
|
||||
Some("1578adccf137024159dd936f44a56e8869ac7775785962f7e92e2faf2c034418");
|
||||
|
||||
const ENV_OVERRIDE: &str = "ARCHY_RELEASE_ROOT_PUBKEY";
|
||||
|
||||
|
||||
@@ -74,7 +74,20 @@ fn is_newer(candidate: &str, current: &str) -> bool {
|
||||
}
|
||||
}
|
||||
|
||||
/// Primary OTA origin. Named host over TLS rather than the bare IP it used
|
||||
/// to be: the IP pinned the fleet to one machine and one plaintext port, so
|
||||
/// moving or fronting the origin meant an OTA to change where OTAs come
|
||||
/// from — the one update you cannot ship if the origin is unreachable. The
|
||||
/// signature is what establishes trust (see `trust::anchor`), not the
|
||||
/// transport, but HTTPS also stops a network observer seeing which version
|
||||
/// a node runs.
|
||||
const DEFAULT_UPDATE_MANIFEST_URL: &str =
|
||||
"https://source.archipelago-foundation.org/lfg2025/archy/raw/branch/main/releases/manifest.json";
|
||||
|
||||
/// The previous IP-based origin, kept as an automatic fallback so a node
|
||||
/// whose DNS or TLS is broken still updates. Dropped from the mirror list
|
||||
/// once the fleet has moved.
|
||||
const LEGACY_UPDATE_MANIFEST_URL: &str =
|
||||
"http://146.59.87.168:3000/lfg2025/archy/raw/branch/main/releases/manifest.json";
|
||||
const UPDATE_STATE_FILE: &str = "update_state.json";
|
||||
const UPDATE_MIRRORS_FILE: &str = "update-mirrors.json";
|
||||
@@ -113,10 +126,19 @@ fn mirrors_path(data_dir: &Path) -> std::path::PathBuf {
|
||||
}
|
||||
|
||||
fn default_mirrors() -> Vec<UpdateMirror> {
|
||||
vec![UpdateMirror {
|
||||
url: DEFAULT_UPDATE_MANIFEST_URL.to_string(),
|
||||
label: "Server 1 (OVH)".to_string(),
|
||||
}]
|
||||
vec![
|
||||
UpdateMirror {
|
||||
url: DEFAULT_UPDATE_MANIFEST_URL.to_string(),
|
||||
label: "Archipelago Foundation".to_string(),
|
||||
},
|
||||
// Fallback, tried only if the named origin fails: a node whose DNS
|
||||
// or clock is wrong (both break TLS) must still be able to update
|
||||
// itself, and the signature check is what makes either source safe.
|
||||
UpdateMirror {
|
||||
url: LEGACY_UPDATE_MANIFEST_URL.to_string(),
|
||||
label: "Direct (fallback)".to_string(),
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
/// Load the operator-configured mirror list. Returns defaults if the
|
||||
@@ -186,15 +208,18 @@ fn force_ovh_update_primary(list: &mut Vec<UpdateMirror>) {
|
||||
}
|
||||
for mirror in list.iter_mut() {
|
||||
if mirror.url == DEFAULT_UPDATE_MANIFEST_URL {
|
||||
mirror.label = "Server 1 (OVH)".to_string();
|
||||
mirror.label = "Archipelago Foundation".to_string();
|
||||
} else if mirror.url == LEGACY_UPDATE_MANIFEST_URL {
|
||||
mirror.label = "Direct (fallback)".to_string();
|
||||
}
|
||||
}
|
||||
list.sort_by_key(|m| {
|
||||
if m.url == DEFAULT_UPDATE_MANIFEST_URL {
|
||||
0
|
||||
} else {
|
||||
1
|
||||
}
|
||||
// Named origin first, its IP fallback second, anything the operator
|
||||
// added after that. Ordering matters: the list is tried in order, so a
|
||||
// stale entry sitting first costs a timeout on every check.
|
||||
list.sort_by_key(|m| match m.url.as_str() {
|
||||
u if u == DEFAULT_UPDATE_MANIFEST_URL => 0,
|
||||
u if u == LEGACY_UPDATE_MANIFEST_URL => 1,
|
||||
_ => 2,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -2373,8 +2398,18 @@ mod tests {
|
||||
async fn test_load_mirrors_returns_defaults_when_absent() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let list = load_mirrors(dir.path()).await.unwrap();
|
||||
assert_eq!(list.len(), 1);
|
||||
assert!(list[0].url.contains("146.59.87.168"));
|
||||
// The named origin leads, its IP fallback follows. A node with broken
|
||||
// DNS or a wrong clock (both break TLS) must still have a way to
|
||||
// update; the signature is what makes either source trustworthy.
|
||||
assert_eq!(list.len(), 2);
|
||||
assert!(
|
||||
list[0]
|
||||
.url
|
||||
.starts_with("https://source.archipelago-foundation.org/"),
|
||||
"the named origin must be primary, got {}",
|
||||
list[0].url
|
||||
);
|
||||
assert!(list[1].url.contains("146.59.87.168"));
|
||||
assert!(
|
||||
!list.iter().any(|m| m.url.contains("git.tx1138.com")),
|
||||
"tx1138 was retired as a release server and must not be a default mirror"
|
||||
|
||||
@@ -1040,6 +1040,12 @@ pub async fn receive_token(data_dir: &Path, token_str: &str) -> Result<u64> {
|
||||
|
||||
let mut wallet = load_wallet(data_dir).await?;
|
||||
let mut received_total = 0u64;
|
||||
// MintClient translates the mint's NUT error code into plain language and
|
||||
// puts it at the top of the error chain (see `mint_error` in
|
||||
// mint_client.rs); `{}` surfaces that, `{:#}` keeps the raw status/body
|
||||
// for the log. Remember the last one so a total failure can tell the user
|
||||
// *why* instead of just "nothing was received".
|
||||
let mut last_reason: Option<String> = None;
|
||||
|
||||
// Swap proofs at each mint
|
||||
for entry in &token.token {
|
||||
@@ -1051,14 +1057,18 @@ pub async fn receive_token(data_dir: &Path, token_str: &str) -> Result<u64> {
|
||||
received_total += amount;
|
||||
}
|
||||
Err(e) => {
|
||||
warn!("Failed to swap proofs from mint {}: {}", entry.mint, e);
|
||||
warn!("Failed to swap proofs from mint {}: {:#}", entry.mint, e);
|
||||
last_reason = Some(e.to_string());
|
||||
// Continue with other mints if any
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if received_total == 0 {
|
||||
anyhow::bail!("Failed to receive any proofs from token");
|
||||
match last_reason {
|
||||
Some(reason) => anyhow::bail!("Could not receive this ecash: {}", reason),
|
||||
None => anyhow::bail!("Failed to receive any proofs from token"),
|
||||
}
|
||||
}
|
||||
|
||||
wallet.record_tx(
|
||||
|
||||
@@ -59,6 +59,72 @@ pub struct MintResult {
|
||||
pub proofs: Vec<Proof>,
|
||||
}
|
||||
|
||||
/// Translate a Cashu NUT "transaction validation" error code into plain
|
||||
/// language a wallet user can act on. Mints respond to a rejected request
|
||||
/// with `{"code": N, "detail": "..."}`; `detail` is implementation-defined
|
||||
/// free text, but `code` is the stable identifier from the spec
|
||||
/// (https://github.com/cashubtc/nuts/blob/main/error_codes.md). Covers the
|
||||
/// 10001-11017 "proof/transaction validation" range plus the 12001-12003
|
||||
/// keyset codes shared by NUT-02/03/04/05 — the codes a swap/melt/mint call
|
||||
/// can actually hit. Returns `None` for anything else (e.g. Lightning/quote
|
||||
/// codes in the 20000s) so the caller falls back to the mint's own `detail`.
|
||||
fn describe_mint_error_code(code: i64) -> Option<&'static str> {
|
||||
Some(match code {
|
||||
10001 => "The mint rejected these coins as invalid.",
|
||||
11001 => "This ecash has already been redeemed — it can't be claimed twice.",
|
||||
11002 => "This ecash is already being redeemed elsewhere — try again in a moment.",
|
||||
11003 => "The mint already issued new coins for this exact request — there's nothing left to redeem.",
|
||||
11004 => "This request is still being processed by the mint — try again in a moment.",
|
||||
11005 => "The token's amounts don't add up (inputs don't match outputs) — it may be corrupt.",
|
||||
11006 => "That amount is outside the range this mint allows.",
|
||||
11007 => "This token contains duplicate coins — it may be corrupt or already used.",
|
||||
11008 => "The mint rejected this as a duplicate request.",
|
||||
11009 | 11010 => "This token mixes incompatible currency units — the mint rejected it.",
|
||||
11011 => "That Lightning invoice has no amount, which isn't supported here.",
|
||||
11012 => "The amount requested doesn't match the Lightning invoice.",
|
||||
11013 => "The mint doesn't support this currency unit.",
|
||||
11014 | 11015 => "This token has too many coins for the mint to process in one request.",
|
||||
11016 => "Duplicate quote IDs were sent in this request.",
|
||||
11017 => "Too many items were sent in a single request.",
|
||||
12001 => "The mint no longer recognizes the keyset that signed this token.",
|
||||
12002 => "The mint's signing key for this token is inactive.",
|
||||
12003 => "The mint's signing key for this token has expired.",
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Parse a mint's error body (`{"code": N, "detail": "..."}`) and pick the
|
||||
/// best user-facing message: the plain-language translation when we know the
|
||||
/// code, otherwise the mint's own `detail` text, otherwise the raw body.
|
||||
fn describe_mint_error_body(status: reqwest::StatusCode, body: &str) -> String {
|
||||
let parsed: Option<serde_json::Value> = serde_json::from_str(body).ok();
|
||||
let code = parsed
|
||||
.as_ref()
|
||||
.and_then(|v| v.get("code"))
|
||||
.and_then(|c| c.as_i64());
|
||||
let detail = parsed
|
||||
.as_ref()
|
||||
.and_then(|v| v.get("detail"))
|
||||
.and_then(|d| d.as_str());
|
||||
|
||||
if let Some(friendly) = code.and_then(describe_mint_error_code) {
|
||||
return friendly.to_string();
|
||||
}
|
||||
match detail {
|
||||
Some(d) if !d.is_empty() => d.to_string(),
|
||||
_ => format!("mint returned {} with no further detail", status),
|
||||
}
|
||||
}
|
||||
|
||||
/// Build the error for a failed mint HTTP call: `op` + status + raw body as
|
||||
/// the technical cause (visible via `{:#}` in logs), with the plain-language
|
||||
/// translation layered on top via `.context()` so `{}` — what reaches the
|
||||
/// wallet user — shows something actionable instead of raw mint JSON.
|
||||
fn mint_error(op: &str, status: reqwest::StatusCode, body: &str) -> anyhow::Error {
|
||||
let friendly = describe_mint_error_body(status, body);
|
||||
anyhow::anyhow!("{} failed ({}): {}", op, status, body).context(friendly)
|
||||
}
|
||||
|
||||
/// HTTP client for a single Cashu mint.
|
||||
pub struct MintClient {
|
||||
url: String,
|
||||
@@ -146,7 +212,7 @@ impl MintClient {
|
||||
if !res.status().is_success() {
|
||||
let status = res.status();
|
||||
let body = res.text().await.unwrap_or_default();
|
||||
anyhow::bail!("Mint quote failed ({}): {}", status, body);
|
||||
return Err(mint_error("Mint quote", status, &body));
|
||||
}
|
||||
|
||||
res.json().await.context("Failed to parse mint quote")
|
||||
@@ -212,7 +278,7 @@ impl MintClient {
|
||||
if !res.status().is_success() {
|
||||
let status = res.status();
|
||||
let body = res.text().await.unwrap_or_default();
|
||||
anyhow::bail!("Mint tokens failed ({}): {}", status, body);
|
||||
return Err(mint_error("Minting tokens", status, &body));
|
||||
}
|
||||
|
||||
let body: serde_json::Value = res.json().await.context("Failed to parse mint response")?;
|
||||
@@ -266,7 +332,7 @@ impl MintClient {
|
||||
if !res.status().is_success() {
|
||||
let status = res.status();
|
||||
let body = res.text().await.unwrap_or_default();
|
||||
anyhow::bail!("Melt quote failed ({}): {}", status, body);
|
||||
return Err(mint_error("Melt quote", status, &body));
|
||||
}
|
||||
|
||||
res.json().await.context("Failed to parse melt quote")
|
||||
@@ -293,7 +359,7 @@ impl MintClient {
|
||||
if !res.status().is_success() {
|
||||
let status = res.status();
|
||||
let body = res.text().await.unwrap_or_default();
|
||||
anyhow::bail!("Melt failed ({}): {}", status, body);
|
||||
return Err(mint_error("Melt", status, &body));
|
||||
}
|
||||
|
||||
res.json().await.context("Failed to parse melt response")
|
||||
@@ -337,7 +403,7 @@ impl MintClient {
|
||||
if !res.status().is_success() {
|
||||
let status = res.status();
|
||||
let body = res.text().await.unwrap_or_default();
|
||||
anyhow::bail!("Swap failed ({}): {}", status, body);
|
||||
return Err(mint_error("Swap", status, &body));
|
||||
}
|
||||
|
||||
let body: serde_json::Value = res.json().await.context("Failed to parse swap response")?;
|
||||
|
||||
@@ -503,6 +503,66 @@ fn default_network_policy() -> String {
|
||||
"isolated".to_string()
|
||||
}
|
||||
|
||||
/// Whether a published port must sit behind the node's app authentication
|
||||
/// gate.
|
||||
///
|
||||
/// The default is deliberately the protected one. Every app port on this
|
||||
/// node was reachable with no credential at all over LAN, Tailscale, Tor and
|
||||
/// the FIPS mesh alike (reproduced 2026-08-03) precisely because exposure
|
||||
/// was the thing you got by saying nothing. Making `Session` the default
|
||||
/// inverts that: a new app is protected unless its manifest argues for an
|
||||
/// exemption, and the exemptions are a `grep auth: none apps/` rather than a
|
||||
/// discovery.
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum PortAuth {
|
||||
/// Default. The daemon's app gate authenticates every connection: a
|
||||
/// valid session (2FA honoured, since a session still pending its TOTP
|
||||
/// step fails validation) or an app-scoped bearer token for machine
|
||||
/// clients. Anything else gets the login page.
|
||||
#[default]
|
||||
Session,
|
||||
/// Exempt — the gate does not touch this port.
|
||||
///
|
||||
/// Only legitimate when the port carries a protocol that authenticates
|
||||
/// itself (LND macaroons, Lightning's noise handshake, TLS client
|
||||
/// certs) or one where a login page would be meaningless and harmful
|
||||
/// (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)]
|
||||
pub struct PortMapping {
|
||||
pub host: u16,
|
||||
@@ -516,6 +576,58 @@ pub struct PortMapping {
|
||||
/// containers keep reaching it via `host.archipelago`).
|
||||
#[serde(default)]
|
||||
pub bind: String,
|
||||
/// 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.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub auth_rationale: Option<String>,
|
||||
/// Forward the node session cookie to the app on authorised requests.
|
||||
///
|
||||
/// The gate normally strips its own credential before proxying — an app
|
||||
/// must never be in a position to log or replay the node session. The
|
||||
/// first-party companion UIs (lnd-ui, bitcoin-ui, electrs-ui, fips-ui)
|
||||
/// are the exception their design requires: their nginx forwards the
|
||||
/// browser's session cookie to the daemon's authenticated endpoints
|
||||
/// (`/proxy/lnd/*`, `/rpc/v1`, `/lnd-connect-info`), so stripping it
|
||||
/// breaks every data call behind the gate with a 401 while the page
|
||||
/// shell still renders (observed as "LND UI unreachable", 2026-08-05).
|
||||
/// Only meaningful on a `auth: gated` port.
|
||||
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
|
||||
pub session_passthrough: bool,
|
||||
}
|
||||
|
||||
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 {
|
||||
@@ -525,6 +637,9 @@ impl From<(u16, u16)> for PortMapping {
|
||||
container,
|
||||
protocol: "tcp".to_string(),
|
||||
bind: String::new(),
|
||||
auth: None,
|
||||
auth_rationale: None,
|
||||
session_passthrough: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1022,6 +1137,34 @@ fn validate_ports(ports: &[PortMapping]) -> Result<(), ManifestError> {
|
||||
port.bind
|
||||
)));
|
||||
}
|
||||
// An exemption from the app gate has to carry its own justification.
|
||||
// Enforcing it here rather than at review time means the reason
|
||||
// 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_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 \
|
||||
port must state why it is safe to expose"
|
||||
)));
|
||||
}
|
||||
(PortAuth::None, Some(rationale)) if rationale.trim().is_empty() => {
|
||||
return Err(ManifestError::Invalid(format!(
|
||||
"ports[{i}].auth_rationale cannot be empty"
|
||||
)));
|
||||
}
|
||||
// A rationale on a gated port means the author wrote an
|
||||
// exemption and did not get one. Silently keeping the port
|
||||
// protected would be safe but misleading, so say so.
|
||||
(PortAuth::Session, Some(_)) => {
|
||||
return Err(ManifestError::Invalid(format!(
|
||||
"ports[{i}] sets auth_rationale without auth: none — the port is gated \
|
||||
and the rationale has no effect"
|
||||
)));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
// The same host port may be listed more than once with different bind
|
||||
// addresses (e.g. loopback + the archy-net gateway); identical
|
||||
// (host, protocol, bind) triples are still rejected.
|
||||
@@ -1519,6 +1662,161 @@ app:
|
||||
}
|
||||
}
|
||||
|
||||
/// Build a manifest with one port block, so each auth case differs only
|
||||
/// in the lines under test.
|
||||
fn manifest_with_port(port_yaml: &str) -> Result<AppManifest, ManifestError> {
|
||||
AppManifest::parse(&format!(
|
||||
"app:\n id: a\n name: a\n version: 1.0.0\n container:\n image: x:y\n ports:\n{port_yaml}"
|
||||
))
|
||||
}
|
||||
|
||||
/// Every manifest we ship must satisfy the schema — including the auth
|
||||
/// rules above. Without this the first exemption typo'd into a manifest
|
||||
/// would only surface when a node refused to load the app.
|
||||
#[test]
|
||||
fn all_shipped_manifests_parse() {
|
||||
let apps = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../apps");
|
||||
let Ok(entries) = std::fs::read_dir(&apps) else {
|
||||
return; // not a full checkout (vendored crate) — nothing to check
|
||||
};
|
||||
let mut checked = 0;
|
||||
for entry in entries.flatten() {
|
||||
let manifest = entry.path().join("manifest.yml");
|
||||
if !manifest.is_file() {
|
||||
continue;
|
||||
}
|
||||
let yaml = std::fs::read_to_string(&manifest).expect("manifest readable");
|
||||
AppManifest::parse(&yaml)
|
||||
.unwrap_or_else(|e| panic!("{} is invalid: {e}", manifest.display()));
|
||||
checked += 1;
|
||||
}
|
||||
assert!(checked > 40, "only found {checked} manifests — path wrong?");
|
||||
}
|
||||
|
||||
/// The exempt set is the node's entire unauthenticated attack surface, so
|
||||
/// it must stay small and deliberate. If this count moves, someone added
|
||||
/// or removed an exemption and it wants a second pair of eyes.
|
||||
#[test]
|
||||
fn unauthenticated_ports_are_all_accounted_for() {
|
||||
let apps = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../apps");
|
||||
let Ok(entries) = std::fs::read_dir(&apps) else {
|
||||
return;
|
||||
};
|
||||
let mut exempt: Vec<(String, u16)> = Vec::new();
|
||||
for entry in entries.flatten() {
|
||||
let manifest = entry.path().join("manifest.yml");
|
||||
if !manifest.is_file() {
|
||||
continue;
|
||||
}
|
||||
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_policy() == PortAuth::None {
|
||||
exempt.push((parsed.app.id.clone(), port.host));
|
||||
}
|
||||
}
|
||||
}
|
||||
exempt.sort();
|
||||
// 25 as of the v1.7.123 port-policy round: bitcoin p2p (8333 ×2),
|
||||
// core-lightning 9736/9835, electrumx 50001, fedimint 8173/8174,
|
||||
// fedimint-gateway 8176/9737, gitea ssh 2222, lightning-stack
|
||||
// 8091/9738/10010, lnd 9735/10009/18080, netbird 3478/8086/8087,
|
||||
// pine TLS 10381 + the three voice ports (10200/10300/10400 — the
|
||||
// disclosed known gap), router SSDP/mDNS 1900/5353. Every one is a
|
||||
// deliberate, rationale-carrying exemption; the release-gate test
|
||||
// stage timed out that cycle, so the count here lagged at 17.
|
||||
assert_eq!(
|
||||
exempt.len(),
|
||||
25,
|
||||
"unauthenticated port set changed — review before updating this count: {exempt:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
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();
|
||||
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]
|
||||
fn port_auth_none_requires_a_rationale() {
|
||||
let err = manifest_with_port(" - host: 8333\n container: 8333\n auth: none\n")
|
||||
.expect_err("auth: none without a rationale must be rejected");
|
||||
assert!(
|
||||
err.to_string().contains("auth_rationale"),
|
||||
"error should name the missing field, got: {err}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn port_auth_none_rejects_a_blank_rationale() {
|
||||
assert!(manifest_with_port(
|
||||
" - host: 8333\n container: 8333\n auth: none\n auth_rationale: \" \"\n"
|
||||
)
|
||||
.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn port_auth_none_with_a_rationale_parses() {
|
||||
let manifest = manifest_with_port(
|
||||
" - host: 8333\n container: 8333\n auth: none\n auth_rationale: Bitcoin p2p gossip\n",
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(manifest.app.ports[0].auth, Some(PortAuth::None));
|
||||
assert_eq!(
|
||||
manifest.app.ports[0].auth_rationale.as_deref(),
|
||||
Some("Bitcoin p2p gossip")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rationale_without_auth_none_is_rejected() {
|
||||
// Catches the author who wrote the justification but forgot the
|
||||
// `auth: none` line: the port stays gated, and shipping it silently
|
||||
// would leave them believing they had an exemption they never got.
|
||||
let err = manifest_with_port(
|
||||
" - host: 8080\n container: 80\n auth_rationale: I meant to exempt this\n",
|
||||
)
|
||||
.expect_err("a rationale on a gated port must be rejected");
|
||||
assert!(err.to_string().contains("no effect"), "got: {err}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hooks_reject_empty_exec() {
|
||||
let yaml = "app:\n id: a\n name: a\n version: 1.0.0\n container:\n image: x:y\n hooks:\n post_install:\n - exec: []\n";
|
||||
|
||||
@@ -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,
|
||||
@@ -330,6 +366,7 @@ impl PodmanClient {
|
||||
}
|
||||
|
||||
let mut mounts = Vec::new();
|
||||
let mut named_volumes = Vec::new();
|
||||
for volume in &manifest.app.volumes {
|
||||
if volume.volume_type == "tmpfs" {
|
||||
let options: Vec<String> = volume
|
||||
@@ -346,6 +383,19 @@ impl PodmanClient {
|
||||
"type": "tmpfs",
|
||||
"options": options,
|
||||
}));
|
||||
} else if volume.volume_type == "volume" {
|
||||
// Named podman volume. The libpod create spec carries these in
|
||||
// the separate `volumes` field ({Name, Dest, Options}), NOT in
|
||||
// `mounts`: sending one as a bind mount makes the API treat
|
||||
// the bare volume name as a host path and the create fails —
|
||||
// which left indeedhub-postgres/-minio permanently absent on
|
||||
// legacy-path nodes (the reconciler removed the old container
|
||||
// for drift, then could never create its replacement).
|
||||
named_volumes.push(serde_json::json!({
|
||||
"Name": volume.source,
|
||||
"Dest": volume.target,
|
||||
"Options": volume.options,
|
||||
}));
|
||||
} else {
|
||||
mounts.push(serde_json::json!({
|
||||
"destination": volume.target,
|
||||
@@ -428,6 +478,7 @@ impl PodmanClient {
|
||||
"image": image_ref,
|
||||
"portmappings": port_mappings,
|
||||
"mounts": mounts,
|
||||
"volumes": named_volumes,
|
||||
"env": env_map,
|
||||
"secret_env": secret_env_map,
|
||||
"labels": labels_map,
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
server {
|
||||
listen 50002;
|
||||
# Loopback ONLY. This container is host-networked, so this nginx binds the
|
||||
# HOST's address directly — `listen 50002;` meant every interface, and the
|
||||
# app gate could never stand in front of it (there is no podman publish to
|
||||
# pin, and the manifest declared no port, so the gate neither protected it
|
||||
# nor reported it — it served this page to anyone who asked, on LAN,
|
||||
# Tailscale and the mesh alike). Binding loopback lets the daemon claim the
|
||||
# external addresses and authenticate them; see appgate::listener.
|
||||
listen 127.0.0.1:50002;
|
||||
server_name _;
|
||||
|
||||
root /usr/share/nginx/html;
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user