// Copyright (C) 2026 Doan Luu Duc Tai // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU Affero General Public License as // published by the Free Software Foundation, either version 3 of the // License, or (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU Affero General Public License for more details. // // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . use std::collections::BTreeSet; use std::fs; use std::path::Path; use std::process::Command; use anyhow::{Context, Result, ensure}; use serde_json::Value; use crate::project::{Mode, Project, Settings, field}; use crate::{files, process}; pub(crate) const RECIPIENT: &str = "3346AA3150F8F16828BB0D14F8C66DF77AFECEA6"; const KEY: &str = "web/.well-known/openpgpkey/luuduct.ai/hu/t76q8xqkeassgy1kr38re1ppj6cpc1dp"; #[derive(Debug)] struct Paused<'a> { project: &'a Project, ids: Vec, } impl Paused<'_> { fn resume(&mut self) -> Result<()> { let mut failure = None; for id in self.ids.iter().rev() { if let Err(error) = process::capture(self.project.command("podman")?.args(["unpause", id])) { process::error(format_args!("Could not resume container {id}: {error:#}"))?; failure = Some(error); } } self.ids.clear(); if let Some(error) = failure { return Err(error); } Ok(()) } } impl Drop for Paused<'_> { fn drop(&mut self) { if let Err(error) = self.resume() { drop(process::error(format_args!( "Container resume failed: {error:#}" ))); } } } pub(crate) fn containers(project: &Project, settings: &Settings) -> Result> { let records = project.inspect_containers(settings)?; let actual: BTreeSet<_> = records .iter() .map(|record| { field(record, &["Config", "Labels", "io.stack.service"])? .as_str() .context("Missing service label") }) .collect::>()?; let configuration = project.configuration(settings)?; let expected: BTreeSet<_> = configuration.services.keys().map(String::as_str).collect(); ensure!( actual == expected, "Running services differ from configuration; refusing an incomplete backup" ); Ok(records) } fn snapshot(project: &Project, records: &[Value], stage: &Path) -> Result<()> { let mut guard = Paused { project, ids: Vec::new(), }; for record in records { if field(record, &["State", "Running"])?.as_bool() == Some(true) && field(record, &["State", "Paused"])?.as_bool() == Some(false) { let id = field(record, &["Id"])? .as_str() .context("Missing container ID")?; process::capture(project.command("podman")?.args(["pause", id]))?; guard.ids.push(id.to_owned()); } } let result = snapshot_files(project, records, stage); let resume = guard.resume(); result?; resume } fn snapshot_files(project: &Project, records: &[Value], stage: &Path) -> Result<()> { files::copy_source(&project.root, stage)?; files::copy_tree(&project.root.join(".env"), &stage.join(".env"))?; if project.root.join(".git").is_dir() { files::copy_tree(&project.root.join(".git"), &stage.join(".git"))?; } let data = project.root.join("var/data"); for item in fs::read_dir(&data)? { let entry = item?; if entry.file_name() != "caddy" { files::copy_tree( &entry.path(), &stage.join("var/data").join(entry.file_name()), )?; } } let caddy = records .iter() .find(|record| { field(record, &["Config", "Labels", "io.stack.service"]) .ok() .and_then(Value::as_str) == Some("caddy") }) .context("Caddy is not present")?; let id = field(caddy, &["Id"])?.as_str().context("Caddy has no ID")?; let target = stage.join("var/data/caddy"); fs::create_dir_all(&target)?; for name in ["data", "config"] { process::capture( project .command("podman")? .args(["cp", &format!("{id}:/{name}")]) .arg(&target), )?; } fs::create_dir_all(stage.join("bin"))?; let executable = [ project.root.join("bin/stack"), project .root .join("target/x86_64-unknown-linux-musl/release/stack"), ] .into_iter() .find(|path| path.is_file()) .context("Build a release locally before exporting so the backup includes a portable binary")?; fs::copy(executable, stage.join("bin/stack"))?; Ok(()) } pub(crate) fn backup( project: &Project, destination: &Path, with_images: bool, recipient: &str, public_key: Option<&Path>, ) -> Result<()> { ensure!( recipient.len() == 40 && recipient.bytes().all(|byte| byte.is_ascii_hexdigit()), "A full 40-character recipient fingerprint is required" ); files::private_dir(destination)?; let settings = project.settings()?; let records = containers(project, &settings)?; let temp = tempfile::Builder::new() .prefix(".export-") .tempdir_in(destination)?; let home = temp.path().join("gnupg"); files::private_dir(&home)?; let key = public_key.map_or_else(|| project.root.join(KEY), Path::to_path_buf); let operation = backup_inner( project, destination, with_images, &Encryption { recipient, key: &key, home: &home, }, &records, temp.path(), ); drop(process::capture( Command::new("gpgconf") .arg("--homedir") .arg(&home) .args(["--kill", "all"]), )); operation } #[derive(Debug)] struct Encryption<'a> { recipient: &'a str, key: &'a Path, home: &'a Path, } fn backup_inner( project: &Project, destination: &Path, with_images: bool, encryption: &Encryption<'_>, records: &[Value], temp: &Path, ) -> Result<()> { let Encryption { recipient, key, home, } = encryption; process::capture( Command::new("gpg") .args(["--no-options", "--batch", "--homedir"]) .arg(home) .args(["--import"]) .arg(key), )?; let listed = process::text( Command::new("gpg") .args(["--no-options", "--batch", "--homedir"]) .arg(home) .args(["--with-colons", "--list-keys", recipient]), )?; ensure!( listed .lines() .filter(|line| line.starts_with("fpr:")) .any(|line| line .split(':') .nth(9) .is_some_and(|found| found.eq_ignore_ascii_case(recipient))), "Public key fingerprint does not match the requested backup recipient" ); let stage = temp.join("stack"); files::private_dir(&stage)?; snapshot(project, records, &stage)?; for entry in fs::read_dir(stage.join("var/data/repos"))? { let repo = entry?.path(); if repo.is_dir() { process::capture( Command::new("git") .arg("--git-dir") .arg(repo) .args(["fsck", "--full"]), )?; } } if with_images { crate::images::save(project, records, &stage.join("var"))?; } files::manifest(&stage, "backup")?; let plain = temp.join("stack.tar.gz"); files::pack(&stage, &plain)?; let name = format!( "stack-{}.tar.gz.gpg", chrono::Utc::now().format("%Y%m%dT%H%M%SZ") ); let encrypted = temp.join(&name); process::run( Command::new("gpg") .args(["--no-options", "--batch", "--homedir"]) .arg(home) .args([ "--rfc4880", "--trust-model", "always", "--recipient", recipient, "--cipher-algo", "AES256", "--compress-algo", "none", "--set-filename", "stack.tar.gz", "--output", ]) .arg(&encrypted) .arg("--encrypt") .arg(plain), )?; let output = destination.join(&name); ensure!( !output.exists(), "An export with this timestamp already exists" ); fs::rename(&encrypted, &output)?; let digest = format!("{} {name}\n", files::hash_file(&output)?); files::write( &destination.join(format!("{name}.sha256")), digest.as_bytes(), 0o600, )?; process::line(format_args!("Encrypted export: {}", output.display())) } pub(crate) fn restore(archive: &Path, destination: &Path) -> Result<()> { ensure!(!destination.exists(), "Restore destination must not exist"); let parent = destination .parent() .context("Restore destination has no parent")?; fs::create_dir_all(parent)?; let temp = tempfile::Builder::new() .prefix(".restore-") .tempdir_in(parent)?; let decrypted = temp.path().join("stack.tar.gz"); let source = if archive.extension().is_some_and(|suffix| suffix == "gpg") { process::run( Command::new("gpg") .args(["--no-options", "--output"]) .arg(&decrypted) .arg("--decrypt") .arg(archive), )?; &decrypted } else { archive }; let stage = temp.path().join("stack"); files::extract(source, &stage)?; let manifest = files::verify(&stage)?; ensure!( manifest.kind == "backup" || manifest.kind == "release", "Unknown archive kind" ); let project = Project::at(&stage)?; let mut settings = project.settings()?; settings.mode = Mode::Local; settings.project = format!( "stack-{}", files::hash_bytes(destination.as_os_str().as_encoded_bytes()) .chars() .take(10) .collect::() ); project.save(&settings)?; if stage.join(".git").is_dir() { process::capture( Command::new("git") .arg("-C") .arg(&stage) .args(["remote", "set-url", "origin"]) .arg(destination.join("var/data/repos/stack")), )?; } fs::rename(&stage, destination)?; process::line(format_args!( "Restored and verified {}. No services started.", destination.display() )) } #[cfg(test)] mod tests { use std::fs; use anyhow::Result; use crate::files; use crate::project::{Mode, Project}; #[test] fn restore_is_local_and_refuses_an_existing_destination() -> Result<()> { let workspace = tempfile::tempdir()?; let source = workspace.path().join("source"); files::private_dir(&source)?; fs::write(source.join(".env"), "SEARXNG_SECRET=private\n")?; let project = Project::at(&source)?; let mut settings = project.settings()?; settings.mode = Mode::Production; project.save(&settings)?; files::manifest(&source, "backup")?; let archive = workspace.path().join("backup.tar.gz"); files::pack(&source, &archive)?; let destination = workspace.path().join("restored"); super::restore(&archive, &destination)?; assert_eq!(Project::at(&destination)?.settings()?.mode, Mode::Local); assert_eq!( fs::read_to_string(destination.join(".env"))?, "SEARXNG_SECRET=private\n" ); assert!(super::restore(&archive, &destination).is_err()); Ok(()) } #[test] fn corrupt_restore_never_publishes_a_destination() -> Result<()> { let workspace = tempfile::tempdir()?; let source = workspace.path().join("source"); files::private_dir(&source)?; fs::write(source.join(".env"), "original")?; files::manifest(&source, "backup")?; fs::write(source.join(".env"), "tampered")?; let archive = workspace.path().join("backup.tar.gz"); files::pack(&source, &archive)?; let destination = workspace.path().join("restored"); assert!(super::restore(&archive, &destination).is_err()); assert!(!destination.exists()); Ok(()) } }