Demo images / Build & push demo images (push) Failing after 2m13s
The repo is source code and guidelines only. Nothing about how Archipelago's own fleet is run, or how the team works, stays in it. Untracked (kept on disk, gitignored) — 250 files: - .planning/ (199) and loop/ — internal development process - fleet operations tooling that targets specific nodes: deploy-to-target, deploy-tailscale, deploy-config-defaults, setup-target-dev, setup-aiui-server, setup-https-dev, debug-frontend, node-profile, fleet-fips-pair/unpair, image-recipe/sync-from-live.sh - image-recipe/INTEGRATION-GUIDE.md and docs/multinode-testing-plan.md, both of which are live-server workflow and fleet node inventories - the Phase 10 on-node verification and evidence records, which cite .planning/ as their evidence base KEY-05-ENTROPY-ENFORCEMENT.md was initially moved out with the other Phase 10 docs and then put back: it is cited as normative rationale from ten places in the codebase, including core/clippy.toml, which bans rand::thread_rng and points at it for the reason. That makes it a guideline, not an internal record. Node names removed from source (48 occurrences across comments, manifests and test fixtures): archi-dev-box, archy-x250*, shorty-s, framework-pt, zaza-optiplex, archi-thinkpad. Comments keep the engineering context and the date, which is what carried the meaning; the machine name did not. Three of those were live test values rather than comments and were replaced with valid stand-ins, not prose: two mDNS hostnames and a mesh peer name. An earlier pass substituted "a test node" into a hostname assertion, producing an invalid hostname; caught and fixed as test-node.local. Wipe mechanism: .local-only/manifest.txt inventories every local-only path and .local-only/wipe.sh deletes them on one confirmation, refusing to touch anything git still tracks. Both are themselves untracked, so the public repo does not carry a map of internal filenames. Verified: cargo check -p archipelago --all-features clean; archipelago-container 75/75 tests pass; appOrigin vitest 7/7; audit-secrets 5/5; every relative link in tracked markdown resolves (0 broken). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
485 lines
20 KiB
Rust
485 lines
20 KiB
Rust
//! 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>,
|
|
/// 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,
|
|
/// 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,
|
|
}
|
|
|
|
/// 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>,
|
|
local: std::collections::HashSet<u16>,
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
/// 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
|
|
/// unauthenticated (test node 2026-08-04). Anything that republishes
|
|
/// a loopback port must consult this set first.
|
|
pub fn is_declared_local(&self, port: u16) -> bool {
|
|
self.local.contains(&port)
|
|
}
|
|
|
|
pub fn is_empty(&self) -> bool {
|
|
self.gated.is_empty() && self.exempt.is_empty() && self.local.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 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
|
|
/// externally bound a deliberately host-local port (a test node
|
|
/// 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.
|
|
pub fn build_port_map() -> PortMap {
|
|
let mut map = PortMap::default();
|
|
let mut seen_apps: std::collections::HashSet<String> = std::collections::HashSet::new();
|
|
|
|
for (app_id, value) in crate::container::app_catalog::catalog_manifest_values() {
|
|
// 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.
|
|
let Some(manifest) =
|
|
crate::container::app_catalog::catalog_manifest_ports_overlay(&app_id, value)
|
|
else {
|
|
// Unparseable/invalid → the orchestrator falls back to disk for
|
|
// this app, so classification must too.
|
|
continue;
|
|
};
|
|
if seen_apps.insert(app_id) {
|
|
classify_manifest(&manifest, &mut map);
|
|
}
|
|
}
|
|
|
|
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;
|
|
};
|
|
if seen_apps.insert(manifest.app.id.clone()) {
|
|
classify_manifest(&manifest, &mut map);
|
|
}
|
|
}
|
|
}
|
|
|
|
map.exempt.sort_by_key(|e| e.port);
|
|
map
|
|
}
|
|
|
|
/// 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
|
|
// 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);
|
|
}
|
|
// 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,
|
|
session_passthrough: port.session_passthrough,
|
|
},
|
|
);
|
|
}
|
|
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
|
|
// (test node 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;
|
|
}
|
|
map.gated.insert(
|
|
port.host,
|
|
GatedPort {
|
|
port: port.host,
|
|
app_id: app_id.clone(),
|
|
app_name: app_name.clone(),
|
|
icon: icon.clone(),
|
|
declared: false,
|
|
// An undeclared port never gets the node session —
|
|
// passthrough is an explicit manifest opt-in only.
|
|
session_passthrough: false,
|
|
},
|
|
);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
#[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
|
|
);
|
|
}
|
|
}
|
|
|
|
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
|
|
/// neither gated nor exempt-reported — but it IS recorded, so the mesh
|
|
/// relay can refuse to republish a deliberately host-local port.
|
|
#[test]
|
|
fn local_ports_are_untouched_but_recorded() {
|
|
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());
|
|
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"
|
|
);
|
|
}
|
|
|
|
/// 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 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.
|
|
#[test]
|
|
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"
|
|
);
|
|
}
|
|
|
|
/// 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 a test node 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.
|
|
#[test]
|
|
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";
|
|
let m = AppManifest::parse(yaml).expect("parses");
|
|
assert_eq!(m.app.ports[0].auth, Some(PA::Gated));
|
|
assert_eq!(m.app.ports[0].bind, "127.0.0.1");
|
|
}
|
|
|
|
/// 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());
|
|
}
|
|
}
|