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>,
|
2026-08-04 08:50:46 -04:00
|
|
|
/// True only when the manifest says `auth: gated` in so many words.
|
|
|
|
|
///
|
|
|
|
|
/// The gated set deliberately also carries undeclared Session-default
|
|
|
|
|
/// ports (so the gate challenges them wherever it can already stand, and
|
|
|
|
|
/// the audit reports them). But everything that CHANGES where traffic
|
|
|
|
|
/// goes — the torrc repoint to 127.0.0.2, the FIPS relay stand-down, the
|
|
|
|
|
/// Tor-upstream bind — must key on this flag: acting on an undeclared
|
|
|
|
|
/// port is the v1.7.121 incident class, whatever the action.
|
|
|
|
|
pub declared: bool,
|
2026-08-05 21:21:52 -04:00
|
|
|
/// Manifest opt-in (`session_passthrough: true` on the port): forward the
|
|
|
|
|
/// node session cookie to the app on authorised requests. First-party
|
|
|
|
|
/// 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,
|
2026-08-03 16:46:23 -04:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// 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>,
|
2026-08-04 10:40:28 -04:00
|
|
|
local: std::collections::HashSet<u16>,
|
2026-08-03 16:46:23 -04:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-04 10:40:28 -04:00
|
|
|
/// Declared `auth: local` — host-local by intent, so NOTHING may make it
|
|
|
|
|
/// externally reachable.
|
|
|
|
|
///
|
|
|
|
|
/// The gate honours this by keeping its hands off, but it is not the only
|
|
|
|
|
/// thing that can publish a port: the FIPS mesh relay bridges the fips0
|
|
|
|
|
/// ULA to `127.0.0.1` for a static port list, and it forwarded nbxplorer
|
|
|
|
|
/// 32838 — declared `local` and pinned to loopback — to the mesh
|
2026-08-07 10:37:20 -04:00
|
|
|
/// unauthenticated (test node 2026-08-04). Anything that republishes
|
2026-08-04 10:40:28 -04:00
|
|
|
/// a loopback port must consult this set first.
|
|
|
|
|
pub fn is_declared_local(&self, port: u16) -> bool {
|
|
|
|
|
self.local.contains(&port)
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-03 16:46:23 -04:00
|
|
|
pub fn is_empty(&self) -> bool {
|
2026-08-04 10:40:28 -04:00
|
|
|
self.gated.is_empty() && self.exempt.is_empty() && self.local.is_empty()
|
2026-08-03 16:46:23 -04:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// 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.
|
|
|
|
|
///
|
2026-08-04 08:50:46 -04:00
|
|
|
/// The signed catalog's embedded manifests are consulted FIRST, because they
|
|
|
|
|
/// are what the orchestrator actually publishes containers from
|
|
|
|
|
/// (origin-wins; see `app_catalog::catalog_manifest_overlay`). Classifying
|
|
|
|
|
/// from disk alone made the gate act on policy the node was no longer
|
|
|
|
|
/// running: the catalog declared nbxplorer `auth: local` and pinned it to
|
|
|
|
|
/// loopback, the stale disk manifest declared nothing, and the gate
|
2026-08-07 10:37:20 -04:00
|
|
|
/// externally bound a deliberately host-local port (a test node
|
2026-08-04 08:50:46 -04:00
|
|
|
/// 2026-08-04).
|
|
|
|
|
///
|
|
|
|
|
/// After the catalog, 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.
|
2026-08-03 16:46:23 -04:00
|
|
|
pub fn build_port_map() -> PortMap {
|
|
|
|
|
let mut map = PortMap::default();
|
2026-08-04 08:50:46 -04:00
|
|
|
let mut seen_apps: std::collections::HashSet<String> = std::collections::HashSet::new();
|
|
|
|
|
|
|
|
|
|
for (app_id, value) in crate::container::app_catalog::catalog_manifest_values() {
|
2026-08-06 09:17:10 -04:00
|
|
|
// Ports-only overlay: unlike the install path, classification also
|
|
|
|
|
// accepts BUILD-SOURCE manifests. The on-node-built companion UIs
|
|
|
|
|
// are exactly the apps whose gate policy (session_passthrough,
|
|
|
|
|
// auth: gated) must arrive reliably, and their disk manifests
|
|
|
|
|
// proved stale or absent fleet-wide in the v1.7.125 rollout. The
|
|
|
|
|
// gate's binds fail safely on conflict with a differently-published
|
|
|
|
|
// container, so a fresher catalog can only tighten, never expose.
|
2026-08-04 08:50:46 -04:00
|
|
|
let Some(manifest) =
|
2026-08-06 09:17:10 -04:00
|
|
|
crate::container::app_catalog::catalog_manifest_ports_overlay(&app_id, value)
|
2026-08-04 08:50:46 -04:00
|
|
|
else {
|
2026-08-06 09:17:10 -04:00
|
|
|
// Unparseable/invalid → the orchestrator falls back to disk for
|
|
|
|
|
// this app, so classification must too.
|
2026-08-04 08:50:46 -04:00
|
|
|
continue;
|
|
|
|
|
};
|
|
|
|
|
if seen_apps.insert(app_id) {
|
|
|
|
|
classify_manifest(&manifest, &mut map);
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-08-03 16:46:23 -04:00
|
|
|
|
|
|
|
|
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;
|
|
|
|
|
};
|
2026-08-04 08:50:46 -04:00
|
|
|
if seen_apps.insert(manifest.app.id.clone()) {
|
|
|
|
|
classify_manifest(&manifest, &mut map);
|
2026-08-03 16:46:23 -04:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
map.exempt.sort_by_key(|e| e.port);
|
|
|
|
|
map
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-04 08:50:46 -04:00
|
|
|
/// Classify one manifest's ports into the map. Split from [`build_port_map`]
|
|
|
|
|
/// so the catalog-overlay pass and the disk pass cannot diverge.
|
|
|
|
|
fn classify_manifest(manifest: &AppManifest, map: &mut PortMap) {
|
|
|
|
|
let app_id = manifest.app.id.clone();
|
|
|
|
|
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_policy() {
|
|
|
|
|
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(),
|
|
|
|
|
}),
|
|
|
|
|
// Declared host-local. Not gated and not reported as
|
|
|
|
|
// exposed, because it is neither — see PortAuth::Local
|
2026-08-04 10:40:28 -04:00
|
|
|
// for why this cannot be inferred from `bind`. Recorded so
|
|
|
|
|
// the mesh relay (and any future republisher) can refuse to
|
|
|
|
|
// expose it.
|
|
|
|
|
PortAuth::Local => {
|
|
|
|
|
map.local.insert(port.host);
|
|
|
|
|
}
|
2026-08-04 08:50:46 -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(),
|
|
|
|
|
declared: true,
|
2026-08-05 21:21:52 -04:00
|
|
|
session_passthrough: port.session_passthrough,
|
2026-08-04 08:50:46 -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;
|
|
|
|
|
}
|
|
|
|
|
// 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
|
2026-08-07 10:37:20 -04:00
|
|
|
// (test node 2026-08-03). Taking over a port is
|
2026-08-04 08:50:46 -04:00
|
|
|
// 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;
|
|
|
|
|
}
|
|
|
|
|
map.gated.insert(
|
|
|
|
|
port.host,
|
|
|
|
|
GatedPort {
|
|
|
|
|
port: port.host,
|
|
|
|
|
app_id: app_id.clone(),
|
|
|
|
|
app_name: app_name.clone(),
|
|
|
|
|
icon: icon.clone(),
|
|
|
|
|
declared: false,
|
2026-08-05 21:21:52 -04:00
|
|
|
// An undeclared port never gets the node session —
|
|
|
|
|
// passthrough is an explicit manifest opt-in only.
|
|
|
|
|
session_passthrough: false,
|
2026-08-04 08:50:46 -04:00
|
|
|
},
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-03 16:46:23 -04:00
|
|
|
#[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
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-04 08:50:46 -04:00
|
|
|
fn manifest(yaml: &str) -> AppManifest {
|
|
|
|
|
AppManifest::parse(yaml).expect("test manifest must parse")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const BASE: &str = r#"
|
|
|
|
|
app:
|
|
|
|
|
id: testapp
|
|
|
|
|
name: Test App
|
|
|
|
|
version: "1.0"
|
|
|
|
|
container:
|
|
|
|
|
image: example.org/testapp:1.0
|
|
|
|
|
"#;
|
|
|
|
|
|
|
|
|
|
/// `auth: gated` is the only classification allowed to redirect traffic —
|
|
|
|
|
/// torrc repoints, relay stand-down, and the 127.0.0.2 bind all key on
|
|
|
|
|
/// `declared`. An undeclared Session port is challenged and audited but
|
|
|
|
|
/// must never be `declared`.
|
|
|
|
|
#[test]
|
|
|
|
|
fn declared_tracks_the_manifest_not_the_default() {
|
|
|
|
|
let mut map = PortMap::default();
|
|
|
|
|
classify_manifest(
|
|
|
|
|
&manifest(&format!(
|
|
|
|
|
"{BASE} ports:\n - host: 8090\n container: 7777\n protocol: tcp\n bind: 127.0.0.1\n auth: gated\n"
|
|
|
|
|
)),
|
|
|
|
|
&mut map,
|
|
|
|
|
);
|
|
|
|
|
assert!(map.gated(8090).expect("gated").declared);
|
|
|
|
|
|
|
|
|
|
let mut map = PortMap::default();
|
|
|
|
|
classify_manifest(
|
|
|
|
|
&manifest(&format!(
|
|
|
|
|
"{BASE} ports:\n - host: 9100\n container: 9100\n protocol: tcp\n"
|
|
|
|
|
)),
|
|
|
|
|
&mut map,
|
|
|
|
|
);
|
|
|
|
|
let undeclared = map.gated(9100).expect("session default is challenged");
|
|
|
|
|
assert!(
|
|
|
|
|
!undeclared.declared,
|
|
|
|
|
"an absent auth field must never read as an instruction"
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// `auth: local` keeps the gate's hands off entirely — the port is
|
2026-08-04 10:40:28 -04:00
|
|
|
/// neither gated nor exempt-reported — but it IS recorded, so the mesh
|
|
|
|
|
/// relay can refuse to republish a deliberately host-local port.
|
2026-08-04 08:50:46 -04:00
|
|
|
#[test]
|
2026-08-04 10:40:28 -04:00
|
|
|
fn local_ports_are_untouched_but_recorded() {
|
2026-08-04 08:50:46 -04:00
|
|
|
let mut map = PortMap::default();
|
|
|
|
|
classify_manifest(
|
|
|
|
|
&manifest(&format!(
|
|
|
|
|
"{BASE} ports:\n - host: 32838\n container: 32838\n protocol: tcp\n bind: 127.0.0.1\n auth: local\n"
|
|
|
|
|
)),
|
|
|
|
|
&mut map,
|
|
|
|
|
);
|
|
|
|
|
assert!(map.gated(32838).is_none());
|
|
|
|
|
assert!(map.exempt_ports().is_empty());
|
2026-08-04 10:40:28 -04:00
|
|
|
assert!(
|
|
|
|
|
map.is_declared_local(32838),
|
|
|
|
|
"the mesh relay needs this to refuse bridging a host-local port"
|
|
|
|
|
);
|
|
|
|
|
assert!(!map.is_declared_local(3000));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// The real corpus: every port the FIPS relay can bridge must be safe to
|
|
|
|
|
/// bridge. A port that is declared `local` (host-local by intent) or
|
|
|
|
|
/// declared `gated` (the app gate owns its external addresses) must be
|
|
|
|
|
/// withheld by the relay — this asserts the two sets the relay consults
|
|
|
|
|
/// actually classify the live manifests, so a future manifest edit that
|
|
|
|
|
/// re-opens one is caught here rather than on a node.
|
|
|
|
|
#[test]
|
|
|
|
|
fn relay_port_list_respects_local_and_gated_declarations() {
|
|
|
|
|
let map = build_port_map();
|
|
|
|
|
let relay_would_expose: Vec<u16> = crate::fips::app_ports::APP_LAUNCH_PORTS
|
|
|
|
|
.iter()
|
|
|
|
|
.copied()
|
|
|
|
|
.filter(|p| map.is_declared_local(*p))
|
|
|
|
|
.collect();
|
|
|
|
|
assert!(
|
|
|
|
|
!relay_would_expose.is_empty(),
|
|
|
|
|
"expected the corpus to contain at least one local port in the relay list \
|
|
|
|
|
(32838/8999) — if this fails the guard is untested, not unnecessary"
|
|
|
|
|
);
|
2026-08-04 08:50:46 -04:00
|
|
|
}
|
|
|
|
|
|
2026-08-03 16:46:23 -04:00
|
|
|
/// 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
|
2026-08-07 10:37:20 -04:00
|
|
|
/// `bind`, and within seconds of deploying to a test node the daemon
|
2026-08-04 00:36:43 -04:00
|
|
|
/// 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());
|
|
|
|
|
}
|
|
|
|
|
}
|