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/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 1ad48533..5f7ce8f4 100644 --- a/core/archipelago/src/api/rpc/dispatcher.rs +++ b/core/archipelago/src/api/rpc/dispatcher.rs @@ -462,6 +462,7 @@ impl RpcHandler { "server.set-location" => self.handle_server_set_location(params).await, // System monitoring + "security.app-gate-status" => self.handle_app_gate_status().await, "system.get-hostname" => self.handle_system_get_hostname().await, "system.stats" => self.handle_system_stats().await, "system.processes" => self.handle_system_processes().await, diff --git a/core/archipelago/src/api/rpc/mod.rs b/core/archipelago/src/api/rpc/mod.rs index 7dd8f8c5..8ecf144c 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 auth; mod backup_rpc; @@ -87,6 +88,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>>, @@ -151,6 +157,13 @@ impl RpcHandler { }); } + let app_gate = Arc::new(crate::appgate::AppGate::new( + session_store.clone(), + auth_manager.clone(), + login_rate_limiter.clone(), + config.data_dir.clone(), + )); + Ok(Self { config, auth_manager, @@ -161,6 +174,7 @@ impl RpcHandler { port_allocator, session_store, login_rate_limiter, + app_gate, endpoint_rate_limiter, response_cache: ResponseCache::new(5), mesh_service: Arc::new(tokio::sync::RwLock::new(None)), diff --git a/core/archipelago/src/appgate/identity.rs b/core/archipelago/src/appgate/identity.rs new file mode 100644 index 00000000..caaecbba --- /dev/null +++ b/core/archipelago/src/appgate/identity.rs @@ -0,0 +1,261 @@ +//! 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 { + 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(), + }), + 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 publish pinned to loopback is not externally + // reachable, so the gate has nothing to stand in + // front of. Gating it would mean binding a port the + // app already holds and breaking in-node clients. + 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 loopback-pinned, so the gate must leave it alone + /// even though its manifest does not declare an exemption. + #[test] + fn loopback_pinned_ports_are_not_gated() { + assert!(build_port_map().gated(8332).is_none()); + } + + /// 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..b02b2eaf --- /dev/null +++ b/core/archipelago/src/appgate/listener.rs @@ -0,0 +1,332 @@ +//! 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, +) { + 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..4d2e0367 --- /dev/null +++ b/core/archipelago/src/appgate/mod.rs @@ -0,0 +1,711 @@ +//! 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("