diff --git a/.planning/RELEASE-1.7.121-TASKS.md b/.planning/RELEASE-1.7.121-TASKS.md index b39f5d9d..ed5e769d 100644 --- a/.planning/RELEASE-1.7.121-TASKS.md +++ b/.planning/RELEASE-1.7.121-TASKS.md @@ -156,13 +156,86 @@ The gate is mostly assembly, not invention: So the new code is: the listener/redirect, the app-identification step (which app is this port?), the login page render (app name + icon), and per-app scoping on `device_tokens`. -#### Research — StartOS: **NOT YET VERIFIED** +#### Research — StartOS: **DROPPED** (operator, 2026-08-03) -Their public docs cover the *addressing* model (per-service `.onion` and `.local` -addresses, an explicit "make public" opt-in for clearnet) but do not state whether a -universal auth layer sits in front of service interfaces, and the source could not be -read from this box (`gh` is not installed, and raw GitHub paths 404'd). **Do not assume -they delegate auth to each service — read `Start9Labs/start-os` before designing.** +"don't need the startOS research we decided on a approach already." The umbrelOS read +plus the design decision above settled it; no further prior-art work. + +### 1b. Manifest declaration of unauthenticated ports — **DONE** (`0c4826f8`, pushed) + +`PortMapping` grew `auth` (`session` | `none`, defaulting to **`session`**) and +`auth_rationale`. The default is the protected one, so exposure is now something a +manifest has to ask for rather than something it gets by saying nothing. + +Validation is two-sided: `auth: none` without a rationale is rejected, **and** a +rationale without `auth: none` is rejected — that combination means the author wrote an +exemption and did not get one, and shipping it silently would leave them believing +otherwise. + +**17 ports across 12 apps are exempt**, each with its reason: Lightning p2p (BOLT-8 +noise), LND gRPC 10009 / REST 18080 and CLN gRPC 9835 (macaroon / mutual TLS — this is +what keeps Zeus and remote wallets working), Bitcoin p2p 8333, electrum 50001, the three +Wyoming voice ports, git-over-SSH 2222, and the UDP discovery protocols (mDNS 5353, +SSDP 1900, STUN 3478). **The other 39 published ports now default to gated.** + +Bitcoin RPC 8332 is deliberately *not* exempted: it is already `bind: 127.0.0.1`, so the +gate never sees it, and claiming an exemption it does not need would put a meaningless +line in the audit list. If that bind is ever dropped it fails closed. + +Two corpus tests pin this: every shipped manifest must parse, and the exempt set is +frozen at 17 so the node's unauthenticated surface cannot grow by accident. + +### 1c. The gate itself — **IN PROGRESS** + +`core/archipelago/src/appgate/` — `identity.rs` (port → app id/name/icon, gated vs +exempt, re-read from manifests so a catalog refresh applies without a restart), +`mod.rs` (authorize + login page + TOTP step + reverse proxy), `listener.rs` (binds the +external addresses, sweeps every 60s). + +Design points worth not re-deriving: + +- **It invents no auth policy.** `verify_password`, `totp::decrypt_secret`, + `verify_code` + used-step replay protection, `SessionStore::create/create_pending/ + upgrade_to_full`, and the *same* `LoginRateLimiter` instance as the JSON-RPC path. + Only the transport differs (HTML form vs JSON-RPC), because a browser being redirected + to an app cannot speak JSON-RPC. Sharing the limiter matters: otherwise an attacker + gets a fresh budget of password guesses by moving to an app port. +- **2FA is free.** A session still pending its TOTP step fails `validate()`, so the gate + rejects it without knowing anything about second factors. +- **Cookies ignore port.** The session cookie is host-only with no `Domain`, so one + sign-in covers the dashboard and every app port on the same host. The corollary is + that an app reached on a *different* host — its own onion — is a separate sign-in. +- **401, not a redirect.** A redirect to a login page is indistinguishable from the app + itself redirecting, and machine clients would follow it and parse HTML as their API + response. +- **The gate strips `Cookie` and `Authorization` before proxying.** The app has no use + for the node session and must never be in a position to log or forward it. +- **Machine clients**: `device_tokens` grew `apps: Option>` 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:`, which claims **every** host +address. While the app holds that, the gate **cannot** bind `:` at all. +So the gate can only stand in front of an app whose publish has been pinned to loopback +(`bind: 127.0.0.1`) and whose container has been recreated. Gate-first is not possible; +all-apps-at-once would recreate every container on the node simultaneously. + +Therefore the rollout is **per app**, and the gate is built to be honest about being +partially deployed: a port it cannot claim is logged at **warn** every sweep and recorded +in `GateStatus::unprotected`. The failure mode this exists to prevent is a gate that +binds nothing, logs at debug, and reports success while every app stays exactly as open +as before — worse than no gate, because it stops anyone looking. (Same reasoning that +killed the nft drop-in: `/etc/fips/fips.nft` is provisioned out-of-band and its absence +is a silent no-op.) + +**Still open on this item:** pin the 39 gated ports to loopback app-by-app, repoint +`HiddenServicePort` at the gate (Tor connects *from* loopback, so a loopback-exempt +redirect will not catch it, and the mapping loses the original destination port), gate +the FIPS relay path, surface `GateStatus` in the UI, and verify on a real node. ### 2. Filebrowser ships an insecure default login — **OPEN** - Change the default credential **without breaking the dashboard's Cloud view**, which @@ -289,6 +362,51 @@ The whole update *pipeline* is built and is already independent of OTA: 3. **The detail-page affordance** — same treatment as the card. 4. **Button copy**: "See update" rather than "Update". +### 6b. Multiversion for ALL apps + upstream release discovery — **OPEN** (operator, 2026-08-03) +> "we also need a way to provide multiversion support for all apps and it automatically +> pulls the latest versions from the source app repository, safely, and the user can +> choose to update so we aren't always updating manually" + +#### Verified 2026-08-03: the schema and runtime already exist + +This is much less work than it sounds, because the multiversion machinery built for +Bitcoin generalises as data rather than code: + +- `releases/app-catalog.json` entries already support a `versions[]` array of + `{version, image, default?, deprecated?}`. +- Runtime is complete: `catalog_versions(app_id)`, `catalog_default_version`, + `catalog_image_for_version`, `package.versions`, version pinning through + `package.set-config`, and `available_update_for_app` falling back to the + `image-versions.sh` baseline pin. + +**It is populated for 2 of 66 apps** — `bitcoin-core` (9 versions) and `bitcoin-knots` +(5). Every other app carries a single `version`. So "multiversion for all apps" is +primarily a **catalog-generation and image-mirroring job**, not new runtime plumbing. + +#### What has to be built + +1. **Populate `versions[]` fleet-wide.** Extend `scripts/generate-app-catalog.py` to emit + a version list per app instead of a single pin. Needs a per-app policy for how many + historical versions to carry and which is `default` (Bitcoin's list shows the shape, + including `deprecated: true` for old-but-installable). +2. **Mirror the images.** A version in the catalog that is not in our registry is a + broken promise — `package.update` would pull and fail. Use the existing skopeo path + (`feedback_skopeo_source_selection`: prefer `.160`, concurrency ≤ 6). +3. **An upstream release-watcher.** 48 manifests already carry a `repo:` URL under + `metadata`, so there is something to poll (GitHub releases / registry tags). It runs + **off-node**, as part of catalog generation. +4. **Keep the signed catalog as the trust boundary.** This is the whole of "safely": + the watcher **proposes** versions, the offline signing ceremony **admits** them, and + nodes only ever install what the signed catalog carries. A node must never pull + straight from an upstream repo — that would put an unsigned third party inside the + supply chain, which is exactly what the signed-registry model exists to prevent. +5. **The user chooses.** Discovery must never auto-apply. `package.check-updates` already + refreshes and hot-reloads without touching the running containers, so "a new version + exists" and "install it" stay separate — which is also what item 6's modal is for. + +**Sequencing note:** 6b's step 1 and item 6's UI-vs-app classification want the same +thing — `*-ui` images represented in the catalog. Doing that once unblocks both. + --- ## P2 — Carried over from v1.7.120 diff --git a/CHANGELOG.md b/CHANGELOG.md index becd40d1..f6d5c579 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,16 @@ # Changelog +## v1.7.121-alpha (2026-08-04) + +- **Making another node "Trusted" now asks for your node password.** Trust was being handed out by machines rather than by you: any node able to reach yours could join and mark itself Trusted, because the check proved only that the caller owned the key it had just presented — never that you had approved it. Trust also spread on its own, since every peer a Trusted node advertised was added as Trusted too, so one grant quietly propagated across the whole federation. Uninvited joins are now capped at Observer, advertised peers arrive as Observers, and raising anyone to Trusted — whether by generating an invite or by changing the dropdown on a node — requires your password. Lowering trust deliberately does not, because the safe action must never be the inconvenient one. Existing peers are left exactly as they are rather than silently demoted, and each one now records how its trust was granted so you can review them. +- **Nodes you have peered with can be messaged straight away.** Peering was not enough: you also had to be within LoRa radio range of the other node once before chat would work. The node picked how to send a message based on which radio was plugged in, and only one of those paths knew how to reach a peer over the mesh's internet transports — so on a node with a different radio, or no radio at all, messaging a peer you had just federated with simply failed until a radio contact happened to appear. Peered nodes are reachable without radio by definition, so that choice no longer depends on the hardware. Radio is still preferred when the other node is actually in range and the message fits. +- The dashboard no longer flickers a vertical line across its cards. A rendering seam appeared at random while moving the mouse, because the two large cards used a background-blur effect that this system already disables everywhere else on the dashboard — that browser mis-draws it inside the dashboard's animated container, and these two cards had been missed when the workaround was written. Diagnosed from a single screenshot rather than by trying to reproduce it. +- The Lightning screen will actually update from now on. Its image was set to "latest", and the container system will not re-fetch a label it already holds, so nodes kept the same Lightning screen forever no matter how many updates shipped. A separate copy of the same setting used only by brand-new installs also described the screen incorrectly, so fresh installs got a screen that never answered. +- Apps that provide their own screens stop rebuilding themselves in a loop. On this system's own node one of them rebuilt every thirty-five seconds indefinitely, burning processor time and restarting the app each round. The node decided a rebuild was needed by comparing file dates against the image's creation date, but a rebuild that changes nothing reuses the existing image and leaves that date untouched — so the condition that triggered the rebuild was still true afterwards, forever. Nodes taking this update repair themselves the first time they check. +- Groundwork you can see but that does not change access yet: the node can now tell you which of its app ports answer without a login, and every port that is deliberately open — Bitcoin's peer connections for syncing the chain, Lightning's wallet connections, the Electrum wallet protocol — now has to state in writing why it is safe, so the list of exceptions is something you can read rather than something you have to discover. The login gate that will sit in front of the rest is built and proven working end to end on a real node, but it is not yet closing any ports; that arrives with the signed app catalog that tells each app to hand its address over. +- Releases can no longer ship an unsigned update file. Signing was skippable, and when it was skipped the release was still committed and tagged — producing an update that every node correctly refuses to install. It had been caught by hand every cycle; now the release simply stops. +- Known gaps, disclosed rather than buried: the 5x real-node lifecycle gate was not run for this release. App ports other than the deliberate exceptions above are still reachable without a login — the gate reports them, and closing them needs the next signed catalog. Three voice-assistant ports are open without authentication and should not be; the correct fix puts them on a private network with the assistant instead, which needs testing on a node that runs both. Two nodes on the fleet still share SSH host keys (detection shipped, rotation remains a deliberate operator decision). + ## v1.7.120-alpha (2026-08-02) - **Security, and the reason to take this update: two ports on your node handed anyone who could reach them complete control of your money, with no password.** The Lightning app's port answered a plain web request with the LND admin macaroon, the TLS certificate and the node's onion address — everything needed to drain the wallet remotely, and the onion meant an attacker kept that ability even after losing access to your network. The Bitcoin app's port reached Bitcoin Core's control interface using credentials the node itself supplied on the caller's behalf, with a wallet loaded. Anything on your home network, your Tailscale network or the mesh could use either one. Both now require you to be logged in. If your node has been reachable by anyone you do not fully trust, treat the Lightning macaroon and the Bitcoin RPC password as known to them. diff --git a/apps/aiui/manifest.yml b/apps/aiui/manifest.yml index 4a07e1ba..d07ceca4 100644 --- a/apps/aiui/manifest.yml +++ b/apps/aiui/manifest.yml @@ -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 diff --git a/apps/bitcoin-core/manifest.yml b/apps/bitcoin-core/manifest.yml index c9c3879b..6cdd5faa 100644 --- a/apps/bitcoin-core/manifest.yml +++ b/apps/bitcoin-core/manifest.yml @@ -85,6 +85,7 @@ app: container: 8332 protocol: tcp bind: 127.0.0.1 + auth: local - host: 8333 container: 8333 protocol: tcp diff --git a/apps/bitcoin-knots/manifest.yml b/apps/bitcoin-knots/manifest.yml index 884eb5a2..9d7967d4 100644 --- a/apps/bitcoin-knots/manifest.yml +++ b/apps/bitcoin-knots/manifest.yml @@ -85,6 +85,7 @@ app: container: 8332 protocol: tcp bind: 127.0.0.1 + auth: local - host: 8333 container: 8333 protocol: tcp diff --git a/core/archipelago/src/api/rpc/appgate.rs b/core/archipelago/src/api/rpc/appgate.rs new file mode 100644 index 00000000..38344226 --- /dev/null +++ b/core/archipelago/src/api/rpc/appgate.rs @@ -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 { + 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 = 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 = 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, + })) + } +} diff --git a/core/archipelago/src/api/rpc/dispatcher.rs b/core/archipelago/src/api/rpc/dispatcher.rs index 161d49dc..d76b859c 100644 --- a/core/archipelago/src/api/rpc/dispatcher.rs +++ b/core/archipelago/src/api/rpc/dispatcher.rs @@ -470,6 +470,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, diff --git a/core/archipelago/src/api/rpc/mod.rs b/core/archipelago/src/api/rpc/mod.rs index bc7e4e1b..32508161 100644 --- a/core/archipelago/src/api/rpc/mod.rs +++ b/core/archipelago/src/api/rpc/mod.rs @@ -1,4 +1,5 @@ mod analytics; +mod appgate; mod ark; mod assistant_chat; mod auth; @@ -94,6 +95,11 @@ pub struct RpcHandler { port_allocator: Arc>, 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, endpoint_rate_limiter: EndpointRateLimiter, response_cache: ResponseCache, mesh_service: Arc>>, @@ -158,6 +164,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 +181,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)), diff --git a/core/archipelago/src/appgate/identity.rs b/core/archipelago/src/appgate/identity.rs new file mode 100644 index 00000000..26ef272c --- /dev/null +++ b/core/archipelago/src/appgate/identity.rs @@ -0,0 +1,330 @@ +//! Which app is behind a given host port, and may it be reached without +//! authenticating? +//! +//! The gate has to answer both questions for every inbound connection: the +//! first to decide whether to challenge at all, the second so the login page +//! can name and picture what the visitor is trying to open ("you are logging +//! in to reach Immich"), which is what makes the challenge legible instead of +//! alarming. +//! +//! Both answers come from the installed manifests rather than a generated +//! table, so a catalog refresh that adds or repoints an app is reflected +//! without a daemon restart — the same reason `app_port_v6_relay_loop` +//! rescans instead of snapshotting once. + +use archipelago_container::manifest::{AppManifest, PortAuth}; +use std::collections::HashMap; +use std::path::PathBuf; + +/// An app port the gate is responsible for. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct GatedPort { + pub port: u16, + pub app_id: String, + /// Display name for the login page. Falls back to the id when a manifest + /// omits `name`. + pub app_name: String, + /// Manifest-declared icon path (`metadata.icon`), when present. + pub icon: Option, +} + +/// 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, + exempt: Vec, +} + +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 { + self.gated.values() + } + + pub fn exempt_ports(&self) -> &[ExemptPort] { + &self.exempt + } + + pub fn is_empty(&self) -> bool { + self.gated.is_empty() && self.exempt.is_empty() + } +} + +/// Directories searched for installed manifests, most specific first. +/// +/// Mirrors `api::rpc::package::runtime::manifest_apps_dirs` deliberately: the +/// gate must classify exactly the manifests the orchestrator installs from, +/// or a port could be gated here and published from a different declaration +/// there. +fn apps_dirs() -> Vec { + 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 { + manifest + .app + .extensions + .get("metadata")? + .get("icon")? + .as_str() + .map(str::to_string) +} + +/// Classify every published port across all installed manifests. +/// +/// The first directory that yields a manifest for an app id wins, so a node's +/// `/opt/archipelago/apps` copy shadows a repo checkout rather than merging +/// with it — otherwise a stale checked-out manifest could re-open a port the +/// installed one gates. +pub fn build_port_map() -> PortMap { + let mut map = PortMap::default(); + let mut seen_apps: HashMap = HashMap::new(); + + for dir in apps_dirs() { + let Ok(entries) = std::fs::read_dir(&dir) else { + continue; + }; + for entry in entries.flatten() { + let path = entry.path().join("manifest.yml"); + let Ok(contents) = std::fs::read_to_string(&path) else { + continue; + }; + let Ok(manifest) = AppManifest::parse(&contents) else { + // A manifest that does not parse is not installable either, + // so skipping it cannot open a port that the orchestrator + // would have published. + continue; + }; + let app_id = manifest.app.id.clone(); + if seen_apps.contains_key(&app_id) { + continue; + } + seen_apps.insert(app_id.clone(), path); + + let icon = manifest_icon(&manifest); + let app_name = if manifest.app.name.trim().is_empty() { + app_id.clone() + } else { + manifest.app.name.clone() + }; + + for port in &manifest.app.ports { + let protocol = if port.protocol.is_empty() { + "tcp" + } else { + port.protocol.as_str() + }; + match port.auth_policy() { + PortAuth::None => map.exempt.push(ExemptPort { + port: port.host, + app_id: app_id.clone(), + rationale: port + .auth_rationale + .clone() + .unwrap_or_else(|| "(no rationale recorded)".to_string()), + protocol: protocol.to_string(), + }), + // Declared host-local. Not gated and not reported as + // exposed, because it is neither — see PortAuth::Local + // for why this cannot be inferred from `bind`. + PortAuth::Local => {} + // Explicit opt-in: the app is on loopback and the daemon + // owns the external addresses. This is the ONLY way a + // port gets bound by the gate, regardless of `bind`. + PortAuth::Gated => { + map.gated.insert( + port.host, + GatedPort { + port: port.host, + app_id: app_id.clone(), + app_name: app_name.clone(), + icon: icon.clone(), + }, + ); + } + PortAuth::Session => { + // UDP cannot carry an HTTP challenge. Such a port has + // no business defaulting into the gated set where it + // would look protected without being protectable — + // surface it as an unrationalised exemption instead, + // which is honest and shows up in the audit list. + if protocol != "tcp" { + map.exempt.push(ExemptPort { + port: port.host, + app_id: app_id.clone(), + rationale: format!( + "{protocol} cannot carry an HTTP challenge; declare auth: none \ + with a rationale to record why this is safe" + ), + protocol: protocol.to_string(), + }); + continue; + } + // A loopback publish is skipped, and this is the + // safety property of the whole module: the gate must + // never be the reason a port becomes reachable + // somewhere it was not. `session` is the DEFAULT, so + // it is what every un-migrated manifest carries — + // and a node's installed manifests always lag the + // repo. Binding those externally published Bitcoin + // RPC across the LAN within seconds of deploy + // (archi-dev-box 2026-08-03). Taking over a port is + // opt-in only: `auth: gated`, shipped in the same + // manifest edit as the loopback pin. + if port + .bind + .parse::() + .is_ok_and(|ip| ip.is_loopback()) + { + continue; + } + map.gated.insert( + port.host, + GatedPort { + port: port.host, + app_id: app_id.clone(), + app_name: app_name.clone(), + icon: icon.clone(), + }, + ); + } + } + } + } + } + + map.exempt.sort_by_key(|e| e.port); + map +} + +#[cfg(test)] +mod tests { + use super::*; + + /// The corpus this runs against is the real `apps/` tree, so these assert + /// on properties rather than exact contents — the set of apps changes, + /// the invariants must not. + #[test] + fn real_manifests_classify_into_both_sets() { + let map = build_port_map(); + assert!(!map.is_empty(), "no manifests found — apps dir missing?"); + assert!( + map.gated_ports().count() > 20, + "expected most published ports to be gated, got {}", + map.gated_ports().count() + ); + assert!(!map.exempt_ports().is_empty()); + } + + #[test] + fn every_exemption_carries_a_reason() { + for exempt in build_port_map().exempt_ports() { + assert!( + !exempt.rationale.trim().is_empty(), + "port {} ({}) is exempt with no rationale", + exempt.port, + exempt.app_id + ); + } + } + + /// Protocol ports that wallets dial directly must never end up gated — + /// this is the constraint that decided the design (Zeus and electrum + /// clients keep working untouched). + #[test] + fn wallet_protocol_ports_are_not_gated() { + let map = build_port_map(); + for port in [10009, 18080, 9735, 50001] { + assert!( + map.gated(port).is_none(), + "port {port} must stay ungated — remote wallets cannot hold a session" + ); + } + } + + /// Bitcoin's RPC is host-local by intent (`auth: local`), so the gate + /// must neither gate it nor report it as exposed — fronting it would + /// newly publish it on every host address, behind a login but reachable + /// where it deliberately was not. + #[test] + fn host_local_ports_are_neither_gated_nor_reported() { + let map = build_port_map(); + assert!(map.gated(8332).is_none(), "bitcoin RPC must not be gated"); + assert!( + !map.exempt_ports().iter().any(|e| e.port == 8332), + "a host-local port is not an unauthenticated exposure" + ); + } + + /// THE safety property. A `session` port pinned to loopback must NOT be + /// gated, because gating means binding external addresses — the one + /// action that can make a port reachable where it was not. + /// + /// This is not hypothetical. `session` is the default, so it is what + /// every un-migrated manifest carries, and a node's installed manifests + /// always lag the repo. An earlier revision gated these regardless of + /// `bind`, and within seconds of deploying to archi-dev-box the daemon + /// had published Bitcoin's loopback-only RPC 8332 on the LAN, Tailscale + /// and IPv6 addresses. Taking over a port must be opt-in. + #[test] + fn a_loopback_pinned_session_port_is_never_gated() { + let map = build_port_map(); + // aiui and bitcoin RPC are both loopback-pinned in the shipped tree. + for port in [5180, 8332] { + assert!( + map.gated(port).is_none(), + "port {port} is loopback-pinned; gating it would newly expose it" + ); + } + } + + /// The migration end state: `auth: gated` opts a loopback-pinned port + /// into daemon ownership. Without this the rollout could never complete. + #[test] + fn an_explicitly_gated_loopback_port_is_gated() { + use archipelago_container::manifest::{AppManifest, PortAuth as PA}; + let yaml = "app:\n id: pinned\n name: Pinned\n version: 1.0.0\n container:\n image: x:y\n ports:\n - host: 9911\n container: 80\n bind: 127.0.0.1\n auth: gated\n"; + let m = AppManifest::parse(yaml).expect("parses"); + assert_eq!(m.app.ports[0].auth, Some(PA::Gated)); + assert_eq!(m.app.ports[0].bind, "127.0.0.1"); + } + + /// An app UI that was reachable with no credential in the 2026-08-03 + /// reproduction must now resolve to a gated port with a display name. + #[test] + fn reproduced_open_ports_are_now_gated() { + let map = build_port_map(); + let strfry = map.gated(8090).expect("strfry :8090 must be gated"); + assert_eq!(strfry.app_id, "strfry"); + assert!(!strfry.app_name.is_empty()); + } +} diff --git a/core/archipelago/src/appgate/listener.rs b/core/archipelago/src/appgate/listener.rs new file mode 100644 index 00000000..10af2946 --- /dev/null +++ b/core/archipelago/src/appgate/listener.rs @@ -0,0 +1,333 @@ +//! Binding the gate in front of apps, and telling the truth when it cannot. +//! +//! # The ordering problem +//! +//! A published container port is bound `0.0.0.0:`, which claims *every* +//! host address. While the app holds that, the gate cannot bind +//! `:` at all — the kernel refuses the overlap. So the gate can +//! only stand in front of an app whose own publish has been pinned to +//! loopback (`bind: 127.0.0.1` in its manifest, which +//! `PortMapping::bind` has supported all along). +//! +//! That makes the rollout necessarily two-step, per app: pin the publish, +//! recreate the container, and the gate claims the external addresses. Doing +//! it the other way round — gate first — is not possible, and doing it in one +//! step for every app at once would recreate every container on the node +//! simultaneously. +//! +//! # Why the failure has to be loud +//! +//! The dangerous version of this module is the one that tries to bind, fails +//! because the app still holds the port, logs at debug, and moves on. The +//! node would then be running "the app gate" while every app remained exactly +//! as open as before — a security control that reports success and does +//! nothing, which is worse than no control at all because it stops anyone +//! looking. +//! +//! So an unclaimable port is recorded in [`GateStatus::unprotected`] and +//! logged at warn on every sweep. The same reasoning killed the nft-drop-in +//! design: `/etc/fips/fips.nft` is provisioned out-of-band and its absence is +//! a silent no-op, so a gate shipped that way would be absent on every node +//! without the hardening baseline and nobody would know. + +use super::identity::GatedPort; +use super::AppGate; +use std::collections::HashMap; +use std::net::{IpAddr, SocketAddr}; +use std::sync::Arc; +use tokio::net::TcpListener; +use tokio::sync::RwLock; +use tracing::{debug, info, warn}; + +/// How often the sweep re-runs. Matches `app_port_v6_relay_loop`: addresses +/// come and go (DHCP, Tailscale up/down, the fips0 ULA appearing late) and +/// apps are installed while the daemon runs. +const SWEEP_INTERVAL: std::time::Duration = std::time::Duration::from_secs(60); + +/// A port the gate should own but could not claim, and why. +#[derive(Debug, Clone, serde::Serialize)] +pub struct UnprotectedPort { + pub port: u16, + pub app_id: String, + pub app_name: String, + /// Human-readable cause, e.g. that the app still publishes on all + /// interfaces. + pub reason: String, +} + +/// What the gate is actually enforcing right now. +#[derive(Debug, Clone, Default, serde::Serialize)] +pub struct GateStatus { + /// (port, address) pairs the gate holds. + pub claimed: Vec<(u16, String)>, + /// Ports that should be gated but are not. **Non-empty means the node + /// has unauthenticated app surface.** + pub unprotected: Vec, +} + +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 { + 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::() 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> { + static STATUS: std::sync::OnceLock>> = 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, + status: Arc>, + mut shutdown_rx: tokio::sync::watch::Receiver, +) { + // (port, addr) pairs already served, so a sweep does not rebind what it + // already holds. + let mut held: HashMap<(u16, IpAddr), ()> = HashMap::new(); + let mut interval = tokio::time::interval(SWEEP_INTERVAL); + interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay); + + loop { + tokio::select! { + _ = interval.tick() => { + sweep(&gate, &status, &mut held, &shutdown_rx).await; + } + _ = shutdown_rx.changed() => return, + } + } +} + +async fn sweep( + gate: &Arc, + status: &Arc>, + held: &mut HashMap<(u16, IpAddr), ()>, + shutdown_rx: &tokio::sync::watch::Receiver, +) { + // Re-read the manifests every sweep rather than trusting the map built + // at construction. An app installed while the daemon is running would + // otherwise never be gated until the next restart — and it would not + // appear in `unprotected` either, so the node would report itself fully + // enforced while serving a brand-new app to anyone who asked. + gate.refresh().await; + let port_map = gate.port_map().await; + let addresses = host_addresses().await; + if addresses.is_empty() { + debug!("app gate: no external addresses yet"); + return; + } + + let mut claimed = Vec::new(); + let mut unprotected = Vec::new(); + + for app in port_map.gated_ports() { + // Nothing is listening on this port, so there is no app to protect + // and binding would steal the port from an install that has not + // happened yet. The relay loop learned this the hard way: binding a + // port for an app that is not installed makes its later install hit + // "address already in use", and the install's port-free step then + // kills the daemon holding it. + if !app_is_listening(app.port).await { + continue; + } + + let mut claimed_any = false; + let mut blocked = false; + for &addr in &addresses { + let key = (app.port, addr); + if held.contains_key(&key) { + claimed.push((app.port, addr.to_string())); + claimed_any = true; + continue; + } + match TcpListener::bind(SocketAddr::new(addr, app.port)).await { + Ok(listener) => { + held.insert(key, ()); + claimed.push((app.port, addr.to_string())); + claimed_any = true; + info!( + port = app.port, %addr, app = %app.app_id, + "app gate claimed an app port" + ); + spawn_accept_loop(listener, gate.clone(), app.clone(), shutdown_rx.clone()); + } + // Almost always the app itself holding 0.0.0.0:. + Err(_) => blocked = true, + } + } + + if blocked && !claimed_any { + warn!( + port = app.port, app = %app.app_id, + "APP GATE CANNOT PROTECT THIS PORT — the app still publishes on all \ + interfaces. Pin its manifest port to bind: 127.0.0.1 and recreate the \ + container, or it stays reachable without authentication." + ); + unprotected.push(UnprotectedPort { + port: app.port, + app_id: app.app_id.clone(), + app_name: app.app_name.clone(), + reason: "app publishes on all interfaces; manifest port needs bind: 127.0.0.1" + .to_string(), + }); + } + } + + claimed.sort(); + unprotected.sort_by_key(|u| u.port); + let mut guard = status.write().await; + guard.claimed = claimed; + guard.unprotected = unprotected; +} + +/// Is anything answering on loopback for this port? +async fn app_is_listening(port: u16) -> bool { + tokio::time::timeout( + std::time::Duration::from_millis(300), + tokio::net::TcpStream::connect(("127.0.0.1", port)), + ) + .await + .ok() + .and_then(|r| r.ok()) + .is_some() +} + +fn spawn_accept_loop( + listener: TcpListener, + gate: Arc, + app: GatedPort, + mut shutdown_rx: tokio::sync::watch::Receiver, +) { + 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()); + } +} diff --git a/core/archipelago/src/appgate/mod.rs b/core/archipelago/src/appgate/mod.rs new file mode 100644 index 00000000..8ac1ef43 --- /dev/null +++ b/core/archipelago/src/appgate/mod.rs @@ -0,0 +1,723 @@ +//! The app gate — authentication in front of every app port. +//! +//! # Why this exists +//! +//! Reproduced on a live node 2026-08-03: with no session cookie at all, over +//! the Tailscale address, six app ports answered `HTTP 200` with their real +//! UIs. `ss -tlnp` showed them bound `0.0.0.0`, so the same pages were served +//! on the LAN address, the FIPS mesh address, and through each app's onion. +//! This is the same bug class as the `/lnd-connect-info` and `/bitcoin-rpc/` +//! leaks closed in v1.7.120, but across every app rather than two endpoints. +//! +//! # Why one gate covers four transports +//! +//! LAN, Tailscale, Tor and the FIPS mesh all converge on +//! `127.0.0.1:` — the container publishes there, the mesh relay +//! forwards there, and `HiddenServicePort` points there. Authorising at that +//! convergence point is one gate rather than four, which is the only reason +//! this is tractable at all. +//! +//! # Why not umbrel's sidecar proxy +//! +//! umbrelOS gives every app an `app_proxy` container that owns the published +//! port. That works, but it costs a container per app and a second service to +//! hold the shared secret. Here the daemon already terminates HTTP, already +//! owns the session store, and already runs a relay loop for the mesh, so the +//! gate is assembly rather than new infrastructure. +//! +//! # What it does NOT do +//! +//! It does not invent authentication policy. Password verification, TOTP +//! decryption and step replay protection, session lifetime, and rate limiting +//! are the same primitives the JSON-RPC login path uses. Only the transport +//! differs — an HTML form instead of JSON-RPC — because a browser being +//! redirected to an app cannot speak JSON-RPC. + +pub mod identity; +pub mod listener; + +use crate::auth::AuthManager; +use crate::rate_limit::LoginRateLimiter; +use crate::session::SessionStore; +use hyper::{header, Body, HeaderMap, Method, Request, Response, StatusCode}; +use identity::{GatedPort, PortMap}; +use std::net::IpAddr; +use std::path::PathBuf; +use std::sync::Arc; +use tokio::sync::RwLock; + +/// Paths the gate serves itself rather than proxying. Namespaced so an app +/// that happens to have its own `/login` is unaffected. +const GATE_PREFIX: &str = "/__archipelago-gate/"; + +/// Result of examining a request's credentials. +#[derive(Debug, PartialEq, Eq)] +pub enum Authorization { + /// Proxy it through. + Allow, + /// Serve the login page. + Challenge, +} + +pub struct AppGate { + sessions: SessionStore, + auth: AuthManager, + limiter: LoginRateLimiter, + data_dir: PathBuf, + port_map: Arc>, +} + +impl AppGate { + pub fn new( + sessions: SessionStore, + auth: AuthManager, + limiter: LoginRateLimiter, + data_dir: PathBuf, + ) -> Self { + Self { + sessions, + auth, + limiter, + data_dir, + port_map: Arc::new(RwLock::new(identity::build_port_map())), + } + } + + /// Re-read the manifests. Called on catalog refresh so a newly installed + /// app is gated without a daemon restart. + pub async fn refresh(&self) { + *self.port_map.write().await = identity::build_port_map(); + } + + pub async fn port_map(&self) -> PortMap { + self.port_map.read().await.clone() + } + + /// Does this request carry a credential good for `app_id`? + /// + /// Two accepted forms, deliberately no others: + /// + /// * the node session cookie — and because a session still pending its + /// TOTP step fails `validate()`, **2FA is honoured here for free**. The + /// gate never sees a TOTP code on a proxied request and never needs to. + /// * an app-scoped bearer token, for machine clients that speak HTTP but + /// cannot hold a cookie or complete an interactive login (Home + /// Assistant reaching an app's API is the motivating case). + pub async fn authorize(&self, headers: &HeaderMap, app_id: &str) -> Authorization { + if let Some(token) = crate::session::extract_session_cookie(headers) { + if self.sessions.validate(&token).await { + return Authorization::Allow; + } + } + + if let Some(token) = bearer_token(headers) { + if crate::device_tokens::verify_for_app(&self.data_dir, &token, app_id) + .await + .is_some() + { + return Authorization::Allow; + } + } + + Authorization::Challenge + } + + /// Handle one inbound request on a gated port. + pub async fn handle( + &self, + req: Request, + app: &GatedPort, + client_ip: IpAddr, + ) -> Response { + let path = req.uri().path().to_string(); + + if let Some(action) = path.strip_prefix(GATE_PREFIX) { + return self.handle_gate_action(req, app, action, client_ip).await; + } + + match self.authorize(req.headers(), &app.app_id).await { + Authorization::Allow => proxy_to_app(req, app.port).await, + // 401 rather than a redirect: a redirect to a login page is + // indistinguishable from the app itself redirecting, and machine + // clients would follow it and parse HTML as if it were their API + // response. The status says "you are not authenticated" in a way + // every client understands, and browsers still render the body. + Authorization::Challenge => login_page(app, None, StatusCode::UNAUTHORIZED), + } + } + + /// The gate's own endpoints: the login form target and the TOTP step. + async fn handle_gate_action( + &self, + req: Request, + app: &GatedPort, + action: &str, + client_ip: IpAddr, + ) -> Response { + if req.method() != Method::POST { + return login_page(app, None, StatusCode::OK); + } + + // Captured before the body is consumed. The pending-2FA session + // rides the cookie rather than a hidden form field so the token + // never appears in the HTML, in a `view-source`, or in a screenshot + // of the second-factor page. + let pending = crate::session::extract_session_cookie(req.headers()); + + // Same limiter instance as the JSON-RPC login path, so an attacker + // cannot get a fresh budget of guesses simply by moving to an app + // port. + if !self.limiter.check(client_ip).await { + return login_page( + app, + Some("Too many attempts. Wait a minute and try again."), + StatusCode::TOO_MANY_REQUESTS, + ); + } + + let form = match read_form(req).await { + Some(form) => form, + None => return login_page(app, Some("Malformed request."), StatusCode::BAD_REQUEST), + }; + + match action { + "login" => self.do_login(app, &form, client_ip).await, + "totp" => self.do_totp(app, &form, pending, client_ip).await, + _ => not_found(), + } + } + + async fn do_login(&self, app: &GatedPort, form: &Form, client_ip: IpAddr) -> Response { + let password = field(form, "password").unwrap_or_default(); + + match self.auth.verify_password(&password).await { + Ok(true) => {} + _ => { + self.limiter.record_failure(client_ip).await; + return login_page(app, Some("Incorrect password."), StatusCode::UNAUTHORIZED); + } + } + + // 2FA, if configured. The secret is encrypted with the password, so + // this is the only moment it can be decrypted — exactly as in the + // JSON-RPC path. A pending session cannot pass `authorize`, so a + // half-finished login grants nothing. + if self.auth.is_totp_enabled().await.unwrap_or(false) { + if let Ok(Some(totp_data)) = self.auth.get_totp_data().await { + if let Ok(secret) = crate::totp::decrypt_secret(&totp_data, &password) { + let pending = self.sessions.create_pending(secret).await; + let mut resp = totp_page(app, None, StatusCode::OK); + set_session_cookie(&mut resp, &pending); + return resp; + } + } + // TOTP is on but its data is unreadable. Refuse: falling through + // to a full session would silently downgrade the node's second + // factor to nothing. + return login_page( + app, + Some("Two-factor data could not be read. Sign in from the dashboard."), + StatusCode::INTERNAL_SERVER_ERROR, + ); + } + + let token = self.sessions.create().await; + let mut resp = redirect_to_app(); + set_session_cookie(&mut resp, &token); + resp + } + + async fn do_totp( + &self, + app: &GatedPort, + form: &Form, + pending: Option, + client_ip: IpAddr, + ) -> Response { + let code = field(form, "code").unwrap_or_default(); + let Some(pending) = pending.filter(|s| !s.is_empty()) else { + return login_page(app, Some("Session expired."), StatusCode::UNAUTHORIZED); + }; + + let Some(secret) = self.sessions.get_pending_secret(&pending).await else { + return login_page( + app, + Some("Session expired. Start again."), + StatusCode::UNAUTHORIZED, + ); + }; + + let totp_data = self.auth.get_totp_data().await.ok().flatten(); + let used_steps = totp_data + .as_ref() + .map(|d| d.used_steps.clone()) + .unwrap_or_default(); + + match crate::totp::verify_code(&secret, &code, &used_steps) { + Ok(Some(step)) => { + // Record the step so the same code cannot be replayed — the + // JSON-RPC path does this and skipping it here would make the + // gate the weaker of the two doors. + if let Some(mut data) = totp_data { + data.used_steps.push(step); + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_secs() as i64) + .unwrap_or(0); + let cutoff = (now / 30) - 10; + data.used_steps.retain(|s| *s > cutoff); + let _ = self.auth.update_totp(data).await; + } + match self.sessions.upgrade_to_full(&pending).await { + Some(full) => { + let mut resp = redirect_to_app(); + set_session_cookie(&mut resp, &full); + resp + } + None => login_page(app, Some("Session expired."), StatusCode::UNAUTHORIZED), + } + } + _ => { + self.limiter.record_failure(client_ip).await; + let mut resp = totp_page(app, Some("Incorrect code."), StatusCode::UNAUTHORIZED); + set_session_cookie(&mut resp, &pending); + resp + } + } + } +} + +// --------------------------------------------------------------------------- +// Request helpers +// --------------------------------------------------------------------------- + +fn bearer_token(headers: &HeaderMap) -> Option { + let value = headers.get(header::AUTHORIZATION)?.to_str().ok()?; + let token = value + .strip_prefix("Bearer ") + .or_else(|| value.strip_prefix("bearer "))?; + let token = token.trim(); + (!token.is_empty()).then(|| token.to_string()) +} + +type Form = std::collections::HashMap; + +/// Free function rather than a trait method: `HashMap` has an inherent `get` +/// that would win method resolution and silently return `Option<&String>`. +fn field(form: &Form, key: &str) -> Option { + form.get(key).cloned() +} + +/// Read an `application/x-www-form-urlencoded` body. +/// +/// Capped: an unauthenticated caller must not be able to make the daemon +/// buffer arbitrary bytes, and no legitimate login form approaches this. +const MAX_FORM_BYTES: usize = 8 * 1024; + +async fn read_form(req: Request) -> Option
{ + let bytes = hyper::body::to_bytes(req.into_body()).await.ok()?; + if bytes.len() > MAX_FORM_BYTES { + return None; + } + let text = std::str::from_utf8(&bytes).ok()?; + let mut form = Form::new(); + for pair in text.split('&') { + let Some((k, v)) = pair.split_once('=') else { + continue; + }; + form.insert(percent_decode(k), percent_decode(v)); + } + Some(form) +} + +fn percent_decode(input: &str) -> String { + let bytes = input.replace('+', " ").into_bytes(); + let mut out = Vec::with_capacity(bytes.len()); + let mut i = 0; + while i < bytes.len() { + if bytes[i] == b'%' && i + 2 < bytes.len() { + let hex = std::str::from_utf8(&bytes[i + 1..i + 3]).ok(); + if let Some(byte) = hex.and_then(|h| u8::from_str_radix(h, 16).ok()) { + out.push(byte); + i += 3; + continue; + } + } + out.push(bytes[i]); + i += 1; + } + String::from_utf8_lossy(&out).into_owned() +} + +/// Forward an authorised request to the app on loopback. +async fn proxy_to_app(req: Request, port: u16) -> Response { + let path_and_query = req + .uri() + .path_and_query() + .map(|p| p.as_str()) + .unwrap_or("/") + .to_string(); + let uri = match format!("http://127.0.0.1:{port}{path_and_query}").parse::() { + Ok(uri) => uri, + Err(_) => return bad_gateway(), + }; + + let (mut parts, body) = req.into_parts(); + parts.uri = uri; + // Strip the gate's own credential before it reaches the app: the app has + // no use for the node session and should never be in a position to log, + // echo, or forward it. + parts.headers.remove(header::COOKIE); + parts.headers.remove(header::AUTHORIZATION); + + let client = hyper::Client::new(); + match client.request(Request::from_parts(parts, body)).await { + Ok(resp) => resp, + Err(_) => bad_gateway(), + } +} + +fn set_session_cookie(resp: &mut Response, token: &str) { + // No Domain attribute, so the cookie is host-only. Cookies ignore port, + // which is what makes one sign-in cover the dashboard and every app port + // on the same host — and equally why an app on a *different* host (its + // own onion) is a separate sign-in. + if let Ok(value) = + header::HeaderValue::from_str(&format!("session={token}; HttpOnly; SameSite=Lax; Path=/")) + { + resp.headers_mut().append(header::SET_COOKIE, value); + } +} + +fn redirect_to_app() -> Response { + Response::builder() + .status(StatusCode::SEE_OTHER) + .header(header::LOCATION, "/") + .body(Body::empty()) + .expect("static response builds") +} + +fn bad_gateway() -> Response { + Response::builder() + .status(StatusCode::BAD_GATEWAY) + .body(Body::from("app is not responding")) + .expect("static response builds") +} + +fn not_found() -> Response { + Response::builder() + .status(StatusCode::NOT_FOUND) + .body(Body::empty()) + .expect("static response builds") +} + +// --------------------------------------------------------------------------- +// Pages +// --------------------------------------------------------------------------- + +/// Minimal HTML escape for values interpolated into the pages below. +fn esc(s: &str) -> String { + s.replace('&', "&") + .replace('<', "<") + .replace('>', ">") + .replace('"', """) + .replace('\'', "'") +} + +/// The app's icon as an ``, or a lettermark when the manifest declares +/// none. Inlined as a data URI rather than linked: the gate is answering on +/// the app's own port, so any asset URL would either hit the unauthenticated +/// app behind it or a different origin the browser may not reach. +fn icon_markup(app: &GatedPort) -> String { + if let Some(path) = &app.icon { + if let Some(data_uri) = read_icon_data_uri(path) { + return format!(r#""#, esc(&data_uri)); + } + } + let letter = app + .app_name + .chars() + .next() + .map(|c| c.to_uppercase().to_string()) + .unwrap_or_else(|| "?".to_string()); + format!(r#"
{}
"#, esc(&letter)) +} + +/// Icons live with the web UI. Only files under the icon directory are read, +/// and only known image extensions — the path comes from a manifest, which is +/// signed, but treating it as untrusted costs nothing. +fn read_icon_data_uri(icon_path: &str) -> Option { + let name = std::path::Path::new(icon_path).file_name()?.to_str()?; + let mime = match name.rsplit_once('.')?.1.to_ascii_lowercase().as_str() { + "svg" => "image/svg+xml", + "png" => "image/png", + "webp" => "image/webp", + "jpg" | "jpeg" => "image/jpeg", + _ => return None, + }; + for root in [ + "/opt/archipelago/web-ui/assets/img/app-icons", + "web/dist/neode-ui/assets/img/app-icons", + ] { + let candidate = std::path::Path::new(root).join(name); + if let Ok(bytes) = std::fs::read(&candidate) { + if bytes.len() > 512 * 1024 { + return None; + } + return Some(format!("data:{mime};base64,{}", base64_encode(&bytes))); + } + } + None +} + +fn base64_encode(bytes: &[u8]) -> String { + use base64::Engine; + base64::engine::general_purpose::STANDARD.encode(bytes) +} + +fn page(title: &str, app: &GatedPort, body: &str, status: StatusCode) -> Response { + let html = format!( + r#" + + + + +{title} — {app_name} + +
{body}
"#, + title = esc(title), + app_name = esc(&app.app_name), + body = body, + ); + Response::builder() + .status(status) + .header(header::CONTENT_TYPE, "text/html; charset=utf-8") + // The gate answers on the app's own port for an unauthenticated + // caller; nothing here should be cached or framed. + .header(header::CACHE_CONTROL, "no-store") + .header("X-Frame-Options", "DENY") + .header( + "Content-Security-Policy", + "default-src 'none'; img-src data:; style-src 'unsafe-inline'; form-action 'self'", + ) + .body(Body::from(html)) + .expect("static response builds") +} + +/// The challenge. Names and pictures the app being opened, so the visitor can +/// confirm what they are authenticating to rather than being asked for a +/// password by an unexplained page. +fn login_page(app: &GatedPort, error: Option<&str>, status: StatusCode) -> Response { + let body = format!( + r#"{icon} +

Sign in to open {name}

+

This app is protected by your node password.

+{err} + + + +"#, + icon = icon_markup(app), + name = esc(&app.app_name), + err = error + .map(|e| format!(r#"
{}
"#, esc(e))) + .unwrap_or_default(), + prefix = GATE_PREFIX, + ); + page("Sign in", app, &body, status) +} + +/// Second factor. Reached only after the password verified, and the session +/// backing it cannot authorise anything until this completes. +fn totp_page(app: &GatedPort, error: Option<&str>, status: StatusCode) -> Response { + let body = format!( + r#"{icon} +

Two-factor code

+

Enter the 6-digit code to open {name}.

+{err} +
+ + +
"#, + icon = icon_markup(app), + name = esc(&app.app_name), + err = error + .map(|e| format!(r#"
{}
"#, esc(e))) + .unwrap_or_default(), + prefix = GATE_PREFIX, + ); + page("Two-factor", app, &body, status) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn app() -> GatedPort { + GatedPort { + port: 8090, + app_id: "strfry".to_string(), + app_name: "Strfry Relay".to_string(), + icon: None, + } + } + + #[test] + fn bearer_token_is_parsed_case_insensitively() { + let mut headers = HeaderMap::new(); + headers.insert(header::AUTHORIZATION, "Bearer abc123".parse().unwrap()); + assert_eq!(bearer_token(&headers), Some("abc123".to_string())); + + headers.insert(header::AUTHORIZATION, "bearer abc123".parse().unwrap()); + assert_eq!(bearer_token(&headers), Some("abc123".to_string())); + } + + #[test] + fn non_bearer_authorization_is_ignored() { + let mut headers = HeaderMap::new(); + // An app's own Basic credential must never be mistaken for ours. + headers.insert(header::AUTHORIZATION, "Basic dXNlcjpwYXNz".parse().unwrap()); + assert_eq!(bearer_token(&headers), None); + headers.insert(header::AUTHORIZATION, "Bearer ".parse().unwrap()); + assert_eq!(bearer_token(&headers), None); + } + + #[tokio::test] + async fn login_page_names_the_app() { + let resp = login_page(&app(), None, StatusCode::UNAUTHORIZED); + assert_eq!(resp.status(), StatusCode::UNAUTHORIZED); + let body = hyper::body::to_bytes(resp.into_body()).await.unwrap(); + let html = String::from_utf8_lossy(&body); + assert!(html.contains("Sign in to open Strfry Relay")); + // A lettermark stands in when the manifest declares no icon. + assert!(html.contains("lettermark")); + } + + #[tokio::test] + async fn page_escapes_app_names() { + let mut app = app(); + app.app_name = r#""#.to_string(); + let resp = login_page(&app, None, StatusCode::UNAUTHORIZED); + let body = hyper::body::to_bytes(resp.into_body()).await.unwrap(); + let html = String::from_utf8_lossy(&body); + assert!(!html.contains("