Compare commits

..
19 changed files with 870 additions and 359 deletions
-7
View File
@@ -106,10 +106,3 @@ app:
- Issue tracking and pull requests - Issue tracking and pull requests
- CI/CD via Gitea Actions - CI/CD via Gitea Actions
- Lightweight SQLite deployment - Lightweight SQLite deployment
nginx_proxy:
listen: 3000
proxy_pass: http://127.0.0.1:3001
extra_headers:
- proxy_hide_header X-Frame-Options
- proxy_hide_header Content-Security-Policy
+8
View File
@@ -14,8 +14,16 @@ app:
container: container:
image: source.archipelago-foundation.org/lfg2025/portainer:2.45.0 image: source.archipelago-foundation.org/lfg2025/portainer:2.45.0
pull_policy: if-not-present pull_policy: if-not-present
# Portainer fetches Git sources and images from services on this same node.
# Rootless pasta copies the host LAN address into its namespace, so a LAN
# URL points back at Portainer itself. Give it a private address with the
# supported rootless slirp backend; public app URLs still traverse the gate.
network: slirp4netns
data_uid: "1000:1000" data_uid: "1000:1000"
# Snapshot state before an upgrade recreates this app with new networking.
backup_on_network_change: true
dependencies: dependencies:
- storage: 1Gi - storage: 1Gi
@@ -1699,32 +1699,10 @@ autopilot.active=false\n",
patch_indeedhub_nostr_provider().await; patch_indeedhub_nostr_provider().await;
} }
// Gitea: keep it on its native host port (3001). The UI opens Gitea // Gitea owns its public URL and security settings in app.ini, including
// in a new tab on that direct port so absolute asset URLs must be // values chosen in its first-run setup. Do not rewrite operator values
// rooted at the host port rather than Archipelago's /app/gitea/ path. // or claim success from best-effort grep/sed commands. The app gate
if package_id == "gitea" { // fronts its declared HTTP port and handles frame headers separately.
let _ = tokio::fs::remove_file("/etc/nginx/conf.d/gitea-iframe.conf").await;
// Set ROOT_URL to the direct launch route so links/assets stay
// anchored under the same origin Gitea is launched from.
let host_ip = &self.config.host_ip;
let _ = tokio::process::Command::new("podman")
.args(["exec", "gitea", "sh", "-c",
&format!("grep -q ROOT_URL /data/gitea/conf/app.ini && sed -i 's|ROOT_URL.*|ROOT_URL = http://{}:3001/|' /data/gitea/conf/app.ini || true", host_ip)])
.output()
.await;
// Also ensure X_FRAME_OPTIONS is empty so Gitea doesn't send the header
let _ = tokio::process::Command::new("podman")
.args(["exec", "gitea", "sh", "-c",
"grep -q X_FRAME_OPTIONS /data/gitea/conf/app.ini && sed -i 's|X_FRAME_OPTIONS.*|X_FRAME_OPTIONS =|' /data/gitea/conf/app.ini || sed -i '/^\\[security\\]/a X_FRAME_OPTIONS =' /data/gitea/conf/app.ini"])
.output()
.await;
info!(
"Gitea: ROOT_URL set to http://{}:3001/, X_FRAME_OPTIONS cleared",
host_ip
);
}
if package_id == "nextcloud" { if package_id == "nextcloud" {
let host_ip = &self.config.host_ip; let host_ip = &self.config.host_ip;
@@ -0,0 +1,251 @@
//! Consistent, private snapshots for declaratively opted-in network migrations.
use anyhow::{bail, Context, Result};
use archipelago_container::AppManifest;
use std::os::unix::fs::PermissionsExt;
use std::path::{Path, PathBuf};
pub fn enabled(manifest: &AppManifest) -> Result<bool> {
match manifest.app.extensions.get("backup_on_network_change") {
None => Ok(false),
Some(value) => value
.as_bool()
.context("backup_on_network_change must be boolean"),
}
}
fn relative_sources(manifest: &AppManifest, data_dir: &Path) -> Result<Vec<PathBuf>> {
let mut sources = Vec::new();
for volume in &manifest.app.volumes {
if volume.options.iter().any(|v| v == "ro") || volume.volume_type == "tmpfs" {
continue;
}
// A runtime socket is a connection, not application state.
if volume.source == "/run/user/1000/podman/podman.sock" {
continue;
}
if volume.volume_type != "bind" {
bail!("network migration backup requires bind-mounted persistent state");
}
let path = Path::new(&volume.source);
let relative = path
.strip_prefix(data_dir)
.context("network migration state must be inside the node data directory")?;
if relative.as_os_str().is_empty()
|| relative
.components()
.any(|c| !matches!(c, std::path::Component::Normal(_)))
{
bail!("invalid network migration state path");
}
sources.push(relative.to_path_buf());
}
sources.sort();
sources.dedup();
let mut roots: Vec<PathBuf> = Vec::new();
for source in sources {
if !roots.iter().any(|root| source.starts_with(root)) {
roots.push(source);
}
}
if roots.is_empty() {
bail!("network migration backup has no persistent state mounts");
}
Ok(roots)
}
/// Caller must gracefully stop the app before this function, and resume the old
/// service if it fails. No source files are changed or deleted by this operation.
pub async fn snapshot(
manifest: &AppManifest,
data_dir: &Path,
previous_unit: Option<&[u8]>,
) -> Result<PathBuf> {
let mut command = tokio::process::Command::new("podman");
command.args(["unshare", "tar"]);
snapshot_with_command(manifest, data_dir, previous_unit, command).await
}
async fn snapshot_with_command(
manifest: &AppManifest,
data_dir: &Path,
previous_unit: Option<&[u8]>,
mut command: tokio::process::Command,
) -> Result<PathBuf> {
let sources = relative_sources(manifest, data_dir)?;
let canonical_root = tokio::fs::canonicalize(data_dir).await?;
for source in &sources {
let path = data_dir.join(source);
if tokio::fs::symlink_metadata(&path)
.await?
.file_type()
.is_symlink()
{
bail!("network migration state mount is a symlink; explicit backup required");
}
let canonical = tokio::fs::canonicalize(&path).await?;
if !canonical.starts_with(&canonical_root) {
bail!("network migration state path resolves outside node data directory");
}
}
let root = data_dir.join("migration-backups");
tokio::fs::create_dir_all(&root).await?;
tokio::fs::set_permissions(&root, std::fs::Permissions::from_mode(0o700)).await?;
let dir = root.join(uuid::Uuid::new_v4().to_string());
tokio::fs::create_dir(&dir).await?;
tokio::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o700)).await?;
if let Some(unit) = previous_unit {
let path = dir.join("previous.container");
tokio::fs::write(&path, unit).await?;
tokio::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600)).await?;
tokio::fs::File::open(&path).await?.sync_all().await?;
}
let partial = dir.join("state.tar.partial");
let archive = dir.join("state.tar");
let output = command
.args([
"--create",
"--numeric-owner",
"--acls",
"--xattrs",
"--file",
])
.arg(&partial)
.arg("--directory")
.arg(data_dir)
.arg("--")
.args(&sources)
.output()
.await
.context("start rootless migration snapshot")?;
if !output.status.success() {
// No tar stderr in public logs: it can contain private filenames.
let _ = tokio::fs::remove_file(&partial).await;
bail!("persistent-state snapshot failed; original state was left intact");
}
tokio::fs::set_permissions(&partial, std::fs::Permissions::from_mode(0o600)).await?;
tokio::fs::File::open(&partial).await?.sync_all().await?;
tokio::fs::rename(&partial, &archive).await?;
let metadata = serde_json::json!({"app": manifest.app.id, "version": manifest.app.version,
"network": manifest.app.container.network, "sources": sources});
tokio::fs::write(
dir.join("metadata.json"),
serde_json::to_vec_pretty(&metadata)?,
)
.await?;
tokio::fs::File::open(&dir).await?.sync_all().await?;
Ok(archive)
}
#[cfg(test)]
mod tests {
use super::*;
fn portainer() -> AppManifest {
AppManifest::parse(include_str!("../../../../apps/portainer/manifest.yml")).unwrap()
}
#[tokio::test]
async fn stopped_state_archive_round_trips_database_compose_and_old_unit() {
let dir = tempfile::tempdir().unwrap();
let state = dir.path().join("portainer");
tokio::fs::create_dir_all(state.join("compose"))
.await
.unwrap();
tokio::fs::write(state.join("portainer.db"), b"fixture database")
.await
.unwrap();
tokio::fs::write(state.join("compose/stack.yml"), b"services: {}\n")
.await
.unwrap();
let mut m = portainer();
m.app.volumes[0].source = state.display().to_string();
m.app.volumes[1].source = state.join("compose").display().to_string();
let archive = snapshot_with_command(
&m,
dir.path(),
Some(b"old unit"),
tokio::process::Command::new("tar"),
)
.await
.unwrap();
assert_eq!(
std::fs::metadata(&archive).unwrap().permissions().mode() & 0o777,
0o600
);
assert_eq!(
tokio::fs::read(archive.parent().unwrap().join("previous.container"))
.await
.unwrap(),
b"old unit"
);
let restored = tempfile::tempdir().unwrap();
assert!(tokio::process::Command::new("tar")
.arg("-xf")
.arg(archive)
.arg("-C")
.arg(restored.path())
.status()
.await
.unwrap()
.success());
assert_eq!(
tokio::fs::read(restored.path().join("portainer/portainer.db"))
.await
.unwrap(),
b"fixture database"
);
assert_eq!(
tokio::fs::read(restored.path().join("portainer/compose/stack.yml"))
.await
.unwrap(),
b"services: {}\n"
);
assert_eq!(
tokio::fs::read(state.join("portainer.db")).await.unwrap(),
b"fixture database"
);
}
#[tokio::test]
async fn failed_snapshot_never_publishes_archive_or_changes_original_state() {
let dir = tempfile::tempdir().unwrap();
let state = dir.path().join("portainer");
tokio::fs::create_dir_all(state.join("compose"))
.await
.unwrap();
tokio::fs::write(state.join("portainer.db"), b"unchanged")
.await
.unwrap();
let mut m = portainer();
m.app.volumes[0].source = state.display().to_string();
m.app.volumes[1].source = state.join("compose").display().to_string();
assert!(
snapshot_with_command(&m, dir.path(), None, tokio::process::Command::new("false"))
.await
.is_err()
);
assert_eq!(
tokio::fs::read(state.join("portainer.db")).await.unwrap(),
b"unchanged"
);
for entry in std::fs::read_dir(dir.path().join("migration-backups")).unwrap() {
assert!(!entry.unwrap().path().join("state.tar").exists());
}
}
#[test]
fn backup_covers_all_portainer_state_once_and_excludes_runtime_socket() {
let m = portainer();
assert!(enabled(&m).unwrap());
assert_eq!(
relative_sources(&m, Path::new("/var/lib/archipelago")).unwrap(),
vec![PathBuf::from("portainer")]
);
}
#[test]
fn backup_refuses_unknown_state_locations_instead_of_silently_omitting_them() {
let mut m = portainer();
m.app.volumes[0].source = "/other/operator/state".into();
assert!(relative_sources(&m, Path::new("/var/lib/archipelago")).is_err());
m.app.volumes[0].source = "/var/lib/archipelago/../secret".into();
assert!(relative_sources(&m, Path::new("/var/lib/archipelago")).is_err());
}
}
+1
View File
@@ -12,6 +12,7 @@ pub mod hooks;
pub mod image_policy; pub mod image_policy;
pub mod image_versions; pub mod image_versions;
pub mod lnd; pub mod lnd;
pub mod migration_backup;
pub mod prod_orchestrator; pub mod prod_orchestrator;
pub mod quadlet; pub mod quadlet;
pub mod registry; pub mod registry;
@@ -91,6 +91,14 @@ fn is_builtin_network_mode(network: &str) -> bool {
) )
} }
// Only an explicitly selected rootless mode establishes drift. An omitted
// network delegates to Podman and must not recreate unrelated installed apps.
fn rootless_network_mode_drifted(expected: Option<&str>, actual: &str) -> bool {
matches!(expected, Some("slirp4netns" | "pasta"))
&& !actual.trim().is_empty()
&& actual.trim().split(':').next() != expected
}
fn uses_pasta_network(manifest: &AppManifest) -> bool { fn uses_pasta_network(manifest: &AppManifest) -> bool {
manifest.app.container.network.as_deref() == Some("pasta") manifest.app.container.network.as_deref() == Some("pasta")
} }
@@ -2499,6 +2507,7 @@ impl ProdContainerOrchestrator {
return Ok(ReconcileAction::NoOp); return Ok(ReconcileAction::NoOp);
} }
tracing::info!(app_id = %app_id, container = %name, "container env drift detected — recreating"); tracing::info!(app_id = %app_id, container = %name, "container env drift detected — recreating");
self.backup_network_change(&name, &resolved_manifest).await?;
let _ = self.runtime.stop_container(&name).await; let _ = self.runtime.stop_container(&name).await;
let _ = self.runtime.remove_container(&name).await; let _ = self.runtime.remove_container(&name).await;
self.install_fresh(lm).await?; self.install_fresh(lm).await?;
@@ -2555,6 +2564,7 @@ impl ProdContainerOrchestrator {
.await .await
{ {
tracing::info!(app_id = %app_id, container = %name, "stopped container env/port drift detected — recreating"); tracing::info!(app_id = %app_id, container = %name, "stopped container env/port drift detected — recreating");
self.backup_network_change(&name, &resolved_manifest).await?;
let _ = self.runtime.remove_container(&name).await; let _ = self.runtime.remove_container(&name).await;
self.install_fresh(lm).await?; self.install_fresh(lm).await?;
return Ok(ReconcileAction::Installed); return Ok(ReconcileAction::Installed);
@@ -3080,13 +3090,9 @@ impl ProdContainerOrchestrator {
/// app is a companion (companion.rs owns those units), or when no /// app is a companion (companion.rs owns those units), or when no
/// unit file exists yet (install_via_quadlet handles first-write). /// unit file exists yet (install_via_quadlet handles first-write).
/// ///
/// We DON'T restart the .service when content changes — running /// Ordinary metadata changes wait for an operator restart. Runtime-affecting
/// containers keep their current config until an operator-initiated /// changes restart the service and retain a durable pending marker until
/// restart picks up the new file. That's the right tradeoff: file /// that succeeds, including across daemon restarts and failed reloads.
/// updates are cheap and non-destructive; service restarts are
/// destructive (the SIGKILL cascade we're trying to eliminate).
/// systemctl --user daemon-reload runs only when content actually
/// changed, so steady-state reconcile ticks pay just one fs read.
async fn sync_quadlet_unit(&self, lm: &LoadedManifest, name: &str) -> Result<()> { async fn sync_quadlet_unit(&self, lm: &LoadedManifest, name: &str) -> Result<()> {
// Companions: same reasoning as migrate_to_quadlet_if_needed — // Companions: same reasoning as migrate_to_quadlet_if_needed —
// companion.rs renders these units with a different shape, syncing // companion.rs renders these units with a different shape, syncing
@@ -3106,7 +3112,7 @@ impl ProdContainerOrchestrator {
} }
let old_body = tokio::fs::read_to_string(&unit_path) let old_body = tokio::fs::read_to_string(&unit_path)
.await .await
.unwrap_or_default(); .with_context(|| format!("read existing quadlet for {name}"))?;
let restart_required = quadlet::contains_stale_health_gate(&old_body); let restart_required = quadlet::contains_stale_health_gate(&old_body);
let mut resolved = lm.manifest.clone(); let mut resolved = lm.manifest.clone();
@@ -3122,49 +3128,47 @@ impl ProdContainerOrchestrator {
quadlet::network_aliases_changed(&old_body, &new_body); quadlet::network_aliases_changed(&old_body, &new_body);
let restart_for_exec_change = quadlet::exec_changed(&old_body, &new_body); let restart_for_exec_change = quadlet::exec_changed(&old_body, &new_body);
let restart_for_health_change = quadlet::health_cmd_changed(&old_body, &new_body); let restart_for_health_change = quadlet::health_cmd_changed(&old_body, &new_body);
let needs_restart = restart_required
|| restart_for_port_change
|| restart_for_network_alias_change
|| restart_for_exec_change
|| restart_for_health_change;
// Record the obligation BEFORE replacing the unit. A failed reload or
// restart must not become a no-op on the next tick just because the
// generated file already matches the manifest.
let pending = quadlet::RestartObligation::prepare(&unit_path, needs_restart).await?;
if pending.is_pending() {
self.ensure_resolved_source_available(lm).await?;
}
if restart_for_network_alias_change {
self.backup_network_change(name, &resolved).await?;
}
let changed = quadlet::write_if_changed(&unit, &unit_dir) let changed = quadlet::write_if_changed(&unit, &unit_dir)
.await .await
.with_context(|| format!("drift-sync quadlet unit for {name}"))?; .with_context(|| format!("drift-sync quadlet unit for {name}"))?;
if changed { if changed || pending.is_pending() {
quadlet::daemon_reload_user() quadlet::daemon_reload_user()
.await .await
.context("systemctl --user daemon-reload after drift-syncing quadlet unit")?; .context("systemctl --user daemon-reload after drift-syncing quadlet unit")?;
tracing::info!(
app_id = %lm.manifest.app.id,
container = %name,
"Quadlet unit drift-synced — file rewritten, .service NOT restarted (operator restart picks up new config)"
);
} }
if changed if pending.is_pending() {
&& (restart_required
|| restart_for_port_change
|| restart_for_network_alias_change
|| restart_for_exec_change
|| restart_for_health_change)
{
self.ensure_resolved_source_available(lm).await?;
let service = unit.service_name(); let service = unit.service_name();
let reason = if restart_required {
"stale health gate"
} else if restart_for_port_change {
"port binding drift"
} else if restart_for_network_alias_change {
"network alias drift"
} else if restart_for_health_change {
"health command drift"
} else {
"exec drift"
};
tracing::info!( tracing::info!(
app_id = %lm.manifest.app.id, app_id = %lm.manifest.app.id,
container = %name, container = %name,
service = %service, service = %service,
reason = reason, "Applying pending Quadlet runtime change"
"Quadlet unit rewrite requires service restart"
); );
quadlet::restart_service(&service) quadlet::restart_service(&service)
.await .await
.with_context(|| format!("restart drifted quadlet service {service}"))?; .with_context(|| format!("restart drifted quadlet service {service}"))?;
pending.complete().await?;
} else if changed {
tracing::info!(
app_id = %lm.manifest.app.id,
container = %name,
"Quadlet metadata updated; operator restart will apply it"
);
} }
Ok(()) Ok(())
} }
@@ -3866,6 +3870,61 @@ impl ProdContainerOrchestrator {
Ok(()) Ok(())
} }
async fn backup_network_change(&self, name: &str, manifest: &AppManifest) -> Result<()> {
if !crate::container::migration_backup::enabled(manifest)? {
return Ok(());
}
// Only back up an actual network migration, not ordinary env drift.
let output = tokio::process::Command::new("podman")
.args(["inspect", name, "--format", "{{.HostConfig.NetworkMode}}"])
.output().await.context("inspect network before migration backup")?;
let present = if output.status.success() {
if !rootless_network_mode_drifted(manifest.app.container.network.as_deref(), &String::from_utf8_lossy(&output.stdout)) {
return Ok(());
}
true
} else {
// A crash after gracefully stopping a --rm Quadlet container can
// 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?;
if exists.code() != Some(1) {
anyhow::bail!("cannot verify existing container before network migration backup");
}
false
};
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?)
} else {
None
};
if managed {
quadlet::stop_service(&service).await?;
} else if present {
self.runtime.stop_container(name).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 network migration");
Ok(())
}
Err(error) => {
// The unit has not been rewritten yet. Restore its previous
// service on backup failure and report the migration failure.
let restored = if managed {
quadlet::enable_now(&service).await
} else {
self.runtime.start_container(name).await
};
restored.context("restore original app after failed migration snapshot")?;
Err(error)
}
}
}
async fn container_env_drifted(&self, name: &str, manifest: &AppManifest) -> bool { async fn container_env_drifted(&self, name: &str, manifest: &AppManifest) -> bool {
if cfg!(test) { if cfg!(test) {
return false; return false;
@@ -3875,6 +3934,23 @@ impl ProdContainerOrchestrator {
return true; return true;
} }
// Quadlet handles declarative Network= drift above. Legacy rootless
// Podman containers need the same convergence when no unit owns them.
if 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),
) {
return true;
}
}
}
let inspect = tokio::process::Command::new("podman") let inspect = tokio::process::Command::new("podman")
.args([ .args([
"inspect", "inspect",
@@ -4917,6 +4993,17 @@ mod tests {
/// recovered when its siblings have live containers (the stack is /// recovered when its siblings have live containers (the stack is
/// installed), and left alone when the whole stack is gone or the app /// installed), and left alone when the whole stack is gone or the app
/// is not a stack member at all. /// is not a stack member at all.
#[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(None, "pasta"));
assert!(!rootless_network_mode_drifted(Some("slirp4netns"), ""));
assert!(!rootless_network_mode_drifted(Some("archy-net"), "bridge"));
}
#[test] #[test]
fn absent_stack_member_recovery_requires_a_live_sibling() { fn absent_stack_member_recovery_requires_a_live_sibling() {
let present: HashSet<String> = ["indeedhub-redis", "indeedhub-relay", "indeedhub"] let present: HashSet<String> = ["indeedhub-redis", "indeedhub-relay", "indeedhub"]
+98
View File
@@ -938,6 +938,53 @@ pub fn health_cmd_changed(old_body: &str, new_body: &str) -> bool {
!= directive_values(new_body, "HealthRetries=") != directive_values(new_body, "HealthRetries=")
} }
/// A unit rewrite and a successful systemd restart are separate operations.
/// Keep the restart obligation across errors or a management-daemon restart.
pub struct RestartObligation {
marker: PathBuf,
pending: bool,
}
impl RestartObligation {
pub async fn prepare(unit_path: &Path, newly_required: bool) -> Result<Self> {
let marker = unit_path.with_extension("restart-pending");
if newly_required {
// Contents contain no manifest environment or credentials. sync_all
// makes the obligation durable before the subsequent unit rename.
let file = tokio::fs::OpenOptions::new()
.write(true)
.create(true)
.truncate(false)
.open(&marker)
.await
.context("record pending Quadlet restart")?;
file.sync_all().await?;
if let Some(parent) = marker.parent() {
tokio::fs::File::open(parent).await?.sync_all().await?;
}
}
let pending = tokio::fs::try_exists(&marker).await?;
Ok(Self { marker, pending })
}
pub fn is_pending(&self) -> bool {
self.pending
}
/// Call only after systemd accepted the replacement service successfully.
pub async fn complete(self) -> Result<()> {
if self.pending {
tokio::fs::remove_file(&self.marker)
.await
.context("clear completed Quadlet restart")?;
if let Some(parent) = self.marker.parent() {
tokio::fs::File::open(parent).await?.sync_all().await?;
}
}
Ok(())
}
}
pub fn publish_ports_changed(old_body: &str, new_body: &str) -> bool { pub fn publish_ports_changed(old_body: &str, new_body: &str) -> bool {
let old_ports = directive_values(old_body, "PublishPort="); let old_ports = directive_values(old_body, "PublishPort=");
let new_ports = directive_values(new_body, "PublishPort="); let new_ports = directive_values(new_body, "PublishPort=");
@@ -1541,6 +1588,28 @@ app:
assert!(!s.contains("Network=host")); assert!(!s.contains("Network=host"));
} }
#[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 new = QuadletUnit::from_manifest(&manifest, "portainer").render();
assert!(new.contains("Network=slirp4netns\n"));
assert!(!new.contains("NetworkAlias="));
assert!(new.contains("PublishPort=127.0.0.1:9000:9000/tcp"));
assert!(!new.contains("PublishPort=0.0.0.0"));
// The upgrade changes networking only: retain both state mounts and the
// existing rootless socket, without an app.ini or repository rewrite.
assert!(new.contains("Volume=/var/lib/archipelago/portainer:/data"));
assert!(new.contains("Volume=/var/lib/archipelago/portainer/compose:/data/compose"));
assert!(new.contains("Volume=/run/user/1000/podman/podman.sock:/var/run/docker.sock"));
let old = new.replace("Network=slirp4netns\n", "");
assert!(network_aliases_changed(&old, &new));
assert!(!network_aliases_changed(&new, &new));
assert!(!publish_ports_changed(&old, &new));
}
#[test] #[test]
fn from_manifest_slirp4netns_omits_network_alias() { fn from_manifest_slirp4netns_omits_network_alias() {
let yaml = r#" let yaml = r#"
@@ -1891,6 +1960,35 @@ app:
assert!(!network_aliases_changed(new, new)); assert!(!network_aliases_changed(new, new));
} }
#[tokio::test]
async fn failed_runtime_change_remains_pending_when_unit_already_matches() {
let dir = tempfile::tempdir().unwrap();
let unit = dir.path().join("portainer.container");
tokio::fs::write(&unit, "[Container]\n").await.unwrap();
let pending = RestartObligation::prepare(&unit, true).await.unwrap();
assert!(pending.is_pending());
tokio::fs::write(&unit, "[Container]\nNetwork=slirp4netns\n")
.await
.unwrap();
// 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");
retry.complete().await.unwrap();
assert!(!RestartObligation::prepare(&unit, false).await.unwrap().is_pending());
}
#[tokio::test]
async fn pending_runtime_change_errors_are_not_reported_as_success() {
let dir = tempfile::tempdir().unwrap();
let missing = dir.path().join("missing/app.container");
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();
assert!(pending.complete().await.is_err());
}
#[test] #[test]
fn network_aliases_changed_detects_network_mode_drift() { fn network_aliases_changed_detects_network_mode_drift() {
let old = "[Container]\nNetwork=slirp4netns\n"; let old = "[Container]\nNetwork=slirp4netns\n";
+22 -31
View File
@@ -1746,40 +1746,28 @@ app:
} }
} }
exempt.sort(); exempt.sort();
// 28 as of 2026-08-23: the 26 below plus cuprate's two exemptions — // Reviewed 2026-09-30: lightning-stack's three retired endpoints
// 18183 (Monero p2p gossip, same reasoning as bitcoin's 8333) and // disappeared; Cuprate restricted RPC moved from none to gate-open.
// 18090 (host mapping for Monero's canonical 18089 restricted RPC, // Compare exact endpoints, not just a count that can hide substitutions.
// upstream's own safe-for-public let expected = [
// subset that wallets connect to directly as a "remote node" over ("bitcoin-core", 8333), ("bitcoin-knots", 8333),
// plain HTTP JSON-RPC — same reasoning as electrumx's 50001). ("core-lightning", 9736), ("core-lightning", 9835),
// cuprate's unrestricted RPC (full node control) stays loopback-only ("cuprate", 18183), ("electrumx", 50001),
// (auth: local), not in this set. ("fedimint", 8173), ("fedimint", 8174),
// ("fedimint-gateway", 8176), ("fedimint-gateway", 9737),
// 26 as of 2026-08-16: the 25 below plus phoenixd 9740, a ("gitea", 2222), ("lnd", 9735), ("lnd", 10009), ("lnd", 18080),
// loopback-only JSON API whose own generated http password ("netbird", 8087), ("netbird-server", 3478), ("netbird-server", 8086),
// authenticates every request (added with the phoenixd onboarding, ("phoenixd", 9740), ("pine", 10381), ("pine-openwakeword", 10400),
// which did not update this count — exactly the drift this test ("pine-piper", 10200), ("pine-whisper", 10300),
// exists to catch). ("router", 1900), ("router", 5353),
// ].into_iter().map(|(id, port)| (id.to_owned(), port)).collect::<Vec<_>>();
// 25 as of the v1.7.123 port-policy round: bitcoin p2p (8333 ×2), assert_eq!(exempt, expected, "unauthenticated endpoint set changed; review each exemption");
// core-lightning 9736/9835, electrumx 50001, fedimint 8173/8174,
// fedimint-gateway 8176/9737, gitea ssh 2222, lightning-stack
// 8091/9738/10010, lnd 9735/10009/18080, netbird 3478/8086/8087,
// pine TLS 10381 + the three voice ports (10200/10300/10400 — the
// disclosed known gap), router SSDP/mDNS 1900/5353. Every one is a
// deliberate, rationale-carrying exemption; the release-gate test
// stage timed out that cycle, so the count here lagged at 17.
assert_eq!(
exempt.len(),
28,
"unauthenticated port set changed — review before updating this count: {exempt:?}"
);
} }
/// `auth: open` ports are served by the gate WITHOUT its login challenge, /// `auth: open` ports are served by the gate WITHOUT its login challenge,
/// so they are the second unauthenticated-by-the-gate surface and get the /// so they are the second unauthenticated-by-the-gate surface and get the
/// same review guard as `auth: none`. Each one must be an app that /// same review guard as `auth: none`. Each must enforce its own login or
/// enforces a real login of its own. /// have an explicitly reviewed public protocol purpose.
#[test] #[test]
fn gate_open_ports_are_all_accounted_for() { fn gate_open_ports_are_all_accounted_for() {
let apps = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../apps"); let apps = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../apps");
@@ -1801,6 +1789,8 @@ app:
} }
} }
open.sort(); open.sort();
// Cuprate 18090 is its deliberately public restricted RPC subset;
// unrestricted node-control RPC remains container-loopback-only.
// Gitea 3001 (git clients speak basic-auth, not browser cookies), // Gitea 3001 (git clients speak basic-auth, not browser cookies),
// BTCPay 23000 (checkout/invoice/webhook endpoints must be reachable // BTCPay 23000 (checkout/invoice/webhook endpoints must be reachable
// by anonymous payers), and — since the v1.8.7 platform round — the // by anonymous payers), and — since the v1.8.7 platform round — the
@@ -1812,11 +1802,12 @@ app:
open, open,
vec![ vec![
("btcpay-server".to_string(), 23000u16), ("btcpay-server".to_string(), 23000u16),
("cuprate".to_string(), 18090u16),
("gitea".to_string(), 3001u16), ("gitea".to_string(), 3001u16),
("nginx-proxy-manager".to_string(), 8081u16), ("nginx-proxy-manager".to_string(), 8081u16),
("tailscale".to_string(), 8240u16), ("tailscale".to_string(), 8240u16),
], ],
"gate-open port set changed — every entry must be an app with its own login" "gate-open port set changed — review login or intentional public protocol purpose"
); );
} }
+29 -53
View File
@@ -310,59 +310,7 @@ impl PodmanClient {
); );
continue; continue;
} }
// Honour the manifest's protocol (default tcp). netbird's STUN port port_mappings.push(podman_publish_mapping(port));
// is 3478/udp; forcing tcp here would publish the wrong protocol and
// silently break relay discovery.
let protocol = match port.protocol.to_ascii_lowercase().as_str() {
"udp" => "udp",
"sctp" => "sctp",
_ => "tcp",
};
// Effective bind. A gated port with no declared bind would
// publish 0.0.0.0 — the app would own every host address, which
// is both the exposure itself and the reason the daemon's app
// gate cannot bind those addresses to authenticate them. Pin it
// to loopback so the gate can take the external addresses.
//
// Doing it HERE, at container creation, is the point: the pin and
// the gate's takeover then both come from the daemon and cannot
// disagree. The earlier attempt put this decision in manifest
// data instead, and a node whose manifests lagged the binary
// published Bitcoin's loopback-only RPC across the LAN
// (test node, 2026-08-03).
//
// A port that already declares a bind is never overridden — that
// is exactly what keeps `bind: 127.0.0.1` ports host-local and
// leaves `auth: none` protocol ports (LND gRPC/REST, electrum)
// published as they are, so remote wallets keep working.
// NOTE: the daemon deliberately does NOT rewrite this. Pinning a
// published port to loopback is how an app hands its external
// addresses to the gate, but it belongs in the manifest, not in
// daemon-side inference:
//
// * `bind` is already honoured by every publish path (here and
// in package::install), so a manifest edit needs no code.
// * inference here would cover only THIS path — proven on
// a test node, where a recreate went through another one and
// the pin never applied.
// * and inferring from an ABSENT field is what republished
// Bitcoin's loopback RPC across the LAN, and came within one
// container-recreate of pinning LND's gRPC/REST and breaking
// every remote wallet.
//
// So the migration ships as `bind: 127.0.0.1` in the signed
// catalog. Verified 2026-08-03 that a disk-only manifest edit is
// overridden by the catalog, which is precisely why the catalog is
// the right and only place to carry it.
let mut mapping = serde_json::json!({
"container_port": port.container,
"host_port": port.host,
"protocol": protocol,
});
if !port.bind.is_empty() {
mapping["host_ip"] = serde_json::json!(port.bind);
}
port_mappings.push(mapping);
} }
let mut mounts = Vec::new(); let mut mounts = Vec::new();
@@ -751,6 +699,25 @@ pub fn image_uses_insecure_registry(image: &str) -> bool {
.is_some_and(|host| INSECURE_REGISTRY_HOSTS.contains(&host)) .is_some_and(|host| INSECURE_REGISTRY_HOSTS.contains(&host))
} }
// Keep the explicitly declared bind and transport identical to Quadlet. The
// app gate owns external listeners; container publication must not bypass it.
fn podman_publish_mapping(port: &crate::manifest::PortMapping) -> serde_json::Value {
let protocol = match port.protocol.to_ascii_lowercase().as_str() {
"udp" => "udp",
"sctp" => "sctp",
_ => "tcp",
};
let mut mapping = serde_json::json!({
"container_port": port.container,
"host_port": port.host,
"protocol": protocol,
});
if !port.bind.is_empty() {
mapping["host_ip"] = serde_json::json!(port.bind);
}
mapping
}
fn podman_network_settings( fn podman_network_settings(
network: Option<&str>, network: Option<&str>,
network_policy: &str, network_policy: &str,
@@ -1110,6 +1077,15 @@ 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"
}));
}
#[test] #[test]
fn podman_network_settings_uses_networks_map_for_custom_networks() { fn podman_network_settings_uses_networks_map_for_custom_networks() {
assert_eq!( assert_eq!(
+34 -19
View File
@@ -618,6 +618,28 @@ 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<Vec<String>> {
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") {
anyhow::bail!("this app requires rootless Podman networking ({network})");
}
let mut args = Vec::new();
if !network.is_empty() && network != "isolated" {
args.extend(["--network".to_owned(), network.to_owned()]);
}
for port in &manifest.app.ports {
let host = port.host.checked_add(offset).context("published port offset overflow")?;
let bind = if port.bind.is_empty() { String::new() } else { format!("{}:", port.bind) };
let protocol = if port.protocol.is_empty() { "tcp" } else { &port.protocol };
args.extend(["-p".to_owned(), format!("{bind}{host}:{}/{protocol}", port.container)]);
}
Ok(args)
}
#[async_trait] #[async_trait]
impl ContainerRuntime for DockerRuntime { impl ContainerRuntime for DockerRuntime {
async fn pull_image(&self, image: &str, signature: Option<&str>) -> Result<()> { async fn pull_image(&self, image: &str, signature: Option<&str>) -> Result<()> {
@@ -657,25 +679,7 @@ impl ContainerRuntime for DockerRuntime {
cmd.arg("--read-only"); cmd.arg("--read-only");
} }
match manifest.app.security.network_policy.as_str() { cmd.args(docker_network_and_ports(manifest, port_offset)?);
"host" => {
cmd.arg("--network").arg("host");
}
"isolated" => {
// Docker uses bridge network by default
}
_ => {
cmd.arg("--network")
.arg(&manifest.app.security.network_policy);
}
}
// Port mappings with offset
for port in &manifest.app.ports {
let host_port = port.host + port_offset;
cmd.arg("-p")
.arg(format!("{}:{}", host_port, port.container));
}
// Volumes // Volumes
for volume in &manifest.app.volumes { for volume in &manifest.app.volumes {
@@ -1035,6 +1039,17 @@ mod tests {
use super::*; use super::*;
use std::collections::HashMap; use std::collections::HashMap;
#[test]
fn docker_fallback_rejects_rootless_only_topology_and_preserves_bind_protocol() {
let mut m = AppManifest::parse(include_str!("../../../apps/portainer/manifest.yml")).unwrap();
assert!(docker_network_and_ports(&m, 0).is_err());
m.app.container.network = Some("bridge".into());
m.app.ports[0].protocol = "udp".into();
let args = docker_network_and_ports(&m, 1).unwrap();
assert_eq!(args, vec!["--network", "bridge", "-p", "127.0.0.1:9001:9000/udp"]);
assert!(docker_network_and_ports(&m, u16::MAX).is_err());
}
#[test] #[test]
fn missing_container_classifier_covers_podman5_phrasings() { fn missing_container_classifier_covers_podman5_phrasings() {
// podman 5.x `inspect` phrasing for a missing container. // podman 5.x `inspect` phrasing for a missing container.
+14 -41
View File
@@ -14,39 +14,6 @@ doc. See [`ROADMAP.md`](ROADMAP.md) for the curated, public-facing direction.
## Next release after 1.8.21 — reported 2026-09-30 ## Next release after 1.8.21 — reported 2026-09-30
- [ ] **Release blocker: Gitea → Portainer repository integration.** Diagnose
smart-HTTP reachability from Portainer's actual request namespace, then provide
one declarative topology and idempotent migration for fresh installs and
existing nodes. Preserve gate/auth boundaries, operator configuration,
repository/key/database mounts and Portainer stacks. Cover install order,
lifecycle/reboot/update convergence, clone/push and source-branch/Compose-file
acceptance with a disposable integration setup. Ship in both OTA and ISO;
a healthy Gitea root page is insufficient. Operator supplied a private handover;
deployment addresses and credentials must not be committed.
- [ ] **New X250: GitWorkshop failed at 70%; slow Nginx installation.** Missing
ISO build contexts restored on-node; package staging/smoke checks added.
GitWorkshop dependency audit refreshed and build/HTTP recovery verified;
Nginx was a slow successful image pull. Aggregate progress label corrected.
Include the validated repair in the next OTA/ISO. See lifecycle evidence.
- [ ] **Angor indexer service in the app store**, requested after the other
current repair/review work (2026-09-30). Follow the repository's app-development
and packaging documentation; treat it as a headless service unless upstream
documentation establishes a UI. Verify Bitcoin/Mempool requirements, decide
whether an existing first-class relay meets Angor's requirements or a relay
must be packaged with the indexer, and use the Angor logo from angor.io for its
service icon. The mentioned setup-documentation link was not included; asked
the operator for it. Include this service in the next-release scope.
- [ ] **App lifecycle: keep installed apps visible through restart and hard
refresh; gate embedded/browser launches on actual web and listener readiness.**
Source repair and scoped live acceptance passed; full release gate pending.
Includes durable inventory reconstruction,
concurrent inventory writes, stale scan/lifecycle updates, delayed HTTP startup,
and the app gate's post-install listener delay. See
[app lifecycle repair evidence](app-lifecycle-repair-20260930.md).
- [x] Review and repair open paid-download PRs #161 and #162, refresh both - [x] Review and repair open paid-download PRs #161 and #162, refresh both
branches from main, run independent and combined isolated suites, and verify branches from main, run independent and combined isolated suites, and verify
rootless file permissions in disposable scratch storage. Combined result: rootless file permissions in disposable scratch storage. Combined result:
@@ -54,17 +21,23 @@ doc. See [`ROADMAP.md`](ROADMAP.md) for the curated, public-facing direction.
[review evidence and remaining acceptance work](pr-review-20260930.md). [review evidence and remaining acceptance work](pr-review-20260930.md).
- [ ] Integrate the reviewed PR branches into the next release and run funded - [ ] Integrate the reviewed PR branches into the next release and run funded
candidate acceptance, including Tor-only transport and payments with change. candidate acceptance, including Tor-only transport and payments with change.
Operator authorized completing the normal merge/closure workflow on PRs remain open; the reviewed code has not been deployed to live wallets.
2026-09-30. Both PRs are now merged and closed through Gitea; integrate
local repair commits and sync git/ngit before release. The reviewed code has not yet been deployed to live wallets.
- [ ] Design durable recovery for an accepted payment whose response is lost. - [ ] Design durable recovery for an accepted payment whose response is lost.
Preserve the truthful unconfirmed-refund warning and prevent automatic Preserve the truthful unconfirmed-refund warning and prevent automatic
duplicate payment while that recovery work is outstanding. duplicate payment while that recovery work is outstanding.
- [x] **ThinkPad X250 kiosk: Bitcoin version choices readable above pruning.** - [ ] **ThinkPad X250 kiosk: Bitcoin installation version selector is unreadable
Replaced the native popup with inline radio choices. Actual Chromium 152 kiosk and appears underneath the pruning information.** Operator reports white
assertions and screenshot verify white-on-dark choices, selection changes and styling with invisible text on the actual kiosk; the same flow works in remote
layout above pruning controls. Focused component tests pass. Included in the Brave. Reproduce on the X250's kiosk engine and record its version, display
next-release source; published 1.8.21 artifacts remain unchanged. scale and resolution. Inspect the native `<select>` in
`neode-ui/src/components/InstallVersionModal.vue`, its option colors, and the
scroll/stacking behavior in `BaseModal.vue`; these are investigation leads,
not a confirmed cause. Fix contrast and popup visibility without changing
version selection or pruning behavior. Validate Core and Knots, open/closed
and scrolled dropdowns, keyboard/touch selection, and pruning on/off on the
actual kiosk, with remote Brave and mobile regression checks. Browser mocks
alone do not establish that the kiosk rendering is fixed. Track for the next
release; the signed 1.8.21 artifacts remain unchanged.
## 1.8.21 repair and release tasks — completed 2026-09-30 ## 1.8.21 repair and release tasks — completed 2026-09-30
-111
View File
@@ -1,111 +0,0 @@
# App lifecycle repair — 2026-09-30
Status: source repairs, optimized build, new-node recovery and scoped live
lifecycle acceptance verified. Full release gate remains pending.
These are next-release changes. Published 1.8.21 artifacts remain unchanged.
## Report
The operator reports that restarting an app can make it disappear, and a hard
refresh offers installation again. Newly installed apps sometimes fail to
connect in both embedded views and browser tabs. The new X250 additionally reproduced GitWorkshop disappearing during install
and Nginx Proxy Manager spending approximately 14 minutes at 70%. A disposable
app on the dev box exposed a separate restart failure.
## Findings and repairs
- Quadlet removes containers during stop/restart. The scanner protected existing
in-memory entries but did not reconstruct an absent app on a fresh daemon.
It now synthesizes stopped entries from the durable installed set, respecting
uninstall records, normalizing container prefixes, and preserving cached
metadata. Absence does not establish an image version or available update.
- Concurrent read/modify/write operations could lose installed-app records;
in-place writes could expose truncated JSON to readers. Serialize writers,
publish by atomic rename, and sync the file and parent directory. Legacy
package install/uninstall success paths update the durable record too.
- Scans and lifecycle/progress operations could replace a newer model from an
older snapshot. Use locked mutations for lifecycle/progress, and merge scan
results only into entries unchanged since the scan's merge snapshot.
- Container running state and TCP accept alone did not establish HTTP readiness.
Add explicit `ui-ready` based on bounded HTTP probes of the loopback upstream;
reject connection failures and server errors, accept normal redirects and
authentication challenges, and do not follow redirects or send credentials.
Self-signed HTTPS apps are probed locally without certificate validation.
- The app gate swept new listeners only every 60 seconds. Wake that sweep
immediately for a ready upstream whose declared gate port is not yet claimed,
and withhold readiness until external and Tor listener claims exist.
- Fixed launch URLs could bypass suppressed runtime URLs. Enforce readiness in
app cards, details, centralized embedded/browser launchers, and session frames.
Starting/restarting clears readiness immediately. A waiting frame does not
load an iframe and resumes when the backend reports readiness.
### New X250 findings
- The published ISO copied only `bitcoin-ui`, `lnd-ui` and `electrs-ui` build
directories. GitWorkshop failed because `/opt/archipelago/docker/archipelago-source`
was missing. Copy the complete docker source tree for bundled and unbundled
ISOs, matching OTA packaging. Validate every manifest build context and
Dockerfile in OTA staging, ISO staging and the mounted ISO smoke test.
- After restoring the omitted contexts, GitWorkshop's retained npm audit rejected
newly reported brace-expansion, fast-uri and ip-address vulnerabilities.
Refresh the existing pinned dependency patch, keeping the audit enabled.
Clean install/audit (zero advisories), type-check, 152 upstream tests and
subpath production build pass. The image builds on the X250 and `/healthz`
returns 200. No wallet or Bitcoin container restart was needed.
- Nginx was receiving data, not frozen: over 1 GB read during the pull. It
completed at 12:40:46 UTC after starting at 12:26:27; its web endpoint returns
200. The orchestrated path previously labelled the entire download/build/start
operation "Creating container" at 70%. Give that aggregate operation its own
truthful label and earlier phase; no byte-level download estimate is claimed.
- Restore install progress immediately from an already-loaded server snapshot,
so a new store created after hard refresh does not wait for another mutation.
- Replace the install modal's native version popup with inline radio choices.
On this actual X250's Chromium 152 kiosk renderer, selection changes work,
options have white text on dark backgrounds, and remain above pruning controls.
Screenshot and browser assertions captured; no install confirmation was clicked.
### Restart safety
The disposable fixture restart at 12:38:05 UTC stopped its container, then
`ss | kill` in runtime port cleanup sent SIGTERM to the management daemon at
12:38:35. The daemon owned the gate listener on the same port at other addresses.
Systemd restarted management; Bitcoin and LND container IDs/start times were
unchanged. Remove port-owner kills and broad `pkill` patterns from restart,
install recovery and Grafana preparation. Recovery now uses the existing
container-ID-aware ghost reaper: absent container ownership must be established
before a process is terminated. A real listening-socket regression checks that
conflict cleanup preserves the host listener. App-gate manifest lookup now honors
`ARCHIPELAGO_APPS_DIR`, matching the orchestrator's configured manifest root.
## Validation
- Full frontend suite: 139 files, 1,126 tests passed; final focused kiosk/store
checks: nine passed. Production frontend build passed.
- Final isolated backend suite: 1,567 passed, zero failed, four existing ignored
tests. Optimized backend build passed and was deployed to the development node.
- Tests cover empty runtime inventory, alias deduplication, uninstall exclusion,
concurrent durable writes, concurrent state changes, stale scan publication,
TCP-without-HTTP, HTTP statuses including 502/503, and gate listener claims.
- Live disposable Node fixture delayed HTTP startup by 25 seconds. Desktop and
mobile retained the waiting screen through hard refresh without mounting an
iframe, then opened the exact fixture page automatically when ready.
- Restart retained the app in both state APIs throughout and returned to ready;
the management PID did not change. Stopping removed the Quadlet container;
restarting management reconstructed its installed/stopped entry without a
false update offer. Starting it again succeeded. Desktop and mobile continued
to show the installed app after hard refresh.
- LAN access required node authentication and returned exact fixture bytes after
authentication. The fixture was uninstalled through the package lifecycle API;
its temporary manifest root and service override were removed.
- Bitcoin and LND container IDs and start times stayed unchanged through all
scoped checks and management restarts. No wallet data was used by the fixture.
- X250 kiosk checks also opened the repaired GitWorkshop and Nginx Proxy Manager
pages successfully, with no failed local resource loads.
## Limits
This prevents the identified lifecycle/readiness failures; it cannot guarantee
that an app or network never fails after a successful readiness check. Actual
application failures must remain visible rather than being labelled successful.
The full lifecycle/reboot release gate and funded acceptance of the reviewed
paid-download PRs remain pending. The X250 kiosk fix has live rendering evidence.
+12
View File
@@ -290,3 +290,15 @@ app:
Validate with `scripts/validate-app-manifest.sh` and regenerate the catalog Validate with `scripts/validate-app-manifest.sh` and regenerate the catalog
with `scripts/generate-app-catalog.py` (drift-checked in CI by with `scripts/generate-app-catalog.py` (drift-checked in CI by
`scripts/check-app-catalog-drift.py`). `scripts/check-app-catalog-drift.py`).
### Persistent-state backup for network migrations
`app.backup_on_network_change: true` opts an app into a stopped-state snapshot
before an explicitly selected rootless network mode is migrated. The orchestrator
archives writable persistent bind mounts under the node data directory, collapses
nested mounts, excludes the runtime Podman socket, and preserves the previous
Quadlet definition for rollback. Named volumes, outside-data-root state and
symlinked mount roots fail closed rather than silently producing an incomplete
backup. A failed snapshot resumes the original service and leaves migration
pending. Private archives are retained under `migration-backups/`; fresh installs
and unchanged network configurations do not create migration snapshots.
+104
View File
@@ -0,0 +1,104 @@
# Same-node Gitea sources in Portainer
Status: root cause reproduced and network repair verified in disposable Portainer
instances; final migration integration and release acceptance remain in progress.
This change belongs to the next signed catalog, OTA and ISO. It does not modify
published 1.8.21 artifacts.
## Confirmed cause
On the affected X250, Gitea 1.27.3 and Portainer 2.45.0 run in rootless Podman
5.4.2, managed by user Quadlet services. Gitea publishes HTTP on loopback and the
Archipelago app gate serves its public port. Gitea's public ROOT_URL already
matches that gate URL.
Portainer had no explicit network selection and Podman selected pasta. Its
network namespace contained the host's LAN address. A Git request to that same
LAN address therefore reached Portainer's namespace rather than the host gate:
connection refused before authentication. The exact smart-HTTP request from the
host returned 200 with `application/x-git-upload-pack-advertisement`. From
Portainer's actual namespace the LAN request was refused, while its host mapping
returned a Git advertisement and the expected branch tip. Direct container-IP
requests timed out. Container health and host-only HTTP checks missed the defect.
A disposable Portainer using `slirp4netns` successfully created a Source through
Portainer's own API, using the original LAN clone URL. Returning that fixture to
pasta reproduced the refusal; recreating with slirp repaired it while preserving
its account and saved Source. Restart also passed. The requested branch tip and
Compose file were read from that actual Portainer network namespace. No user
stack was deployed. Deployment addresses and repository details are kept outside
this public record.
## Source changes
- Declare Portainer's rootless `slirp4netns` mode in its manifest. No shared static
container IP, host networking, all-interface backend publication or auth bypass.
- Keep Gitea's loopback HTTP backend and gate port; machine Git uses Gitea's
authentication. Remove obsolete port-3000 nginx metadata/template and the old
best-effort installer commands which silently rewrote app.ini and falsely
claimed success. Gitea owns first-run setup and operator configuration.
- Existing Quadlet reconciliation applies Network= drift. Record a durable
pending restart before updating the unit and clear it only after a successful
restart, so failed reloads/restarts and management interruptions retry.
- Detect explicit rootless network-mode drift in the older Podman runtime too.
Unspecified networks do not trigger inferred changes to unrelated apps.
- Portainer opts into `backup_on_network_change`. Before recreation, gracefully
stop the app and archive its writable persistent bind mounts, including nested
Compose state, once each. Runtime sockets are excluded. Save the previous
Quadlet definition, where present. Archives live under the node data directory's
private `migration-backups/<id>/` directory; state is never deleted. Backup
failures resume the original service and fail the migration visibly.
- Keep Podman API and Quadlet bind/network behavior covered by actual-manifest
tests. Docker remains a development fallback: it now preserves bind/protocol
declarations and rejects Podman-only networking instead of silently changing it.
## Operator use and diagnostics
Use Gitea's advertised HTTP(S) clone URL in Portainer Sources, with the Gitea
username and token in the credential fields. On first-run Gitea setup, the public
base URL must match the origin opened through Archipelago (including its port).
Keep a deliberately configured HTTPS/domain origin when one exists. Do not use a
container IP or put a token into the URL. A private repository requires repository
read permission. A successful Source check fetches Git refs; it does not deploy
a stack or establish that a Compose build uses a desired application revision.
`scripts/check-portainer-git-source.py` calls Portainer's own read-only Source
connection test. Supply a private mode-600 JSON credential file containing
`api_key` or `jwt`, and optionally `git: {username, password}`. Pass
`--portainer-url`, `--repository-url` and `--credentials-file`. It does not create
Sources or stacks and prints no credentials or raw server errors. It distinguishes
Portainer login/API failures from Git connection refusal, timeout, DNS/TLS
failure, HTML/login interception and repository authentication failure. TLS
verification stays enabled and API redirects are refused.
## Upgrade and rollback
The signed catalog embeds manifests and overrides installed disk copies. A disk
edit alone cannot deliver this fix. Publish the matching catalog with the tested
runtime, then verify the generated unit, actual network mode and Source API.
Expect a Portainer interruption while the snapshot and recreation run; duration
depends on its saved state size.
Gitea does not need recreation or an app.ini rewrite for this repair.
Keep the previous trusted catalog/runtime for rollback. Restore that catalog
before restoring the saved `previous.container`, reloading user systemd and
starting Portainer; otherwise reconciliation will correctly reapply the new
manifest. The archive is a stopped-state emergency backup, not an instruction to
roll back a live database automatically. Restore it only with Portainer stopped
and after preserving any newer state. Do not replace Gitea data/config, keys,
repositories or the production Portainer database with disposable test data.
## Validation and remaining gates
- Disposable X250 Portainer Source API: old mode refuses; repaired mode succeeds;
saved account/Source survive recreation; restart succeeds.
- Invalid Git credentials produce a repository-authentication error, distinct
from TCP refusal. Requested branch and Compose file read from Portainer context.
- Final expanded backend suite: 1,575 passed, zero failed, four existing ignored
tests, including stopped-state archive round trips and failure preservation. Container runtime suite: 78 passed.
Five diagnostic regression tests passed. Combined tests with the merged
paid-download PRs remain pending.
- Still required before release: live automatic migration with the new runtime,
snapshot/rollback verification, private-repository and install-order acceptance,
lifecycle/reboot convergence, and signed-catalog delivery to the existing app.
Record LFS/registry/SSH/browser checks and actual hardware/runtime coverage.
-14
View File
@@ -111,17 +111,3 @@ Logs on the development box:
protected. protected.
- The separately reported X250 kiosk version-selector rendering issue remains - The separately reported X250 kiosk version-selector rendering issue remains
open in `TODO.md` and requires validation on the actual kiosk. open in `TODO.md` and requires validation on the actual kiosk.
## Authorized merge — 2026-09-30
The operator explicitly requested normal merged/closed PR status after review.
Re-read both PRs and verified their heads still exactly matched the reviewed
commits. Changes from the integration-test base to main were documentation only.
Gitea normal merges completed and read-back confirmed `merged=true`, `state=closed`:
- #161: `3daea6623be3e2c7222101b8e6ac411423c7e16c`.
- #162: `b02ba4100d922dd1b75c6a78121ef446c2159a54`.
Local next-release lifecycle work will be integrated with this main before the
next release. Funded release acceptance and the documented delivery-receipt
limitation remain as recorded above; merging does not claim a new release.
@@ -1,21 +0,0 @@
# Gitea iframe proxy — strips X-Frame-Options so Gitea works in Archipelago iframe.
# Gitea container binds to port 3001, this proxy listens on port 3000 (the public port).
# Deployed to /etc/nginx/conf.d/gitea-iframe.conf
server {
listen 3000;
server_name _;
client_max_body_size 1G;
location / {
proxy_pass http://127.0.0.1:3001;
proxy_set_header Host $http_host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_hide_header X-Frame-Options;
proxy_hide_header Content-Security-Policy;
}
}
+95
View File
@@ -0,0 +1,95 @@
#!/usr/bin/env python3
"""Test Git from Portainer's server context without creating a Source or stack."""
import argparse
import json
import pathlib
import socket
import stat
import urllib.error
import urllib.parse
import urllib.request
class NoRedirect(urllib.request.HTTPRedirectHandler):
def redirect_request(self, req, fp, code, msg, headers, newurl):
return None
def classify(error):
text = error.lower()
for category, patterns in (
('connection-refused', ('connection refused',)),
('dns-failure', ('no such host', 'name resolution', 'server misbehaving')),
('timeout', ('timeout', 'timed out', 'deadline exceeded')),
('tls-failure', ('x509:', 'certificate', 'tls handshake')),
('proxy-or-login-interception', ('text/html', '<html', '<!doctype', 'unexpected content-type', 'invalid pkt-len')),
('repository-authentication', ('authentication required', 'authentication failed', 'authorization failed', '401', '403')),
('repository-not-found-or-private', ('repository not found', '404')),
):
if any(pattern in text for pattern in patterns):
return category
return 'git-error'
def safe_url(value):
parsed = urllib.parse.urlsplit(value)
if parsed.scheme not in ('http', 'https') or not parsed.hostname:
raise ValueError('Use an HTTP(S) URL')
if parsed.username is not None or parsed.password is not None or parsed.query or parsed.fragment:
raise ValueError('URLs must not contain credentials, query parameters or fragments')
return value.rstrip('/')
def private_json(path):
path = pathlib.Path(path)
if stat.S_IMODE(path.stat().st_mode) & 0o077:
raise ValueError('Credential file must be private (chmod 600)')
return json.loads(path.read_text())
def check(base, repository, credentials, opener=None):
base, repository = safe_url(base), safe_url(repository)
# JWT/API keys and Git credentials travel in headers/body, never URLs or logs.
headers = {'Content-Type': 'application/json'}
if credentials.get('api_key'):
headers['X-API-Key'] = credentials['api_key']
elif credentials.get('jwt'):
headers['Authorization'] = 'Bearer ' + credentials['jwt']
else:
raise ValueError('Credential file needs api_key or jwt')
payload = {'url': repository, 'tlsSkipVerify': False, 'interval': '5m'}
if credentials.get('git'):
payload['authentication'] = credentials['git']
request = urllib.request.Request(base + '/api/gitops/sources/test',
data=json.dumps(payload).encode(), headers=headers)
opener = opener or urllib.request.build_opener(NoRedirect())
try:
with opener.open(request, timeout=45) as response:
result = json.load(response)
except urllib.error.HTTPError as error:
return {'success': False, 'category': 'portainer-authentication' if error.code in (401, 403) else 'portainer-api-error', 'http_status': error.code}
except (urllib.error.URLError, TimeoutError, socket.timeout) as error:
return {'success': False, 'category': 'portainer-api-' + classify(str(error))}
except (ValueError, UnicodeError):
return {'success': False, 'category': 'portainer-api-invalid-response'}
if not isinstance(result, dict) or not isinstance(result.get('success'), bool):
return {'success': False, 'category': 'portainer-api-invalid-response'}
return {'success': result['success'], 'category': 'git-refs-readable' if result['success'] else classify(str(result.get('error', '')))}
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--portainer-url', required=True, help='Reachable Portainer origin, without /api')
parser.add_argument('--repository-url', required=True, help='The same clone URL entered in Portainer')
parser.add_argument('--credentials-file', required=True, help='Mode 600 JSON: api_key or jwt; optional git: {username,password}')
args = parser.parse_args()
try:
result = check(args.portainer_url, args.repository_url, private_json(args.credentials_file))
except (OSError, ValueError):
parser.exit(2, 'Invalid URL or private credential file; no credentials were printed.\n')
print(json.dumps(result))
return 0 if result['success'] else 1
if __name__ == '__main__':
raise SystemExit(main())
+6 -1
View File
@@ -9,7 +9,12 @@ command -v setpriv >/dev/null
sudo -n true || { echo 'Isolated backend tests require noninteractive sudo for systemd namespaces.' >&2; exit 1; } sudo -n true || { echo 'Isolated backend tests require noninteractive sudo for systemd namespaces.' >&2; exit 1; }
metadata=$(mktemp) metadata=$(mktemp)
trap 'rm -f "$metadata"' EXIT trap 'rm -f "$metadata"' EXIT
if ! cargo test --manifest-path "$REPO/core/Cargo.toml" -p archipelago --bin archipelago \ case "${ARCHY_TEST_PACKAGE:-archipelago}" in
archipelago) test_target=(-p archipelago --bin archipelago) ;;
archipelago-container) test_target=(-p archipelago-container --lib) ;;
*) echo 'Unsupported isolated test package' >&2; exit 2 ;;
esac
if ! cargo test --manifest-path "$REPO/core/Cargo.toml" "${test_target[@]}" \
--locked --no-run --message-format=json --config 'profile.test.package.archipelago.opt-level=0' > "$metadata"; then --locked --no-run --message-format=json --config 'profile.test.package.archipelago.opt-level=0' > "$metadata"; then
python3 - "$metadata" <<'PYDIAG' python3 - "$metadata" <<'PYDIAG'
import json,sys import json,sys
@@ -0,0 +1,70 @@
#!/usr/bin/env python3
import importlib.util
import io
import json
import pathlib
import unittest
import urllib.error
ROOT = pathlib.Path(__file__).resolve().parents[2]
spec = importlib.util.spec_from_file_location('diagnostic', ROOT / 'scripts/check-portainer-git-source.py')
m = importlib.util.module_from_spec(spec)
spec.loader.exec_module(m)
class Response(io.BytesIO):
pass
class FakeAPI:
def __init__(self, result=None, error=None):
self.result, self.error, self.request = result, error, None
def open(self, request, timeout):
self.request = request
if self.error:
raise self.error
return Response(json.dumps(self.result).encode())
class Diagnostics(unittest.TestCase):
def test_server_context_credentials_not_in_url_and_tls_stays_enabled(self):
api = FakeAPI({'success': True})
result = m.check('http://localhost:9000', 'http://node:3001/user/repo',
{'jwt': 'test-jwt', 'git': {'username': 'test-user', 'password': 'test-secret'}}, api)
self.assertTrue(result['success'])
self.assertEqual(api.request.full_url, 'http://localhost:9000/api/gitops/sources/test')
payload = json.loads(api.request.data)
self.assertFalse(payload['tlsSkipVerify'])
self.assertEqual(payload['authentication']['password'], 'test-secret')
self.assertNotIn('test-secret', json.dumps(result))
def test_failure_categories_from_source_api(self):
cases = [('dial tcp: connection refused', 'connection-refused'),
('lookup node: no such host', 'dns-failure'),
('context deadline exceeded', 'timeout'),
('unexpected content-type text/html', 'proxy-or-login-interception'),
('authentication required', 'repository-authentication'),
('x509: certificate signed by unknown authority', 'tls-failure'),
('repository not found', 'repository-not-found-or-private')]
for error, expected in cases:
with self.subTest(error=error):
result = m.check('http://localhost:9000', 'http://node/repo', {'jwt': 'test'}, FakeAPI({'success': False, 'error': error}))
self.assertEqual(result['category'], expected)
self.assertFalse(result['success'])
def test_portainer_auth_is_distinct_from_repository_auth(self):
api = FakeAPI(error=urllib.error.HTTPError('http://localhost', 401, 'Unauthorized', {}, None))
self.assertEqual(m.check('http://localhost', 'http://node/repo', {'jwt': 'bad'}, api)['category'], 'portainer-authentication')
def test_html_or_malformed_api_response_never_proves_git_success(self):
for value in ({'status': 1}, {'success': 'true'}, [], '<html>login</html>'):
self.assertFalse(m.check('http://localhost', 'http://node/repo', {'jwt': 'test'}, FakeAPI(value))['success'])
def test_credential_urls_rejected_before_request(self):
for value in ('http://user:secret@node/repo', 'http://node/repo?token=secret', 'file:///data/repo'):
with self.assertRaises(ValueError):
m.check('http://localhost', value, {'jwt': 'test'}, FakeAPI())
if __name__ == '__main__':
unittest.main()