From 955c54b713f9af50444b4f32d3181a84bdf8ef6f Mon Sep 17 00:00:00 2001 From: archipelago Date: Sun, 21 Jun 2026 11:45:28 -0400 Subject: [PATCH] feat(hooks): post_install executor + install-path wiring (#20 phase 2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add container::hooks::run_post_install — runs an app's declarative post_install hooks against its own running container: - Exec -> podman exec (60s timeout-bounded) - CopyFromHost -> resolve src against allowlist roots (/ and /opt/archipelago), canonicalise + prefix-check (defeats symlink escape), then podman cp : Best-effort + idempotent: a failed step is warned and skipped, never fails the install — matching the legacy patch_indeedhub_nostr_provider behaviour this replaces. Wired into install_fresh after the container is up, so it runs only on a freshly created container (not plain start), and re-applies on recreate-after-drift. 5 unit tests on resolve_copy_src (accept in-data-dir, reject absolute / traversal / missing / symlink-escape). cargo test -p archipelago green. Co-Authored-By: Claude Opus 4.8 (1M context) --- core/archipelago/src/container/hooks.rs | 185 ++++++++++++++++++ core/archipelago/src/container/mod.rs | 1 + .../src/container/prod_orchestrator.rs | 5 + 3 files changed, 191 insertions(+) create mode 100644 core/archipelago/src/container/hooks.rs diff --git a/core/archipelago/src/container/hooks.rs b/core/archipelago/src/container/hooks.rs new file mode 100644 index 00000000..f75cf36b --- /dev/null +++ b/core/archipelago/src/container/hooks.rs @@ -0,0 +1,185 @@ +//! Manifest-driven lifecycle hook executor (Task #20). +//! +//! Runs an app's declarative `post_install` hooks against its **own** running +//! container. Hooks are an allowlisted, reviewed escape hatch — NOT arbitrary +//! host scripts: +//! +//! - `exec` runs *inside the container* (`podman exec`), never on the host, and +//! inherits the container's (already dropped) capabilities. +//! - `copy_from_host.src` is resolved against an allowlist root, canonicalised, +//! and rejected on any escape; only then is it `podman cp`'d into the container. +//! - Execution is **best-effort + idempotent**: each step is logged, a failure is +//! warned and the remaining steps still run, so a transient hook error never +//! bricks an install. Authors must make steps safe to re-run (e.g. `grep -q … ||`). +//! +//! See `docs/manifest-hooks-design.md`. + +use std::path::{Path, PathBuf}; +use std::time::Duration; + +use anyhow::{bail, Result}; +use archipelago_container::{AppManifest, HookStep}; + +/// Upper bound on a single hook command. Generous — config rewrites + nginx +/// reloads are fast, but an image with a hung entrypoint shouldn't wedge install. +const HOOK_TIMEOUT: Duration = Duration::from_secs(60); + +/// Roots a `copy_from_host.src` may resolve within. A src is joined onto each +/// root, canonicalised, and accepted only if it stays inside that root: +/// - the app's own data dir (`/`), and +/// - `/opt/archipelago` (covers the orchestrator's bundled `web-ui/` assets, +/// e.g. indeedhub's `web-ui/nostr-provider.js`). +fn allowlist_roots(app_id: &str, data_dir: &Path) -> Vec { + vec![data_dir.join(app_id), PathBuf::from("/opt/archipelago")] +} + +/// Resolve a hook copy source against the allowlist. Returns the canonical +/// absolute path iff it exists and lies within an allowlist root. Defence in +/// depth: `AppManifest::validate` already rejects absolute / `..` srcs, but we +/// re-check here and canonicalise so a symlink inside a root can't escape it. +fn resolve_copy_src(src: &str, app_id: &str, data_dir: &Path) -> Result { + if src.is_empty() || src.starts_with('/') || src.contains("..") { + bail!("hook copy src '{src}' is not an allowlisted relative path"); + } + for root in allowlist_roots(app_id, data_dir) { + let Ok(root_canon) = root.canonicalize() else { + continue; + }; + let Ok(canon) = root.join(src).canonicalize() else { + continue; + }; + if canon.starts_with(&root_canon) { + return Ok(canon); + } + } + bail!("hook copy src '{src}' did not resolve inside an allowlist root") +} + +/// Run an app's declarative `post_install` hooks against its running container. +/// Best-effort: never returns an error — a failed step is warned and skipped. +/// Called from the install path after the container is created + running, and +/// only when a fresh container was created (see `install_fresh`). +pub async fn run_post_install(manifest: &AppManifest, container_name: &str, data_dir: &Path) { + let steps = &manifest.app.hooks.post_install; + if steps.is_empty() { + return; + } + let app_id = &manifest.app.id; + tracing::info!( + app_id = %app_id, + container = %container_name, + steps = steps.len(), + "running manifest post_install hooks" + ); + for (i, step) in steps.iter().enumerate() { + match run_step(step, container_name, app_id, data_dir).await { + Ok(()) => tracing::debug!(app_id = %app_id, step = i, "post_install hook step ok"), + Err(err) => tracing::warn!( + app_id = %app_id, + container = %container_name, + step = i, + error = %err, + "post_install hook step failed (continuing best-effort)" + ), + } + } +} + +async fn run_step( + step: &HookStep, + container: &str, + app_id: &str, + data_dir: &Path, +) -> Result<()> { + match step { + HookStep::Exec { exec } => { + let mut args: Vec<&str> = Vec::with_capacity(exec.len() + 2); + args.push("exec"); + args.push(container); + args.extend(exec.iter().map(String::as_str)); + run_podman(&args).await + } + HookStep::CopyFromHost { copy_from_host } => { + let abs = resolve_copy_src(©_from_host.src, app_id, data_dir)?; + let abs = abs.to_string_lossy().into_owned(); + let dest = format!("{container}:{}", copy_from_host.dest); + run_podman(&["cp", &abs, &dest]).await + } + } +} + +async fn run_podman(args: &[&str]) -> Result<()> { + let rendered = args.join(" "); + let out = tokio::time::timeout( + HOOK_TIMEOUT, + tokio::process::Command::new("podman").args(args).output(), + ) + .await + .map_err(|_| anyhow::anyhow!("podman {rendered} timed out after {:?}", HOOK_TIMEOUT))? + .map_err(|e| anyhow::anyhow!("podman {rendered}: {e}"))?; + + if !out.status.success() { + bail!( + "podman {rendered} exited {}: {}", + out.status, + String::from_utf8_lossy(&out.stderr).trim() + ); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn resolve_copy_src_accepts_file_in_app_data_dir() { + let tmp = tempfile::tempdir().unwrap(); + let data_dir = tmp.path(); + let app_dir = data_dir.join("myapp/web-ui"); + std::fs::create_dir_all(&app_dir).unwrap(); + std::fs::write(app_dir.join("provider.js"), b"x").unwrap(); + + let got = resolve_copy_src("web-ui/provider.js", "myapp", data_dir).unwrap(); + assert!(got.ends_with("myapp/web-ui/provider.js")); + assert!(got.is_absolute()); + } + + #[test] + fn resolve_copy_src_rejects_absolute() { + let tmp = tempfile::tempdir().unwrap(); + assert!(resolve_copy_src("/etc/passwd", "myapp", tmp.path()).is_err()); + } + + #[test] + fn resolve_copy_src_rejects_traversal() { + let tmp = tempfile::tempdir().unwrap(); + assert!(resolve_copy_src("web-ui/../../etc/shadow", "myapp", tmp.path()).is_err()); + } + + #[test] + fn resolve_copy_src_rejects_missing_file() { + // Inside the allowlist shape but the file doesn't exist → canonicalize fails. + let tmp = tempfile::tempdir().unwrap(); + std::fs::create_dir_all(tmp.path().join("myapp")).unwrap(); + assert!(resolve_copy_src("nope.js", "myapp", tmp.path()).is_err()); + } + + #[test] + fn resolve_copy_src_rejects_symlink_escape() { + // A symlink inside the app dir pointing outside it must be rejected by + // the post-canonicalisation prefix check. + let tmp = tempfile::tempdir().unwrap(); + let app_dir = tmp.path().join("myapp"); + std::fs::create_dir_all(&app_dir).unwrap(); + let secret = tmp.path().join("secret.txt"); + std::fs::write(&secret, b"s").unwrap(); + let link = app_dir.join("link.js"); + if std::os::unix::fs::symlink(&secret, &link).is_ok() { + // `secret.txt` lives in the tmp root, NOT under /myapp, so + // the canonical target escapes the app-data root. It also isn't under + // /opt/archipelago. Must be rejected. + assert!(resolve_copy_src("link.js", "myapp", tmp.path()).is_err()); + } + } +} diff --git a/core/archipelago/src/container/mod.rs b/core/archipelago/src/container/mod.rs index 2a4f2007..80f9957f 100644 --- a/core/archipelago/src/container/mod.rs +++ b/core/archipelago/src/container/mod.rs @@ -6,6 +6,7 @@ pub mod data_manager; pub mod dev_orchestrator; pub mod docker_packages; pub mod filebrowser; +pub mod hooks; pub mod image_versions; pub mod lnd; pub mod prod_orchestrator; diff --git a/core/archipelago/src/container/prod_orchestrator.rs b/core/archipelago/src/container/prod_orchestrator.rs index 0d281ed8..e488fd44 100644 --- a/core/archipelago/src/container/prod_orchestrator.rs +++ b/core/archipelago/src/container/prod_orchestrator.rs @@ -1818,6 +1818,11 @@ impl ProdContainerOrchestrator { .with_context(|| format!("start_container {name}"))?; } self.run_post_start_hooks(&lm.manifest.app.id).await?; + // Declarative manifest post_install hooks (Task #20). Runs only here, on a + // freshly created container — exactly when container mutations (e.g. + // indeedhub's nginx X-Frame-Options strip + nostr-provider injection) must + // be re-applied. Best-effort + idempotent: never fails the install. + crate::container::hooks::run_post_install(&resolved_manifest, &name, &self.data_dir).await; if uses_pasta_network(&resolved_manifest) { if let Err(err) = wait_for_manifest_host_ports( &resolved_manifest,