fix(apps): resolve latent host-port collisions + detect at load time

The strfry/vaultwarden 8082 collision shipped in a signed catalog
because nothing checks cross-app host ports. Fix the remaining latent
pairs from the cfb8d953 audit — did-wallet 8083→8088 (filebrowser
keeps 8083), morphos-server 8086→8089 (netbird-server keeps 8086),
lightning-stack 8087→8091/9737→9738 (netbird and fedimint-gateway
keep theirs) — and fix their healthchecks, which probed the HOST port
inside the container (same class as strfry 6fe62355; now 127.0.0.1 +
container port). load_manifests now flags collisions across the
merged disk+catalog set (bitcoin variants and the mempool umbrella
pair are exempt as mutually exclusive), and a repo-level test parses
apps/*/manifest.yml so a future latent pair fails CI instead of
shipping.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
archipelago 2026-07-10 12:25:42 -04:00
parent 9b1c090e21
commit ebd904262e
4 changed files with 100 additions and 7 deletions

View File

@ -27,7 +27,7 @@ app:
apparmor_profile: did-wallet apparmor_profile: did-wallet
ports: ports:
- host: 8083 - host: 8088
container: 8080 container: 8080
protocol: tcp # Web UI protocol: tcp # Web UI
@ -42,7 +42,7 @@ app:
health_check: health_check:
type: http type: http
endpoint: http://localhost:8083 endpoint: http://127.0.0.1:8080
path: /health path: /health
interval: 30s interval: 30s
timeout: 5s timeout: 5s

View File

@ -29,13 +29,13 @@ app:
apparmor_profile: lightning-stack apparmor_profile: lightning-stack
ports: ports:
- host: 9737 - host: 9738
container: 9735 container: 9735
protocol: tcp # P2P protocol: tcp # P2P
- host: 10010 - host: 10010
container: 10009 container: 10009
protocol: tcp # gRPC protocol: tcp # gRPC
- host: 8087 - host: 8091
container: 8080 container: 8080
protocol: tcp # REST/Web UI protocol: tcp # REST/Web UI
@ -53,7 +53,7 @@ app:
health_check: health_check:
type: http type: http
endpoint: http://localhost:8087 endpoint: http://127.0.0.1:8080
path: /v1/getinfo path: /v1/getinfo
interval: 30s interval: 30s
timeout: 5s timeout: 5s

View File

@ -27,7 +27,7 @@ app:
apparmor_profile: morphos-server apparmor_profile: morphos-server
ports: ports:
- host: 8086 - host: 8089
container: 8080 container: 8080
protocol: tcp # Web UI protocol: tcp # Web UI
@ -43,7 +43,7 @@ app:
health_check: health_check:
type: http type: http
endpoint: http://localhost:8086 endpoint: http://127.0.0.1:8080
path: /health path: /health
interval: 30s interval: 30s
timeout: 5s timeout: 5s

View File

@ -1032,6 +1032,37 @@ fn resolve_catalog_image(resolved_manifest: &mut AppManifest) {
} }
} }
/// Host-port collisions across a set of manifests, excluding pairs that are
/// mutually exclusive by design (the bitcoin-core/knots variants; the legacy
/// mempool umbrella vs its split frontend, which render the same container).
/// The strfry/vaultwarden 8082 collision shipped in a SIGNED catalog
/// (2026-07-09) because nothing checked — surface these loudly at load time.
fn host_port_collisions<'m>(
manifests: impl Iterator<Item = &'m AppManifest>,
) -> Vec<(u16, String, String)> {
fn mutually_exclusive(a: &str, b: &str) -> bool {
let pair = |x: &str, y: &str| (a == x && b == y) || (a == y && b == x);
pair("bitcoin-core", "bitcoin-knots") || pair("mempool", "archy-mempool-web")
}
let mut by_port: std::collections::BTreeMap<u16, Vec<&str>> = Default::default();
for m in manifests {
for p in &m.app.ports {
by_port.entry(p.host).or_default().push(m.app.id.as_str());
}
}
let mut out = Vec::new();
for (port, apps) in by_port {
for i in 0..apps.len() {
for j in i + 1..apps.len() {
if apps[i] != apps[j] && !mutually_exclusive(apps[i], apps[j]) {
out.push((port, apps[i].to_string(), apps[j].to_string()));
}
}
}
}
out
}
/// Internal: track a manifest together with the absolute directory it was loaded /// Internal: track a manifest together with the absolute directory it was loaded
/// from, so Build sources can resolve relative `context:` paths. /// from, so Build sources can resolve relative `context:` paths.
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
@ -1387,6 +1418,16 @@ impl ProdContainerOrchestrator {
); );
} }
} }
for (port, a, b) in
host_port_collisions(state.manifests.values().map(|lm| &lm.manifest))
{
tracing::error!(
port,
app_a = %a,
app_b = %b,
"host-port collision between app manifests — installing both will break whichever starts second"
);
}
Ok(state.manifests.len()) Ok(state.manifests.len())
} }
@ -5269,6 +5310,58 @@ app:
assert!(!calls.iter().any(|c| c.starts_with("start_container:"))); assert!(!calls.iter().any(|c| c.starts_with("start_container:")));
} }
#[test]
fn host_port_collisions_flags_pairs_and_skips_exclusive_variants() {
let with_port = |id: &str, port: u16| {
let yaml = format!(
"app:\n id: {id}\n name: {id}\n version: 1.0.0\n container:\n image: {id}:1\n ports:\n - host: {port}\n container: 80\n protocol: tcp\n"
);
AppManifest::parse(&yaml).unwrap()
};
let a = with_port("strfry", 8082);
let b = with_port("vaultwarden", 8082);
let c = with_port("grafana", 3001);
let core = with_port("bitcoin-core", 8332);
let knots = with_port("bitcoin-knots", 8332);
let found = host_port_collisions([&a, &b, &c, &core, &knots].into_iter());
assert_eq!(
found,
vec![(8082, "strfry".to_string(), "vaultwarden".to_string())]
);
}
#[test]
fn repo_app_manifests_have_no_host_port_collisions() {
// The signed catalog regenerates from apps/*/manifest.yml — a latent
// collision here ships fleet-wide at the next ceremony (strfry/
// vaultwarden 8082, found live 2026-07-09). Keep the tree clean.
let apps_dir = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../apps");
if !apps_dir.exists() {
return; // not a full checkout (e.g. crate published standalone)
}
let mut manifests = Vec::new();
for entry in std::fs::read_dir(&apps_dir).unwrap() {
let path = entry.unwrap().path().join("manifest.yml");
if !path.exists() {
continue;
}
if let Ok(m) = AppManifest::from_file(&path) {
manifests.push(m);
}
}
assert!(
manifests.len() > 10,
"expected to parse the repo app manifests, got {}",
manifests.len()
);
let found = host_port_collisions(manifests.iter());
assert!(
found.is_empty(),
"host-port collisions in apps/ manifests: {found:?}"
);
}
#[test] #[test]
fn cascade_pairs_cover_backend_recreate_with_running_dependent() { fn cascade_pairs_cover_backend_recreate_with_running_dependent() {
use std::collections::HashSet; use std::collections::HashSet;