feat(appgate): apps with their own login can skip the node login
Demo images / Build & push demo images (push) Successful in 3m33s

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/
  <id>.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 <noreply@anthropic.com>
This commit is contained in:
archipelago
2026-08-16 11:40:07 -04:00
co-authored by Claude Fable 5
parent 9b789a64ad
commit 58cdea5e79
15 changed files with 537 additions and 7 deletions
+2
View File
@@ -2,6 +2,8 @@
## v1.8.4-alpha (draft — date set at cut) ## 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. - **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. - **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. - **"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.
+11 -1
View File
@@ -46,7 +46,17 @@ app:
container: 49392 container: 49392
protocol: tcp protocol: tcp
bind: 127.0.0.1 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: volumes:
- type: bind - type: bind
+10 -1
View File
@@ -27,7 +27,16 @@ app:
container: 3000 container: 3000
protocol: tcp protocol: tcp
bind: 127.0.0.1 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 - host: 2222
container: 22 container: 22
protocol: tcp protocol: tcp
+62
View File
@@ -42,6 +42,13 @@ impl RpcHandler {
"port": g.port, "port": g.port,
"app_id": g.app_id, "app_id": g.app_id,
"app_name": g.app_name, "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(); .collect();
@@ -56,4 +63,59 @@ impl RpcHandler {
"exempt": exempt, "exempt": exempt,
})) }))
} }
/// `security.set-app-gate` — the operator's per-app gate toggle.
///
/// Params: `{ id: "<app_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<serde_json::Value>,
) -> Result<serde_json::Value> {
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<serde_json::Value> = 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 }))
}
} }
@@ -489,6 +489,7 @@ impl RpcHandler {
// System monitoring // System monitoring
"security.app-gate-status" => self.handle_app_gate_status().await, "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.get-hostname" => self.handle_system_get_hostname().await,
"system.stats" => self.handle_system_stats().await, "system.stats" => self.handle_system_stats().await,
"system.processes" => self.handle_system_processes().await, "system.processes" => self.handle_system_processes().await,
+30 -1
View File
@@ -40,6 +40,15 @@ pub struct GatedPort {
/// companion UIs proxy that cookie to the daemon's authenticated /// companion UIs proxy that cookie to the daemon's authenticated
/// endpoints; for every other app the gate strips its own credential. /// endpoints; for every other app the gate strips its own credential.
pub session_passthrough: bool, 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. /// 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.exempt.sort_by_key(|e| e.port);
map map
} }
@@ -229,7 +250,10 @@ fn classify_manifest(manifest: &AppManifest, map: &mut PortMap) {
// Explicit opt-in: the app is on loopback and the daemon // Explicit opt-in: the app is on loopback and the daemon
// owns the external addresses. This is the ONLY way a // owns the external addresses. This is the ONLY way a
// port gets bound by the gate, regardless of `bind`. // 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( map.gated.insert(
port.host, port.host,
GatedPort { GatedPort {
@@ -239,6 +263,7 @@ fn classify_manifest(manifest: &AppManifest, map: &mut PortMap) {
icon: icon.clone(), icon: icon.clone(),
declared: true, declared: true,
session_passthrough: port.session_passthrough, 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 — // An undeclared port never gets the node session —
// passthrough is an explicit manifest opt-in only. // passthrough is an explicit manifest opt-in only.
session_passthrough: false, 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,
}, },
); );
} }
+20
View File
@@ -139,6 +139,14 @@ impl AppGate {
app: &GatedPort, app: &GatedPort,
client_ip: IpAddr, client_ip: IpAddr,
) -> Response<Body> { ) -> Response<Body> {
// 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(); let path = req.uri().path().to_string();
if let Some(action) = path.strip_prefix(GATE_PREFIX) { if let Some(action) = path.strip_prefix(GATE_PREFIX) {
@@ -169,6 +177,17 @@ impl AppGate {
return proxy_to_app(req, app, true).await; 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 { match self.authorize(req.headers(), &app.app_id).await {
// The credential was a cookie (or none was needed): the // The credential was a cookie (or none was needed): the
// Authorization header, if any, belongs to the app. Forward it. // Authorization header, if any, belongs to the app. Forward it.
@@ -1164,6 +1183,7 @@ mod tests {
icon: None, icon: None,
declared: true, declared: true,
session_passthrough: false, session_passthrough: false,
auth_enabled: true,
} }
} }
@@ -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/<app_id>.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<String, Value> {
match std::fs::read_to_string(config_path(app_id)) {
Ok(s) => serde_json::from_str::<Value>(&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<bool> {
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<String, bool> {
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<bool>) -> 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<T>(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)));
});
}
}
+1
View File
@@ -1,4 +1,5 @@
pub mod app_catalog; pub mod app_catalog;
pub mod app_gate_config;
pub mod bitcoin_ui; pub mod bitcoin_ui;
pub mod boot_reconciler; pub mod boot_reconciler;
pub mod companion; pub mod companion;
+75 -1
View File
@@ -561,6 +561,21 @@ pub enum PortAuth {
/// manifest to say so means the loopback pin and the daemon takeover /// manifest to say so means the loopback pin and the daemon takeover
/// ship together, atomically, and a stale manifest fails safe. /// ship together, atomically, and a stale manifest fails safe.
Gated, 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)] #[derive(Debug, Clone, Serialize, Deserialize)]
@@ -1154,6 +1169,20 @@ fn validate_ports(ports: &[PortMapping]) -> Result<(), ManifestError> {
"ports[{i}].auth_rationale cannot be empty" "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 // A rationale on a gated port means the author wrote an
// exemption and did not get one. Silently keeping the port // exemption and did not get one. Silently keeping the port
// protected would be safe but misleading, so say so. // protected would be safe but misleading, so say so.
@@ -1717,6 +1746,12 @@ app:
} }
} }
exempt.sort(); 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), // 25 as of the v1.7.123 port-policy round: bitcoin p2p (8333 ×2),
// core-lightning 9736/9835, electrumx 50001, fedimint 8173/8174, // core-lightning 9736/9835, electrumx 50001, fedimint 8173/8174,
// fedimint-gateway 8176/9737, gitea ssh 2222, lightning-stack // 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. // stage timed out that cycle, so the count here lagged at 17.
assert_eq!( assert_eq!(
exempt.len(), exempt.len(),
25, 26,
"unauthenticated port set changed — review before updating this count: {exempt:?}" "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] #[test]
fn an_undeclared_port_classifies_as_session_but_is_not_declared() { fn an_undeclared_port_classifies_as_session_but_is_not_declared() {
// Two different questions, and conflating them caused both gate // Two different questions, and conflating them caused both gate
+15
View File
@@ -152,6 +152,21 @@ know the mechanics:
policy, only its framing policy. You do not need a bespoke reverse proxy, policy, only its framing policy. You do not need a bespoke reverse proxy,
header patches, or app config to be embeddable. 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 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 things headers can't fix — the app frame-busts in JavaScript, requires being
the top-level origin (OAuth redirect flows, WebAuthn), or sets the top-level origin (OAuth redirect flows, WebAuthn), or sets
+52 -1
View File
@@ -29,7 +29,7 @@ reusable manifest primitive.
| `dependencies` | list | — | `- storage: "10GB"`, `- { app_id: bitcoin, version: … }`, or a bare string. | | `dependencies` | list | — | `- storage: "10GB"`, `- { app_id: bitcoin, version: … }`, or a bare string. |
| `resources` | ResourceLimits | — | `cpu_limit` (int), `memory_limit` (e.g. `"512m"`), `disk_limit`. | | `resources` | ResourceLimits | — | `cpu_limit` (int), `memory_limit` (e.g. `"512m"`), `disk_limit`. |
| `security` | SecurityPolicy | — | See [Security](#security). | | `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). | | `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. | | `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). | | `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. `secret_env`/`generated_secrets` names must be bare filenames.
- Hook steps are validated against the hook allow-list (below). - 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/<id>.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 ## Volumes
```yaml ```yaml
+41
View File
@@ -44,6 +44,24 @@ export interface PackageVersionsResponse {
versions: CatalogVersionInfo[] 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 { export interface SetPackageConfigResponse {
status: 'ok' | 'confirm_required' status: 'ok' | 'confirm_required'
id: string 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<AppGateStatusResponse> {
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<{ async checkPackageUpdates(): Promise<{
status: string status: string
refreshed: boolean refreshed: boolean
+6
View File
@@ -575,6 +575,12 @@
"installed": "Installed", "installed": "Installed",
"noLaunchUrl": "No launch URL available for this app yet", "noLaunchUrl": "No launch URL available for this app yet",
"versionUpdates": "Version & Updates", "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", "runningVersion": "Running version",
"selectVersion": "Version", "selectVersion": "Version",
"alwaysUseLatestVersion": "Always use the latest version", "alwaysUseLatestVersion": "Always use the latest version",
+75 -2
View File
@@ -91,6 +91,38 @@
</div> </div>
</div> </div>
<!-- App gate card: per-app login-challenge toggle. Shown only for apps
the gate actually fronts (has gate-claimed ports). -->
<div v-if="gatePorts.length" class="glass-card p-6">
<h3 class="text-lg font-bold text-white mb-4">{{ t('appDetails.appGate') }}</h3>
<div class="space-y-3">
<label class="flex items-center justify-between gap-3 cursor-pointer">
<span class="text-white/80 text-sm">{{ t('appDetails.appGateRequireLogin') }}</span>
<input
type="checkbox"
v-model="gateEnabled"
:disabled="gateBusy"
class="h-4 w-4 accent-orange-500"
/>
</label>
<p class="text-white/40 text-xs leading-relaxed">
{{ gateEnabled ? t('appDetails.appGateOnNote') : t('appDetails.appGateOffNote') }}
</p>
<div v-if="!gateEnabled" class="rounded-lg border border-orange-400/40 bg-orange-500/10 p-3">
<p class="text-orange-200 text-xs leading-relaxed"> {{ t('appDetails.appGateOffWarning') }}</p>
</div>
<button
type="button"
class="w-full glass-button glass-button-warning rounded-lg disabled:opacity-50 text-sm font-medium py-2"
:disabled="gateBusy || !gateDirty"
@click="applyGate"
>
{{ gateBusy ? t('appDetails.applyingVersion') : t('appDetails.appGateApply') }}
</button>
<p v-if="gateError" class="text-red-300 text-xs">{{ gateError }}</p>
</div>
</div>
<!-- Fedimint Services Card --> <!-- Fedimint Services Card -->
<div v-if="packageKey === 'fedimint'" class="glass-card p-6"> <div v-if="packageKey === 'fedimint'" class="glass-card p-6">
<h3 class="text-lg font-bold text-white mb-4">{{ t('appDetails.services') }}</h3> <h3 class="text-lg font-bold text-white mb-4">{{ t('appDetails.services') }}</h3>
@@ -250,7 +282,7 @@
import { computed, ref, watch } from 'vue' import { computed, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import type { AppCredentialsResponse } from '@/types/api' 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' import { displayVersion } from '@/utils/version'
const { t } = useI18n() const { t } = useI18n()
@@ -406,10 +438,51 @@ function cancelDowngrade() {
if (info) selectedVersion.value = pickSelection(info) if (info) selectedVersion.value = pickSelection(info)
} }
// ---- App gate (per-app login-challenge toggle) -----------------------------
const gatePorts = ref<AppGatePortStatus[]>([])
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( watch(
() => props.packageKey, () => props.packageKey,
(key) => { (key) => {
if (key) void loadVersions(key) if (key) {
void loadVersions(key)
void loadGate(key)
}
}, },
{ immediate: true }, { immediate: true },
) )