feat(security): app gate — authenticate every app port
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:
archipelago
2026-08-03 16:46:23 -04:00
co-authored by Claude Opus 5
parent 63d0183dd2
commit 0de67ca6ae
12 changed files with 1581 additions and 6 deletions
+261
View File
@@ -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());
}
}