summaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/archive.rs25
-rw-r--r--src/chat.rs163
-rw-r--r--src/main.rs6
-rw-r--r--src/project.rs48
-rw-r--r--src/release.rs10
-rw-r--r--src/render.rs16
6 files changed, 233 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..dea7e28 100644
--- a/src/render.rs
+++ b/src/render.rs
@@ -96,17 +96,17 @@ 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"));
+ 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)?;
- 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")