diff options
Diffstat (limited to 'src')
| -rw-r--r-- | src/archive.rs | 25 | ||||
| -rw-r--r-- | src/chat.rs | 163 | ||||
| -rw-r--r-- | src/main.rs | 6 | ||||
| -rw-r--r-- | src/project.rs | 48 | ||||
| -rw-r--r-- | src/release.rs | 10 | ||||
| -rw-r--r-- | src/render.rs | 15 |
6 files changed, 232 insertions, 35 deletions
diff --git a/src/archive.rs b/src/archive.rs index 4d2c01c..1e58992 100644 --- a/src/archive.rs +++ b/src/archive.rs @@ -84,7 +84,8 @@ fn snapshot(project: &Project, records: &[Value], stage: &Path) -> Result<()> { ids: Vec::new(), }; for record in records { - if field(record, &["State", "Running"])?.as_bool() == Some(true) + 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"])? @@ -100,12 +101,12 @@ fn snapshot(project: &Project, records: &[Value], stage: &Path) -> Result<()> { resume } -fn snapshot_chat(root: &Path, stage: &Path) -> Result<()> { - let source = root.join("var/data/chat/chat.sqlite"); +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/chat"); + 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)?; @@ -128,14 +129,19 @@ fn snapshot_files(project: &Project, records: &[Value], stage: &Path) -> Result< let data = project.root.join("var/data"); for item in fs::read_dir(&data)? { let entry = item?; - if entry.file_name() != "caddy" && entry.file_name() != "chat" { + 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()), )?; } } - snapshot_chat(&project.root, stage)?; + for name in ["chat", "chat-local"] { + snapshot_chat(&project.root, stage, name)?; + } let caddy = records .iter() .find(|record| { @@ -167,6 +173,7 @@ fn snapshot_files(project: &Project, records: &[Value], stage: &Path) -> Result< .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(()) } @@ -310,6 +317,10 @@ fn backup_inner( !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( @@ -397,7 +408,7 @@ mod tests { )?; let transaction = connection.unchecked_transaction()?; transaction.execute("INSERT INTO messages VALUES ('uncommitted')", [])?; - super::snapshot_chat(&root, &stage)?; + super::snapshot_chat(&root, &stage, "chat")?; let snapshot = stage.join("var/data/chat/chat.sqlite"); let restored = rusqlite::Connection::open(&snapshot)?; let count: i64 = diff --git a/src/chat.rs b/src/chat.rs new file mode 100644 index 0000000..be852d5 --- /dev/null +++ b/src/chat.rs @@ -0,0 +1,163 @@ +// Copyright (C) 2026 Doan Luu Duc Tai <doan@luuduct.ai> +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see <https://www.gnu.org/licenses/>. + +use 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<String> { + 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-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"), + ("crates/chat-crypto/browser/wasi.mjs", "wasi.mjs"), + ("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))?; + } + files::copy_tree( + &project.root.join("crates/chat-server/web"), + &directory.join("web"), + )?; + 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/main.rs b/src/main.rs index 7f5bcdc..5ee1f9e 100644 --- a/src/main.rs +++ b/src/main.rs @@ -16,6 +16,7 @@ #![doc = "Rust commands for personal web infrastructure."] pub(crate) mod archive; +pub(crate) mod chat; pub(crate) mod files; pub(crate) mod images; pub(crate) mod process; @@ -50,7 +51,7 @@ struct Cli { #[derive(Debug, Subcommand)] enum Action { - #[command(about = "Run the experimental local chat or initialize its single .env")] + #[command(about = "Run experimental local chat or administer its packaged deployment")] Chat { #[arg(long, conflicts_with_all = ["reset_owner", "owner_fingerprint"])] init: bool, @@ -252,6 +253,9 @@ fn chat( 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"]) diff --git a/src/project.rs b/src/project.rs index 6ef619a..57185b1 100644 --- a/src/project.rs +++ b/src/project.rs @@ -65,6 +65,8 @@ pub(crate) struct Service { environment: Vec<String>, #[serde(default)] drop_capabilities: bool, + #[serde(default)] + read_only: bool, health: Vec<String>, mounts: Vec<Mount>, #[serde(default)] @@ -236,18 +238,23 @@ impl Project { pub(crate) fn initialize(&self, settings: &Settings) -> Result<()> { files::private_dir(&self.root.join("var/data"))?; let env_file = self.root.join(".env"); - if !Config::load(&self.root)?.is_explicit("SEARXNG_SECRET") { - 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, "\nSEARXNG_SECRET={}", hex::encode(random))?; + 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))?; } @@ -350,6 +357,9 @@ impl Project { && (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]) @@ -400,6 +410,9 @@ impl Project { "--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"]); } @@ -416,9 +429,12 @@ impl Project { let http = environment.port("HTTP_PORT")?; let https = environment.port("HTTPS_PORT")?; vec![ - format!("[::]:{http}:{http}"), - format!("[::]:{https}:{https}"), - format!("[::]:{https}:{https}/udp"), + 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 { @@ -431,11 +447,19 @@ impl Project { .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" }; diff --git a/src/release.rs b/src/release.rs index 88b4093..92f2df4 100644 --- a/src/release.rs +++ b/src/release.rs @@ -23,13 +23,7 @@ use crate::{files, images, process}; pub(crate) fn create(project: &Project, destination: &Path) -> Result<()> { crate::check(project)?; - process::run(project.command("cargo")?.args([ - "build", - "--locked", - "--release", - "--target", - "x86_64-unknown-linux-musl", - ]))?; + crate::chat::build(project)?; files::write( &project.root.join("bin/stack"), &fs::read( @@ -52,6 +46,7 @@ pub(crate) fn create(project: &Project, destination: &Path) -> Result<()> { 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 @@ -120,6 +115,7 @@ pub(crate) fn activate(archive: &Path, project: &Project) -> Result<()> { &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), diff --git a/src/render.rs b/src/render.rs index 8e1ee1e..c141405 100644 --- a/src/render.rs +++ b/src/render.rs @@ -96,17 +96,16 @@ mod tests { assert!(caddy.contains("https://home.example.net:8443")); assert!(caddy.contains("http_port 8181")); assert!(!caddy.contains("do-not-publish")); - assert!( - fs::read_to_string(output.join("web/index.html"))? - .contains("https://git.example.net:8443") - ); + let homepage = fs::read_to_string(output.join("web/index.html"))?; + assert!(homepage.contains("https://git.example.net:8443")); + assert!(homepage.contains("https://chat.doan.luuduct.ai:8443/LIMITATIONS.txt")); + assert!(homepage.contains("A home-grown experimental messenger. Know its")); settings.mode = Mode::Local; settings.port = 8180; prepare(&project, &settings)?; - assert!( - fs::read_to_string(directory(&project, &settings).join("web/index.html"))? - .contains("href=\"/git/\"") - ); + 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") |
