feat(hooks): post_install executor + install-path wiring (#20 phase 2)

Add container::hooks::run_post_install — runs an app's declarative
post_install hooks against its own running container:
- Exec  -> podman exec <container> <args…> (60s timeout-bounded)
- CopyFromHost -> resolve src against allowlist roots (<data_dir>/<app>
  and /opt/archipelago), canonicalise + prefix-check (defeats symlink
  escape), then podman cp <abs-src> <container>:<dest>

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) <noreply@anthropic.com>
This commit is contained in:
archipelago 2026-06-21 11:45:28 -04:00
parent 4c1a4e5976
commit 955c54b713
3 changed files with 191 additions and 0 deletions

View File

@ -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 (`<data_dir>/<app_id>`), 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<PathBuf> {
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<PathBuf> {
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(&copy_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 <data_dir>/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());
}
}
}

View File

@ -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;

View File

@ -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,