From 58cdea5e798c4553176cb458ab533460ca15be72 Mon Sep 17 00:00:00 2001 From: archipelago Date: Sun, 16 Aug 2026 11:40:07 -0400 Subject: [PATCH] feat(appgate): apps with their own login can skip the node login MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Some apps carry a complete account system and are broken by an upstream challenge: git clients speak basic-auth (not browser cookies), and a BTCPay checkout link handed to a customer must open for that customer. Both were behind the gate's login page — the "non-browser clients need an access token" gap disclosed in five consecutive releases. - New manifest port policy `auth: open`: the daemon still fronts the port exactly like `gated` (loopback pin, external binds, frame-header fixes, app-down retry page, Tor upstream) but serves it without the login challenge. Requires auth_rationale, same burden of proof as `none`. Gitea 3001 and BTCPay 23000 declare it. - Runtime operator override per app (security.set-app-gate → app-configs/ .json "gateEnabled"), surfaced as Settings → app → Access control. Wins over the manifest in both directions and applies on the next request — no restart, and it works today on catalog-covered apps whose signed manifest still says `gated`. - The gate resolves policy per-request from the live port map, so a toggle takes effect without waiting for the 60s rebind sweep. "Off" never releases the port: gated apps are loopback-pinned, so releasing would strand them, not open them. - security.app-gate-status now reports gate_enabled + any override. - New guard test pins the `auth: open` set (both entries reviewed); the `auth: none` count moves 25 → 26, absorbing pre-existing drift from the phoenixd onboarding (loopback JSON API with its own generated password). - Docs: the manifest spec's ports row documented only host/container/ protocol — bind, auth, auth_rationale and session_passthrough were undocumented. Added a full "Ports & the app gate" section plus a developer-guide entry telling app authors to enforce their own auth regardless, since the operator can flip the gate either way. Verified live on archi-dev-box from an external address: gated → 401 gate page; override off → Gitea 200 own page, BTCPay 302 to its own login, git-over-HTTP info/refs 200; override on → 401 again; clear → default. Co-Authored-By: Claude Fable 5 --- CHANGELOG.md | 2 + apps/btcpay-server/manifest.yml | 12 +- apps/gitea/manifest.yml | 11 +- core/archipelago/src/api/rpc/appgate.rs | 62 ++++++++ core/archipelago/src/api/rpc/dispatcher.rs | 1 + core/archipelago/src/appgate/identity.rs | 31 +++- core/archipelago/src/appgate/mod.rs | 20 +++ .../src/container/app_gate_config.rs | 136 ++++++++++++++++++ core/archipelago/src/container/mod.rs | 1 + core/container/src/manifest.rs | 76 +++++++++- docs/app-developer-guide.md | 15 ++ docs/app-manifest-spec.md | 53 ++++++- neode-ui/src/api/rpc-client.ts | 41 ++++++ neode-ui/src/locales/en.json | 6 + neode-ui/src/views/appDetails/AppSidebar.vue | 77 +++++++++- 15 files changed, 537 insertions(+), 7 deletions(-) create mode 100644 core/archipelago/src/container/app_gate_config.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index a9a4e768..13a5386e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,8 @@ ## v1.8.4-alpha (draft — date set at cut) +- **Apps with their own login can now skip the node's login screen — Gitea and BTCPay Server do so out of the box.** Some apps bring a complete account system of their own, and putting the node's password page in front of them broke real workflows: git clients can't answer a browser login, and a BTCPay checkout link handed to a customer must open for that customer. These apps are now served directly on their own login, while the node still fronts the connection for everything else it does (embedding fixes, the "app is restarting" page, Tor). Every app gets a new **Settings → app → Access control** switch, so you can put the node login back in front of any app — or take it away from one — with one click, effective immediately. App developers declare the default in their manifest (`auth: open`), documented in the developer guide. + - **The phone remote now works inside apps on the TV — tap, scroll, and type everywhere.** The companion remote and keyboard drove the dashboard beautifully but died at the edge of any app screen (Gitea, BTCPay, and friends): for the browser, each app is a separate website embedded in the page, and simulated input is forbidden from crossing that wall. The on-screen display now accepts the remote's input the way a real mouse and keyboard arrive — below the page, through the browser itself — so it lands anywhere on screen, app screens and tabs included. Taps click, two-finger scrolling scrolls the app, and typing goes into whichever field you tapped. Existing kiosks pick this up with the update, no reinstall needed. - **While you're driving with the phone remote, the old mouse pointer gets out of the way.** The computer's own pointer used to sit frozen wherever the physical mouse last left it — a second, dead cursor next to the live orange one. It now hides while the remote is in use and returns half a minute after the last remote input. - **"Are you sure?" questions no longer freeze the remote.** A handful of confirmations (clearing mesh history, rebooting, deleting a backup, uninstalling an app) used the browser's built-in popup, which stops the whole page — including remote input — until someone clicks it with a real mouse. From the couch, that meant asking a question you couldn't answer. All of them are now proper in-app windows in the house style, fully driveable by remote. diff --git a/apps/btcpay-server/manifest.yml b/apps/btcpay-server/manifest.yml index 705feed1..8bfd4353 100644 --- a/apps/btcpay-server/manifest.yml +++ b/apps/btcpay-server/manifest.yml @@ -46,7 +46,17 @@ app: container: 49392 protocol: tcp bind: 127.0.0.1 - auth: gated + # open, not gated: BTCPay has its own account system, and its public + # surfaces (checkout/invoice pages, payment buttons, webhooks) must be + # reachable by anonymous payers and machines — a dashboard login in + # front of a checkout link breaks the product. The gate still fronts + # the port; the operator can force the dashboard login back on from + # Settings → BTCPay Server → Access control. + auth: open + auth_rationale: >- + BTCPay enforces its own login for administration, and its checkout, + invoice and webhook endpoints are designed to be reached by + anonymous payers and payment processors. volumes: - type: bind diff --git a/apps/gitea/manifest.yml b/apps/gitea/manifest.yml index 0a4a2293..9738b5a5 100644 --- a/apps/gitea/manifest.yml +++ b/apps/gitea/manifest.yml @@ -27,7 +27,16 @@ app: container: 3000 protocol: tcp bind: 127.0.0.1 - auth: gated + # open, not gated: Gitea carries a complete login of its own, and git + # clients speak HTTP basic-auth — a cookie challenge in front of + # git-over-HTTP breaks every clone/push. The gate still fronts the + # port (iframe header fixes, retry page, Tor); the operator can force + # the dashboard login back on from Settings → Gitea → Access control. + auth: open + auth_rationale: >- + Gitea enforces its own account login on every page and API route; + git clients authenticate with basic-auth/tokens and cannot complete + a browser login challenge. - host: 2222 container: 22 protocol: tcp diff --git a/core/archipelago/src/api/rpc/appgate.rs b/core/archipelago/src/api/rpc/appgate.rs index 38344226..3cb34f09 100644 --- a/core/archipelago/src/api/rpc/appgate.rs +++ b/core/archipelago/src/api/rpc/appgate.rs @@ -42,6 +42,13 @@ impl RpcHandler { "port": g.port, "app_id": g.app_id, "app_name": g.app_name, + // Is the login challenge active on this port right now + // (manifest default + operator override, resolved)? + "gate_enabled": g.auth_enabled, + // Whether an operator override is recorded, and what the + // manifest would do without it — the UI needs all three + // to render a meaningful toggle. + "override": crate::container::app_gate_config::gate_override(&g.app_id), }) }) .collect(); @@ -56,4 +63,59 @@ impl RpcHandler { "exempt": exempt, })) } + + /// `security.set-app-gate` — the operator's per-app gate toggle. + /// + /// Params: `{ id: "", enabled: true | false | null }`. + /// `enabled: null` clears the override so the manifest default applies + /// again. Takes effect on the next request (the gate resolves per-request + /// policy from the live port map) — no rebind, no restart. + pub(in crate::api::rpc) async fn handle_set_app_gate( + &self, + params: Option, + ) -> Result { + let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?; + let app_id = params + .get("id") + .and_then(|v| v.as_str()) + .ok_or_else(|| anyhow::anyhow!("Missing id"))? + .to_string(); + let enabled = match params.get("enabled") { + None | Some(serde_json::Value::Null) => None, + Some(serde_json::Value::Bool(b)) => Some(*b), + Some(other) => anyhow::bail!("enabled must be true, false or null, got {other}"), + }; + + // Only apps the gate actually fronts have a challenge to toggle. + // Writing an override for anything else would sit silently in the + // config doing nothing — reject instead so a typo'd id is loud. + let port_map = self.app_gate.port_map().await; + if !port_map.gated_ports().any(|g| g.app_id == app_id) { + anyhow::bail!( + "'{app_id}' has no gate-fronted ports — nothing to toggle \ + (auth: none/local ports are manifest-declared, not runtime-toggled)" + ); + } + + crate::container::app_gate_config::write_gate_override(&app_id, enabled) + .map_err(|e| anyhow::anyhow!("Failed to persist gate override: {e}"))?; + // Rebuild the port map now so the change is live on the next request + // instead of after the next 60s sweep. + self.app_gate.refresh().await; + + let effective: Vec = self + .app_gate + .port_map() + .await + .gated_ports() + .filter(|g| g.app_id == app_id) + .map(|g| serde_json::json!({ "port": g.port, "gate_enabled": g.auth_enabled })) + .collect(); + tracing::info!( + app = %app_id, + override_ = ?enabled, + "app gate override updated by operator" + ); + Ok(serde_json::json!({ "id": app_id, "override": enabled, "ports": effective })) + } } diff --git a/core/archipelago/src/api/rpc/dispatcher.rs b/core/archipelago/src/api/rpc/dispatcher.rs index 3e44de6c..2b42e34e 100644 --- a/core/archipelago/src/api/rpc/dispatcher.rs +++ b/core/archipelago/src/api/rpc/dispatcher.rs @@ -489,6 +489,7 @@ impl RpcHandler { // System monitoring "security.app-gate-status" => self.handle_app_gate_status().await, + "security.set-app-gate" => self.handle_set_app_gate(params).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/appgate/identity.rs b/core/archipelago/src/appgate/identity.rs index 68c07098..716b28d3 100644 --- a/core/archipelago/src/appgate/identity.rs +++ b/core/archipelago/src/appgate/identity.rs @@ -40,6 +40,15 @@ pub struct GatedPort { /// companion UIs proxy that cookie to the daemon's authenticated /// endpoints; for every other app the gate strips its own credential. pub session_passthrough: bool, + /// Does the gate challenge for the dashboard login on this port? + /// + /// Default comes from the manifest (`auth: gated`/undeclared → true, + /// `auth: open` → false); the operator's runtime override + /// (`app_gate_config`, Settings → app → App gate) wins over both. + /// False does NOT release the port — the gate keeps binding and + /// proxying (frame-header fixes, app-down page, Tor upstream); it just + /// forwards every request to the app's own authentication. + pub auth_enabled: bool, } /// A port deliberately left unauthenticated, and the manifest's stated reason. @@ -187,6 +196,18 @@ pub fn build_port_map() -> PortMap { } } + // The operator's runtime override wins over the manifest default, in + // both directions: un-gate an app that fronts its own login, or force + // the challenge back onto an `auth: open` port. Overrides only toggle + // the challenge on gate-fronted ports — they never bind or release + // anything, so a stale override cannot expose or strand a port. + let overrides = crate::container::app_gate_config::all_gate_overrides(); + for gp in map.gated.values_mut() { + if let Some(enabled) = overrides.get(&gp.app_id) { + gp.auth_enabled = *enabled; + } + } + map.exempt.sort_by_key(|e| e.port); map } @@ -229,7 +250,10 @@ fn classify_manifest(manifest: &AppManifest, map: &mut PortMap) { // 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 => { + // `open` is the same takeover with the login challenge + // defaulted off — the app fronts its own authentication + // (rationale-required, see PortAuth::Open). + PortAuth::Gated | PortAuth::Open => { map.gated.insert( port.host, GatedPort { @@ -239,6 +263,7 @@ fn classify_manifest(manifest: &AppManifest, map: &mut PortMap) { icon: icon.clone(), declared: true, session_passthrough: port.session_passthrough, + auth_enabled: port.auth_policy() == PortAuth::Gated, }, ); } @@ -289,6 +314,10 @@ fn classify_manifest(manifest: &AppManifest, map: &mut PortMap) { // An undeclared port never gets the node session — // passthrough is an explicit manifest opt-in only. session_passthrough: false, + // Undeclared ports are challenged wherever the gate + // can stand: reporting-and-protecting is the safe + // default (operator override still applies below). + auth_enabled: true, }, ); } diff --git a/core/archipelago/src/appgate/mod.rs b/core/archipelago/src/appgate/mod.rs index af4706fb..f07788f9 100644 --- a/core/archipelago/src/appgate/mod.rs +++ b/core/archipelago/src/appgate/mod.rs @@ -139,6 +139,14 @@ impl AppGate { app: &GatedPort, client_ip: IpAddr, ) -> Response { + // The accept loop captured its GatedPort at bind time; per-request + // policy (the operator's gate on/off toggle, session_passthrough) + // must come from the live map or a Settings change would only apply + // to ports (re)bound after the next sweep. Falls back to the bound + // snapshot when the port momentarily leaves the map mid-refresh. + let live = self.port_map.read().await.gated(app.port).cloned(); + let app = live.as_ref().unwrap_or(app); + let path = req.uri().path().to_string(); if let Some(action) = path.strip_prefix(GATE_PREFIX) { @@ -169,6 +177,17 @@ impl AppGate { return proxy_to_app(req, app, true).await; } + // Gate challenge disabled for this app (manifest `auth: open`, or + // the operator's Settings toggle): forward everything to the app's + // own authentication. The Authorization header passes through + // untouched — git clients speak basic-auth to Gitea, API clients + // carry the app's own tokens. Gate cookies are still stripped in + // proxy_to_app (an ungated app must never see the node session), + // and the frame fixes / app-down page still apply. + if !app.auth_enabled { + return proxy_to_app(req, app, false).await; + } + match self.authorize(req.headers(), &app.app_id).await { // The credential was a cookie (or none was needed): the // Authorization header, if any, belongs to the app. Forward it. @@ -1164,6 +1183,7 @@ mod tests { icon: None, declared: true, session_passthrough: false, + auth_enabled: true, } } diff --git a/core/archipelago/src/container/app_gate_config.rs b/core/archipelago/src/container/app_gate_config.rs new file mode 100644 index 00000000..08e845ff --- /dev/null +++ b/core/archipelago/src/container/app_gate_config.rs @@ -0,0 +1,136 @@ +//! Per-app operator override for the app gate's login requirement. +//! +//! The manifest declares each port's *default* policy (`auth: gated` = the +//! gate challenges, the new `auth: open` = the gate fronts the port but does +//! not challenge). This store holds the operator's runtime override — set +//! from Settings → app details — so a node owner can un-gate an app that +//! carries its own login (Gitea, BTCPay) or force the gate back onto an +//! `open` app, without editing manifests or waiting for a catalog re-sign. +//! +//! Lives in the same merge-preserving per-app JSON files as the version +//! preferences (`/var/lib/archipelago/app-configs/.json`, key +//! `"gateEnabled"`). Absent key = follow the manifest default. + +use std::collections::HashMap; +use std::path::PathBuf; + +use serde_json::{Map, Value}; + +fn config_dir() -> PathBuf { + let base = std::env::var("ARCHIPELAGO_DATA_DIR") + .unwrap_or_else(|_| "/var/lib/archipelago".to_string()); + PathBuf::from(base).join("app-configs") +} + +fn config_path(app_id: &str) -> PathBuf { + config_dir().join(format!("{app_id}.json")) +} + +fn read_raw(app_id: &str) -> Map { + match std::fs::read_to_string(config_path(app_id)) { + Ok(s) => serde_json::from_str::(&s) + .ok() + .and_then(|v| v.as_object().cloned()) + .unwrap_or_default(), + Err(_) => Map::new(), + } +} + +/// The operator's gate override for one app. `None` = no override recorded — +/// the manifest default applies. +pub fn gate_override(app_id: &str) -> Option { + read_raw(app_id).get("gateEnabled").and_then(Value::as_bool) +} + +/// Every recorded override, keyed by app id (the config file stem). Used by +/// the gate's port-map build so one directory scan covers all apps. +pub fn all_gate_overrides() -> HashMap { + let mut out = HashMap::new(); + let Ok(entries) = std::fs::read_dir(config_dir()) else { + return out; + }; + for entry in entries.flatten() { + let path = entry.path(); + if path.extension().and_then(|e| e.to_str()) != Some("json") { + continue; + } + let Some(app_id) = path.file_stem().and_then(|s| s.to_str()) else { + continue; + }; + if let Some(v) = gate_override(app_id) { + out.insert(app_id.to_string(), v); + } + } + out +} + +/// Set (`Some`) or clear (`None`) the override, preserving every other key in +/// the app's config file. Temp+rename so a crash mid-write can't truncate. +pub fn write_gate_override(app_id: &str, enabled: Option) -> std::io::Result<()> { + let path = config_path(app_id); + let mut obj = read_raw(app_id); + match enabled { + Some(v) => { + obj.insert("gateEnabled".to_string(), Value::Bool(v)); + } + None => { + obj.remove("gateEnabled"); + } + } + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent)?; + } + let serialized = serde_json::to_string_pretty(&Value::Object(obj)) + .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?; + let tmp = path.with_extension("json.tmp"); + std::fs::write(&tmp, serialized.as_bytes())?; + std::fs::rename(&tmp, &path) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn with_tmp_data_dir(f: impl FnOnce() -> T) -> T { + let dir = tempfile::tempdir().expect("tempdir"); + // Serialize env mutation across tests in this module. + static LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(()); + let _guard = LOCK.lock().unwrap_or_else(|e| e.into_inner()); + std::env::set_var("ARCHIPELAGO_DATA_DIR", dir.path()); + let out = f(); + std::env::remove_var("ARCHIPELAGO_DATA_DIR"); + out + } + + #[test] + fn absent_file_means_no_override() { + with_tmp_data_dir(|| { + assert_eq!(gate_override("gitea"), None); + assert!(all_gate_overrides().is_empty()); + }); + } + + #[test] + fn write_read_clear_roundtrip_preserves_other_keys() { + with_tmp_data_dir(|| { + // Seed an existing config with an unrelated key. + std::fs::create_dir_all(config_dir()).unwrap(); + std::fs::write(config_path("gitea"), r#"{"autoUpdate": true}"#).unwrap(); + + write_gate_override("gitea", Some(false)).unwrap(); + assert_eq!(gate_override("gitea"), Some(false)); + assert_eq!(all_gate_overrides().get("gitea"), Some(&false)); + + // The unrelated key survives. + let raw = std::fs::read_to_string(config_path("gitea")).unwrap(); + let v: Value = serde_json::from_str(&raw).unwrap(); + assert_eq!(v.get("autoUpdate"), Some(&Value::Bool(true))); + + write_gate_override("gitea", None).unwrap(); + assert_eq!(gate_override("gitea"), None); + let raw = std::fs::read_to_string(config_path("gitea")).unwrap(); + let v: Value = serde_json::from_str(&raw).unwrap(); + assert_eq!(v.get("autoUpdate"), Some(&Value::Bool(true))); + }); + } +} diff --git a/core/archipelago/src/container/mod.rs b/core/archipelago/src/container/mod.rs index 8212611a..16ede7b8 100644 --- a/core/archipelago/src/container/mod.rs +++ b/core/archipelago/src/container/mod.rs @@ -1,4 +1,5 @@ pub mod app_catalog; +pub mod app_gate_config; pub mod bitcoin_ui; pub mod boot_reconciler; pub mod companion; diff --git a/core/container/src/manifest.rs b/core/container/src/manifest.rs index bc3c9d36..3318c096 100644 --- a/core/container/src/manifest.rs +++ b/core/container/src/manifest.rs @@ -561,6 +561,21 @@ pub enum PortAuth { /// manifest to say so means the loopback pin and the daemon takeover /// ship together, atomically, and a stale manifest fails safe. Gated, + /// Like `gated` — loopback-pinned app, daemon owns the external + /// addresses — but the gate does NOT require the dashboard login. + /// + /// For apps that carry a complete login of their own and are broken by + /// an upstream challenge: Gitea (git clients speak basic-auth, not + /// cookies), BTCPay (checkout pages must be reachable by anonymous + /// payers). The gate still fronts the port — frame-header neutralising, + /// the app-down retry page, the Tor upstream — it just lets every + /// request through to the app's own authentication. Requires + /// `auth_rationale`, exactly like `none`: an unchallenged surface nobody + /// can explain is one nobody reviewed. The operator can flip any + /// gate-fronted app between `gated` and `open` behaviour at runtime + /// (Settings → app → App gate; stored node-side, manifest sets the + /// default). + Open, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -1154,6 +1169,20 @@ fn validate_ports(ports: &[PortMapping]) -> Result<(), ManifestError> { "ports[{i}].auth_rationale cannot be empty" ))); } + // `open` serves the app without the gate's login challenge, so it + // carries the same burden of proof as `none`. + (PortAuth::Open, None) => { + return Err(ManifestError::Invalid(format!( + "ports[{i}] sets auth: open but no auth_rationale — a port served \ + without the gate's login must state why (typically: the app \ + enforces its own authentication)" + ))); + } + (PortAuth::Open, Some(rationale)) if rationale.trim().is_empty() => { + return Err(ManifestError::Invalid(format!( + "ports[{i}].auth_rationale cannot be empty" + ))); + } // A rationale on a gated port means the author wrote an // exemption and did not get one. Silently keeping the port // protected would be safe but misleading, so say so. @@ -1717,6 +1746,12 @@ app: } } exempt.sort(); + // 26 as of 2026-08-16: the 25 below plus phoenixd 9740, a + // loopback-only JSON API whose own generated http password + // authenticates every request (added with the phoenixd onboarding, + // which did not update this count — exactly the drift this test + // exists to catch). + // // 25 as of the v1.7.123 port-policy round: bitcoin p2p (8333 ×2), // core-lightning 9736/9835, electrumx 50001, fedimint 8173/8174, // fedimint-gateway 8176/9737, gitea ssh 2222, lightning-stack @@ -1727,11 +1762,50 @@ app: // stage timed out that cycle, so the count here lagged at 17. assert_eq!( exempt.len(), - 25, + 26, "unauthenticated port set changed — review before updating this count: {exempt:?}" ); } + /// `auth: open` ports are served by the gate WITHOUT its login challenge, + /// so they are the second unauthenticated-by-the-gate surface and get the + /// same review guard as `auth: none`. Each one must be an app that + /// enforces a real login of its own. + #[test] + fn gate_open_ports_are_all_accounted_for() { + let apps = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../apps"); + let Ok(entries) = std::fs::read_dir(&apps) else { + return; + }; + let mut open: Vec<(String, u16)> = Vec::new(); + for entry in entries.flatten() { + let manifest = entry.path().join("manifest.yml"); + if !manifest.is_file() { + continue; + } + let yaml = std::fs::read_to_string(&manifest).expect("manifest readable"); + let parsed = AppManifest::parse(&yaml).expect("manifest valid"); + for port in &parsed.app.ports { + if port.auth_policy() == PortAuth::Open { + open.push((parsed.app.id.clone(), port.host)); + } + } + } + open.sort(); + // Gitea 3001 (git clients speak basic-auth, not browser cookies) and + // BTCPay 23000 (checkout/invoice/webhook endpoints must be reachable + // by anonymous payers). Both enforce their own account login, and an + // operator can re-gate either from Settings → Access control. + assert_eq!( + open, + vec![ + ("btcpay-server".to_string(), 23000u16), + ("gitea".to_string(), 3001u16) + ], + "gate-open port set changed — every entry must be an app with its own login" + ); + } + #[test] fn an_undeclared_port_classifies_as_session_but_is_not_declared() { // Two different questions, and conflating them caused both gate diff --git a/docs/app-developer-guide.md b/docs/app-developer-guide.md index 11d61770..42e00f07 100644 --- a/docs/app-developer-guide.md +++ b/docs/app-developer-guide.md @@ -152,6 +152,21 @@ know the mechanics: policy, only its framing policy. You do not need a bespoke reverse proxy, header patches, or app config to be embeddable. +### Apps with their own login: `auth: open` + +If your app carries a complete account system of its own — and especially if +non-browser clients must reach it (git over HTTP, mobile apps, payment +webhooks) — declare its gated port `auth: open` with an `auth_rationale` +instead of `auth: gated`. The daemon still fronts the port exactly like a +gated one (loopback pin, external binds, frame-header fixes, retry page, +Tor onion), but serves it without the dashboard-login challenge, so your +app's own authentication is the one users and API clients meet. Gitea and +BTCPay Server ship this way. The node operator can override your default in +either direction at runtime (Settings → app → Access control), so never +treat the gate as your app's authorization layer — enforce your own auth on +every sensitive route regardless. See “Ports & the app gate” in +[`app-manifest-spec.md`](app-manifest-spec.md). + Set `metadata.launch.open_in_new_tab: true` only when embedding is broken by things headers can't fix — the app frame-busts in JavaScript, requires being the top-level origin (OAuth redirect flows, WebAuthn), or sets diff --git a/docs/app-manifest-spec.md b/docs/app-manifest-spec.md index ecd29f05..0f80397b 100644 --- a/docs/app-manifest-spec.md +++ b/docs/app-manifest-spec.md @@ -29,7 +29,7 @@ reusable manifest primitive. | `dependencies` | list | — | `- storage: "10GB"`, `- { app_id: bitcoin, version: … }`, or a bare string. | | `resources` | ResourceLimits | — | `cpu_limit` (int), `memory_limit` (e.g. `"512m"`), `disk_limit`. | | `security` | SecurityPolicy | — | See [Security](#security). | -| `ports` | list of PortMapping | — | `- { host: 8080, container: 80, protocol: tcp }`. | +| `ports` | list of PortMapping | — | See [Ports & the app gate](#ports--the-app-gate). | | `volumes` | list of Volume | — | See [Volumes](#volumes). | | `files` | list of GeneratedFile | — | Config files written before create: `{ path, content, overwrite }`. `path` must sit under a declared bind mount. | | `environment` | list of string | — | `- KEY=value` pairs (static). | @@ -82,6 +82,57 @@ Validation (enforced at `AppManifest::validate()`): `secret_env`/`generated_secrets` names must be bare filenames. - Hook steps are validated against the hook allow-list (below). +## Ports & the app gate + +```yaml +ports: + - host: 3001 # host port your app is reachable on + container: 3000 + protocol: tcp # default tcp + bind: 127.0.0.1 # empty = all interfaces + auth: gated # session | none | local | gated | open + auth_rationale: "…" # REQUIRED for auth: none and auth: open + session_passthrough: false +``` + +| Field | Notes | +|-------|-------| +| `bind` | Host address for the publish. Empty = all interfaces. List the same host/container pair twice with different binds to serve several addresses. | +| `auth` | The port's authentication policy — see below. **Absent means "no instruction"**: the daemon reports on the port but never changes how it is published. | +| `auth_rationale` | Why the port is safe without the gate's login. Required for `none` and `open`, rejected elsewhere. | +| `session_passthrough` | Forward the node session cookie to the app on authorised requests. First-party companion UIs only; the gate otherwise strips its own credential. Only meaningful on `auth: gated`. | + +### `auth` policies + +- **`session`** (default when declared): the app gate authenticates every + connection — node session cookie or an app-scoped bearer token. +- **`gated`**: the app publishes on loopback **only** (`bind: 127.0.0.1`) and + the daemon owns the external addresses: it binds them, authenticates every + connection, fixes frame-blocking headers so the app embeds in the + dashboard, serves a retrying page while the app is down, and fronts the + Tor onion for the port. This is the migrated end state for most apps. +- **`open`**: same daemon takeover as `gated` — loopback pin, external + binds, header fixes, retry page, Tor — but **no dashboard login + challenge**. For apps that carry a complete login of their own and are + broken by an upstream challenge: Gitea (git clients speak basic-auth, not + cookies), BTCPay (checkout pages must be reachable by anonymous payers). + Requires `auth_rationale`. +- **`none`**: the gate does not touch the port at all. Only for protocols + that authenticate themselves (LND macaroons, TLS client certs) or where a + login page is meaningless (p2p gossip). Requires `auth_rationale`. +- **`local`**: host-local by intent — the gate must never bind or expose + this port anywhere (e.g. Bitcoin RPC). + +### Runtime override + +The manifest sets the **default**. The node operator can flip any +gate-fronted app between `gated` and `open` behaviour at runtime from +**Settings → app → Access control** (RPC `security.set-app-gate`), stored +per-app on the node (`app-configs/.json`, key `gateEnabled`). The +override wins over the manifest in both directions and applies on the next +request — your app cannot assume the gate is or isn't in front of it, so it +must always enforce its own authorization for sensitive operations. + ## Volumes ```yaml diff --git a/neode-ui/src/api/rpc-client.ts b/neode-ui/src/api/rpc-client.ts index 55d2f9c5..67753d0e 100644 --- a/neode-ui/src/api/rpc-client.ts +++ b/neode-ui/src/api/rpc-client.ts @@ -44,6 +44,24 @@ export interface PackageVersionsResponse { versions: CatalogVersionInfo[] } +export interface AppGatePortStatus { + port: number + app_id: string + app_name: string + /** Login challenge active right now (manifest default + operator override, resolved). */ + gate_enabled: boolean + /** Operator override on record; null/undefined = manifest default applies. */ + override?: boolean | null +} + +export interface AppGateStatusResponse { + fully_enforced: boolean + claimed: [number, string][] + unprotected: { port: number; app_id: string; app_name: string; reason: string }[] + gated: AppGatePortStatus[] + exempt: { port: number; app_id: string; protocol: string; rationale: string }[] +} + export interface SetPackageConfigResponse { status: 'ok' | 'confirm_required' id: string @@ -758,6 +776,29 @@ class RPCClient { }) } + // What the app gate enforces: which app ports are fronted, whether each + // one's login challenge is active, exemptions with their rationale. + async getAppGateStatus(): Promise { + return this.call({ + method: 'security.app-gate-status', + timeout: 15000, + }) + } + + // Per-app gate toggle. enabled=true forces the login challenge, false + // serves the app on its own authentication, null clears the override so + // the manifest default applies. Live on the next request — no restart. + async setAppGate( + id: string, + enabled: boolean | null, + ): Promise<{ id: string; override: boolean | null; ports: { port: number; gate_enabled: boolean }[] }> { + return this.call({ + method: 'security.set-app-gate', + params: { id, enabled }, + timeout: 15000, + }) + } + async checkPackageUpdates(): Promise<{ status: string refreshed: boolean diff --git a/neode-ui/src/locales/en.json b/neode-ui/src/locales/en.json index 087acf49..2a606d45 100644 --- a/neode-ui/src/locales/en.json +++ b/neode-ui/src/locales/en.json @@ -575,6 +575,12 @@ "installed": "Installed", "noLaunchUrl": "No launch URL available for this app yet", "versionUpdates": "Version & Updates", + "appGate": "Access control", + "appGateRequireLogin": "Require dashboard login (app gate)", + "appGateOnNote": "Every visit to this app must sign in with your node password first. The app's own login (if any) comes after.", + "appGateOffNote": "This app is served directly with its own login. The node still fronts the connection (embedding fixes, retry page, Tor), but does not ask for your dashboard password.", + "appGateOffWarning": "Anyone who can reach this node — LAN, Tailscale, Tor — reaches this app's own login page. Only turn this off for apps with a real login of their own (Gitea, BTCPay).", + "appGateApply": "Apply", "runningVersion": "Running version", "selectVersion": "Version", "alwaysUseLatestVersion": "Always use the latest version", diff --git a/neode-ui/src/views/appDetails/AppSidebar.vue b/neode-ui/src/views/appDetails/AppSidebar.vue index 82d4af78..203ce035 100644 --- a/neode-ui/src/views/appDetails/AppSidebar.vue +++ b/neode-ui/src/views/appDetails/AppSidebar.vue @@ -91,6 +91,38 @@ + +
+

{{ t('appDetails.appGate') }}

+
+ +

+ {{ gateEnabled ? t('appDetails.appGateOnNote') : t('appDetails.appGateOffNote') }} +

+
+

⚠️ {{ t('appDetails.appGateOffWarning') }}

+
+ +

{{ gateError }}

+
+
+

{{ t('appDetails.services') }}

@@ -250,7 +282,7 @@ import { computed, ref, watch } from 'vue' import { useI18n } from 'vue-i18n' import type { AppCredentialsResponse } from '@/types/api' -import { rpcClient, type PackageVersionsResponse, type CatalogVersionInfo } from '../../api/rpc-client' +import { rpcClient, type PackageVersionsResponse, type CatalogVersionInfo, type AppGatePortStatus } from '../../api/rpc-client' import { displayVersion } from '@/utils/version' const { t } = useI18n() @@ -406,10 +438,51 @@ function cancelDowngrade() { if (info) selectedVersion.value = pickSelection(info) } +// ---- App gate (per-app login-challenge toggle) ----------------------------- +const gatePorts = ref([]) +const gateEnabled = ref(true) +const gateBusy = ref(false) +const gateError = ref('') + +const gateDirty = computed(() => { + const current = gatePorts.value[0]?.gate_enabled + return current !== undefined && gateEnabled.value !== current +}) + +async function loadGate(appId: string) { + gatePorts.value = [] + gateError.value = '' + try { + const status = await rpcClient.getAppGateStatus() + gatePorts.value = status.gated.filter((g) => g.app_id === appId) + const first = gatePorts.value[0] + if (first) gateEnabled.value = first.gate_enabled + } catch (err) { + if (import.meta.env.DEV) console.warn('[AppSidebar] getAppGateStatus failed:', err) + } +} + +async function applyGate() { + if (!props.packageKey || !gatePorts.value.length) return + gateBusy.value = true + gateError.value = '' + try { + await rpcClient.setAppGate(props.packageKey, gateEnabled.value) + await loadGate(props.packageKey) + } catch (err: unknown) { + gateError.value = err instanceof Error ? err.message : String(err) + } finally { + gateBusy.value = false + } +} + watch( () => props.packageKey, (key) => { - if (key) void loadVersions(key) + if (key) { + void loadVersions(key) + void loadGate(key) + } }, { immediate: true }, )