diff --git a/core/archipelago/src/api/rpc/package/dependencies.rs b/core/archipelago/src/api/rpc/package/dependencies.rs index 059d1a37..ae7eee1b 100644 --- a/core/archipelago/src/api/rpc/package/dependencies.rs +++ b/core/archipelago/src/api/rpc/package/dependencies.rs @@ -25,8 +25,12 @@ fn manifest_declares_archival_bitcoin(package_id: &str) -> bool { // Registry-only apps need the same guard as OTA-bundled manifests. Honor // the verified catalog's effective manifest before the disk fallback. if let Some((_, value)) = crate::container::app_catalog::catalog_manifest_values() - .into_iter().find(|(id, _)| id == package_id) { - if let Some(manifest) = crate::container::app_catalog::catalog_manifest_overlay(package_id, value) { + .into_iter() + .find(|(id, _)| id == package_id) + { + if let Some(manifest) = + crate::container::app_catalog::catalog_manifest_overlay(package_id, value) + { return dependency_list_declares_archival_bitcoin(&manifest.app.dependencies); } } @@ -1063,9 +1067,14 @@ mod tests { // edit to `requires_unpruned_bitcoin`. assert!(manifest_declares_archival_bitcoin("electrumx")); assert!(manifest_declares_archival_bitcoin("mempool")); - let angor = archipelago_container::AppManifest::parse(include_str!(concat!(env!("CARGO_MANIFEST_DIR"), - "/../../apps/angor-indexer/manifest.yml"))).unwrap(); - assert!(dependency_list_declares_archival_bitcoin(&angor.app.dependencies)); + let angor = archipelago_container::AppManifest::parse(include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../apps/angor-indexer/manifest.yml" + ))) + .unwrap(); + assert!(dependency_list_declares_archival_bitcoin( + &angor.app.dependencies + )); // An app whose manifest exists but never declares the marker. assert!(!manifest_declares_archival_bitcoin("bitcoin-knots")); // An id with no manifest on disk at all. diff --git a/core/archipelago/src/container/app_catalog.rs b/core/archipelago/src/container/app_catalog.rs index ce646460..f632bec6 100644 --- a/core/archipelago/src/container/app_catalog.rs +++ b/core/archipelago/src/container/app_catalog.rs @@ -117,9 +117,12 @@ pub struct CatalogManifestVariant { fn selected_manifest(entry: AppCatalogEntry) -> Option { // Never let an unknown future requirement become an unsafe partial match. for variant in entry.manifest_variants.into_iter().rev() { - if !variant.requires.is_empty() && variant.requires.iter().all(|capability| { - capability == "runtime-migration-backup-v1" - }) { + if !variant.requires.is_empty() + && variant + .requires + .iter() + .all(|capability| capability == "runtime-migration-backup-v1") + { return Some(variant.manifest); } } @@ -587,7 +590,9 @@ mod tests { "manifest": {"app": {"id": "portainer", "container": {"network": "slirp4netns"}, "backup_before_runtime_change": true}}}] }); #[derive(Deserialize)] - struct OldEntry { manifest: serde_json::Value } + struct OldEntry { + manifest: serde_json::Value, + } let old: OldEntry = serde_json::from_value(raw.clone()).unwrap(); assert!(old.manifest["app"]["container"].get("network").is_none()); let current: AppCatalogEntry = serde_json::from_value(raw.clone()).unwrap(); @@ -595,7 +600,10 @@ mod tests { assert_eq!(chosen["app"]["container"]["network"], "slirp4netns"); assert_eq!(chosen["app"]["backup_before_runtime_change"], true); let mut future = raw; - future["manifest_variants"][0]["requires"].as_array_mut().unwrap().push(serde_json::json!("unknown-next-capability")); + future["manifest_variants"][0]["requires"] + .as_array_mut() + .unwrap() + .push(serde_json::json!("unknown-next-capability")); let chosen = selected_manifest(serde_json::from_value(future).unwrap()).unwrap(); assert!(chosen["app"]["container"].get("network").is_none()); } diff --git a/core/archipelago/src/container/prod_orchestrator.rs b/core/archipelago/src/container/prod_orchestrator.rs index df1cf2ac..afdede91 100644 --- a/core/archipelago/src/container/prod_orchestrator.rs +++ b/core/archipelago/src/container/prod_orchestrator.rs @@ -107,7 +107,9 @@ fn rootless_network_mode_drifted(expected: Option<&str>, actual: &str) -> bool { fn missing_declared_capability(expected: &[String], actual: &[String]) -> bool { expected.iter().any(|required| { let required = required.strip_prefix("CAP_").unwrap_or(required); - !actual.iter().any(|cap| cap.strip_prefix("CAP_").unwrap_or(cap) == required) + !actual + .iter() + .any(|cap| cap.strip_prefix("CAP_").unwrap_or(cap) == required) }) } @@ -2484,7 +2486,8 @@ impl ProdContainerOrchestrator { .await { tracing::info!(app_id = %app_id, container = %name, "container published-port drift detected — recreating"); - self.backup_runtime_change(&name, &resolved_manifest).await?; + self.backup_runtime_change(&name, &resolved_manifest) + .await?; let _ = self.runtime.stop_container(&name).await; let _ = self.runtime.remove_container(&name).await; self.install_fresh(lm).await?; @@ -2520,7 +2523,8 @@ impl ProdContainerOrchestrator { return Ok(ReconcileAction::NoOp); } tracing::info!(app_id = %app_id, container = %name, "container env drift detected — recreating"); - self.backup_runtime_change(&name, &resolved_manifest).await?; + self.backup_runtime_change(&name, &resolved_manifest) + .await?; let _ = self.runtime.stop_container(&name).await; let _ = self.runtime.remove_container(&name).await; self.install_fresh(lm).await?; @@ -2577,7 +2581,8 @@ impl ProdContainerOrchestrator { .await { tracing::info!(app_id = %app_id, container = %name, "stopped container env/port drift detected — recreating"); - self.backup_runtime_change(&name, &resolved_manifest).await?; + self.backup_runtime_change(&name, &resolved_manifest) + .await?; let _ = self.runtime.remove_container(&name).await; self.install_fresh(lm).await?; return Ok(ReconcileAction::Installed); @@ -2634,7 +2639,8 @@ impl ProdContainerOrchestrator { self.prepare_for_start(&resolved_manifest).await?; if self.container_env_drifted(&name, &resolved_manifest).await { tracing::info!(app_id = %app_id, container = %name, "created container env drift detected — recreating"); - self.backup_runtime_change(&name, &resolved_manifest).await?; + self.backup_runtime_change(&name, &resolved_manifest) + .await?; let _ = self.runtime.remove_container(&name).await; self.install_fresh(lm).await?; return Ok(ReconcileAction::Installed); @@ -3899,7 +3905,9 @@ impl ProdContainerOrchestrator { // opted-in manifests identify their persistent state through bind mounts. let output = tokio::process::Command::new("podman") .args(["inspect", name, "--format", "{{.HostConfig.NetworkMode}}"]) - .output().await.context("inspect network before migration backup")?; + .output() + .await + .context("inspect network before migration backup")?; let present = if output.status.success() { true } else { @@ -3907,7 +3915,9 @@ impl ProdContainerOrchestrator { // leave only its data and old unit. Prove absence before snapshotting // stopped state; an inspect/Podman failure is not proof of absence. let exists = tokio::process::Command::new("podman") - .args(["container", "exists", name]).status().await?; + .args(["container", "exists", name]) + .status() + .await?; if exists.code() != Some(1) { anyhow::bail!("cannot verify existing container before runtime migration backup"); } @@ -3916,7 +3926,10 @@ impl ProdContainerOrchestrator { let service = format!("{name}.service"); let managed = quadlet::unit_exists(name).await; let previous_unit = if managed { - Some(tokio::fs::read(quadlet::unit_dir().await?.join(format!("{name}.container"))).await?) + Some( + tokio::fs::read(quadlet::unit_dir().await?.join(format!("{name}.container"))) + .await?, + ) } else { None }; @@ -3925,7 +3938,13 @@ impl ProdContainerOrchestrator { } else if present { self.runtime.stop_container(name).await?; } - match crate::container::migration_backup::snapshot(manifest, &self.data_dir, previous_unit.as_deref()).await { + match crate::container::migration_backup::snapshot( + manifest, + &self.data_dir, + previous_unit.as_deref(), + ) + .await + { Ok(archive) => { tracing::info!(container = %name, backup = %archive.display(), "Persistent state saved before runtime migration"); Ok(()) @@ -3961,11 +3980,13 @@ impl ProdContainerOrchestrator { if unmanaged && !manifest.app.security.capabilities.is_empty() { if let Ok(output) = tokio::process::Command::new("podman") .args(["inspect", name, "--format", "{{json .BoundingCaps}}"]) - .output().await + .output() + .await { if output.status.success() { if let Ok(actual) = serde_json::from_slice::>(&output.stdout) { - if missing_declared_capability(&manifest.app.security.capabilities, &actual) { + if missing_declared_capability(&manifest.app.security.capabilities, &actual) + { return true; } } @@ -3975,16 +3996,23 @@ impl ProdContainerOrchestrator { // Quadlet handles declarative Network= drift above. Legacy rootless // Podman containers need the same convergence when no unit owns them. - if unmanaged && matches!(manifest.app.container.network.as_deref(), Some("slirp4netns" | "pasta")) { + if unmanaged + && matches!( + manifest.app.container.network.as_deref(), + Some("slirp4netns" | "pasta") + ) + { if let Ok(output) = tokio::process::Command::new("podman") .args(["inspect", name, "--format", "{{.HostConfig.NetworkMode}}"]) .output() .await { - if output.status.success() && rootless_network_mode_drifted( - manifest.app.container.network.as_deref(), - &String::from_utf8_lossy(&output.stdout), - ) { + if output.status.success() + && rootless_network_mode_drifted( + manifest.app.container.network.as_deref(), + &String::from_utf8_lossy(&output.stdout), + ) + { return true; } } @@ -4560,21 +4588,38 @@ impl ContainerOrchestrator for ProdContainerOrchestrator { // Optional shared-service preconditions are checked before recording // installation or creating anything. A headless adapter must not claim // successful installation against a missing indexing stack. - if let Some(required) = lm.manifest.app.extensions.get("install_prerequisites") - .and_then(|value| value.as_sequence()) { - let present = self.runtime.list_containers().await + if let Some(required) = lm + .manifest + .app + .extensions + .get("install_prerequisites") + .and_then(|value| value.as_sequence()) + { + let present = self + .runtime + .list_containers() + .await .context("check installed prerequisite services")?; for id in required.iter().filter_map(|value| value.as_str()) { let dependency = self.loaded(id).await.map_err(|_| InstallPrerequisiteError( format!("Required app {id} is unavailable. Refresh the app catalog before installing {}.", lm.manifest.app.name)))?; let name = compute_container_name(&dependency.manifest); - if !present.iter().any(|container| container.name.trim_start_matches('/') == name) { + if !present + .iter() + .any(|container| container.name.trim_start_matches('/') == name) + { let owner = crate::app_ops::owning_package(id); - let title = self.loaded(owner).await.map(|app| app.manifest.app.name) + let title = self + .loaded(owner) + .await + .map(|app| app.manifest.app.name) .unwrap_or(dependency.manifest.app.name); return Err(InstallPrerequisiteError(format!( - "Install {title} first, then install {}.", lm.manifest.app.name)).into()); + "Install {title} first, then install {}.", + lm.manifest.app.name + )) + .into()); } } } @@ -5055,37 +5100,71 @@ mod tests { /// is not a stack member at all. #[tokio::test] async fn gitea_fresh_url_seed_preserves_operator_config_and_reports_write_failure() { - let manifest = AppManifest::parse(include_str!("../../../../apps/gitea/manifest.yml")).unwrap(); + let manifest = + AppManifest::parse(include_str!("../../../../apps/gitea/manifest.yml")).unwrap(); let seed = &manifest.app.files[0]; assert!(!seed.overwrite); let content = seed.content.replace("{{HOST_IP}}", "192.0.2.1"); assert!(content.contains("ROOT_URL = http://192.0.2.1:3001/")); let dir = tempfile::tempdir().unwrap(); let path = dir.path().join("fresh/app.ini"); - assert_eq!(ensure_rendered_file(path.to_str().unwrap(), &content, seed.overwrite).await.unwrap(), HookOutcome::Rewritten); - assert!(tokio::fs::read_to_string(&path).await.unwrap().contains("ROOT_URL")); - let custom = "[server]\nROOT_URL = https://git.example.test/\n[database]\nDB_TYPE = postgres\n"; + assert_eq!( + ensure_rendered_file(path.to_str().unwrap(), &content, seed.overwrite) + .await + .unwrap(), + HookOutcome::Rewritten + ); + assert!(tokio::fs::read_to_string(&path) + .await + .unwrap() + .contains("ROOT_URL")); + let custom = + "[server]\nROOT_URL = https://git.example.test/\n[database]\nDB_TYPE = postgres\n"; tokio::fs::write(&path, custom).await.unwrap(); - assert_eq!(ensure_rendered_file(path.to_str().unwrap(), &content, seed.overwrite).await.unwrap(), HookOutcome::Unchanged); + assert_eq!( + ensure_rendered_file(path.to_str().unwrap(), &content, seed.overwrite) + .await + .unwrap(), + HookOutcome::Unchanged + ); assert_eq!(tokio::fs::read_to_string(&path).await.unwrap(), custom); let impossible = path.join("app.ini"); - assert!(ensure_rendered_file(impossible.to_str().unwrap(), &content, seed.overwrite).await.is_err()); + assert!( + ensure_rendered_file(impossible.to_str().unwrap(), &content, seed.overwrite) + .await + .is_err() + ); } #[test] fn ssh_sandbox_capability_repair_uses_bounding_set_and_preserves_extra_overrides() { let required = vec!["CHOWN".into(), "SYS_CHROOT".into()]; - assert!(missing_declared_capability(&required, &["CAP_CHOWN".into()])); - assert!(!missing_declared_capability(&required, &["CAP_CHOWN".into(), "CAP_SYS_CHROOT".into()])); - assert!(!missing_declared_capability(&required, &["CHOWN".into(), "SYS_CHROOT".into(), "CAP_KILL".into()])); + assert!(missing_declared_capability( + &required, + &["CAP_CHOWN".into()] + )); + assert!(!missing_declared_capability( + &required, + &["CAP_CHOWN".into(), "CAP_SYS_CHROOT".into()] + )); + assert!(!missing_declared_capability( + &required, + &["CHOWN".into(), "SYS_CHROOT".into(), "CAP_KILL".into()] + )); } #[test] fn explicit_rootless_network_change_converges_without_guessing_defaults() { assert!(rootless_network_mode_drifted(Some("slirp4netns"), "pasta")); assert!(rootless_network_mode_drifted(Some("slirp4netns"), "bridge")); - assert!(!rootless_network_mode_drifted(Some("slirp4netns"), "slirp4netns")); - assert!(!rootless_network_mode_drifted(Some("slirp4netns"), "slirp4netns:allow_host_loopback=true")); + assert!(!rootless_network_mode_drifted( + Some("slirp4netns"), + "slirp4netns" + )); + assert!(!rootless_network_mode_drifted( + Some("slirp4netns"), + "slirp4netns:allow_host_loopback=true" + )); assert!(!rootless_network_mode_drifted(None, "pasta")); assert!(!rootless_network_mode_drifted(Some("slirp4netns"), "")); assert!(!rootless_network_mode_drifted(Some("archy-net"), "bridge")); @@ -5777,19 +5856,31 @@ app: let rt = Arc::new(MockRuntime::default()); let orch = orch_with(rt.clone()).await; let mut app = pull_manifest("indexer-adapter", "docker.io/library/alpine:3.20"); - app.app.extensions.insert("install_prerequisites".into(), - serde_yaml::to_value(vec!["shared-index"]).unwrap()); - orch.insert_manifest_for_test(app, PathBuf::from("/tmp")).await; - orch.insert_manifest_for_test(pull_manifest("shared-index", "index:1"), PathBuf::from("/tmp")).await; + app.app.extensions.insert( + "install_prerequisites".into(), + serde_yaml::to_value(vec!["shared-index"]).unwrap(), + ); + orch.insert_manifest_for_test(app, PathBuf::from("/tmp")) + .await; + orch.insert_manifest_for_test( + pull_manifest("shared-index", "index:1"), + PathBuf::from("/tmp"), + ) + .await; let error = orch.install("indexer-adapter").await.unwrap_err(); assert!(error.downcast_ref::().is_some()); - assert!(!crate::crash_recovery::load_installed_apps(&orch.data_dir).await.contains("indexer-adapter")); + assert!(!crate::crash_recovery::load_installed_apps(&orch.data_dir) + .await + .contains("indexer-adapter")); assert_eq!(rt.calls(), vec!["list_containers"]); // An installed prerequisite satisfies the guard; it is never recreated // or reconfigured as part of installing this adapter. rt.set_state("shared-index", ContainerState::Running); orch.install("indexer-adapter").await.unwrap(); - assert!(!rt.calls().iter().any(|c| c.starts_with("create_container:shared-index"))); + assert!(!rt + .calls() + .iter() + .any(|c| c.starts_with("create_container:shared-index"))); } fn pull_manifest_with_dynamic_env(id: &str, image: &str) -> AppManifest { diff --git a/core/archipelago/src/container/quadlet.rs b/core/archipelago/src/container/quadlet.rs index 17979345..1d5ff4f8 100644 --- a/core/archipelago/src/container/quadlet.rs +++ b/core/archipelago/src/container/quadlet.rs @@ -390,7 +390,10 @@ fn shell_join(parts: &[String]) -> String { .iter() .map(|p| { let p = p.replace(['\r', '\n'], " ").replace('%', "%%"); - if p.is_empty() || p.chars().any(|c| c.is_whitespace() || "\"\\$`".contains(c)) { + if p.is_empty() + || p.chars() + .any(|c| c.is_whitespace() || "'\"\\$`".contains(c)) + { let escaped = p .replace('\\', "\\\\") .replace('"', "\\\"") @@ -410,7 +413,7 @@ fn quote_environment(env: &str) -> String { if env.is_empty() || env .chars() - .any(|c| c.is_whitespace() || "\"\\$`".contains(c)) + .any(|c| c.is_whitespace() || "'\"\\$`".contains(c)) { let escaped = env .replace('\\', "\\\\") @@ -992,13 +995,21 @@ pub fn publish_ports_changed(old_body: &str, new_body: &str) -> bool { } pub fn security_changed(old_body: &str, new_body: &str) -> bool { - ["AddCapability=", "DropCapability=", "NoNewPrivileges=", "ReadOnly=", "User="] - .iter().any(|directive| { - let mut old = directive_values(old_body, directive); - let mut new = directive_values(new_body, directive); - old.sort(); new.sort(); - old != new - }) + [ + "AddCapability=", + "DropCapability=", + "NoNewPrivileges=", + "ReadOnly=", + "User=", + ] + .iter() + .any(|directive| { + let mut old = directive_values(old_body, directive); + let mut new = directive_values(new_body, directive); + old.sort(); + new.sort(); + old != new + }) } pub fn network_aliases_changed(old_body: &str, new_body: &str) -> bool { @@ -1386,6 +1397,18 @@ app: ); } + #[test] + fn apostrophes_survive_quadlet_argument_and_environment_parsing() { + // A whitespace-free Node script reproduced this in a real Quadlet: + // unquoted apostrophes were consumed by the parser, changing JS strings + // into identifiers and preventing the app from starting. + assert_eq!( + shell_join(&["require('http')".into()]), + "\"require('http')\"" + ); + assert_eq!(quote_environment("NAME=O'Brien"), "\"NAME=O'Brien\""); + } + #[test] fn quote_environment_quotes_values_with_spaces() { assert_eq!( @@ -1600,10 +1623,8 @@ app: #[test] fn portainer_catalog_network_repairs_same_node_routing_without_exposing_backend() { - let manifest = AppManifest::parse(include_str!( - "../../../../apps/portainer/manifest.yml" - )) - .expect("shipped Portainer manifest must parse"); + let manifest = AppManifest::parse(include_str!("../../../../apps/portainer/manifest.yml")) + .expect("shipped Portainer manifest must parse"); let new = QuadletUnit::from_manifest(&manifest, "portainer").render(); assert!(new.contains("Network=slirp4netns\n")); assert!(!new.contains("NetworkAlias=")); @@ -1983,9 +2004,15 @@ app: // Simulate systemctl failure or daemon interruption after unit rewrite. drop(pending); let retry = RestartObligation::prepare(&unit, false).await.unwrap(); - assert!(retry.is_pending(), "matching unit must not discard failed restart"); + assert!( + retry.is_pending(), + "matching unit must not discard failed restart" + ); retry.complete().await.unwrap(); - assert!(!RestartObligation::prepare(&unit, false).await.unwrap().is_pending()); + assert!(!RestartObligation::prepare(&unit, false) + .await + .unwrap() + .is_pending()); } #[tokio::test] @@ -1995,20 +2022,26 @@ app: assert!(RestartObligation::prepare(&missing, true).await.is_err()); let unit = dir.path().join("app.container"); let pending = RestartObligation::prepare(&unit, true).await.unwrap(); - tokio::fs::remove_file(unit.with_extension("restart-pending")).await.unwrap(); + tokio::fs::remove_file(unit.with_extension("restart-pending")) + .await + .unwrap(); assert!(pending.complete().await.is_err()); } #[test] fn gitea_ssh_sandbox_capability_is_applied_as_a_runtime_change() { - let manifest = AppManifest::parse(include_str!("../../../../apps/gitea/manifest.yml")).unwrap(); + let manifest = + AppManifest::parse(include_str!("../../../../apps/gitea/manifest.yml")).unwrap(); manifest.validate().unwrap(); let new = QuadletUnit::from_manifest(&manifest, "gitea").render(); assert!(new.contains("AddCapability=SYS_CHROOT\n")); let old = new.replace("AddCapability=SYS_CHROOT\n", ""); assert!(security_changed(&old, &new)); assert!(!security_changed(&new, &new)); - assert!(!security_changed("AddCapability=CHOWN\nAddCapability=SETUID\n", "AddCapability=SETUID\nAddCapability=CHOWN\n")); + assert!(!security_changed( + "AddCapability=CHOWN\nAddCapability=SETUID\n", + "AddCapability=SETUID\nAddCapability=CHOWN\n" + )); } #[test] diff --git a/core/archipelago/src/fips/app_ports.rs b/core/archipelago/src/fips/app_ports.rs index 233de6ed..21b122eb 100644 --- a/core/archipelago/src/fips/app_ports.rs +++ b/core/archipelago/src/fips/app_ports.rs @@ -5,6 +5,7 @@ //! are reachable over the mesh; ports of apps that aren't installed have //! no listener, so allowing them is inert. +#[rustfmt::skip] pub const APP_LAUNCH_PORTS: &[u16] = &[ 2283, 2342, diff --git a/core/container/src/manifest.rs b/core/container/src/manifest.rs index 2701d97a..f9ffe101 100644 --- a/core/container/src/manifest.rs +++ b/core/container/src/manifest.rs @@ -990,14 +990,20 @@ impl AppManifest { validate_ports(&self.app.ports)?; validate_interfaces(&self.app.interfaces)?; if let Some(value) = self.app.extensions.get("install_prerequisites") { - let items = value.as_sequence().ok_or_else(|| ManifestError::Invalid( - "install_prerequisites must be a list of app ids".into()))?; + let items = value.as_sequence().ok_or_else(|| { + ManifestError::Invalid("install_prerequisites must be a list of app ids".into()) + })?; for item in items { let id = item.as_str().unwrap_or_default(); - if id.is_empty() || id == self.app.id || !id.bytes().all(|b| - b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-') { + if id.is_empty() + || id == self.app.id + || !id + .bytes() + .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-') + { return Err(ManifestError::Invalid( - "install_prerequisites must contain valid other app ids".into())); + "install_prerequisites must contain valid other app ids".into(), + )); } } } @@ -1088,7 +1094,9 @@ impl AppManifest { if let Some(value) = self.app.extensions.get("backup_before_runtime_change") { if value.as_bool().is_none() { - return Err(ManifestError::Invalid("backup_before_runtime_change must be boolean".into())); + return Err(ManifestError::Invalid( + "backup_before_runtime_change must be boolean".into(), + )); } } @@ -1769,18 +1777,38 @@ app: // disappeared; Cuprate restricted RPC moved from none to gate-open. // Compare exact endpoints, not just a count that can hide substitutions. let expected = [ - ("bitcoin-core", 8333), ("bitcoin-knots", 8333), - ("core-lightning", 9736), ("core-lightning", 9835), - ("cuprate", 18183), ("electrumx", 50001), - ("fedimint", 8173), ("fedimint", 8174), - ("fedimint-gateway", 8176), ("fedimint-gateway", 9737), - ("gitea", 2222), ("lnd", 9735), ("lnd", 10009), ("lnd", 18080), - ("netbird", 8087), ("netbird-server", 3478), ("netbird-server", 8086), - ("phoenixd", 9740), ("pine", 10381), ("pine-openwakeword", 10400), - ("pine-piper", 10200), ("pine-whisper", 10300), - ("router", 1900), ("router", 5353), - ].into_iter().map(|(id, port)| (id.to_owned(), port)).collect::>(); - assert_eq!(exempt, expected, "unauthenticated endpoint set changed; review each exemption"); + ("bitcoin-core", 8333), + ("bitcoin-knots", 8333), + ("core-lightning", 9736), + ("core-lightning", 9835), + ("cuprate", 18183), + ("electrumx", 50001), + ("fedimint", 8173), + ("fedimint", 8174), + ("fedimint-gateway", 8176), + ("fedimint-gateway", 9737), + ("gitea", 2222), + ("lnd", 9735), + ("lnd", 10009), + ("lnd", 18080), + ("netbird", 8087), + ("netbird-server", 3478), + ("netbird-server", 8086), + ("phoenixd", 9740), + ("pine", 10381), + ("pine-openwakeword", 10400), + ("pine-piper", 10200), + ("pine-whisper", 10300), + ("router", 1900), + ("router", 5353), + ] + .into_iter() + .map(|(id, port)| (id.to_owned(), port)) + .collect::>(); + assert_eq!( + exempt, expected, + "unauthenticated endpoint set changed; review each exemption" + ); } /// `auth: open` ports are served by the gate WITHOUT its login challenge, @@ -1839,7 +1867,10 @@ app: fn invalid_install_prerequisites_are_rejected() { for value in ["not-a-list", "[demo]", "['../other']", "[false]", "['']"] { let yaml = format!("app:\n id: demo\n name: Demo\n version: 1.0.0\n container:\n image: docker.io/library/alpine:3.20\n install_prerequisites: {value}\n"); - assert!(AppManifest::parse(&yaml).unwrap_err().to_string().contains("install_prerequisites")); + assert!(AppManifest::parse(&yaml) + .unwrap_err() + .to_string() + .contains("install_prerequisites")); } } diff --git a/core/container/src/podman_client.rs b/core/container/src/podman_client.rs index 5f39c379..db8c9a55 100644 --- a/core/container/src/podman_client.rs +++ b/core/container/src/podman_client.rs @@ -1080,10 +1080,19 @@ mod tests { #[test] fn portainer_manifest_keeps_private_network_and_loopback_api_publication() { let m = AppManifest::parse(include_str!("../../../apps/portainer/manifest.yml")).unwrap(); - assert_eq!(podman_network_settings(m.app.container.network.as_deref(), &m.app.security.network_policy), ("slirp4netns", None)); - assert_eq!(podman_publish_mapping(&m.app.ports[0]), serde_json::json!({ - "container_port": 9000, "host_port": 9000, "protocol": "tcp", "host_ip": "127.0.0.1" - })); + assert_eq!( + podman_network_settings( + m.app.container.network.as_deref(), + &m.app.security.network_policy + ), + ("slirp4netns", None) + ); + assert_eq!( + podman_publish_mapping(&m.app.ports[0]), + serde_json::json!({ + "container_port": 9000, "host_port": 9000, "protocol": "tcp", "host_ip": "127.0.0.1" + }) + ); } #[test] diff --git a/core/container/src/runtime.rs b/core/container/src/runtime.rs index 6d7c8eb9..5b318a18 100644 --- a/core/container/src/runtime.rs +++ b/core/container/src/runtime.rs @@ -621,7 +621,11 @@ impl DockerRuntime { // Docker is a development fallback. Refuse Podman-only network modes instead // of silently installing a different topology; still honor binds for other apps. fn docker_network_and_ports(manifest: &AppManifest, offset: u16) -> Result> { - let network = manifest.app.container.network.as_deref() + let network = manifest + .app + .container + .network + .as_deref() .filter(|v| !v.is_empty()) .unwrap_or(&manifest.app.security.network_policy); if matches!(network, "slirp4netns" | "pasta") { @@ -632,10 +636,24 @@ fn docker_network_and_ports(manifest: &AppManifest, offset: u16) -> Result str: "//! are reachable over the mesh; ports of apps that aren\'t installed have", "//! no listener, so allowing them is inert.", "", + "#[rustfmt::skip]", "pub const APP_LAUNCH_PORTS: &[u16] = &[", ] lines.extend(f" {port}," for port in distinct)