diff options
Diffstat (limited to 'src/app/archive.rs')
| -rw-r--r-- | src/app/archive.rs | 475 |
1 files changed, 475 insertions, 0 deletions
diff --git a/src/app/archive.rs b/src/app/archive.rs new file mode 100644 index 0000000..2a78644 --- /dev/null +++ b/src/app/archive.rs @@ -0,0 +1,475 @@ +// Copyright (C) 2026 Doan Luu Duc Tai <doan@luuduct.ai> +// +// 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 <https://www.gnu.org/licenses/>. + +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::app::project::{Mode, Project, Settings, field}; +use crate::app::{files, process}; + +#[derive(Debug)] +struct Paused<'a> { + project: &'a Project, + ids: Vec<String>, +} + +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(super) fn containers(project: &Project, settings: &Settings) -> Result<Vec<Value>> { + 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::<Result<_>>()?; + 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, &["Config", "Labels", "io.stack.service"])?.as_str() != Some("chat") + && 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_chat(root: &Path, stage: &Path, name: &str) -> Result<()> { + let source = root.join("var/data").join(name).join("chat.sqlite"); + if !source.is_file() { + return Ok(()); + } + let directory = stage.join("var/data").join(name); + files::private_dir(&directory)?; + let database = + rusqlite::Connection::open_with_flags(source, rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY)?; + database.busy_timeout(std::time::Duration::from_secs(5))?; + let destination = directory.join("chat.sqlite"); + database.backup("main", &destination, None::<fn(rusqlite::backup::Progress)>)?; + fs::set_permissions( + destination, + std::os::unix::fs::PermissionsExt::from_mode(0o600), + )?; + Ok(()) +} + +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 !["caddy", "chat", "chat-local"] + .iter() + .any(|name| entry.file_name() == *name) + { + files::copy_tree( + &entry.path(), + &stage.join("var/data").join(entry.file_name()), + )?; + } + } + for name in ["chat", "chat-local"] { + snapshot_chat(&project.root, stage, 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"))?; + crate::app::chat::copy_artifacts(project, stage)?; + Ok(()) +} + +pub(super) 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 configuration = stack_config::Config::load(&project.root)?; + let key = public_key.map_or_else( + || Ok(project.root.join(configuration.value("BACKUP_PUBLIC_KEY")?)), + |path| Ok::<_, anyhow::Error>(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::app::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::set_permissions( + &encrypted, + std::os::unix::fs::PermissionsExt::from_mode(0o600), + )?; + 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(super) 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::<String>() + ); + 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::app::files; + use crate::app::project::{Mode, Project}; + + #[test] + fn chat_snapshot_is_consistent_and_private() -> Result<()> { + let workspace = tempfile::tempdir()?; + let root = workspace.path().join("source"); + let stage = workspace.path().join("snapshot"); + files::private_dir(&root.join("var/data/chat"))?; + let connection = rusqlite::Connection::open(root.join("var/data/chat/chat.sqlite"))?; + connection.execute_batch( + "CREATE TABLE messages (body TEXT); INSERT INTO messages VALUES ('ciphertext-before');", + )?; + let transaction = connection.unchecked_transaction()?; + transaction.execute("INSERT INTO messages VALUES ('uncommitted')", [])?; + super::snapshot_chat(&root, &stage, "chat")?; + let snapshot = stage.join("var/data/chat/chat.sqlite"); + let restored = rusqlite::Connection::open(&snapshot)?; + let count: i64 = + restored.query_row("SELECT COUNT(*) FROM messages", [], |row| row.get(0))?; + anyhow::ensure!((count) == (1), "Test invariant failed"); + anyhow::ensure!( + (std::os::unix::fs::PermissionsExt::mode(&fs::metadata(snapshot)?.permissions()) + & 0o777) + == (0o600), + "Test invariant failed" + ); + transaction.rollback()?; + Ok(()) + } + + #[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)?; + anyhow::ensure!( + (Project::at(&destination)?.settings()?.mode) == (Mode::Local), + "Test invariant failed" + ); + anyhow::ensure!( + (fs::read_to_string(destination.join(".env"))?) == ("SEARXNG_SECRET=private\n"), + "Test invariant failed" + ); + anyhow::ensure!( + super::restore(&archive, &destination).is_err(), + "Test invariant failed" + ); + 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"); + anyhow::ensure!( + super::restore(&archive, &destination).is_err(), + "Test invariant failed" + ); + anyhow::ensure!(!destination.exists(), "Test invariant failed"); + Ok(()) + } +} |
