From a80df869a92c330fbad9695057720b11a18af901 Mon Sep 17 00:00:00 2001 From: Doan Luu Duc Tai Date: Sat, 31 Oct 2026 13:08:08 -0500 Subject: Forbid Rust lints and enforce strict typed TypeScript linting --- src/app.rs | 252 +++++++++++++++++++++++++ src/app/archive.rs | 475 ++++++++++++++++++++++++++++++++++++++++++++++ src/app/chat.rs | 177 ++++++++++++++++++ src/app/cli.rs | 329 ++++++++++++++++++++++++++++++++ src/app/files.rs | 384 ++++++++++++++++++++++++++++++++++++++ src/app/images.rs | 215 +++++++++++++++++++++ src/app/process.rs | 70 +++++++ src/app/project.rs | 539 +++++++++++++++++++++++++++++++++++++++++++++++++++++ src/app/release.rs | 127 +++++++++++++ src/app/render.rs | 130 +++++++++++++ src/archive.rs | 464 --------------------------------------------- src/chat.rs | 177 ------------------ src/files.rs | 343 ---------------------------------- src/images.rs | 215 --------------------- src/main.rs | 324 +------------------------------- src/process.rs | 70 ------- src/project.rs | 536 ---------------------------------------------------- src/release.rs | 127 ------------- src/render.rs | 114 ----------- 19 files changed, 2703 insertions(+), 2365 deletions(-) create mode 100644 src/app.rs create mode 100644 src/app/archive.rs create mode 100644 src/app/chat.rs create mode 100644 src/app/cli.rs create mode 100644 src/app/files.rs create mode 100644 src/app/images.rs create mode 100644 src/app/process.rs create mode 100644 src/app/project.rs create mode 100644 src/app/release.rs create mode 100644 src/app/render.rs delete mode 100644 src/archive.rs delete mode 100644 src/chat.rs delete mode 100644 src/files.rs delete mode 100644 src/images.rs delete mode 100644 src/process.rs delete mode 100644 src/project.rs delete mode 100644 src/release.rs delete mode 100644 src/render.rs (limited to 'src') diff --git a/src/app.rs b/src/app.rs new file mode 100644 index 0000000..d655774 --- /dev/null +++ b/src/app.rs @@ -0,0 +1,252 @@ +// 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 . + +#![doc = "Rust commands for personal web infrastructure."] + +mod archive; +mod chat; +mod cli; +mod files; +mod images; +mod process; +mod project; +mod release; +mod render; + +use std::path::{Path, PathBuf}; +use std::process::ExitCode; + +use crate::app::cli::{Action, Cli}; +use anyhow::Result; + +use crate::app::project::{Mode, Project}; + +#[doc = "Runs the checked command-line entry point and returns its process status."] +#[must_use] +pub fn run() -> ExitCode { + let arguments = match Cli::try_parse() { + Ok(arguments) => arguments, + Err(error) => { + let status = if error.use_stderr() { + ExitCode::FAILURE + } else { + ExitCode::SUCCESS + }; + drop(error.print()); + return status; + } + }; + match execute(arguments) { + Ok(()) => ExitCode::SUCCESS, + Err(error) => { + drop(process::error(format_args!("Error: {error:#}"))); + ExitCode::FAILURE + } + } +} + +fn execute(arguments: Cli) -> Result<()> { + if let Action::Restore { + archive, + destination, + } = &arguments.command + { + return archive::restore(&absolute(archive)?, &absolute(destination)?); + } + let project = Project::discover(arguments.root.as_deref())?; + if let Action::Chat { + init, + reset_owner, + owner_fingerprint, + } = arguments.command + { + return chat(&project, init, reset_owner.as_deref(), owner_fingerprint); + } + + let _operation = project.lock()?; + match arguments.command { + Action::Dev { port } => { + let mut settings = project.settings()?; + settings.mode = Mode::Local; + if let Some(selected_port) = port { + settings.port = selected_port; + } + images::prepare(&project, &mut settings)?; + project.up(&settings) + } + Action::Up { + production, + local, + port, + online, + } => { + let mut settings = project.settings()?; + if production { + settings.mode = Mode::Production; + } + if local { + settings.mode = Mode::Local; + } + if let Some(selected_port) = port { + settings.port = selected_port; + } + if online { + settings.online = true; + } + images::prepare(&project, &mut settings)?; + project.up(&settings) + } + Action::Config { production, port } => { + let mut settings = project.settings()?; + settings.mode = if production { + Mode::Production + } else { + Mode::Local + }; + if let Some(selected_port) = port { + settings.port = selected_port; + } + stack_config::initialize_public(&project.root)?; + render::prepare(&project, &settings)?; + process::line(format_args!( + "Public configuration: {}", + render::directory(&project, &settings).display() + )) + } + Action::Status => project.status(&project.settings()?), + Action::Down => project.down(&project.settings()?), + Action::Backup { + destination, + include_images, + recipient, + key, + } => { + let output = destination.map_or_else( + || Ok(project.root.join("var/exports")), + |path| absolute(&path), + )?; + let configuration = stack_config::Config::load(&project.root)?; + let selected_recipient = + recipient.unwrap_or(configuration.value("BACKUP_RECIPIENT")?.to_owned()); + archive::backup( + &project, + &output, + include_images, + &selected_recipient, + key.as_deref(), + ) + } + Action::Release { destination } => { + let output = destination + .map_or_else(|| Ok(project.root.join("dist")), |path| absolute(&path))?; + release::create(&project, &output) + } + Action::Activate { archive } => release::activate(&absolute(&archive)?, &project), + Action::Check => check(&project), + Action::Licenses => licenses(&project), + Action::Chat { .. } => anyhow::bail!("Chat action was not handled"), + Action::Restore { .. } => anyhow::bail!("Restore action was not handled"), + } +} + +fn chat( + project: &Project, + init: bool, + reset_owner: Option<&str>, + owner_fingerprint: bool, +) -> Result<()> { + if project.settings()?.mode == Mode::Production { + return chat::admin(project, init, reset_owner, owner_fingerprint); + } + let mut command = project.command("cargo")?; + command + .args(["run", "--locked", "-p", "stack-chat-server", "--", "--root"]) + .arg(&project.root); + if init { + command.arg("--init"); + } + if let Some(fingerprint) = reset_owner { + command.arg("--reset-owner").arg(fingerprint); + } + if owner_fingerprint { + command.arg("--owner-fingerprint"); + } + process::run(&mut command) +} + +fn absolute(path: &Path) -> Result { + Ok(if path.is_absolute() { + path.to_path_buf() + } else { + std::env::current_dir()?.join(path) + }) +} + +fn licenses(project: &Project) -> Result<()> { + process::run(project.command("cargo")?.args([ + "about", + "generate", + "--workspace", + "--locked", + "about.hbs", + "--output-file", + "web/THIRD-PARTY-LICENSES.html", + ])) +} + +fn check(project: &Project) -> Result<()> { + process::run(project.command("sh")?.arg("infra/chat/build-typescript.sh"))?; + process::run( + project + .command("cargo")? + .args(["fmt", "--all", "--", "--check"]), + )?; + process::run(project.command("cargo")?.args([ + "clippy", + "--locked", + "--workspace", + "--all-targets", + "--all-features", + "--", + "-D", + "warnings", + ]))?; + process::run(project.command("cargo")?.args([ + "test", + "--locked", + "--workspace", + "--all-targets", + "--all-features", + ]))?; + process::run( + project + .command("cargo")? + .env("RUSTDOCFLAGS", "-D warnings") + .args([ + "doc", + "--locked", + "--workspace", + "--no-deps", + "--document-private-items", + ]), + )?; + process::run(project.command("cargo")?.args([ + "deny", + "--workspace", + "--all-features", + "check", + ]))?; + licenses(project) +} 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 +// +// 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::app::project::{Mode, Project, Settings, field}; +use crate::app::{files, process}; + +#[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(super) 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, &["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::)?; + 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::() + ); + 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(()) + } +} diff --git a/src/app/chat.rs b/src/app/chat.rs new file mode 100644 index 0000000..1647018 --- /dev/null +++ b/src/app/chat.rs @@ -0,0 +1,177 @@ +// 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 crate::app::project::{Mode, Project, Settings}; +use crate::app::{files, process}; +use anyhow::{Context, Result, ensure}; +use stack_config::Config; +use std::fs; +use std::net::IpAddr; +use std::path::Path; +use std::process::Command; + +pub(super) fn web_bind(configuration: &Config) -> Result { + let address: IpAddr = configuration.value("WEB_BIND")?.parse()?; + Ok(match address { + IpAddr::V4(_) => address.to_string(), + IpAddr::V6(_) => format!("[{address}]"), + }) +} + +pub(super) fn environment( + command: &mut Command, + configuration: &Config, + settings: &Settings, +) -> Result<()> { + let local = (settings.mode == Mode::Local).then_some(settings.port); + let mut public = configuration.public_values(local)?; + let origin = public + .get("CHAT_PUBLIC_ORIGIN") + .context("Missing chat gateway origin")? + .clone(); + public.insert("CHAT_ORIGIN".into(), origin); + public.insert("CHAT_BIND".into(), "0.0.0.0:8080".into()); + for (key, value) in public { + command.arg("--env").arg(&key).env(key, value); + } + Ok(()) +} + +pub(super) fn build(project: &Project) -> Result<()> { + process::run(project.command("sh")?.arg("infra/chat/build-typescript.sh"))?; + process::run(project.command("sh")?.arg("infra/chat/build-native.sh"))?; + let wasm_build = if project + .root + .join(".tools/openssl-wasi/lib/libcrypto.a") + .is_file() + { + "infra/chat/build-browser.sh" + } else { + "infra/chat/build-openssl-wasi.sh" + }; + process::run(project.command("sh")?.arg(wasm_build))?; + process::run(project.command("sh")?.arg("infra/chat/build-source.sh"))?; + package(project) +} + +pub(super) fn package(project: &Project) -> Result<()> { + let directory = project.root.join("bin/chat"); + fs::create_dir_all(&directory)?; + for (source, target) in [ + ( + "target/x86_64-unknown-linux-musl/release/stack-chat-server", + "server", + ), + ( + "target/wasm32-wasip1/release/stack-chat-crypto.wasm", + "crypto.wasm", + ), + ("var/build/chat-source.tar.gz", "SOURCE.tar.gz"), + ("infra/containers/chat/Containerfile", "Containerfile"), + ( + "var/build/typescript/crates/chat-crypto/browser/wasi.js", + "wasi.mjs", + ), + ( + "var/build/typescript/crates/chat-server/web/app.js", + "web/app.js", + ), + ( + "var/build/typescript/crates/chat-server/web/worker.js", + "web/worker.js", + ), + ("web/THIRD-PARTY-LICENSES.html", "THIRD-PARTY-LICENSES.html"), + ("docs/CHAT-APPLICATION.txt", "CHAT-APPLICATION.txt"), + ("docs/CHAT-PRODUCTION.txt", "CHAT-PRODUCTION.txt"), + ("LICENSE", "LICENSE"), + ] { + files::copy_tree(&project.root.join(source), &directory.join(target))?; + } + for name in ["index.html", "style.css", "javascript.html"] { + files::copy_tree( + &project.root.join("crates/chat-server/web").join(name), + &directory.join("web").join(name), + )?; + } + Ok(()) +} + +pub(super) fn copy_artifacts(project: &Project, destination: &Path) -> Result<()> { + let artifacts = project.root.join("bin/chat"); + ensure!( + artifacts.join("server").is_file() + && artifacts.join("crypto.wasm").is_file() + && artifacts.join("SOURCE.tar.gz").is_file(), + "Build a complete chat release locally before export" + ); + files::copy_tree(&artifacts, &destination.join("bin/chat")) +} + +pub(super) fn admin( + project: &Project, + init: bool, + reset_owner: Option<&str>, + owner_fingerprint: bool, +) -> Result<()> { + let settings = project.settings()?; + if init { + project.initialize(&settings)?; + return process::line(format_args!( + "Owner setup token is available in the single root .env" + )); + } + ensure!( + reset_owner.is_some() || owner_fingerprint, + "Use up --production to run chat; maintenance options are --owner-fingerprint and --reset-owner" + ); + let configuration = Config::load(&project.root)?; + let services = project.configuration(&settings)?; + let service = services + .services + .get("chat") + .context("Chat is not packaged in this release")?; + let mut command = project.command("podman")?; + command + .args([ + "run", + "--rm", + "--network", + "none", + "--read-only", + "--read-only-tmpfs=false", + "--cap-drop", + "ALL", + "--security-opt", + "no-new-privileges", + "--env", + "CHAT_SETUP_TOKEN", + ]) + .env("CHAT_SETUP_TOKEN", configuration.value("CHAT_SETUP_TOKEN")?); + environment(&mut command, &configuration, &settings)?; + command + .arg("--volume") + .arg(format!( + "{}:/app/var/data/chat:rw,z", + project.root.join("var/data/chat").display() + )) + .arg(&service.image); + if let Some(fingerprint) = reset_owner { + command.arg("--reset-owner").arg(fingerprint); + } + if owner_fingerprint { + command.arg("--owner-fingerprint"); + } + process::run(&mut command) +} diff --git a/src/app/cli.rs b/src/app/cli.rs new file mode 100644 index 0000000..1830ec1 --- /dev/null +++ b/src/app/cli.rs @@ -0,0 +1,329 @@ +// 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 clap::{Arg, ArgAction, ArgMatches, Command, error::ErrorKind}; +use std::ffi::OsString; +use std::path::PathBuf; + +#[derive(Debug)] + +pub(super) struct Cli { + pub(super) root: Option, + + pub(super) command: Action, +} + +#[derive(Debug)] +pub(super) enum Action { + Chat { + init: bool, + + reset_owner: Option, + + owner_fingerprint: bool, + }, + + Dev { + port: Option, + }, + + Up { + production: bool, + + local: bool, + + port: Option, + + online: bool, + }, + + Config { + production: bool, + + port: Option, + }, + + Status, + + Down, + + Backup { + destination: Option, + + include_images: bool, + + recipient: Option, + + key: Option, + }, + + Restore { + archive: PathBuf, + destination: PathBuf, + }, + + Check, + + Release { + destination: Option, + }, + + Activate { + archive: PathBuf, + }, + + Licenses, +} + +fn flag(name: &'static str) -> Arg { + Arg::new(name).long(name).action(ArgAction::SetTrue) +} + +fn path(name: &'static str) -> Arg { + Arg::new(name).value_parser(clap::builder::PathBufValueParser::new()) +} + +fn port() -> Arg { + Arg::new("port") + .long("port") + .value_parser(clap::builder::RangedU64ValueParser::::new()) +} + +fn development() -> [Command; 3] { + [ + Command::new("dev") + .about("Start the local development services") + .arg(port()), + Command::new("up") + .about("Start or update services in the selected mode") + .args([ + flag("production").conflicts_with("local"), + flag("local"), + port(), + flag("online"), + ]), + Command::new("config") + .about("Render public Caddy, website, and cgit configuration without starting services") + .args([flag("production"), port()]), + ] +} + +fn archives() -> [Command; 4] { + [ + Command::new("backup") + .about("Create a GPG-encrypted application export") + .args([ + path("destination"), + flag("include-images"), + Arg::new("recipient").long("recipient"), + path("key").long("key"), + ]), + Command::new("restore") + .about("Decrypt, validate, and restore into a new directory without starting services") + .args([ + path("archive").required(true), + path("destination").required(true), + ]), + Command::new("release") + .about("Check and build a portable production release locally") + .arg(path("destination")), + Command::new("activate") + .about("Install a prebuilt release without compiling on the server") + .arg(path("archive").required(true)), + ] +} + +fn command() -> Command { + Command::new("stack") + .version(env!("CARGO_PKG_VERSION")) + .about("Develop, deploy, and back up the personal web stack") + .subcommand_required(true) + .arg_required_else_help(true) + .arg( + path("root").long("root").global(true).help( + "Application root; otherwise discovered from the working directory or binary", + ), + ) + .subcommand( + Command::new("chat") + .about("Run experimental local chat or administer its packaged deployment") + .args([ + flag("init").conflicts_with_all(["reset-owner", "owner-fingerprint"]), + Arg::new("reset-owner") + .long("reset-owner") + .value_name("FINGERPRINT") + .conflicts_with("owner-fingerprint") + .help( + "Permanently clear owner registration and chat history after key loss", + ), + flag("owner-fingerprint") + .help("Print the registered owner's public fingerprint"), + ]), + ) + .subcommands(development()) + .subcommands(archives()) + .subcommands([ + Command::new("status").about("Show service status"), + Command::new("down").about("Stop services while retaining data"), + Command::new("check") + .about("Run formatting, strict lints, tests, documentation, and dependency checks"), + Command::new("licenses") + .about("Generate the third-party license page with GNU license names"), + ]) +} + +fn required( + matches: &mut ArgMatches, + name: &str, +) -> Result { + matches.remove_one(name).ok_or_else(|| { + clap::Error::raw( + ErrorKind::MissingRequiredArgument, + format!("Missing {name}"), + ) + }) +} + +fn action(name: &str, args: &mut ArgMatches) -> Result { + Ok(match name { + "chat" => Action::Chat { + init: args.get_flag("init"), + reset_owner: args.remove_one("reset-owner"), + owner_fingerprint: args.get_flag("owner-fingerprint"), + }, + "dev" => Action::Dev { + port: args.remove_one("port"), + }, + "up" => Action::Up { + production: args.get_flag("production"), + local: args.get_flag("local"), + port: args.remove_one("port"), + online: args.get_flag("online"), + }, + "config" => Action::Config { + production: args.get_flag("production"), + port: args.remove_one("port"), + }, + "status" => Action::Status, + "down" => Action::Down, + "backup" => Action::Backup { + destination: args.remove_one("destination"), + include_images: args.get_flag("include-images"), + recipient: args.remove_one("recipient"), + key: args.remove_one("key"), + }, + "restore" => Action::Restore { + archive: required(args, "archive")?, + destination: required(args, "destination")?, + }, + "check" => Action::Check, + "release" => Action::Release { + destination: args.remove_one("destination"), + }, + "activate" => Action::Activate { + archive: required(args, "archive")?, + }, + "licenses" => Action::Licenses, + _ => { + return Err(clap::Error::raw( + ErrorKind::InvalidSubcommand, + "Unknown command", + )); + } + }) +} + +impl Cli { + pub(super) fn try_parse() -> Result { + Self::try_parse_from(std::env::args_os()) + } + + fn try_parse_from( + arguments: impl IntoIterator + Clone>, + ) -> Result { + let mut matches = command().try_get_matches_from(arguments)?; + let root = matches.remove_one("root"); + let (name, mut arguments) = matches.remove_subcommand().ok_or_else(|| { + clap::Error::raw(ErrorKind::MissingSubcommand, "Select a stack command") + })?; + Ok(Self { + root, + command: action(&name, &mut arguments)?, + }) + } +} + +#[cfg(test)] +mod tests { + use super::{Action, Cli, command}; + use anyhow::Result; + use std::path::PathBuf; + + #[test] + fn checked_command_schema() { + command().debug_assert(); + } + + #[test] + fn modes_conflicts_and_required_paths() -> Result<()> { + let parsed = Cli::try_parse_from([ + "stack", + "up", + "--root", + "/tmp/stack", + "--local", + "--port", + "8180", + "--online", + ])?; + anyhow::ensure!( + (parsed.root) == (Some(PathBuf::from("/tmp/stack"))), + "Test invariant failed" + ); + anyhow::ensure!( + matches!( + parsed.command, + Action::Up { + production: false, + local: true, + port: Some(8180), + online: true + } + ), + "Test invariant failed" + ); + for args in [ + vec!["stack", "up", "--local", "--production"], + vec!["stack", "dev", "--port", "65536"], + vec!["stack", "chat", "--init", "--reset-owner", "abc"], + vec![ + "stack", + "chat", + "--reset-owner", + "abc", + "--owner-fingerprint", + ], + vec!["stack", "restore", "archive"], + vec!["stack", "activate"], + ] { + anyhow::ensure!(Cli::try_parse_from(args).is_err(), "Test invariant failed"); + } + anyhow::ensure!( + matches!(Cli::try_parse_from(["stack", "restore", "export.gpg", "new-root"])?.command, Action::Restore { archive, destination } if archive == std::path::Path::new("export.gpg") && destination == std::path::Path::new("new-root")), + "Test invariant failed" + ); + Ok(()) + } +} diff --git a/src/app/files.rs b/src/app/files.rs new file mode 100644 index 0000000..2c612a9 --- /dev/null +++ b/src/app/files.rs @@ -0,0 +1,384 @@ +// 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::BTreeMap; +use std::fs::{self, File, OpenOptions, Permissions}; +use std::io::{Read, Write}; +use std::os::unix::fs::{DirBuilderExt, OpenOptionsExt, PermissionsExt}; +use std::path::{Component, Path, PathBuf}; + +use anyhow::{Context, Result, bail, ensure}; +use flate2::Compression; +use flate2::read::GzDecoder; +use flate2::write::GzEncoder; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use walkdir::WalkDir; + +pub(super) const SOURCE: &[&str] = &[ + "Cargo.toml", + "Cargo.lock", + "clippy.toml", + "eslint.config.ts", + "package.json", + "package-lock.json", + "tsconfig.json", + "tsconfig.browser.json", + "tsconfig.worker.json", + "tsconfig.tests.json", + "tsconfig.tools.json", + "rust-toolchain.toml", + ".cargo", + ".gitignore", + ".gitattributes", + ".forgejo", + "src", + "tests", + "crates", + "infra", + "web", + "docs", + "README.txt", + "LICENSE", + "deny.toml", + "about.toml", + "about.hbs", + ".env.example", +]; + +#[derive(Debug, Deserialize, Serialize)] +#[serde(crate = "serde", deny_unknown_fields)] +pub(super) struct Manifest { + pub(super) format: u32, + pub(super) kind: String, + pub(super) version: String, + pub(super) created_utc: String, + pub(super) files: BTreeMap, +} + +pub(super) fn private_dir(path: &Path) -> Result<()> { + fs::DirBuilder::new() + .recursive(true) + .mode(0o700) + .create(path)?; + fs::set_permissions(path, Permissions::from_mode(0o700))?; + Ok(()) +} + +pub(super) fn write(path: &Path, data: &[u8], mode: u32) -> Result<()> { + let parent = path.parent().context("File has no parent directory")?; + fs::create_dir_all(parent)?; + let mut pending = tempfile::NamedTempFile::new_in(parent)?; + pending + .as_file_mut() + .set_permissions(Permissions::from_mode(mode))?; + pending.write_all(data)?; + pending.as_file().sync_all()?; + pending.persist(path).map_err(|error| error.error)?; + Ok(()) +} + +pub(super) fn hash_bytes(bytes: &[u8]) -> String { + hex::encode(Sha256::digest(bytes)) +} + +pub(super) fn hash_file(path: &Path) -> Result { + let mut file = File::open(path).with_context(|| format!("Cannot read {}", path.display()))?; + let mut digest = Sha256::new(); + let mut buffer = [0; 8192]; + loop { + let length = file.read(&mut buffer)?; + if length == 0 { + break; + } + digest.update(buffer.get(..length).context("Invalid read length")?); + } + Ok(hex::encode(digest.finalize())) +} + +pub(super) fn relative(path: &Path, base: &Path) -> Result { + Ok(path + .strip_prefix(base)? + .to_str() + .context("Paths must be UTF-8")? + .to_owned()) +} + +pub(super) fn regular_tree(path: &Path) -> Result<()> { + for item in WalkDir::new(path).follow_links(false) { + let entry = item?; + ensure!( + entry.file_type().is_dir() || entry.file_type().is_file(), + "Links and special files are not accepted: {}", + entry.path().display() + ); + } + Ok(()) +} + +pub(super) fn copy_tree(source: &Path, destination: &Path) -> Result<()> { + regular_tree(source)?; + if source.is_file() { + let parent = destination.parent().context("File has no parent")?; + fs::create_dir_all(parent)?; + fs::copy(source, destination)?; + return Ok(()); + } + for item in WalkDir::new(source).follow_links(false) { + let entry = item?; + let target = destination.join(entry.path().strip_prefix(source)?); + if entry.file_type().is_dir() { + fs::create_dir_all(&target)?; + fs::set_permissions(&target, entry.metadata()?.permissions())?; + } else { + let parent = target.parent().context("File has no parent")?; + fs::create_dir_all(parent)?; + fs::copy(entry.path(), &target)?; + } + } + Ok(()) +} + +pub(super) fn copy_source(root: &Path, destination: &Path) -> Result<()> { + for name in SOURCE { + let source = root.join(name); + if source.exists() { + copy_tree(&source, &destination.join(name))?; + } + } + Ok(()) +} + +pub(super) fn inventory(root: &Path) -> Result> { + let mut files = BTreeMap::new(); + for item in WalkDir::new(root).follow_links(false) { + let entry = item?; + ensure!(!entry.file_type().is_symlink(), "Archive contains a link"); + if entry.file_type().is_file() { + let name = relative(entry.path(), root)?; + if name != "manifest.json" { + files.insert(name, hash_file(entry.path())?); + } + } + } + Ok(files) +} + +pub(super) fn manifest(root: &Path, kind: &str) -> Result<()> { + let record = Manifest { + format: 2, + kind: kind.to_owned(), + version: env!("CARGO_PKG_VERSION").to_owned(), + created_utc: chrono::Utc::now().to_rfc3339(), + files: inventory(root)?, + }; + write( + &root.join("manifest.json"), + &serde_json::to_vec_pretty(&record)?, + 0o600, + ) +} + +pub(super) fn verify(root: &Path) -> Result { + let record: Manifest = serde_json::from_reader(File::open(root.join("manifest.json"))?)?; + ensure!( + record.format == 2, + "Unsupported export format; legacy Python exports retain their embedded recovery helper" + ); + ensure!( + inventory(root)? == record.files, + "Archive file list or checksums do not match the manifest" + ); + Ok(record) +} + +pub(super) fn pack(root: &Path, archive: &Path) -> Result<()> { + regular_tree(root)?; + let file = OpenOptions::new() + .write(true) + .create_new(true) + .mode(0o600) + .open(archive)?; + let encoder = GzEncoder::new(file, Compression::new(3)); + let mut builder = tar::Builder::new(encoder); + builder.follow_symlinks(false); + builder.append_dir_all("stack", root)?; + builder.into_inner()?.finish()?.sync_all()?; + Ok(()) +} + +pub(super) fn member_path(path: &Path, destination: &Path) -> Result { + let mut parts = path.components(); + ensure!( + parts.next() == Some(Component::Normal("stack".as_ref())), + "Archive must have a stack root" + ); + let mut target = destination.to_path_buf(); + for part in parts { + match part { + Component::Normal(name) => target.push(name), + _ => bail!("Unsafe archive path: {}", path.display()), + } + } + Ok(target) +} + +pub(super) fn extract(archive: &Path, destination: &Path) -> Result<()> { + private_dir(destination)?; + let mut reader = tar::Archive::new(GzDecoder::new(File::open(archive)?)); + let mut directories = Vec::new(); + for item in reader.entries()? { + let mut entry = item?; + let kind = entry.header().entry_type(); + ensure!( + kind.is_dir() || kind.is_file(), + "Archive links and special files are forbidden" + ); + let target = member_path(&entry.path()?, destination)?; + let mode = entry.header().mode()? & 0o777; + if kind.is_dir() { + fs::create_dir_all(&target)?; + directories.push((target, mode | 0o700)); + } else { + let parent = target.parent().context("File has no parent")?; + fs::create_dir_all(parent)?; + let mut output = OpenOptions::new() + .write(true) + .create_new(true) + .mode(mode) + .open(&target)?; + std::io::copy(&mut entry, &mut output)?; + output.sync_all()?; + } + } + for (path, mode) in directories.into_iter().rev() { + fs::set_permissions(path, Permissions::from_mode(mode))?; + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use std::fs; + use std::path::Path; + + use anyhow::Result; + + use super::{copy_source, extract, manifest, member_path, pack, verify}; + + #[test] + fn rejects_paths_outside_archive_root() -> Result<()> { + let destination = Path::new("/restore"); + for name in ["/etc/passwd", "../escape", "stack/../escape", "other/file"] { + anyhow::ensure!( + member_path(Path::new(name), destination).is_err(), + "Test invariant failed" + ); + } + anyhow::ensure!( + (member_path(Path::new("stack/web/index.html"), destination).ok()) + == (Some(destination.join("web/index.html"))), + "Test invariant failed" + ); + Ok(()) + } + + #[test] + fn detects_modified_and_extra_files() -> Result<()> { + let root = tempfile::tempdir()?; + fs::write(root.path().join("public.txt"), "original")?; + manifest(root.path(), "release")?; + anyhow::ensure!(verify(root.path()).is_ok(), "Test invariant failed"); + fs::write(root.path().join("public.txt"), "changed")?; + anyhow::ensure!(verify(root.path()).is_err(), "Test invariant failed"); + fs::write(root.path().join("public.txt"), "original")?; + fs::write(root.path().join("unexpected.txt"), "extra")?; + anyhow::ensure!(verify(root.path()).is_err(), "Test invariant failed"); + Ok(()) + } + + #[test] + fn source_export_excludes_secrets_and_runtime() -> Result<()> { + let source = tempfile::tempdir()?; + let target = tempfile::tempdir()?; + fs::write(source.path().join("Cargo.toml"), "source")?; + for name in ["clippy.toml", "eslint.config.ts", "tsconfig.tools.json"] { + fs::write(source.path().join(name), "public lint configuration")?; + } + fs::create_dir_all(source.path().join("node_modules"))?; + fs::write( + source.path().join("node_modules/private-fixture"), + "private", + )?; + fs::write(source.path().join(".env"), "private")?; + fs::create_dir(source.path().join("var"))?; + fs::write(source.path().join("var/private.key"), "private")?; + copy_source(source.path(), target.path())?; + anyhow::ensure!( + target.path().join("Cargo.toml").is_file(), + "Test invariant failed" + ); + anyhow::ensure!( + !target.path().join(".env").exists(), + "Test invariant failed" + ); + anyhow::ensure!(!target.path().join("var").exists(), "Test invariant failed"); + for name in ["clippy.toml", "eslint.config.ts", "tsconfig.tools.json"] { + anyhow::ensure!( + target.path().join(name).is_file(), + "Required lint configuration omitted from export" + ); + } + anyhow::ensure!( + !target.path().join("node_modules").exists(), + "Installed development packages entered export" + ); + Ok(()) + } + + #[test] + fn archives_round_trip_without_overwriting() -> Result<()> { + let workspace = tempfile::tempdir()?; + let source = workspace.path().join("source"); + fs::create_dir(&source)?; + fs::write(source.join(".env"), "secret")?; + manifest(&source, "backup")?; + let archive = workspace.path().join("backup.tar.gz"); + pack(&source, &archive)?; + let restored = workspace.path().join("restored"); + extract(&archive, &restored)?; + anyhow::ensure!(verify(&restored).is_ok(), "Test invariant failed"); + anyhow::ensure!( + (fs::read_to_string(restored.join(".env"))?) == ("secret"), + "Test invariant failed" + ); + anyhow::ensure!( + extract(&archive, &restored).is_err(), + "Test invariant failed" + ); + Ok(()) + } + + #[test] + fn rejects_symbolic_links() -> Result<()> { + let root = tempfile::tempdir()?; + std::os::unix::fs::symlink("/etc/passwd", root.path().join("link"))?; + anyhow::ensure!( + super::regular_tree(root.path()).is_err(), + "Test invariant failed" + ); + Ok(()) + } +} diff --git a/src/app/images.rs b/src/app/images.rs new file mode 100644 index 0000000..fac7aa7 --- /dev/null +++ b/src/app/images.rs @@ -0,0 +1,215 @@ +// 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::BTreeMap; +use std::fs::{self, File}; +use std::path::Path; + +use anyhow::{Context, Result, ensure}; +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +use crate::app::project::{Mode, Project, Settings, field}; +use crate::app::{files, process}; + +#[derive(Debug, Deserialize, Serialize)] +#[serde(crate = "serde", deny_unknown_fields)] +struct Image { + reference: String, + architecture: String, + layers: Value, + id: String, +} + +#[derive(Debug, Deserialize, Serialize)] +#[serde(crate = "serde", deny_unknown_fields)] +struct ImageLock { + format: u32, + platform: String, + services: BTreeMap, +} + +fn inspect(project: &Project, name: &str) -> Result { + let records: Vec = serde_json::from_slice( + &process::capture(project.command("podman")?.args(["image", "inspect", name]))?.stdout, + )?; + records + .into_iter() + .next() + .context("Podman returned no image") +} + +pub(super) fn save(project: &Project, containers: &[Value], destination: &Path) -> Result<()> { + fs::create_dir_all(destination)?; + let platform = project.platform()?; + let mut services = BTreeMap::new(); + let mut new_tags = Vec::new(); + let result = save_inner( + project, + containers, + destination, + &mut services, + &mut new_tags, + ); + for tag in new_tags { + if let Err(error) = process::capture(project.command("podman")?.args(["image", "rm", &tag])) + { + process::error(format_args!( + "Could not remove temporary image tag {tag}: {error:#}" + ))?; + } + } + result?; + let lock = ImageLock { + format: 2, + platform, + services, + }; + files::write( + &destination.join("image-lock.json"), + &serde_json::to_vec_pretty(&lock)?, + 0o600, + ) +} + +fn save_inner( + project: &Project, + containers: &[Value], + destination: &Path, + services: &mut BTreeMap, + new_tags: &mut Vec, +) -> Result<()> { + for container in containers { + let service = field(container, &["Config", "Labels", "io.stack.service"])? + .as_str() + .context("Missing service label")?; + let id = field(container, &["Image"])? + .as_str() + .context("Missing image ID")?; + let suffix: String = id.trim_start_matches("sha256:").chars().take(16).collect(); + let alias = format!("localhost/stack-export-{service}:{suffix}"); + if !process::available( + project + .command("podman")? + .args(["image", "inspect", &alias]), + ) { + process::capture( + project + .command("podman")? + .args(["image", "tag", id, &alias]), + )?; + new_tags.push(alias.clone()); + } + let actual = inspect(project, &alias)?; + services.insert( + service.to_owned(), + Image { + reference: alias, + architecture: field(&actual, &["Architecture"])? + .as_str() + .context("Missing architecture")? + .to_owned(), + layers: field(&actual, &["RootFS", "Layers"])?.clone(), + id: field(&actual, &["Id"])? + .as_str() + .context("Missing image ID")? + .trim_start_matches("sha256:") + .to_owned(), + }, + ); + } + let mut command = project.command("podman")?; + command + .args([ + "save", + "--multi-image-archive", + "--format", + "docker-archive", + "--output", + ]) + .arg(destination.join("images.tar")); + for image in services.values() { + command.arg(&image.reference); + } + process::run(&mut command) +} + +pub(super) fn prepare(project: &Project, settings: &mut Settings) -> Result<()> { + let path = project.root.join("var/image-lock.json"); + if settings.online || !path.is_file() { + return Ok(()); + } + let lock: ImageLock = serde_json::from_reader(File::open(path)?)?; + ensure!(lock.format == 2, "Unsupported image lock format"); + let platform = project.platform()?; + if platform.trim() != lock.platform { + ensure!( + settings.mode == Mode::Local, + "Release images target {}, but this server is {}; build a native release locally", + lock.platform, + platform.trim() + ); + settings.online = true; + return process::line(format_args!( + "Using native images/builds for {}", + platform.trim() + )); + } + let missing = lock.services.values().any(|item| { + project.command("podman").is_ok_and(|mut command| { + !process::available(command.args(["image", "inspect", &item.reference])) + }) + }); + if missing { + let archive = project.root.join("var/images.tar"); + ensure!( + archive.is_file(), + "Release image archive is missing; deploy a complete release" + ); + process::run( + project + .command("podman")? + .args(["image", "load", "--input"]) + .arg(archive), + )?; + } + for (service, image) in lock.services { + let actual = inspect(project, &image.reference)?; + ensure!( + field(&actual, &["Architecture"])?.as_str() == Some(image.architecture.as_str()) + && field(&actual, &["RootFS", "Layers"])? == &image.layers + && field(&actual, &["Id"])? + .as_str() + .context("Missing image ID")? + .trim_start_matches("sha256:") + == image.id, + "Loaded image does not match its content lock: {service}" + ); + } + Ok(()) +} + +pub(super) fn configured(project: &Project, settings: &Settings) -> Result> { + let config = project.configuration(settings)?; + let mut records = Vec::new(); + for (service, value) in config.services { + let actual = inspect(project, &value.image)?; + records.push(serde_json::json!({ + "Image": field(&actual, &["Id"])?, + "Config": {"Labels": {"io.stack.service": service}} + })); + } + Ok(records) +} diff --git a/src/app/process.rs b/src/app/process.rs new file mode 100644 index 0000000..02f6a6b --- /dev/null +++ b/src/app/process.rs @@ -0,0 +1,70 @@ +// 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::fmt; +use std::io::{self, Write}; +use std::process::{Command, Output}; + +use anyhow::{Context, Result, bail}; + +pub(super) fn capture(command: &mut Command) -> Result { + let output = command.output().with_context(|| { + format!( + "Could not start {}", + command.get_program().to_string_lossy() + ) + })?; + if !output.status.success() { + bail!( + "{} failed ({}): {}", + command.get_program().to_string_lossy(), + output.status, + String::from_utf8_lossy(&output.stderr).trim() + ); + } + Ok(output) +} + +pub(super) fn text(command: &mut Command) -> Result { + String::from_utf8(capture(command)?.stdout).context("Command returned non-UTF-8 output") +} + +pub(super) fn run(command: &mut Command) -> Result<()> { + let status = command.status().with_context(|| { + format!( + "Could not start {}", + command.get_program().to_string_lossy() + ) + })?; + if !status.success() { + bail!( + "{} failed ({status})", + command.get_program().to_string_lossy() + ); + } + Ok(()) +} + +pub(super) fn available(command: &mut Command) -> bool { + command.output().is_ok_and(|output| output.status.success()) +} + +pub(super) fn line(message: fmt::Arguments<'_>) -> Result<()> { + writeln!(io::stdout().lock(), "{message}").context("Could not write command output") +} + +pub(super) fn error(message: fmt::Arguments<'_>) -> io::Result<()> { + writeln!(io::stderr().lock(), "{message}") +} diff --git a/src/app/project.rs b/src/app/project.rs new file mode 100644 index 0000000..ac55ff3 --- /dev/null +++ b/src/app/project.rs @@ -0,0 +1,539 @@ +// 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::BTreeMap; +use std::env; +use std::fs::{self, File, OpenOptions, Permissions}; +use std::io::{Read, Write}; +use std::os::unix::fs::{OpenOptionsExt, PermissionsExt}; +use std::path::{Component, Path, PathBuf}; +use std::process::Command; +use std::time::Duration; + +use anyhow::{Context, Result, bail, ensure}; +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use stack_config::Config; + +use crate::app::{files, process}; + +#[derive(Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq, Serialize)] +#[serde(crate = "serde", rename_all = "lowercase")] +pub(super) enum Mode { + #[default] + Local, + Production, +} + +#[derive(Debug, Deserialize, Serialize)] +#[serde(crate = "serde", deny_unknown_fields)] +pub(super) struct Settings { + pub(super) mode: Mode, + pub(super) port: u16, + pub(super) project: String, + #[serde(default)] + pub(super) online: bool, +} + +#[derive(Debug, Deserialize, Serialize)] +#[serde(crate = "serde", deny_unknown_fields)] +pub(super) struct Configuration { + pub(super) format: u32, + pub(super) services: BTreeMap, +} + +#[derive(Debug, Deserialize, Serialize)] +#[serde(crate = "serde", deny_unknown_fields)] +pub(super) struct Service { + pub(super) image: String, + pub(super) build: Option, + #[serde(default)] + gateway: bool, + #[serde(default)] + environment: Vec, + #[serde(default)] + drop_capabilities: bool, + #[serde(default)] + read_only: bool, + health: Vec, + mounts: Vec, + #[serde(default)] + tmpfs: Vec, +} + +#[derive(Debug, Deserialize, Serialize)] +#[serde(crate = "serde", deny_unknown_fields)] +struct Mount { + source: String, + target: String, + #[serde(default)] + read_only: bool, +} + +#[derive(Debug)] +pub(super) struct Project { + pub(super) root: PathBuf, +} + +impl Project { + pub(super) fn discover(explicit: Option<&Path>) -> Result { + if let Some(path) = explicit { + return Self::at(path); + } + let current = env::current_dir()?; + for candidate in current.ancestors() { + if candidate.join("infra/services.toml").is_file() { + return Self::at(candidate); + } + } + let executable = env::current_exe()?; + for candidate in executable.ancestors().skip(1).take(3) { + if candidate.join("infra/services.toml").is_file() { + return Self::at(candidate); + } + } + bail!("Run from the stack directory or supply --root PATH") + } + + pub(super) fn at(path: &Path) -> Result { + let root = if path.is_absolute() { + path.to_path_buf() + } else { + env::current_dir()?.join(path) + }; + Ok(Self { root }) + } + + pub(super) fn settings(&self) -> Result { + let path = self.root.join("var/state.json"); + if path.exists() { + let mut settings: Settings = serde_json::from_reader(File::open(path)?)?; + let config = Config::load(&self.root)?; + if config.is_explicit("LOCAL_HTTP_PORT") { + settings.port = config.port("LOCAL_HTTP_PORT")?; + } + return Ok(settings); + } + Ok(Settings { + mode: Mode::Local, + port: Config::load(&self.root)?.port("LOCAL_HTTP_PORT")?, + project: format!( + "stack-{}", + files::hash_bytes(self.root.as_os_str().as_encoded_bytes()) + .chars() + .take(10) + .collect::() + ), + online: false, + }) + } + + pub(super) fn save(&self, settings: &Settings) -> Result<()> { + files::write( + &self.root.join("var/state.json"), + &serde_json::to_vec_pretty(settings)?, + 0o600, + ) + } + + pub(super) fn lock(&self) -> Result { + files::private_dir(&self.root.join("var"))?; + let file = OpenOptions::new() + .read(true) + .write(true) + .create(true) + .truncate(false) + .mode(0o600) + .open(self.root.join("var/operation.lock"))?; + file.try_lock() + .context("Another stack operation is already running")?; + Ok(file) + } + + pub(super) fn command(&self, name: &str) -> Result { + let mut command = Command::new(name); + command.current_dir(&self.root); + if let Some(tools) = self + .root + .ancestors() + .map(|p| p.join(".tools")) + .find(|p| p.join("bin/cargo-deny").is_file()) + { + let mut paths = vec![tools.join("bin")]; + if let Some(inherited) = env::var_os("PATH") { + paths.extend(env::split_paths(&inherited)); + } + command.env("PATH", env::join_paths(paths)?); + } + if name == "podman" { + let version = process::text(Command::new("podman").arg("--version"))?; + let major = version + .split_whitespace() + .nth(2) + .and_then(|value| value.split('.').next()) + .context("Cannot determine the Podman version")? + .parse::()?; + if major < 6 { + command.env( + "CONTAINERS_CONF_OVERRIDE", + self.root.join("infra/podman.conf"), + ); + } + command + .arg("--root") + .arg(self.root.join("var/podman/storage")) + .arg("--runroot") + .arg(self.root.join("var/podman/run")); + } + Ok(command) + } + + pub(super) fn platform(&self) -> Result { + let info: Value = serde_json::from_slice( + &process::capture(self.command("podman")?.args(["info", "--format", "json"]))?.stdout, + )?; + Ok(format!( + "{}/{}", + field(&info, &["host", "os"])? + .as_str() + .context("Missing host OS")?, + field(&info, &["host", "arch"])? + .as_str() + .context("Missing host architecture")? + )) + } + + pub(super) fn configuration(&self, settings: &Settings) -> Result { + let mut config: Configuration = + toml::from_str(&fs::read_to_string(self.root.join("infra/services.toml"))?)?; + ensure!( + config.format == 1 && config.services.contains_key("caddy"), + "Invalid services configuration" + ); + if self.root.join("var/image-lock.json").is_file() && !settings.online { + let lock: Value = + serde_json::from_reader(File::open(self.root.join("var/image-lock.json"))?)?; + for (name, service) in &mut config.services { + field(&lock, &["services", name, "reference"])? + .as_str() + .context("Invalid image lock")? + .clone_into(&mut service.image); + } + } + Ok(config) + } + + pub(super) fn initialize(&self, settings: &Settings) -> Result<()> { + files::private_dir(&self.root.join("var/data"))?; + let env_file = self.root.join(".env"); + for key in ["SEARXNG_SECRET", "CHAT_SETUP_TOKEN"] { + if !Config::load(&self.root)?.is_explicit(key) { + let mut random = [0; 32]; + File::open("/dev/urandom")?.read_exact(&mut random)?; + let mut file = OpenOptions::new() + .create(true) + .append(true) + .mode(0o600) + .open(&env_file)?; + writeln!(file, "\n{key}={}", hex::encode(random))?; + } + } + stack_config::initialize_public(&self.root)?; + fs::set_permissions(&env_file, Permissions::from_mode(0o600))?; + for name in ["chat", "chat-local"] { + files::private_dir(&self.root.join("var/data").join(name))?; + } + for name in ["repos", "caddy/data", "caddy/config", "searxng"] { + fs::create_dir_all(self.root.join("var/data").join(name))?; + } + fs::set_permissions( + self.root.join("var/data/repos"), + Permissions::from_mode(0o755), + )?; + let repository = self.root.join("var/data/repos/stack"); + if !repository.exists() { + if self.root.join(".git").is_dir() { + process::capture( + self.command("git")? + .args(["clone", "--bare", "."]) + .arg(&repository), + )?; + } else { + process::capture( + self.command("git")? + .args(["init", "--bare", "--initial-branch=main"]) + .arg(&repository), + )?; + } + } + files::copy_tree( + &self.root.join("infra/searxng"), + &self.root.join("var/data/searxng"), + )?; + crate::app::render::prepare(self, settings) + } + + pub(super) fn container_name(settings: &Settings, service: &str) -> String { + format!("{}-{service}", settings.project) + } + + pub(super) fn inspect_containers(&self, settings: &Settings) -> Result> { + let output = process::text(self.command("podman")?.args([ + "ps", + "--all", + "--quiet", + "--filter", + &format!("label=io.stack.project={}", settings.project), + ]))?; + let ids: Vec<_> = output.split_whitespace().collect(); + ensure!(!ids.is_empty(), "No stack containers found"); + Ok(serde_json::from_slice( + &process::capture(self.command("podman")?.arg("inspect").args(ids))?.stdout, + )?) + } + + pub(super) fn up(&self, settings: &Settings) -> Result<()> { + ensure!( + (1024..=65_534).contains(&settings.port), + "Local port must be between 1024 and 65534" + ); + self.initialize(settings)?; + self.save(settings)?; + let config = self.configuration(settings)?; + let network = format!("{}-net", settings.project); + if !process::available( + self.command("podman")? + .args(["network", "exists", &network]), + ) { + process::capture( + self.command("podman")? + .args(["network", "create", &network]), + )?; + } + let environment = Config::load(&self.root)?; + for (name, service) in config + .services + .iter() + .filter(|(_, service)| !service.gateway) + .chain( + config + .services + .iter() + .filter(|(_, service)| service.gateway), + ) + { + self.ensure_image(settings, service)?; + self.start_service(settings, &network, name, service, &environment)?; + self.wait_service(settings, name, service)?; + } + process::line(format_args!( + "Running {}", + if settings.mode == Mode::Production { + environment + .public_values(None)? + .get("SITE_ORIGIN") + .context("Missing public site origin")? + .to_owned() + } else { + format!("http://localhost:{}", settings.port) + } + )) + } + + pub(super) fn ensure_image(&self, settings: &Settings, service: &Service) -> Result<()> { + if settings.mode == Mode::Local + && (settings.online || !self.root.join("var/image-lock.json").exists()) + && let Some(context) = &service.build + { + if context == "bin/chat" && !self.root.join("bin/chat/server").is_file() { + crate::app::chat::build(self)?; + } + return process::run( + self.command("podman")? + .args(["build", "--tag", &service.image]) + .arg(self.path(context)?), + ); + } + if process::available( + self.command("podman")? + .args(["image", "exists", &service.image]), + ) { + return Ok(()); + } + ensure!( + settings.mode == Mode::Local, + "Production images must be built and deployed from the development machine" + ); + process::run(self.command("podman")?.args(["pull", &service.image])) + } + + fn start_service( + &self, + settings: &Settings, + network: &str, + name: &str, + service: &Service, + environment: &Config, + ) -> Result<()> { + let container = Self::container_name(settings, name); + let mut command = self.command("podman")?; + command.args([ + "run", + "--detach", + "--replace", + "--pull", + "never", + "--name", + &container, + "--network", + network, + "--network-alias", + name, + "--restart", + "unless-stopped", + "--label", + &format!("io.stack.project={}", settings.project), + "--label", + &format!("io.stack.service={name}"), + "--security-opt", + "no-new-privileges", + ]); + if service.read_only { + command.args(["--read-only", "--read-only-tmpfs=false"]); + } + if service.drop_capabilities { + command.args(["--cap-drop", "ALL"]); + } + if service.gateway { + let ports = if settings.mode == Mode::Local { + let tls = settings.port.checked_add(1).context("Port overflow")?; + [ + format!("127.0.0.1:{}:80", settings.port), + format!("127.0.0.1:{tls}:443"), + format!("127.0.0.1:{tls}:443/udp"), + ] + .to_vec() + } else { + let http = environment.port("HTTP_PORT")?; + let https = environment.port("HTTPS_PORT")?; + vec![ + format!("{}:{http}:{http}", crate::app::chat::web_bind(environment)?), + format!( + "{}:{https}:{https}", + crate::app::chat::web_bind(environment)? + ), + format!( + "{}:{https}:{https}/udp", + crate::app::chat::web_bind(environment)? + ), + ] + }; + for port in ports { + command.args(["--publish", &port]); + } + } + for key in &service.environment { + command + .arg("--env") + .arg(key) + .env(key, environment.value(key)?); + } + if name == "chat" { + crate::app::chat::environment(&mut command, environment, settings)?; + } + for mount in &service.mounts { + let source = match mount.source.as_str() { + "@caddy" => crate::app::render::directory(self, settings).join("Caddyfile"), + "@web" => crate::app::render::directory(self, settings).join("web"), + "@cgit" => crate::app::render::directory(self, settings).join("cgitrc"), + "@chat" => self.root.join(if settings.mode == Mode::Local { + "var/data/chat-local" + } else { + "var/data/chat" + }), + other => self.path(other)?, + }; + let mode = if mount.read_only { "ro,z" } else { "rw,z" }; + command + .arg("--volume") + .arg(format!("{}:{}:{mode}", source.display(), mount.target)); + } + for mount in &service.tmpfs { + command.args(["--tmpfs", mount]); + } + process::capture(command.arg(&service.image))?; + Ok(()) + } + + fn wait_service(&self, settings: &Settings, name: &str, service: &Service) -> Result<()> { + let container = Self::container_name(settings, name); + for _attempt in 0..120 { + if process::available( + self.command("podman")? + .args(["exec", &container]) + .args(&service.health), + ) { + return Ok(()); + } + std::thread::sleep(Duration::from_millis(500)); + } + bail!("Service did not become ready: {name}; inspect its Podman logs") + } + + pub(super) fn status(&self, settings: &Settings) -> Result<()> { + process::run(self.command("podman")?.args([ + "ps", + "--all", + "--filter", + &format!("label=io.stack.project={}", settings.project), + ])) + } + + pub(super) fn down(&self, settings: &Settings) -> Result<()> { + for name in self.configuration(settings)?.services.keys() { + process::capture(self.command("podman")?.args([ + "rm", + "--force", + "--ignore", + &Self::container_name(settings, name), + ]))?; + } + let network = format!("{}-net", settings.project); + if process::available( + self.command("podman")? + .args(["network", "exists", &network]), + ) { + process::capture(self.command("podman")?.args(["network", "rm", &network]))?; + } + Ok(()) + } + + fn path(&self, relative: &str) -> Result { + let path = Path::new(relative); + ensure!( + path.components() + .all(|part| matches!(part, Component::Normal(_))), + "Service paths must stay inside stack" + ); + Ok(self.root.join(path)) + } +} + +pub(super) fn field<'a>(value: &'a Value, path: &[&str]) -> Result<&'a Value> { + path.iter().try_fold(value, |node, key| { + node.get(key) + .with_context(|| format!("Missing JSON field {key}")) + }) +} diff --git a/src/app/release.rs b/src/app/release.rs new file mode 100644 index 0000000..268a3c2 --- /dev/null +++ b/src/app/release.rs @@ -0,0 +1,127 @@ +// 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::fs; +use std::path::Path; + +use anyhow::{Context, Result, ensure}; + +use crate::app::project::{Mode, Project}; +use crate::app::{files, images, process}; + +pub(super) fn create(project: &Project, destination: &Path) -> Result<()> { + crate::app::check(project)?; + crate::app::chat::build(project)?; + files::write( + &project.root.join("bin/stack"), + &fs::read( + project + .root + .join("target/x86_64-unknown-linux-musl/release/stack"), + )?, + 0o755, + )?; + let mut settings = project.settings()?; + settings.mode = Mode::Local; + settings.online = true; + for service in project.configuration(&settings)?.services.values() { + project.ensure_image(&settings, service)?; + } + files::private_dir(destination)?; + let temp = tempfile::Builder::new() + .prefix(".release-") + .tempdir_in(destination)?; + let stage = temp.path().join("stack"); + files::private_dir(&stage)?; + files::copy_source(&project.root, &stage)?; + crate::app::chat::copy_artifacts(project, &stage)?; + fs::create_dir_all(stage.join("bin"))?; + fs::copy( + project + .root + .join("target/x86_64-unknown-linux-musl/release/stack"), + stage.join("bin/stack"), + )?; + images::save( + project, + &images::configured(project, &settings)?, + &stage.join("var"), + )?; + files::manifest(&stage, "release")?; + let output = destination.join(format!( + "stack-release-{}.tar.gz", + chrono::Utc::now().format("%Y%m%dT%H%M%SZ") + )); + files::pack(&stage, &output)?; + let filename = output + .file_name() + .context("Missing archive name")? + .to_string_lossy(); + files::write( + &output.with_extension("gz.sha256"), + format!("{} {filename}\n", files::hash_file(&output)?).as_bytes(), + 0o600, + )?; + process::line(format_args!("Release: {}", output.display())) +} + +pub(super) fn activate(archive: &Path, project: &Project) -> Result<()> { + let temp = tempfile::Builder::new() + .prefix(".activate-") + .tempdir_in(&project.root)?; + let stage = temp.path().join("stack"); + files::extract(archive, &stage)?; + ensure!( + files::verify(&stage)?.kind == "release", + "Activation requires a release archive" + ); + let pending = Project::at(&stage)?; + let mut settings = project.settings()?; + settings.mode = Mode::Production; + settings.online = false; + let lock = stage.join("var/image-lock.json"); + let info: serde_json::Value = serde_json::from_slice(&fs::read(&lock)?)?; + ensure!( + info.get("platform").and_then(serde_json::Value::as_str) + == Some(project.platform()?.as_str()), + "Release architecture does not match the server" + ); + let _config = pending.configuration(&settings)?; + for name in files::SOURCE { + let source = stage.join(name); + if source.exists() { + let target = project.root.join(name); + if target.is_dir() { + files::regular_tree(&target)?; + fs::remove_dir_all(&target)?; + } + files::copy_tree(&source, &target)?; + } + } + files::write( + &project.root.join("bin/stack"), + &fs::read(stage.join("bin/stack"))?, + 0o755, + )?; + crate::app::chat::copy_artifacts(&pending, &project.root)?; + for name in ["images.tar", "image-lock.json"] { + files::copy_tree( + &stage.join("var").join(name), + &project.root.join("var").join(name), + )?; + } + images::prepare(project, &mut settings)?; + project.up(&settings) +} diff --git a/src/app/render.rs b/src/app/render.rs new file mode 100644 index 0000000..8a2d2ee --- /dev/null +++ b/src/app/render.rs @@ -0,0 +1,130 @@ +// 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 crate::app::files; +use crate::app::project::{Mode, Project, Settings}; +use anyhow::Result; +use stack_config::Config; +use std::fs; +use std::path::PathBuf; + +pub(super) fn directory(project: &Project, settings: &Settings) -> PathBuf { + project.root.join(match settings.mode { + Mode::Local => "var/local", + Mode::Production => "var/production", + }) +} + +pub(super) fn prepare(project: &Project, settings: &Settings) -> Result<()> { + let config = Config::load(&project.root)?; + let local = (settings.mode == Mode::Local).then_some(settings.port); + let output = directory(project, settings); + let web = output.join("web"); + if web.exists() { + files::regular_tree(&web)?; + fs::remove_dir_all(&web)?; + } + files::copy_tree(&project.root.join("web"), &web)?; + let file = web.join("index.html"); + files::write( + &file, + config + .render(&fs::read_to_string(&file)?, local)? + .as_bytes(), + 0o644, + )?; + let template = project.root.join(if settings.mode == Mode::Local { + "infra/Caddyfile.local" + } else { + "infra/Caddyfile" + }); + files::write( + &output.join("Caddyfile"), + config + .render(&fs::read_to_string(template)?, local)? + .as_bytes(), + 0o644, + )?; + let cgit = fs::read_to_string(project.root.join("infra/containers/cgit/cgitrc"))?; + let mut rendered = config.render(&cgit, local)?; + if let Some(port) = local { + rendered = rendered.replace( + "clone-prefix=/git", + &format!("clone-prefix=http://localhost:{port}/git"), + ); + } + files::write(&output.join("cgitrc"), rendered.as_bytes(), 0o644) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn custom_domains_and_ports_reach_public_outputs_without_secrets() -> Result<()> { + let root = tempfile::tempdir()?; + for name in ["web", "infra"] { + files::copy_tree( + &PathBuf::from(env!("CARGO_MANIFEST_DIR")).join(name), + &root.path().join(name), + )?; + } + fs::write( + root.path().join(".env"), + "SITE_HOST=home.example.net\nGIT_HOST=git.example.net\nHTTP_PORT=8181\nHTTPS_PORT=8443\nSEARXNG_SECRET=do-not-publish-this-sentinel\n", + )?; + let project = Project::at(root.path())?; + let mut settings = project.settings()?; + settings.mode = Mode::Production; + prepare(&project, &settings)?; + let output = directory(&project, &settings); + let caddy = fs::read_to_string(output.join("Caddyfile"))?; + anyhow::ensure!( + caddy.contains("https://home.example.net:8443"), + "Test invariant failed" + ); + anyhow::ensure!(caddy.contains("http_port 8181"), "Test invariant failed"); + anyhow::ensure!(!caddy.contains("do-not-publish"), "Test invariant failed"); + let homepage = fs::read_to_string(output.join("web/index.html"))?; + anyhow::ensure!( + homepage.contains("https://git.example.net:8443"), + "Test invariant failed" + ); + let stylesheet = files::hash_file(&root.path().join("web/styles.css"))?; + anyhow::ensure!( + homepage.contains(&format!("/styles.css?v={stylesheet}")), + "Test invariant failed" + ); + anyhow::ensure!( + homepage.contains("https://chat.doan.luuduct.ai:8443/LIMITATIONS.txt"), + "Test invariant failed" + ); + settings.mode = Mode::Local; + settings.port = 8180; + prepare(&project, &settings)?; + let preview = fs::read_to_string(directory(&project, &settings).join("web/index.html"))?; + anyhow::ensure!(preview.contains("href=\"/git/\""), "Test invariant failed"); + anyhow::ensure!( + preview.contains("http://chat.localhost:8180/LIMITATIONS.txt"), + "Test invariant failed" + ); + anyhow::ensure!( + fs::read_to_string(directory(&project, &settings).join("cgitrc"))? + .contains("clone-prefix=http://localhost:8180/git"), + "Test invariant failed" + ); + Ok(()) + } +} diff --git a/src/archive.rs b/src/archive.rs deleted file mode 100644 index 1e58992..0000000 --- a/src/archive.rs +++ /dev/null @@ -1,464 +0,0 @@ -// 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}; - -#[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, &["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::)?; - 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::chat::copy_artifacts(project, stage)?; - 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 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::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(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 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))?; - assert_eq!(count, 1); - assert_eq!( - std::os::unix::fs::PermissionsExt::mode(&fs::metadata(snapshot)?.permissions()) & 0o777, - 0o600 - ); - 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)?; - 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(()) - } -} diff --git a/src/chat.rs b/src/chat.rs deleted file mode 100644 index 0739f22..0000000 --- a/src/chat.rs +++ /dev/null @@ -1,177 +0,0 @@ -// 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 crate::project::{Mode, Project, Settings}; -use crate::{files, process}; -use anyhow::{Context, Result, ensure}; -use stack_config::Config; -use std::fs; -use std::net::IpAddr; -use std::path::Path; -use std::process::Command; - -pub(crate) fn web_bind(configuration: &Config) -> Result { - let address: IpAddr = configuration.value("WEB_BIND")?.parse()?; - Ok(match address { - IpAddr::V4(_) => address.to_string(), - IpAddr::V6(_) => format!("[{address}]"), - }) -} - -pub(crate) fn environment( - command: &mut Command, - configuration: &Config, - settings: &Settings, -) -> Result<()> { - let local = (settings.mode == Mode::Local).then_some(settings.port); - let mut public = configuration.public_values(local)?; - let origin = public - .get("CHAT_PUBLIC_ORIGIN") - .context("Missing chat gateway origin")? - .clone(); - public.insert("CHAT_ORIGIN".into(), origin); - public.insert("CHAT_BIND".into(), "0.0.0.0:8080".into()); - for (key, value) in public { - command.arg("--env").arg(&key).env(key, value); - } - Ok(()) -} - -pub(crate) fn build(project: &Project) -> Result<()> { - process::run(project.command("sh")?.arg("infra/chat/build-typescript.sh"))?; - process::run(project.command("sh")?.arg("infra/chat/build-native.sh"))?; - let wasm_build = if project - .root - .join(".tools/openssl-wasi/lib/libcrypto.a") - .is_file() - { - "infra/chat/build-browser.sh" - } else { - "infra/chat/build-openssl-wasi.sh" - }; - process::run(project.command("sh")?.arg(wasm_build))?; - process::run(project.command("sh")?.arg("infra/chat/build-source.sh"))?; - package(project) -} - -pub(crate) fn package(project: &Project) -> Result<()> { - let directory = project.root.join("bin/chat"); - fs::create_dir_all(&directory)?; - for (source, target) in [ - ( - "target/x86_64-unknown-linux-musl/release/stack-chat-server", - "server", - ), - ( - "target/wasm32-wasip1/release/stack-chat-crypto.wasm", - "crypto.wasm", - ), - ("var/build/chat-source.tar.gz", "SOURCE.tar.gz"), - ("infra/containers/chat/Containerfile", "Containerfile"), - ( - "var/build/typescript/crates/chat-crypto/browser/wasi.js", - "wasi.mjs", - ), - ( - "var/build/typescript/crates/chat-server/web/app.js", - "web/app.js", - ), - ( - "var/build/typescript/crates/chat-server/web/worker.js", - "web/worker.js", - ), - ("web/THIRD-PARTY-LICENSES.html", "THIRD-PARTY-LICENSES.html"), - ("docs/CHAT-APPLICATION.txt", "CHAT-APPLICATION.txt"), - ("docs/CHAT-PRODUCTION.txt", "CHAT-PRODUCTION.txt"), - ("LICENSE", "LICENSE"), - ] { - files::copy_tree(&project.root.join(source), &directory.join(target))?; - } - for name in ["index.html", "style.css", "javascript.html"] { - files::copy_tree( - &project.root.join("crates/chat-server/web").join(name), - &directory.join("web").join(name), - )?; - } - Ok(()) -} - -pub(crate) fn copy_artifacts(project: &Project, destination: &Path) -> Result<()> { - let artifacts = project.root.join("bin/chat"); - ensure!( - artifacts.join("server").is_file() - && artifacts.join("crypto.wasm").is_file() - && artifacts.join("SOURCE.tar.gz").is_file(), - "Build a complete chat release locally before export" - ); - files::copy_tree(&artifacts, &destination.join("bin/chat")) -} - -pub(crate) fn admin( - project: &Project, - init: bool, - reset_owner: Option<&str>, - owner_fingerprint: bool, -) -> Result<()> { - let settings = project.settings()?; - if init { - project.initialize(&settings)?; - return process::line(format_args!( - "Owner setup token is available in the single root .env" - )); - } - ensure!( - reset_owner.is_some() || owner_fingerprint, - "Use up --production to run chat; maintenance options are --owner-fingerprint and --reset-owner" - ); - let configuration = Config::load(&project.root)?; - let services = project.configuration(&settings)?; - let service = services - .services - .get("chat") - .context("Chat is not packaged in this release")?; - let mut command = project.command("podman")?; - command - .args([ - "run", - "--rm", - "--network", - "none", - "--read-only", - "--read-only-tmpfs=false", - "--cap-drop", - "ALL", - "--security-opt", - "no-new-privileges", - "--env", - "CHAT_SETUP_TOKEN", - ]) - .env("CHAT_SETUP_TOKEN", configuration.value("CHAT_SETUP_TOKEN")?); - environment(&mut command, &configuration, &settings)?; - command - .arg("--volume") - .arg(format!( - "{}:/app/var/data/chat:rw,z", - project.root.join("var/data/chat").display() - )) - .arg(&service.image); - if let Some(fingerprint) = reset_owner { - command.arg("--reset-owner").arg(fingerprint); - } - if owner_fingerprint { - command.arg("--owner-fingerprint"); - } - process::run(&mut command) -} diff --git a/src/files.rs b/src/files.rs deleted file mode 100644 index 81c57b0..0000000 --- a/src/files.rs +++ /dev/null @@ -1,343 +0,0 @@ -// 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::BTreeMap; -use std::fs::{self, File, OpenOptions, Permissions}; -use std::io::{Read, Write}; -use std::os::unix::fs::{DirBuilderExt, OpenOptionsExt, PermissionsExt}; -use std::path::{Component, Path, PathBuf}; - -use anyhow::{Context, Result, bail, ensure}; -use flate2::Compression; -use flate2::read::GzDecoder; -use flate2::write::GzEncoder; -use serde::{Deserialize, Serialize}; -use sha2::{Digest, Sha256}; -use walkdir::WalkDir; - -pub(crate) const SOURCE: &[&str] = &[ - "Cargo.toml", - "Cargo.lock", - "package.json", - "package-lock.json", - "tsconfig.json", - "tsconfig.browser.json", - "tsconfig.worker.json", - "tsconfig.tests.json", - "rust-toolchain.toml", - ".cargo", - ".gitignore", - ".gitattributes", - ".forgejo", - "src", - "tests", - "crates", - "infra", - "web", - "docs", - "README.txt", - "LICENSE", - "deny.toml", - "about.toml", - "about.hbs", - ".env.example", -]; - -#[derive(Debug, Deserialize, Serialize)] -#[serde(deny_unknown_fields)] -pub(crate) struct Manifest { - pub(crate) format: u32, - pub(crate) kind: String, - pub(crate) version: String, - pub(crate) created_utc: String, - pub(crate) files: BTreeMap, -} - -pub(crate) fn private_dir(path: &Path) -> Result<()> { - fs::DirBuilder::new() - .recursive(true) - .mode(0o700) - .create(path)?; - fs::set_permissions(path, Permissions::from_mode(0o700))?; - Ok(()) -} - -pub(crate) fn write(path: &Path, data: &[u8], mode: u32) -> Result<()> { - let parent = path.parent().context("File has no parent directory")?; - fs::create_dir_all(parent)?; - let mut pending = tempfile::NamedTempFile::new_in(parent)?; - pending - .as_file_mut() - .set_permissions(Permissions::from_mode(mode))?; - pending.write_all(data)?; - pending.as_file().sync_all()?; - pending.persist(path).map_err(|error| error.error)?; - Ok(()) -} - -pub(crate) fn hash_bytes(bytes: &[u8]) -> String { - hex::encode(Sha256::digest(bytes)) -} - -pub(crate) fn hash_file(path: &Path) -> Result { - let mut file = File::open(path).with_context(|| format!("Cannot read {}", path.display()))?; - let mut digest = Sha256::new(); - let mut buffer = [0; 8192]; - loop { - let length = file.read(&mut buffer)?; - if length == 0 { - break; - } - digest.update(buffer.get(..length).context("Invalid read length")?); - } - Ok(hex::encode(digest.finalize())) -} - -pub(crate) fn relative(path: &Path, base: &Path) -> Result { - Ok(path - .strip_prefix(base)? - .to_str() - .context("Paths must be UTF-8")? - .to_owned()) -} - -pub(crate) fn regular_tree(path: &Path) -> Result<()> { - for item in WalkDir::new(path).follow_links(false) { - let entry = item?; - ensure!( - entry.file_type().is_dir() || entry.file_type().is_file(), - "Links and special files are not accepted: {}", - entry.path().display() - ); - } - Ok(()) -} - -pub(crate) fn copy_tree(source: &Path, destination: &Path) -> Result<()> { - regular_tree(source)?; - if source.is_file() { - let parent = destination.parent().context("File has no parent")?; - fs::create_dir_all(parent)?; - fs::copy(source, destination)?; - return Ok(()); - } - for item in WalkDir::new(source).follow_links(false) { - let entry = item?; - let target = destination.join(entry.path().strip_prefix(source)?); - if entry.file_type().is_dir() { - fs::create_dir_all(&target)?; - fs::set_permissions(&target, entry.metadata()?.permissions())?; - } else { - let parent = target.parent().context("File has no parent")?; - fs::create_dir_all(parent)?; - fs::copy(entry.path(), &target)?; - } - } - Ok(()) -} - -pub(crate) fn copy_source(root: &Path, destination: &Path) -> Result<()> { - for name in SOURCE { - let source = root.join(name); - if source.exists() { - copy_tree(&source, &destination.join(name))?; - } - } - Ok(()) -} - -pub(crate) fn inventory(root: &Path) -> Result> { - let mut files = BTreeMap::new(); - for item in WalkDir::new(root).follow_links(false) { - let entry = item?; - ensure!(!entry.file_type().is_symlink(), "Archive contains a link"); - if entry.file_type().is_file() { - let name = relative(entry.path(), root)?; - if name != "manifest.json" { - files.insert(name, hash_file(entry.path())?); - } - } - } - Ok(files) -} - -pub(crate) fn manifest(root: &Path, kind: &str) -> Result<()> { - let record = Manifest { - format: 2, - kind: kind.to_owned(), - version: env!("CARGO_PKG_VERSION").to_owned(), - created_utc: chrono::Utc::now().to_rfc3339(), - files: inventory(root)?, - }; - write( - &root.join("manifest.json"), - &serde_json::to_vec_pretty(&record)?, - 0o600, - ) -} - -pub(crate) fn verify(root: &Path) -> Result { - let record: Manifest = serde_json::from_reader(File::open(root.join("manifest.json"))?)?; - ensure!( - record.format == 2, - "Unsupported export format; legacy Python exports retain their embedded recovery helper" - ); - ensure!( - inventory(root)? == record.files, - "Archive file list or checksums do not match the manifest" - ); - Ok(record) -} - -pub(crate) fn pack(root: &Path, archive: &Path) -> Result<()> { - regular_tree(root)?; - let file = OpenOptions::new() - .write(true) - .create_new(true) - .mode(0o600) - .open(archive)?; - let encoder = GzEncoder::new(file, Compression::new(3)); - let mut builder = tar::Builder::new(encoder); - builder.follow_symlinks(false); - builder.append_dir_all("stack", root)?; - builder.into_inner()?.finish()?.sync_all()?; - Ok(()) -} - -pub(crate) fn member_path(path: &Path, destination: &Path) -> Result { - let mut parts = path.components(); - ensure!( - parts.next() == Some(Component::Normal("stack".as_ref())), - "Archive must have a stack root" - ); - let mut target = destination.to_path_buf(); - for part in parts { - match part { - Component::Normal(name) => target.push(name), - _ => bail!("Unsafe archive path: {}", path.display()), - } - } - Ok(target) -} - -pub(crate) fn extract(archive: &Path, destination: &Path) -> Result<()> { - private_dir(destination)?; - let mut reader = tar::Archive::new(GzDecoder::new(File::open(archive)?)); - let mut directories = Vec::new(); - for item in reader.entries()? { - let mut entry = item?; - let kind = entry.header().entry_type(); - ensure!( - kind.is_dir() || kind.is_file(), - "Archive links and special files are forbidden" - ); - let target = member_path(&entry.path()?, destination)?; - let mode = entry.header().mode()? & 0o777; - if kind.is_dir() { - fs::create_dir_all(&target)?; - directories.push((target, mode | 0o700)); - } else { - let parent = target.parent().context("File has no parent")?; - fs::create_dir_all(parent)?; - let mut output = OpenOptions::new() - .write(true) - .create_new(true) - .mode(mode) - .open(&target)?; - std::io::copy(&mut entry, &mut output)?; - output.sync_all()?; - } - } - for (path, mode) in directories.into_iter().rev() { - fs::set_permissions(path, Permissions::from_mode(mode))?; - } - Ok(()) -} - -#[cfg(test)] -mod tests { - use std::fs; - use std::path::Path; - - use anyhow::Result; - - use super::{copy_source, extract, manifest, member_path, pack, verify}; - - #[test] - fn rejects_paths_outside_archive_root() { - let destination = Path::new("/restore"); - for name in ["/etc/passwd", "../escape", "stack/../escape", "other/file"] { - assert!(member_path(Path::new(name), destination).is_err()); - } - assert_eq!( - member_path(Path::new("stack/web/index.html"), destination).ok(), - Some(destination.join("web/index.html")) - ); - } - - #[test] - fn detects_modified_and_extra_files() -> Result<()> { - let root = tempfile::tempdir()?; - fs::write(root.path().join("public.txt"), "original")?; - manifest(root.path(), "release")?; - assert!(verify(root.path()).is_ok()); - fs::write(root.path().join("public.txt"), "changed")?; - assert!(verify(root.path()).is_err()); - fs::write(root.path().join("public.txt"), "original")?; - fs::write(root.path().join("unexpected.txt"), "extra")?; - assert!(verify(root.path()).is_err()); - Ok(()) - } - - #[test] - fn source_export_excludes_secrets_and_runtime() -> Result<()> { - let source = tempfile::tempdir()?; - let target = tempfile::tempdir()?; - fs::write(source.path().join("Cargo.toml"), "source")?; - fs::write(source.path().join(".env"), "private")?; - fs::create_dir(source.path().join("var"))?; - fs::write(source.path().join("var/private.key"), "private")?; - copy_source(source.path(), target.path())?; - assert!(target.path().join("Cargo.toml").is_file()); - assert!(!target.path().join(".env").exists()); - assert!(!target.path().join("var").exists()); - Ok(()) - } - - #[test] - fn archives_round_trip_without_overwriting() -> Result<()> { - let workspace = tempfile::tempdir()?; - let source = workspace.path().join("source"); - fs::create_dir(&source)?; - fs::write(source.join(".env"), "secret")?; - manifest(&source, "backup")?; - let archive = workspace.path().join("backup.tar.gz"); - pack(&source, &archive)?; - let restored = workspace.path().join("restored"); - extract(&archive, &restored)?; - assert!(verify(&restored).is_ok()); - assert_eq!(fs::read_to_string(restored.join(".env"))?, "secret"); - assert!(extract(&archive, &restored).is_err()); - Ok(()) - } - - #[test] - fn rejects_symbolic_links() -> Result<()> { - let root = tempfile::tempdir()?; - std::os::unix::fs::symlink("/etc/passwd", root.path().join("link"))?; - assert!(super::regular_tree(root.path()).is_err()); - Ok(()) - } -} diff --git a/src/images.rs b/src/images.rs deleted file mode 100644 index 30019ca..0000000 --- a/src/images.rs +++ /dev/null @@ -1,215 +0,0 @@ -// 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::BTreeMap; -use std::fs::{self, File}; -use std::path::Path; - -use anyhow::{Context, Result, ensure}; -use serde::{Deserialize, Serialize}; -use serde_json::Value; - -use crate::project::{Mode, Project, Settings, field}; -use crate::{files, process}; - -#[derive(Debug, Deserialize, Serialize)] -#[serde(deny_unknown_fields)] -struct Image { - reference: String, - architecture: String, - layers: Value, - id: String, -} - -#[derive(Debug, Deserialize, Serialize)] -#[serde(deny_unknown_fields)] -struct ImageLock { - format: u32, - platform: String, - services: BTreeMap, -} - -fn inspect(project: &Project, name: &str) -> Result { - let records: Vec = serde_json::from_slice( - &process::capture(project.command("podman")?.args(["image", "inspect", name]))?.stdout, - )?; - records - .into_iter() - .next() - .context("Podman returned no image") -} - -pub(crate) fn save(project: &Project, containers: &[Value], destination: &Path) -> Result<()> { - fs::create_dir_all(destination)?; - let platform = project.platform()?; - let mut services = BTreeMap::new(); - let mut new_tags = Vec::new(); - let result = save_inner( - project, - containers, - destination, - &mut services, - &mut new_tags, - ); - for tag in new_tags { - if let Err(error) = process::capture(project.command("podman")?.args(["image", "rm", &tag])) - { - process::error(format_args!( - "Could not remove temporary image tag {tag}: {error:#}" - ))?; - } - } - result?; - let lock = ImageLock { - format: 2, - platform, - services, - }; - files::write( - &destination.join("image-lock.json"), - &serde_json::to_vec_pretty(&lock)?, - 0o600, - ) -} - -fn save_inner( - project: &Project, - containers: &[Value], - destination: &Path, - services: &mut BTreeMap, - new_tags: &mut Vec, -) -> Result<()> { - for container in containers { - let service = field(container, &["Config", "Labels", "io.stack.service"])? - .as_str() - .context("Missing service label")?; - let id = field(container, &["Image"])? - .as_str() - .context("Missing image ID")?; - let suffix: String = id.trim_start_matches("sha256:").chars().take(16).collect(); - let alias = format!("localhost/stack-export-{service}:{suffix}"); - if !process::available( - project - .command("podman")? - .args(["image", "inspect", &alias]), - ) { - process::capture( - project - .command("podman")? - .args(["image", "tag", id, &alias]), - )?; - new_tags.push(alias.clone()); - } - let actual = inspect(project, &alias)?; - services.insert( - service.to_owned(), - Image { - reference: alias, - architecture: field(&actual, &["Architecture"])? - .as_str() - .context("Missing architecture")? - .to_owned(), - layers: field(&actual, &["RootFS", "Layers"])?.clone(), - id: field(&actual, &["Id"])? - .as_str() - .context("Missing image ID")? - .trim_start_matches("sha256:") - .to_owned(), - }, - ); - } - let mut command = project.command("podman")?; - command - .args([ - "save", - "--multi-image-archive", - "--format", - "docker-archive", - "--output", - ]) - .arg(destination.join("images.tar")); - for image in services.values() { - command.arg(&image.reference); - } - process::run(&mut command) -} - -pub(crate) fn prepare(project: &Project, settings: &mut Settings) -> Result<()> { - let path = project.root.join("var/image-lock.json"); - if settings.online || !path.is_file() { - return Ok(()); - } - let lock: ImageLock = serde_json::from_reader(File::open(path)?)?; - ensure!(lock.format == 2, "Unsupported image lock format"); - let platform = project.platform()?; - if platform.trim() != lock.platform { - ensure!( - settings.mode == Mode::Local, - "Release images target {}, but this server is {}; build a native release locally", - lock.platform, - platform.trim() - ); - settings.online = true; - return process::line(format_args!( - "Using native images/builds for {}", - platform.trim() - )); - } - let missing = lock.services.values().any(|item| { - project.command("podman").is_ok_and(|mut command| { - !process::available(command.args(["image", "inspect", &item.reference])) - }) - }); - if missing { - let archive = project.root.join("var/images.tar"); - ensure!( - archive.is_file(), - "Release image archive is missing; deploy a complete release" - ); - process::run( - project - .command("podman")? - .args(["image", "load", "--input"]) - .arg(archive), - )?; - } - for (service, image) in lock.services { - let actual = inspect(project, &image.reference)?; - ensure!( - field(&actual, &["Architecture"])?.as_str() == Some(image.architecture.as_str()) - && field(&actual, &["RootFS", "Layers"])? == &image.layers - && field(&actual, &["Id"])? - .as_str() - .context("Missing image ID")? - .trim_start_matches("sha256:") - == image.id, - "Loaded image does not match its content lock: {service}" - ); - } - Ok(()) -} - -pub(crate) fn configured(project: &Project, settings: &Settings) -> Result> { - let config = project.configuration(settings)?; - let mut records = Vec::new(); - for (service, value) in config.services { - let actual = inspect(project, &value.image)?; - records.push(serde_json::json!({ - "Image": field(&actual, &["Id"])?, - "Config": {"Labels": {"io.stack.service": service}} - })); - } - Ok(records) -} diff --git a/src/main.rs b/src/main.rs index e6323e2..942a379 100644 --- a/src/main.rs +++ b/src/main.rs @@ -13,326 +13,12 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . -#![doc = "Rust commands for personal web infrastructure."] +#![doc = "Checked command-line entry point for the personal infrastructure stack."] -pub(crate) mod archive; -pub(crate) mod chat; -pub(crate) mod files; -pub(crate) mod images; -pub(crate) mod process; -pub(crate) mod project; -pub(crate) mod release; -pub(crate) mod render; +mod app; -use std::path::{Path, PathBuf}; -use std::process::ExitCode; +pub use app::run; -use anyhow::Result; -use clap::{Parser, Subcommand}; - -use crate::project::{Mode, Project}; - -#[derive(Debug, Parser)] -#[command( - name = "stack", - version, - about = "Develop, deploy, and back up the personal web stack" -)] -struct Cli { - #[arg( - long, - global = true, - help = "Application root; otherwise discovered from the working directory or binary" - )] - root: Option, - #[command(subcommand)] - command: Action, -} - -#[derive(Debug, Subcommand)] -enum Action { - #[command(about = "Run experimental local chat or administer its packaged deployment")] - Chat { - #[arg(long, conflicts_with_all = ["reset_owner", "owner_fingerprint"])] - init: bool, - #[arg( - long, - value_name = "FINGERPRINT", - conflicts_with = "owner_fingerprint", - help = "Permanently clear owner registration and chat history after key loss" - )] - reset_owner: Option, - #[arg(long, help = "Print the registered owner's public fingerprint")] - owner_fingerprint: bool, - }, - #[command(about = "Start the local development services")] - Dev { - #[arg(long)] - port: Option, - }, - #[command(about = "Start or update services in the selected mode")] - Up { - #[arg(long, conflicts_with = "local")] - production: bool, - #[arg(long)] - local: bool, - #[arg(long)] - port: Option, - #[arg(long)] - online: bool, - }, - #[command( - about = "Render public Caddy, website, and cgit configuration without starting services" - )] - Config { - #[arg(long)] - production: bool, - #[arg(long)] - port: Option, - }, - #[command(about = "Show service status")] - Status, - #[command(about = "Stop services while retaining data")] - Down, - #[command(about = "Create a GPG-encrypted application export")] - Backup { - destination: Option, - #[arg(long)] - include_images: bool, - #[arg(long)] - recipient: Option, - #[arg(long)] - key: Option, - }, - #[command( - about = "Decrypt, validate, and restore into a new directory without starting services" - )] - Restore { - archive: PathBuf, - destination: PathBuf, - }, - #[command(about = "Run formatting, strict lints, tests, documentation, and dependency checks")] - Check, - #[command(about = "Check and build a portable production release locally")] - Release { destination: Option }, - #[command(about = "Install a prebuilt release without compiling on the server")] - Activate { archive: PathBuf }, - #[command(about = "Generate the third-party license page with GNU license names")] - Licenses, -} - -fn main() -> ExitCode { - let arguments = match Cli::try_parse() { - Ok(arguments) => arguments, - Err(error) => { - let status = if error.use_stderr() { - ExitCode::FAILURE - } else { - ExitCode::SUCCESS - }; - drop(error.print()); - return status; - } - }; - match execute(arguments) { - Ok(()) => ExitCode::SUCCESS, - Err(error) => { - drop(process::error(format_args!("Error: {error:#}"))); - ExitCode::FAILURE - } - } -} - -fn execute(arguments: Cli) -> Result<()> { - if let Action::Restore { - archive, - destination, - } = &arguments.command - { - return archive::restore(&absolute(archive)?, &absolute(destination)?); - } - let project = Project::discover(arguments.root.as_deref())?; - if let Action::Chat { - init, - reset_owner, - owner_fingerprint, - } = arguments.command - { - return chat(&project, init, reset_owner.as_deref(), owner_fingerprint); - } - - let _operation = project.lock()?; - match arguments.command { - Action::Dev { port } => { - let mut settings = project.settings()?; - settings.mode = Mode::Local; - if let Some(selected_port) = port { - settings.port = selected_port; - } - images::prepare(&project, &mut settings)?; - project.up(&settings) - } - Action::Up { - production, - local, - port, - online, - } => { - let mut settings = project.settings()?; - if production { - settings.mode = Mode::Production; - } - if local { - settings.mode = Mode::Local; - } - if let Some(selected_port) = port { - settings.port = selected_port; - } - if online { - settings.online = true; - } - images::prepare(&project, &mut settings)?; - project.up(&settings) - } - Action::Config { production, port } => { - let mut settings = project.settings()?; - settings.mode = if production { - Mode::Production - } else { - Mode::Local - }; - if let Some(selected_port) = port { - settings.port = selected_port; - } - stack_config::initialize_public(&project.root)?; - render::prepare(&project, &settings)?; - process::line(format_args!( - "Public configuration: {}", - render::directory(&project, &settings).display() - )) - } - Action::Status => project.status(&project.settings()?), - Action::Down => project.down(&project.settings()?), - Action::Backup { - destination, - include_images, - recipient, - key, - } => { - let output = destination.map_or_else( - || Ok(project.root.join("var/exports")), - |path| absolute(&path), - )?; - let configuration = stack_config::Config::load(&project.root)?; - let selected_recipient = - recipient.unwrap_or(configuration.value("BACKUP_RECIPIENT")?.to_owned()); - archive::backup( - &project, - &output, - include_images, - &selected_recipient, - key.as_deref(), - ) - } - Action::Release { destination } => { - let output = destination - .map_or_else(|| Ok(project.root.join("dist")), |path| absolute(&path))?; - release::create(&project, &output) - } - Action::Activate { archive } => release::activate(&absolute(&archive)?, &project), - Action::Check => check(&project), - Action::Licenses => licenses(&project), - Action::Chat { .. } => anyhow::bail!("Chat action was not handled"), - Action::Restore { .. } => anyhow::bail!("Restore action was not handled"), - } -} - -fn chat( - project: &Project, - init: bool, - reset_owner: Option<&str>, - owner_fingerprint: bool, -) -> Result<()> { - if project.settings()?.mode == Mode::Production { - return chat::admin(project, init, reset_owner, owner_fingerprint); - } - let mut command = project.command("cargo")?; - command - .args(["run", "--locked", "-p", "stack-chat-server", "--", "--root"]) - .arg(&project.root); - if init { - command.arg("--init"); - } - if let Some(fingerprint) = reset_owner { - command.arg("--reset-owner").arg(fingerprint); - } - if owner_fingerprint { - command.arg("--owner-fingerprint"); - } - process::run(&mut command) -} - -fn absolute(path: &Path) -> Result { - Ok(if path.is_absolute() { - path.to_path_buf() - } else { - std::env::current_dir()?.join(path) - }) -} - -fn licenses(project: &Project) -> Result<()> { - process::run(project.command("cargo")?.args([ - "about", - "generate", - "--workspace", - "--locked", - "about.hbs", - "--output-file", - "web/THIRD-PARTY-LICENSES.html", - ])) -} - -fn check(project: &Project) -> Result<()> { - process::run(project.command("sh")?.arg("infra/chat/build-typescript.sh"))?; - process::run( - project - .command("cargo")? - .args(["fmt", "--all", "--", "--check"]), - )?; - process::run(project.command("cargo")?.args([ - "clippy", - "--locked", - "--workspace", - "--all-targets", - "--all-features", - "--", - "-D", - "warnings", - ]))?; - process::run(project.command("cargo")?.args([ - "test", - "--locked", - "--workspace", - "--all-targets", - "--all-features", - ]))?; - process::run( - project - .command("cargo")? - .env("RUSTDOCFLAGS", "-D warnings") - .args([ - "doc", - "--locked", - "--workspace", - "--no-deps", - "--document-private-items", - ]), - )?; - process::run(project.command("cargo")?.args([ - "deny", - "--workspace", - "--all-features", - "check", - ]))?; - licenses(project) +fn main() -> std::process::ExitCode { + run() } diff --git a/src/process.rs b/src/process.rs deleted file mode 100644 index eb48bc8..0000000 --- a/src/process.rs +++ /dev/null @@ -1,70 +0,0 @@ -// 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::fmt; -use std::io::{self, Write}; -use std::process::{Command, Output}; - -use anyhow::{Context, Result, bail}; - -pub(crate) fn capture(command: &mut Command) -> Result { - let output = command.output().with_context(|| { - format!( - "Could not start {}", - command.get_program().to_string_lossy() - ) - })?; - if !output.status.success() { - bail!( - "{} failed ({}): {}", - command.get_program().to_string_lossy(), - output.status, - String::from_utf8_lossy(&output.stderr).trim() - ); - } - Ok(output) -} - -pub(crate) fn text(command: &mut Command) -> Result { - String::from_utf8(capture(command)?.stdout).context("Command returned non-UTF-8 output") -} - -pub(crate) fn run(command: &mut Command) -> Result<()> { - let status = command.status().with_context(|| { - format!( - "Could not start {}", - command.get_program().to_string_lossy() - ) - })?; - if !status.success() { - bail!( - "{} failed ({status})", - command.get_program().to_string_lossy() - ); - } - Ok(()) -} - -pub(crate) fn available(command: &mut Command) -> bool { - command.output().is_ok_and(|output| output.status.success()) -} - -pub(crate) fn line(message: fmt::Arguments<'_>) -> Result<()> { - writeln!(io::stdout().lock(), "{message}").context("Could not write command output") -} - -pub(crate) fn error(message: fmt::Arguments<'_>) -> io::Result<()> { - writeln!(io::stderr().lock(), "{message}") -} diff --git a/src/project.rs b/src/project.rs deleted file mode 100644 index 57185b1..0000000 --- a/src/project.rs +++ /dev/null @@ -1,536 +0,0 @@ -// 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::BTreeMap; -use std::env; -use std::fs::{self, File, OpenOptions, Permissions}; -use std::io::{Read, Write}; -use std::os::unix::fs::{OpenOptionsExt, PermissionsExt}; -use std::path::{Component, Path, PathBuf}; -use std::process::Command; -use std::time::Duration; - -use anyhow::{Context, Result, bail, ensure}; -use serde::{Deserialize, Serialize}; -use serde_json::Value; -use stack_config::Config; - -use crate::{files, process}; - -#[derive(Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq, Serialize)] -#[serde(rename_all = "lowercase")] -pub(crate) enum Mode { - #[default] - Local, - Production, -} - -#[derive(Debug, Deserialize, Serialize)] -#[serde(deny_unknown_fields)] -pub(crate) struct Settings { - pub(crate) mode: Mode, - pub(crate) port: u16, - pub(crate) project: String, - #[serde(default)] - pub(crate) online: bool, -} - -#[derive(Debug, Deserialize, Serialize)] -#[serde(deny_unknown_fields)] -pub(crate) struct Configuration { - pub(crate) format: u32, - pub(crate) services: BTreeMap, -} - -#[derive(Debug, Deserialize, Serialize)] -#[serde(deny_unknown_fields)] -pub(crate) struct Service { - pub(crate) image: String, - pub(crate) build: Option, - #[serde(default)] - gateway: bool, - #[serde(default)] - environment: Vec, - #[serde(default)] - drop_capabilities: bool, - #[serde(default)] - read_only: bool, - health: Vec, - mounts: Vec, - #[serde(default)] - tmpfs: Vec, -} - -#[derive(Debug, Deserialize, Serialize)] -#[serde(deny_unknown_fields)] -struct Mount { - source: String, - target: String, - #[serde(default)] - read_only: bool, -} - -#[derive(Debug)] -pub(crate) struct Project { - pub(crate) root: PathBuf, -} - -impl Project { - pub(crate) fn discover(explicit: Option<&Path>) -> Result { - if let Some(path) = explicit { - return Self::at(path); - } - let current = env::current_dir()?; - for candidate in current.ancestors() { - if candidate.join("infra/services.toml").is_file() { - return Self::at(candidate); - } - } - let executable = env::current_exe()?; - for candidate in executable.ancestors().skip(1).take(3) { - if candidate.join("infra/services.toml").is_file() { - return Self::at(candidate); - } - } - bail!("Run from the stack directory or supply --root PATH") - } - - pub(crate) fn at(path: &Path) -> Result { - let root = if path.is_absolute() { - path.to_path_buf() - } else { - env::current_dir()?.join(path) - }; - Ok(Self { root }) - } - - pub(crate) fn settings(&self) -> Result { - let path = self.root.join("var/state.json"); - if path.exists() { - let mut settings: Settings = serde_json::from_reader(File::open(path)?)?; - let config = Config::load(&self.root)?; - if config.is_explicit("LOCAL_HTTP_PORT") { - settings.port = config.port("LOCAL_HTTP_PORT")?; - } - return Ok(settings); - } - Ok(Settings { - mode: Mode::Local, - port: Config::load(&self.root)?.port("LOCAL_HTTP_PORT")?, - project: format!( - "stack-{}", - files::hash_bytes(self.root.as_os_str().as_encoded_bytes()) - .chars() - .take(10) - .collect::() - ), - online: false, - }) - } - - pub(crate) fn save(&self, settings: &Settings) -> Result<()> { - files::write( - &self.root.join("var/state.json"), - &serde_json::to_vec_pretty(settings)?, - 0o600, - ) - } - - pub(crate) fn lock(&self) -> Result { - files::private_dir(&self.root.join("var"))?; - let file = OpenOptions::new() - .read(true) - .write(true) - .create(true) - .truncate(false) - .mode(0o600) - .open(self.root.join("var/operation.lock"))?; - file.try_lock() - .context("Another stack operation is already running")?; - Ok(file) - } - - pub(crate) fn command(&self, name: &str) -> Result { - let mut command = Command::new(name); - command.current_dir(&self.root); - if let Some(tools) = self - .root - .ancestors() - .map(|p| p.join(".tools")) - .find(|p| p.join("bin/cargo-deny").is_file()) - { - let mut paths = vec![tools.join("bin")]; - if let Some(inherited) = env::var_os("PATH") { - paths.extend(env::split_paths(&inherited)); - } - command.env("PATH", env::join_paths(paths)?); - } - if name == "podman" { - let version = process::text(Command::new("podman").arg("--version"))?; - let major = version - .split_whitespace() - .nth(2) - .and_then(|value| value.split('.').next()) - .context("Cannot determine the Podman version")? - .parse::()?; - if major < 6 { - command.env( - "CONTAINERS_CONF_OVERRIDE", - self.root.join("infra/podman.conf"), - ); - } - command - .arg("--root") - .arg(self.root.join("var/podman/storage")) - .arg("--runroot") - .arg(self.root.join("var/podman/run")); - } - Ok(command) - } - - pub(crate) fn platform(&self) -> Result { - let info: Value = serde_json::from_slice( - &process::capture(self.command("podman")?.args(["info", "--format", "json"]))?.stdout, - )?; - Ok(format!( - "{}/{}", - field(&info, &["host", "os"])? - .as_str() - .context("Missing host OS")?, - field(&info, &["host", "arch"])? - .as_str() - .context("Missing host architecture")? - )) - } - - pub(crate) fn configuration(&self, settings: &Settings) -> Result { - let mut config: Configuration = - toml::from_str(&fs::read_to_string(self.root.join("infra/services.toml"))?)?; - ensure!( - config.format == 1 && config.services.contains_key("caddy"), - "Invalid services configuration" - ); - if self.root.join("var/image-lock.json").is_file() && !settings.online { - let lock: Value = - serde_json::from_reader(File::open(self.root.join("var/image-lock.json"))?)?; - for (name, service) in &mut config.services { - field(&lock, &["services", name, "reference"])? - .as_str() - .context("Invalid image lock")? - .clone_into(&mut service.image); - } - } - Ok(config) - } - - pub(crate) fn initialize(&self, settings: &Settings) -> Result<()> { - files::private_dir(&self.root.join("var/data"))?; - let env_file = self.root.join(".env"); - for key in ["SEARXNG_SECRET", "CHAT_SETUP_TOKEN"] { - if !Config::load(&self.root)?.is_explicit(key) { - let mut random = [0; 32]; - File::open("/dev/urandom")?.read_exact(&mut random)?; - let mut file = OpenOptions::new() - .create(true) - .append(true) - .mode(0o600) - .open(&env_file)?; - writeln!(file, "\n{key}={}", hex::encode(random))?; - } - } - stack_config::initialize_public(&self.root)?; - fs::set_permissions(&env_file, Permissions::from_mode(0o600))?; - for name in ["chat", "chat-local"] { - files::private_dir(&self.root.join("var/data").join(name))?; - } - for name in ["repos", "caddy/data", "caddy/config", "searxng"] { - fs::create_dir_all(self.root.join("var/data").join(name))?; - } - fs::set_permissions( - self.root.join("var/data/repos"), - Permissions::from_mode(0o755), - )?; - let repository = self.root.join("var/data/repos/stack"); - if !repository.exists() { - if self.root.join(".git").is_dir() { - process::capture( - self.command("git")? - .args(["clone", "--bare", "."]) - .arg(&repository), - )?; - } else { - process::capture( - self.command("git")? - .args(["init", "--bare", "--initial-branch=main"]) - .arg(&repository), - )?; - } - } - files::copy_tree( - &self.root.join("infra/searxng"), - &self.root.join("var/data/searxng"), - )?; - crate::render::prepare(self, settings) - } - - pub(crate) fn container_name(settings: &Settings, service: &str) -> String { - format!("{}-{service}", settings.project) - } - - pub(crate) fn inspect_containers(&self, settings: &Settings) -> Result> { - let output = process::text(self.command("podman")?.args([ - "ps", - "--all", - "--quiet", - "--filter", - &format!("label=io.stack.project={}", settings.project), - ]))?; - let ids: Vec<_> = output.split_whitespace().collect(); - ensure!(!ids.is_empty(), "No stack containers found"); - Ok(serde_json::from_slice( - &process::capture(self.command("podman")?.arg("inspect").args(ids))?.stdout, - )?) - } - - pub(crate) fn up(&self, settings: &Settings) -> Result<()> { - ensure!( - (1024..=65_534).contains(&settings.port), - "Local port must be between 1024 and 65534" - ); - self.initialize(settings)?; - self.save(settings)?; - let config = self.configuration(settings)?; - let network = format!("{}-net", settings.project); - if !process::available( - self.command("podman")? - .args(["network", "exists", &network]), - ) { - process::capture( - self.command("podman")? - .args(["network", "create", &network]), - )?; - } - let environment = Config::load(&self.root)?; - for (name, service) in config - .services - .iter() - .filter(|(_, service)| !service.gateway) - .chain( - config - .services - .iter() - .filter(|(_, service)| service.gateway), - ) - { - self.ensure_image(settings, service)?; - self.start_service(settings, &network, name, service, &environment)?; - self.wait_service(settings, name, service)?; - } - process::line(format_args!( - "Running {}", - if settings.mode == Mode::Production { - environment - .public_values(None)? - .get("SITE_ORIGIN") - .context("Missing public site origin")? - .to_owned() - } else { - format!("http://localhost:{}", settings.port) - } - )) - } - - pub(crate) fn ensure_image(&self, settings: &Settings, service: &Service) -> Result<()> { - if settings.mode == Mode::Local - && (settings.online || !self.root.join("var/image-lock.json").exists()) - && let Some(context) = &service.build - { - if context == "bin/chat" && !self.root.join("bin/chat/server").is_file() { - crate::chat::build(self)?; - } - return process::run( - self.command("podman")? - .args(["build", "--tag", &service.image]) - .arg(self.path(context)?), - ); - } - if process::available( - self.command("podman")? - .args(["image", "exists", &service.image]), - ) { - return Ok(()); - } - ensure!( - settings.mode == Mode::Local, - "Production images must be built and deployed from the development machine" - ); - process::run(self.command("podman")?.args(["pull", &service.image])) - } - - fn start_service( - &self, - settings: &Settings, - network: &str, - name: &str, - service: &Service, - environment: &Config, - ) -> Result<()> { - let container = Self::container_name(settings, name); - let mut command = self.command("podman")?; - command.args([ - "run", - "--detach", - "--replace", - "--pull", - "never", - "--name", - &container, - "--network", - network, - "--network-alias", - name, - "--restart", - "unless-stopped", - "--label", - &format!("io.stack.project={}", settings.project), - "--label", - &format!("io.stack.service={name}"), - "--security-opt", - "no-new-privileges", - ]); - if service.read_only { - command.args(["--read-only", "--read-only-tmpfs=false"]); - } - if service.drop_capabilities { - command.args(["--cap-drop", "ALL"]); - } - if service.gateway { - let ports = if settings.mode == Mode::Local { - let tls = settings.port.checked_add(1).context("Port overflow")?; - [ - format!("127.0.0.1:{}:80", settings.port), - format!("127.0.0.1:{tls}:443"), - format!("127.0.0.1:{tls}:443/udp"), - ] - .to_vec() - } else { - let http = environment.port("HTTP_PORT")?; - let https = environment.port("HTTPS_PORT")?; - vec![ - format!("{}:{http}:{http}", crate::chat::web_bind(environment)?), - format!("{}:{https}:{https}", crate::chat::web_bind(environment)?), - format!( - "{}:{https}:{https}/udp", - crate::chat::web_bind(environment)? - ), - ] - }; - for port in ports { - command.args(["--publish", &port]); - } - } - for key in &service.environment { - command - .arg("--env") - .arg(key) - .env(key, environment.value(key)?); - } - if name == "chat" { - crate::chat::environment(&mut command, environment, settings)?; - } - for mount in &service.mounts { - let source = match mount.source.as_str() { - "@caddy" => crate::render::directory(self, settings).join("Caddyfile"), - "@web" => crate::render::directory(self, settings).join("web"), - "@cgit" => crate::render::directory(self, settings).join("cgitrc"), - "@chat" => self.root.join(if settings.mode == Mode::Local { - "var/data/chat-local" - } else { - "var/data/chat" - }), - other => self.path(other)?, - }; - let mode = if mount.read_only { "ro,z" } else { "rw,z" }; - command - .arg("--volume") - .arg(format!("{}:{}:{mode}", source.display(), mount.target)); - } - for mount in &service.tmpfs { - command.args(["--tmpfs", mount]); - } - process::capture(command.arg(&service.image))?; - Ok(()) - } - - fn wait_service(&self, settings: &Settings, name: &str, service: &Service) -> Result<()> { - let container = Self::container_name(settings, name); - for _attempt in 0..120 { - if process::available( - self.command("podman")? - .args(["exec", &container]) - .args(&service.health), - ) { - return Ok(()); - } - std::thread::sleep(Duration::from_millis(500)); - } - bail!("Service did not become ready: {name}; inspect its Podman logs") - } - - pub(crate) fn status(&self, settings: &Settings) -> Result<()> { - process::run(self.command("podman")?.args([ - "ps", - "--all", - "--filter", - &format!("label=io.stack.project={}", settings.project), - ])) - } - - pub(crate) fn down(&self, settings: &Settings) -> Result<()> { - for name in self.configuration(settings)?.services.keys() { - process::capture(self.command("podman")?.args([ - "rm", - "--force", - "--ignore", - &Self::container_name(settings, name), - ]))?; - } - let network = format!("{}-net", settings.project); - if process::available( - self.command("podman")? - .args(["network", "exists", &network]), - ) { - process::capture(self.command("podman")?.args(["network", "rm", &network]))?; - } - Ok(()) - } - - fn path(&self, relative: &str) -> Result { - let path = Path::new(relative); - ensure!( - path.components() - .all(|part| matches!(part, Component::Normal(_))), - "Service paths must stay inside stack" - ); - Ok(self.root.join(path)) - } -} - -pub(crate) fn field<'a>(value: &'a Value, path: &[&str]) -> Result<&'a Value> { - path.iter().try_fold(value, |node, key| { - node.get(key) - .with_context(|| format!("Missing JSON field {key}")) - }) -} diff --git a/src/release.rs b/src/release.rs deleted file mode 100644 index 92f2df4..0000000 --- a/src/release.rs +++ /dev/null @@ -1,127 +0,0 @@ -// 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::fs; -use std::path::Path; - -use anyhow::{Context, Result, ensure}; - -use crate::project::{Mode, Project}; -use crate::{files, images, process}; - -pub(crate) fn create(project: &Project, destination: &Path) -> Result<()> { - crate::check(project)?; - crate::chat::build(project)?; - files::write( - &project.root.join("bin/stack"), - &fs::read( - project - .root - .join("target/x86_64-unknown-linux-musl/release/stack"), - )?, - 0o755, - )?; - let mut settings = project.settings()?; - settings.mode = Mode::Local; - settings.online = true; - for service in project.configuration(&settings)?.services.values() { - project.ensure_image(&settings, service)?; - } - files::private_dir(destination)?; - let temp = tempfile::Builder::new() - .prefix(".release-") - .tempdir_in(destination)?; - let stage = temp.path().join("stack"); - files::private_dir(&stage)?; - files::copy_source(&project.root, &stage)?; - crate::chat::copy_artifacts(project, &stage)?; - fs::create_dir_all(stage.join("bin"))?; - fs::copy( - project - .root - .join("target/x86_64-unknown-linux-musl/release/stack"), - stage.join("bin/stack"), - )?; - images::save( - project, - &images::configured(project, &settings)?, - &stage.join("var"), - )?; - files::manifest(&stage, "release")?; - let output = destination.join(format!( - "stack-release-{}.tar.gz", - chrono::Utc::now().format("%Y%m%dT%H%M%SZ") - )); - files::pack(&stage, &output)?; - let filename = output - .file_name() - .context("Missing archive name")? - .to_string_lossy(); - files::write( - &output.with_extension("gz.sha256"), - format!("{} {filename}\n", files::hash_file(&output)?).as_bytes(), - 0o600, - )?; - process::line(format_args!("Release: {}", output.display())) -} - -pub(crate) fn activate(archive: &Path, project: &Project) -> Result<()> { - let temp = tempfile::Builder::new() - .prefix(".activate-") - .tempdir_in(&project.root)?; - let stage = temp.path().join("stack"); - files::extract(archive, &stage)?; - ensure!( - files::verify(&stage)?.kind == "release", - "Activation requires a release archive" - ); - let pending = Project::at(&stage)?; - let mut settings = project.settings()?; - settings.mode = Mode::Production; - settings.online = false; - let lock = stage.join("var/image-lock.json"); - let info: serde_json::Value = serde_json::from_slice(&fs::read(&lock)?)?; - ensure!( - info.get("platform").and_then(serde_json::Value::as_str) - == Some(project.platform()?.as_str()), - "Release architecture does not match the server" - ); - let _config = pending.configuration(&settings)?; - for name in files::SOURCE { - let source = stage.join(name); - if source.exists() { - let target = project.root.join(name); - if target.is_dir() { - files::regular_tree(&target)?; - fs::remove_dir_all(&target)?; - } - files::copy_tree(&source, &target)?; - } - } - files::write( - &project.root.join("bin/stack"), - &fs::read(stage.join("bin/stack"))?, - 0o755, - )?; - crate::chat::copy_artifacts(&pending, &project.root)?; - for name in ["images.tar", "image-lock.json"] { - files::copy_tree( - &stage.join("var").join(name), - &project.root.join("var").join(name), - )?; - } - images::prepare(project, &mut settings)?; - project.up(&settings) -} diff --git a/src/render.rs b/src/render.rs deleted file mode 100644 index a552643..0000000 --- a/src/render.rs +++ /dev/null @@ -1,114 +0,0 @@ -// 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 crate::files; -use crate::project::{Mode, Project, Settings}; -use anyhow::Result; -use stack_config::Config; -use std::fs; -use std::path::PathBuf; - -pub(crate) fn directory(project: &Project, settings: &Settings) -> PathBuf { - project.root.join(match settings.mode { - Mode::Local => "var/local", - Mode::Production => "var/production", - }) -} - -pub(crate) fn prepare(project: &Project, settings: &Settings) -> Result<()> { - let config = Config::load(&project.root)?; - let local = (settings.mode == Mode::Local).then_some(settings.port); - let output = directory(project, settings); - let web = output.join("web"); - if web.exists() { - files::regular_tree(&web)?; - fs::remove_dir_all(&web)?; - } - files::copy_tree(&project.root.join("web"), &web)?; - let file = web.join("index.html"); - files::write( - &file, - config - .render(&fs::read_to_string(&file)?, local)? - .as_bytes(), - 0o644, - )?; - let template = project.root.join(if settings.mode == Mode::Local { - "infra/Caddyfile.local" - } else { - "infra/Caddyfile" - }); - files::write( - &output.join("Caddyfile"), - config - .render(&fs::read_to_string(template)?, local)? - .as_bytes(), - 0o644, - )?; - let cgit = fs::read_to_string(project.root.join("infra/containers/cgit/cgitrc"))?; - let mut rendered = config.render(&cgit, local)?; - if let Some(port) = local { - rendered = rendered.replace( - "clone-prefix=/git", - &format!("clone-prefix=http://localhost:{port}/git"), - ); - } - files::write(&output.join("cgitrc"), rendered.as_bytes(), 0o644) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn custom_domains_and_ports_reach_public_outputs_without_secrets() -> Result<()> { - let root = tempfile::tempdir()?; - for name in ["web", "infra"] { - files::copy_tree( - &PathBuf::from(env!("CARGO_MANIFEST_DIR")).join(name), - &root.path().join(name), - )?; - } - fs::write( - root.path().join(".env"), - "SITE_HOST=home.example.net\nGIT_HOST=git.example.net\nHTTP_PORT=8181\nHTTPS_PORT=8443\nSEARXNG_SECRET=do-not-publish-this-sentinel\n", - )?; - let project = Project::at(root.path())?; - let mut settings = project.settings()?; - settings.mode = Mode::Production; - prepare(&project, &settings)?; - let output = directory(&project, &settings); - let caddy = fs::read_to_string(output.join("Caddyfile"))?; - assert!(caddy.contains("https://home.example.net:8443")); - assert!(caddy.contains("http_port 8181")); - assert!(!caddy.contains("do-not-publish")); - let homepage = fs::read_to_string(output.join("web/index.html"))?; - assert!(homepage.contains("https://git.example.net:8443")); - let stylesheet = files::hash_file(&root.path().join("web/styles.css"))?; - assert!(homepage.contains(&format!("/styles.css?v={stylesheet}"))); - assert!(homepage.contains("https://chat.doan.luuduct.ai:8443/LIMITATIONS.txt")); - settings.mode = Mode::Local; - settings.port = 8180; - prepare(&project, &settings)?; - let preview = fs::read_to_string(directory(&project, &settings).join("web/index.html"))?; - assert!(preview.contains("href=\"/git/\"")); - assert!(preview.contains("http://chat.localhost:8180/LIMITATIONS.txt")); - assert!( - fs::read_to_string(directory(&project, &settings).join("cgitrc"))? - .contains("clone-prefix=http://localhost:8180/git") - ); - Ok(()) - } -} -- cgit v1.2.3