feat(security): app gate — authenticate every app port
Demo images / Build & push demo images (push) Successful in 4m18s
Demo images / Build & push demo images (push) Successful in 4m18s
Reproduced again on this node today: with no session cookie, six app ports answered HTTP 200 with their real UIs (18083 LND, 8334, 8175 Fedimint Guardian, 8336 FIPS Mesh, 8090, 7777), all bound 0.0.0.0 and so served on every host address. Same bug class as the /lnd-connect-info and /bitcoin-rpc/ leaks closed in v1.7.120, but across every app. LAN, Tailscale, Tor and the FIPS mesh all converge on 127.0.0.1:<port>, so this is one gate rather than four. It lives in the daemon rather than a per-app sidecar (umbrel's app_proxy model): rootless, no extra container per app, and it can reuse machinery that already exists. It invents no authentication policy. verify_password, TOTP secret decryption, verify_code with used-step replay protection, the session store, and — importantly — the SAME LoginRateLimiter instance as the JSON-RPC path, so an attacker cannot get a fresh budget of password guesses by moving to an app port. Only the transport differs, an HTML form instead of JSON-RPC, because a browser being sent to an app cannot speak JSON-RPC. 2FA comes for free: a session still pending its TOTP step fails validate(), so the gate rejects it without knowing what a second factor is. Details worth keeping: - 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. - Cookie and Authorization are stripped before proxying. The app has no use for the node session and must never be able to log or forward it. - The challenge page names and pictures the app being opened, so the visitor can confirm what they are authenticating to. - device_tokens grew `apps: Option<Vec<String>>` and verify_for_app for machine clients. None = node-wide, which every existing companion token is; migrating them by guessing a scope would silently revoke access nobody asked to revoke. An empty list is rejected rather than minted, since it reads as unrestricted while authorising nothing. The rollout is necessarily per-app and the gate is built to say so. A container publishing 0.0.0.0:<port> claims every host address, so the gate cannot bind that port until the app is pinned to bind: 127.0.0.1 and recreated — gate-first is impossible, and all-at-once would recreate every container on a node simultaneously. Every port it cannot claim is logged at warn each sweep and recorded in GateStatus::unprotected, surfaced by security.app-gate-status. The failure mode being designed against 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 ruled out an nft drop-in, whose absence is a silent no-op. Not yet done: pinning the 39 gated ports to loopback, repointing HiddenServicePort at the gate, and on-node verification. Tests: 21/21 appgate, workspace builds clean. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
63d0183dd2
commit
0de67ca6ae
@@ -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<String>,
|
||||
}
|
||||
|
||||
/// A port deliberately left unauthenticated, and the manifest's stated reason.
|
||||
///
|
||||
/// Carried around rather than discarded because "which ports are open and
|
||||
/// why" is the question an operator actually asks, and the answer should be
|
||||
/// one RPC call rather than an audit of 56 YAML files.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ExemptPort {
|
||||
pub port: u16,
|
||||
pub app_id: String,
|
||||
pub rationale: String,
|
||||
/// UDP ports are listed for completeness. The gate is TCP-only, so it
|
||||
/// could not touch them even if they were marked `session`.
|
||||
pub protocol: String,
|
||||
}
|
||||
|
||||
/// Everything the gate knows about the node's published surface.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct PortMap {
|
||||
gated: HashMap<u16, GatedPort>,
|
||||
exempt: Vec<ExemptPort>,
|
||||
}
|
||||
|
||||
impl PortMap {
|
||||
/// The app behind `port`, if the gate is responsible for it.
|
||||
pub fn gated(&self, port: u16) -> Option<&GatedPort> {
|
||||
self.gated.get(&port)
|
||||
}
|
||||
|
||||
pub fn gated_ports(&self) -> impl Iterator<Item = &GatedPort> {
|
||||
self.gated.values()
|
||||
}
|
||||
|
||||
pub fn exempt_ports(&self) -> &[ExemptPort] {
|
||||
&self.exempt
|
||||
}
|
||||
|
||||
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<PathBuf> {
|
||||
let mut dirs = Vec::new();
|
||||
if let Ok(manifest_dir) = std::env::var("CARGO_MANIFEST_DIR") {
|
||||
dirs.push(PathBuf::from(manifest_dir).join("../../apps"));
|
||||
}
|
||||
dirs.extend([
|
||||
PathBuf::from("apps"),
|
||||
PathBuf::from("/opt/archipelago/apps"),
|
||||
PathBuf::from("/opt/archipelago/web-ui/archipelago-runtime/apps"),
|
||||
]);
|
||||
dirs
|
||||
}
|
||||
|
||||
/// Read `metadata.icon` out of the manifest's untyped extension bag.
|
||||
fn manifest_icon(manifest: &AppManifest) -> Option<String> {
|
||||
manifest
|
||||
.app
|
||||
.extensions
|
||||
.get("metadata")?
|
||||
.get("icon")?
|
||||
.as_str()
|
||||
.map(str::to_string)
|
||||
}
|
||||
|
||||
/// Classify every published port across all installed manifests.
|
||||
///
|
||||
/// The 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<String, PathBuf> = 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::<std::net::IpAddr>().is_ok_and(|ip| ip.is_loopback()) {
|
||||
continue;
|
||||
}
|
||||
map.gated.insert(
|
||||
port.host,
|
||||
GatedPort {
|
||||
port: port.host,
|
||||
app_id: app_id.clone(),
|
||||
app_name: app_name.clone(),
|
||||
icon: icon.clone(),
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user