2026-08-03 16:46:23 -04:00
|
|
|
//! 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()
|
|
|
|
|
};
|
2026-08-04 00:36:43 -04:00
|
|
|
match port.auth_policy() {
|
2026-08-03 16:46:23 -04:00
|
|
|
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(),
|
|
|
|
|
}),
|
2026-08-03 18:41:29 -04:00
|
|
|
// Declared host-local. Not gated and not reported as
|
|
|
|
|
// exposed, because it is neither — see PortAuth::Local
|
|
|
|
|
// for why this cannot be inferred from `bind`.
|
|
|
|
|
PortAuth::Local => {}
|
2026-08-04 00:36:43 -04:00
|
|
|
// 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 => {
|
|
|
|
|
map.gated.insert(
|
|
|
|
|
port.host,
|
|
|
|
|
GatedPort {
|
|
|
|
|
port: port.host,
|
|
|
|
|
app_id: app_id.clone(),
|
|
|
|
|
app_name: app_name.clone(),
|
|
|
|
|
icon: icon.clone(),
|
|
|
|
|
},
|
|
|
|
|
);
|
|
|
|
|
}
|
2026-08-03 16:46:23 -04:00
|
|
|
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;
|
|
|
|
|
}
|
2026-08-04 00:36:43 -04:00
|
|
|
// A loopback publish is skipped, and this is the
|
|
|
|
|
// safety property of the whole module: the gate must
|
|
|
|
|
// never be the reason a port becomes reachable
|
|
|
|
|
// somewhere it was not. `session` is the DEFAULT, so
|
|
|
|
|
// it is what every un-migrated manifest carries —
|
|
|
|
|
// and a node's installed manifests always lag the
|
|
|
|
|
// repo. Binding those externally published Bitcoin
|
|
|
|
|
// RPC across the LAN within seconds of deploy
|
|
|
|
|
// (archi-dev-box 2026-08-03). Taking over a port is
|
|
|
|
|
// opt-in only: `auth: gated`, shipped in the same
|
|
|
|
|
// manifest edit as the loopback pin.
|
|
|
|
|
if port
|
|
|
|
|
.bind
|
|
|
|
|
.parse::<std::net::IpAddr>()
|
|
|
|
|
.is_ok_and(|ip| ip.is_loopback())
|
|
|
|
|
{
|
|
|
|
|
continue;
|
|
|
|
|
}
|
2026-08-03 16:46:23 -04:00
|
|
|
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"
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-03 18:41:29 -04:00
|
|
|
/// Bitcoin's RPC is host-local by intent (`auth: local`), so the gate
|
|
|
|
|
/// must neither gate it nor report it as exposed — fronting it would
|
|
|
|
|
/// newly publish it on every host address, behind a login but reachable
|
|
|
|
|
/// where it deliberately was not.
|
2026-08-03 16:46:23 -04:00
|
|
|
#[test]
|
2026-08-03 18:41:29 -04:00
|
|
|
fn host_local_ports_are_neither_gated_nor_reported() {
|
|
|
|
|
let map = build_port_map();
|
|
|
|
|
assert!(map.gated(8332).is_none(), "bitcoin RPC must not be gated");
|
|
|
|
|
assert!(
|
|
|
|
|
!map.exempt_ports().iter().any(|e| e.port == 8332),
|
|
|
|
|
"a host-local port is not an unauthenticated exposure"
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-04 00:36:43 -04:00
|
|
|
/// THE safety property. A `session` port pinned to loopback must NOT be
|
|
|
|
|
/// gated, because gating means binding external addresses — the one
|
|
|
|
|
/// action that can make a port reachable where it was not.
|
|
|
|
|
///
|
|
|
|
|
/// This is not hypothetical. `session` is the default, so it is what
|
|
|
|
|
/// every un-migrated manifest carries, and a node's installed manifests
|
|
|
|
|
/// always lag the repo. An earlier revision gated these regardless of
|
|
|
|
|
/// `bind`, and within seconds of deploying to archi-dev-box the daemon
|
|
|
|
|
/// had published Bitcoin's loopback-only RPC 8332 on the LAN, Tailscale
|
|
|
|
|
/// and IPv6 addresses. Taking over a port must be opt-in.
|
2026-08-03 18:41:29 -04:00
|
|
|
#[test]
|
2026-08-04 00:36:43 -04:00
|
|
|
fn a_loopback_pinned_session_port_is_never_gated() {
|
|
|
|
|
let map = build_port_map();
|
|
|
|
|
// aiui and bitcoin RPC are both loopback-pinned in the shipped tree.
|
|
|
|
|
for port in [5180, 8332] {
|
|
|
|
|
assert!(
|
|
|
|
|
map.gated(port).is_none(),
|
|
|
|
|
"port {port} is loopback-pinned; gating it would newly expose it"
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// The migration end state: `auth: gated` opts a loopback-pinned port
|
|
|
|
|
/// into daemon ownership. Without this the rollout could never complete.
|
|
|
|
|
#[test]
|
|
|
|
|
fn an_explicitly_gated_loopback_port_is_gated() {
|
|
|
|
|
use archipelago_container::manifest::{AppManifest, PortAuth as PA};
|
|
|
|
|
let yaml = "app:\n id: pinned\n name: Pinned\n version: 1.0.0\n container:\n image: x:y\n ports:\n - host: 9911\n container: 80\n bind: 127.0.0.1\n auth: gated\n";
|
2026-08-03 18:41:29 -04:00
|
|
|
let m = AppManifest::parse(yaml).expect("parses");
|
2026-08-04 00:36:43 -04:00
|
|
|
assert_eq!(m.app.ports[0].auth, Some(PA::Gated));
|
|
|
|
|
assert_eq!(m.app.ports[0].bind, "127.0.0.1");
|
2026-08-03 16:46:23 -04:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// 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());
|
|
|
|
|
}
|
|
|
|
|
}
|