diff options
41 files changed, 1412 insertions, 244 deletions
diff --git a/.env.example b/.env.example index 521bcd8..de820ba 100644 --- a/.env.example +++ b/.env.example @@ -21,10 +21,13 @@ GIT_HOST=git.doan.luuduct.ai SEARCH_HOST=search.doan.luuduct.ai WKD_HOST=openpgpkey.luuduct.ai CHAT_HOST=chat.doan.luuduct.ai +CHAT_LOCAL_HOST=chat.localhost # Production TCP HTTP and TCP/UDP HTTPS listener/published ports. # Keep 80/443 for ordinary public HTTPS and ACME validation. Nonstandard ports # also require matching external routing and a suitable certificate setup. +# IPv6 :: publishes on IPv4 and IPv6; use 127.0.0.1 for isolated staging. +WEB_BIND=:: HTTP_PORT=80 HTTPS_PORT=443 @@ -36,8 +39,10 @@ LOCAL_HTTP_PORT=8080 # The bundled cgit and SearXNG images listen internally on 8080. CGIT_UPSTREAM=cgit:8080 SEARXNG_UPSTREAM=searxng:8080 +CHAT_UPSTREAM=chat:8080 -# Chat remains a separate local experimental process. --bind overrides CHAT_BIND. +# Direct development process only. Container origins come from the selected +# public HTTPS host or local chat.localhost gateway. --bind overrides CHAT_BIND. # CHAT_ORIGIN must match the browser's exact origin; existing identity files pin it. # Changing an origin is not a private-key or identity-file migration. CHAT_BIND=127.0.0.1:9080 @@ -606,6 +606,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" [[package]] +name = "futures-sink" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d" + +[[package]] name = "futures-task" version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -1671,6 +1677,7 @@ dependencies = [ "stack-config", "tempfile", "tokio", + "tokio-util", ] [[package]] @@ -1835,6 +1842,19 @@ dependencies = [ ] [[package]] +name = "tokio-util" +version = "0.7.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e464cf451ba96ebfc6f9b6542f17ee8b8956e33f1e40d9690624e59d7a7f8a4b" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "pin-project-lite", + "tokio", +] + +[[package]] name = "toml" version = "1.1.8+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -22,8 +22,11 @@ STACK GETTING STARTED Install Rust through rustup, Podman, Git, and GnuPG on a Linux development - machine. The pinned toolchain includes rustfmt, Clippy, and the static - x86_64 Linux target. + machine. Chat builds also need a GCC-compatible C compiler, Linux UAPI + headers, libclang, pkg-config, make, Perl, curl, tar, and OpenSSL 3.5+ + development libraries for native workspace checks. The pinned toolchain + includes rustfmt, Clippy, and the static x86_64 Linux target. + See docs/CHAT-PRODUCTION.txt for the native/WASI release build. From this directory: @@ -31,7 +34,7 @@ GETTING STARTED cargo stack status cargo stack down - Browse http://localhost:8080, /git/, and /search/. + Browse http://localhost:8080, /git/, /search/, and http://chat.localhost:8080/. A fresh checkout generates a private .env automatically. Local mode binds only loopback and does not request public certificates. @@ -52,7 +55,7 @@ DEVELOPMENT AND PRODUCTION and exports. Generated TLS private keys are runtime data under var/. .env, var/, .tools/, target/, bin/, and dist/ are excluded from Git. - The three existing services are Caddy, cgit, and SearXNG. The management + The four services are Caddy, cgit, SearXNG, and the Rust chat server. The management command is Rust. SearXNG and the cgit container's upstream supervisor retain their third-party Python implementation. No first-party Python is needed. @@ -75,15 +78,16 @@ CHECKS AND LICENSES Detailed manual backup, recovery, deployment, and host setup instructions are in docs/OPERATIONS.txt. Read docs/POLICY.txt before changing conventions. -EXPERIMENTAL CHAT BRANCH +EXPERIMENTAL CHAT - feature/private-chat contains a local Rust/WASI chat application. Start with + main includes the experimental Rust/WASI chat application. Start with docs/CHAT-APPLICATION.txt for setup, protected files, tests, backup/restore, source licensing, and security limitations. cargo stack chat --init appends chat configuration to the existing single root .env; cargo stack chat runs - the local service. This does not deploy or enable production chat. + the direct local service. docs/CHAT-PRODUCTION.txt describes its packaged + HTTPS gateway, release deployment, production owner setup, and recovery. + Building a release does not activate production. - SQLite snapshots now use the bundled SQLite C library. Portable stack - release builds consequently also require a musl C compiler on the development - machine, in addition to the Rust musl target. Runtime binaries remain static; - no new compiler is required on the VPS. + SQLite snapshots use the bundled SQLite C library. Release scripts build + static OpenSSL against hash-verified musl headers and the Rust musl target. + Runtime binaries remain static; no compiler is required on the VPS. diff --git a/crates/chat-crypto/README.txt b/crates/chat-crypto/README.txt index e04246c..a08d850 100644 --- a/crates/chat-crypto/README.txt +++ b/crates/chat-crypto/README.txt @@ -31,9 +31,9 @@ PRIVATE CHAT CRYPTOGRAPHY data must remain in local memory and must never be submitted to the server. See ../../docs/CHAT-APPLICATION.txt for implemented formats, local setup, - tests, backups, and limitations. Work remains experimental and local on - feature/private-chat. Production remains unchanged. Do not mistake the - compatibility probe for an application security audit. + tests, backups, and limitations. See ../../docs/CHAT-PRODUCTION.txt for + packaged HTTPS integration. Work remains experimental; + compatibility tests are not an application security audit. ../../docs/CHAT-OPENSSL-MIGRATION.txt records the complete backend decision, pinned source hashes, build flags, FFI/capability limitations, and reviewed diff --git a/crates/chat-crypto/src/application.rs b/crates/chat-crypto/src/application.rs index 98e8a20..963e1de 100644 --- a/crates/chat-crypto/src/application.rs +++ b/crates/chat-crypto/src/application.rs @@ -32,7 +32,8 @@ use std::io::{Read, Write}; use zeroize::Zeroizing; const PURPOSE: &str = "Stack private chat identity only; do not reuse"; -const FORMAT: &str = "stack-chat-file/v1;argon2id-m65536-t3-p1;aes256-gcm"; +const LEGACY_FORMAT: &str = "stack-chat-file/v1;argon2id-m65536-t3-p1;aes256-gcm"; +const FORMAT: &str = "stack-chat-file/v2;argon2id-m65536-t3-p1;aes256-gcm"; const LIMIT: usize = 262_144; #[derive(Deserialize, JsonSerialize)] @@ -50,10 +51,17 @@ struct Envelope { format: String, salt: String, nonce: String, - ciphertext: String, + ciphertext: FileCiphertext, tag: String, } +#[derive(Deserialize, JsonSerialize)] +#[serde(untagged)] +enum FileCiphertext { + Compact(String), + Wrapped(Vec<String>), +} + #[derive(Deserialize)] #[serde(tag = "op", rename_all = "snake_case", deny_unknown_fields)] enum Request { @@ -201,11 +209,17 @@ fn protect(identity: &Identity, password: &str) -> Result<String> { &plaintext, &mut tag, )?; - Ok(serde_json::to_string(&Envelope { + let encoded = BASE64_STANDARD.encode(ciphertext); + let chunks = encoded + .as_bytes() + .chunks(80) + .map(|chunk| std::str::from_utf8(chunk).map(str::to_owned)) + .collect::<std::result::Result<Vec<_>, _>>()?; + Ok(serde_json::to_string_pretty(&Envelope { format: FORMAT.into(), salt: BASE64_STANDARD.encode(salt), nonce: BASE64_STANDARD.encode(nonce), - ciphertext: BASE64_STANDARD.encode(ciphertext), + ciphertext: FileCiphertext::Wrapped(chunks), tag: BASE64_STANDARD.encode(tag), })?) } @@ -213,10 +227,20 @@ fn protect(identity: &Identity, password: &str) -> Result<String> { fn unlock(file: &str, password: &str, origin: &str) -> Result<(Identity, Cert)> { ensure!(file.len() <= LIMIT, "Identity file exceeds its limit"); let envelope: Envelope = serde_json::from_str(file)?; - ensure!( - envelope.format == FORMAT, - "Unsupported identity file format" - ); + let encoded = match (envelope.format.as_str(), &envelope.ciphertext) { + (LEGACY_FORMAT, FileCiphertext::Compact(value)) => value.clone(), + (FORMAT, FileCiphertext::Wrapped(chunks)) => { + ensure!( + !chunks.is_empty() + && chunks + .iter() + .all(|chunk| !chunk.is_empty() && chunk.len() <= 80), + "Invalid ciphertext wrapping" + ); + chunks.concat() + } + _ => anyhow::bail!("Unsupported identity file format"), + }; let salt = decode(&envelope.salt)?; let nonce = decode(&envelope.nonce)?; let tag = decode(&envelope.tag)?; @@ -224,13 +248,13 @@ fn unlock(file: &str, password: &str, origin: &str) -> Result<(Identity, Cert)> salt.len() == 16 && nonce.len() == 12 && tag.len() == 16, "Malformed envelope" ); - let ciphertext = decode(&envelope.ciphertext)?; + let ciphertext = decode(&encoded)?; let key = derive(password, &salt)?; let plaintext = Zeroizing::new(decrypt_aead( Cipher::aes_256_gcm(), key.as_ref(), Some(&nonce), - FORMAT.as_bytes(), + envelope.format.as_bytes(), &ciphertext, &tag, )?); @@ -643,7 +667,7 @@ mod tests { format: FORMAT.into(), salt: BASE64_STANDARD.encode([0; 15]), nonce: BASE64_STANDARD.encode([0; 12]), - ciphertext: String::new(), + ciphertext: FileCiphertext::Wrapped(Vec::new()), tag: BASE64_STANDARD.encode([0; 16]), })?; ensure!( @@ -657,6 +681,74 @@ mod tests { Ok(()) } + fn check_file_formats( + file: &str, + identity: &Identity, + password: &str, + origin: &str, + ) -> Result<()> { + ensure!( + file.starts_with("{\n \"format\""), + "Envelope is not indented" + ); + let envelope: Envelope = serde_json::from_str(file)?; + let FileCiphertext::Wrapped(chunks) = &envelope.ciphertext else { + anyhow::bail!("Ciphertext is not wrapped"); + }; + let (last, complete) = chunks.split_last().context("Missing ciphertext")?; + ensure!( + complete.iter().all(|chunk| chunk.len() == 80) && (1..=80).contains(&last.len()), + "Wrong ciphertext width" + ); + let salt = random::<16>()?; + let nonce = random::<12>()?; + let key = derive(password, &salt)?; + let mut tag = [0; 16]; + let plaintext = Zeroizing::new(serde_json::to_vec(identity)?); + let ciphertext = encrypt_aead( + Cipher::aes_256_gcm(), + key.as_ref(), + Some(&nonce), + LEGACY_FORMAT.as_bytes(), + &plaintext, + &mut tag, + )?; + let legacy = Envelope { + format: LEGACY_FORMAT.into(), + salt: BASE64_STANDARD.encode(salt), + nonce: BASE64_STANDARD.encode(nonce), + ciphertext: FileCiphertext::Compact(BASE64_STANDARD.encode(ciphertext)), + tag: BASE64_STANDARD.encode(tag), + }; + for historical in [ + serde_json::to_string(&legacy)?, + serde_json::to_string_pretty(&legacy)?, + ] { + let (restored, _certificate) = unlock(&historical, password, origin)?; + ensure!( + restored.secret.as_str() == identity.secret.as_str(), + "Legacy identity changed" + ); + ensure!( + unlock(&historical, "wrong-passphrase", origin).is_err(), + "Legacy password protection failed" + ); + } + let mut malformed = serde_json::to_value(&envelope)?; + *malformed.get_mut("ciphertext").context("Ciphertext")? = json!(["A".repeat(81)]); + ensure!( + unlock(&malformed.to_string(), password, origin).is_err(), + "Overlong ciphertext chunk accepted" + ); + *malformed.get_mut("format").context("Format")? = json!(LEGACY_FORMAT); + *malformed.get_mut("ciphertext").context("Ciphertext")? = json!(chunks.concat()); + ensure!( + unlock(&malformed.to_string(), password, origin).is_err(), + "Unauthenticated file-version downgrade accepted" + ); + Ok(()) + } + #[test] fn protected_file_and_authenticated_messages() -> Result<()> { let origin = "http://127.0.0.1:9080"; @@ -668,6 +760,7 @@ mod tests { .as_str() .context("File")?; let (owner_identity, owner_cert) = unlock(owner_file, password, origin)?; + check_file_formats(owner_file, &owner_identity, password, origin)?; let owner_public = public(&owner_cert)?; let visitor = create(origin.into(), "Visitor".into(), password, owner_public)?; let visitor_file = visitor diff --git a/crates/chat-server/Cargo.toml b/crates/chat-server/Cargo.toml index 709c37f..6ebb2df 100644 --- a/crates/chat-server/Cargo.toml +++ b/crates/chat-server/Cargo.toml @@ -37,6 +37,7 @@ openssl = "0.10" rusqlite = { version = "0.41", default-features = false, features = ["bundled", "backup"] } serde_json = "1" stack-chat-crypto = { path = "../chat-crypto", version = "0.1.0" } +tokio-util = { version = "0.7", features = ["io"] } tokio = { version = "1", features = ["rt-multi-thread", "net", "macros", "signal", "sync", "fs"] } [dev-dependencies] diff --git a/crates/chat-server/README.txt b/crates/chat-server/README.txt index a7aba5d..0562385 100644 --- a/crates/chat-server/README.txt +++ b/crates/chat-server/README.txt @@ -19,4 +19,5 @@ EXPERIMENTAL PRIVATE CHAT SERVER See docs/CHAT-APPLICATION.txt at the workspace root for local setup, file formats, API/security limits, backups, recovery, source licensing, and tests. Start with cargo stack chat --init, then cargo stack chat. - Production is intentionally not configured on this feature branch. + See docs/CHAT-PRODUCTION.txt for the prebuilt Podman/HTTPS deployment, + production owner setup, and full encrypted export/recovery procedure. diff --git a/crates/chat-server/src/http.rs b/crates/chat-server/src/http.rs index 1c7286f..7394b71 100644 --- a/crates/chat-server/src/http.rs +++ b/crates/chat-server/src/http.rs @@ -17,9 +17,10 @@ use crate::service::Service; use anyhow::Result; use axum::{ Json, Router, + body::Body, extract::{DefaultBodyLimit, State}, http::{HeaderMap, HeaderValue, StatusCode}, - response::{IntoResponse, Response}, + response::{IntoResponse, Redirect, Response}, routing::{get, post}, }; use serde_json::{Value, json}; @@ -93,6 +94,14 @@ async fn api(State(app): State<App>, headers: HeaderMap, Json(request): Json<Val } async fn asset(State(app): State<App>, uri: axum::http::Uri) -> Response { + if uri.path() == "/THIRD-PARTY-LICENSES.html" { + let Some(origin) = app.public.get("SITE_ORIGIN") else { + return secured(StatusCode::INTERNAL_SERVER_ERROR.into_response()); + }; + return secured( + Redirect::permanent(&format!("{origin}/THIRD-PARTY-LICENSES.html")).into_response(), + ); + } let (path, mime) = match uri.path() { "/" => ( "crates/chat-server/web/index.html", @@ -115,25 +124,33 @@ async fn asset(State(app): State<App>, uri: axum::http::Uri) -> Response { "text/css; charset=utf-8", ), "/crypto.wasm" => ( - "target/wasm32-wasip1/release/stack-chat-crypto.wasm", + if app.root.join("bin/chat/crypto.wasm").is_file() { + "bin/chat/crypto.wasm" + } else { + "target/wasm32-wasip1/release/stack-chat-crypto.wasm" + }, "application/wasm", ), "/javascript.html" => ( "crates/chat-server/web/javascript.html", "text/html; charset=utf-8", ), - "/SOURCE.tar.gz" => ("var/build/chat-source.tar.gz", "application/gzip"), - "/THIRD-PARTY-LICENSES.html" => { - ("web/THIRD-PARTY-LICENSES.html", "text/html; charset=utf-8") - } + "/SOURCE.tar.gz" => ( + if app.root.join("bin/chat/SOURCE.tar.gz").is_file() { + "bin/chat/SOURCE.tar.gz" + } else { + "var/build/chat-source.tar.gz" + }, + "application/gzip", + ), "/LIMITATIONS.txt" => ("docs/CHAT-APPLICATION.txt", "text/plain; charset=utf-8"), "/LICENSE" => ("LICENSE", "text/plain; charset=utf-8"), _ => return secured(StatusCode::NOT_FOUND.into_response()), }; - let Ok(bytes) = tokio::fs::read(app.root.join(path)).await else { - return secured(StatusCode::NOT_FOUND.into_response()); - }; if matches!(uri.path(), "/" | "/javascript.html") { + let Ok(bytes) = tokio::fs::read(app.root.join(path)).await else { + return secured(StatusCode::NOT_FOUND.into_response()); + }; let rendered = String::from_utf8(bytes) .map_err(anyhow::Error::from) .and_then(|source| stack_config::render(&source, &app.public)); @@ -142,7 +159,22 @@ async fn asset(State(app): State<App>, uri: axum::http::Uri) -> Response { |html| secured(([("content-type", mime)], html).into_response()), ); } - secured(([("content-type", mime)], bytes).into_response()) + let Ok(file) = tokio::fs::File::open(app.root.join(path)).await else { + return secured(StatusCode::NOT_FOUND.into_response()); + }; + let length = file.metadata().await.ok().map(|metadata| metadata.len()); + let mut response = Response::new(Body::from_stream(tokio_util::io::ReaderStream::new(file))); + response + .headers_mut() + .insert("content-type", HeaderValue::from_static(mime)); + if let Some(header_length) = + length.and_then(|value| HeaderValue::from_str(&value.to_string()).ok()) + { + response + .headers_mut() + .insert("content-length", header_length); + } + secured(response) } pub(crate) async fn serve( @@ -159,6 +191,10 @@ pub(crate) async fn serve( public, }; let routes = Router::new() + .route( + "/healthz", + get(|| async { secured("ok\n".into_response()) }), + ) .route("/api", post(api)) .fallback(get(asset)) .layer(DefaultBodyLimit::max(262_144)) diff --git a/crates/chat-server/src/main.rs b/crates/chat-server/src/main.rs index cfd3e7a..1d600b7 100644 --- a/crates/chat-server/src/main.rs +++ b/crates/chat-server/src/main.rs @@ -13,7 +13,7 @@ // 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/>. -#![doc = "Local experimental chat server with ciphertext-only durable storage."] +#![doc = "Encrypted private chat server and backend maintenance commands."] mod http; mod maintenance; @@ -22,18 +22,23 @@ mod store; use anyhow::{Context, Result, ensure}; use clap::Parser; -use std::io::Write; +use std::io::{Read, Write}; use std::net::SocketAddr; use std::path::PathBuf; use std::process::ExitCode; #[derive(Debug, Parser)] -#[command(about = "Run the experimental private chat locally; production is not enabled")] +#[command(about = "Run private chat directly or behind the stack gateway")] struct Arguments { #[arg(long, default_value = ".")] root: PathBuf, - #[arg(long)] - bind: Option<SocketAddr>, + #[command(flatten)] + listener: Listener, + #[arg( + long, + help = "Check the local HTTP health endpoint without opening the database" + )] + healthcheck: bool, #[arg(long, conflicts_with_all = ["reset_owner", "owner_fingerprint", "snapshot"])] init: bool, #[arg(long, value_name = "FINGERPRINT", conflicts_with_all = ["owner_fingerprint", "snapshot"], help = "Permanently clear the matching owner registration and all chat history after key loss")] @@ -48,6 +53,17 @@ struct Arguments { snapshot: Option<PathBuf>, } +#[derive(Debug, clap::Args)] +struct Listener { + #[arg(long)] + bind: Option<SocketAddr>, + #[arg( + long, + help = "Run behind the stack gateway using its explicit container environment" + )] + proxy: bool, +} + fn initialize(root: &std::path::Path) -> Result<()> { let env = root.join(".env"); let existing = if env.exists() { @@ -76,13 +92,70 @@ fn initialize(root: &std::path::Path) -> Result<()> { stack_config::initialize_public(root) } +fn healthcheck(mut address: SocketAddr) -> Result<()> { + if address.ip().is_unspecified() { + address.set_ip(std::net::IpAddr::V4(std::net::Ipv4Addr::LOCALHOST)); + } + let mut socket = + std::net::TcpStream::connect_timeout(&address, std::time::Duration::from_secs(2))?; + socket.set_read_timeout(Some(std::time::Duration::from_secs(2)))?; + socket.set_write_timeout(Some(std::time::Duration::from_secs(2)))?; + socket.write_all(b"GET /healthz HTTP/1.0\r\nHost: localhost\r\n\r\n")?; + let mut response = String::new(); + socket.take(8192).read_to_string(&mut response)?; + ensure!( + response.starts_with("HTTP/1.0 200") || response.starts_with("HTTP/1.1 200"), + "Chat health endpoint failed" + ); + Ok(()) +} + +fn validate_origin( + configuration: &stack_config::Config, + bind: SocketAddr, + proxy: bool, +) -> Result<()> { + let origin = configuration.value("CHAT_ORIGIN")?; + if proxy { + let public = configuration.public_values(None)?; + let canonical = public + .get("CHAT_PUBLIC_ORIGIN") + .context("Missing public chat origin")?; + let local = format!( + "http://{}:{}", + configuration.value("CHAT_LOCAL_HOST")?, + configuration.port("LOCAL_HTTP_PORT")? + ); + ensure!( + origin == canonical || origin == local, + "Gateway chat origin must match the configured HTTPS hostname or dedicated local hostname" + ); + } else { + ensure!( + bind.ip().is_loopback() && origin == format!("http://{bind}"), + "Direct development chat requires an exact loopback HTTP origin" + ); + } + Ok(()) +} + async fn run(arguments: Arguments) -> Result<()> { let root = arguments.root.canonicalize()?; if arguments.init { return initialize(&root); } - let configuration = stack_config::Config::load(&root)?; - let bind = arguments.bind.unwrap_or(configuration.chat_bind()?); + let configuration = if arguments.listener.proxy { + stack_config::Config::from_environment()? + } else { + stack_config::Config::load(&root)? + }; + let bind = arguments + .listener + .bind + .unwrap_or(configuration.chat_bind()?); + if arguments.healthcheck { + return healthcheck(bind); + } let data = root.join("var/data/chat"); std::fs::create_dir_all(&data)?; std::fs::set_permissions(&data, std::os::unix::fs::PermissionsExt::from_mode(0o700))?; @@ -101,7 +174,7 @@ async fn run(arguments: Arguments) -> Result<()> { return maintenance::fingerprint(&store); } if let Some(expected) = arguments.reset_owner { - return maintenance::reset(&mut store, &expected, bind); + return maintenance::reset(&mut store, &expected, bind, arguments.listener.proxy); } if let Some(destination) = arguments.snapshot { store.snapshot(&destination)?; @@ -111,15 +184,8 @@ async fn run(arguments: Arguments) -> Result<()> { )?; return Ok(()); } - ensure!( - bind.ip().is_loopback(), - "Experimental server must bind to loopback" - ); let origin = configuration.value("CHAT_ORIGIN")?; - ensure!( - origin == format!("http://{bind}"), - "CHAT_ORIGIN must match the loopback CHAT_BIND or --bind address" - ); + validate_origin(&configuration, bind, arguments.listener.proxy)?; let setup = configuration .value("CHAT_SETUP_TOKEN") .context("Run --init to configure chat")?; @@ -127,9 +193,12 @@ async fn run(arguments: Arguments) -> Result<()> { let listener = tokio::net::TcpListener::bind(bind).await?; writeln!( std::io::stdout().lock(), - "Experimental chat: {origin} (owner setup: /#setup)" + "Chat ready: {origin} (owner setup: /#setup)" )?; - http::serve(listener, state, &root, configuration.public_values(None)?).await + let local = (arguments.listener.proxy && origin.starts_with("http://")) + .then(|| configuration.port("LOCAL_HTTP_PORT")) + .transpose()?; + http::serve(listener, state, &root, configuration.public_values(local)?).await } #[tokio::main] diff --git a/crates/chat-server/src/maintenance.rs b/crates/chat-server/src/maintenance.rs index eac689d..d416d73 100644 --- a/crates/chat-server/src/maintenance.rs +++ b/crates/chat-server/src/maintenance.rs @@ -45,13 +45,24 @@ pub(crate) fn fingerprint(store: &Store) -> Result<()> { Ok(()) } -pub(crate) fn reset(store: &mut Store, expected: &str, address: SocketAddr) -> Result<()> { - ensure!( - address.ip().is_loopback(), - "Reset must use the local chat address" - ); - let _port = - TcpListener::bind(address).context("Stop the chat server before resetting its owner")?; +pub(crate) fn reset( + store: &mut Store, + expected: &str, + address: SocketAddr, + proxy: bool, +) -> Result<()> { + let _port = if proxy { + None + } else { + ensure!( + address.ip().is_loopback(), + "Reset must use the local chat address" + ); + Some( + TcpListener::bind(address) + .context("Stop the chat server before resetting its owner")?, + ) + }; store.reset_owner(expected)?; writeln!( std::io::stdout().lock(), diff --git a/crates/chat-server/tests/browser.mjs b/crates/chat-server/tests/browser.mjs index e12c3da..b0a9041 100644 --- a/crates/chat-server/tests/browser.mjs +++ b/crates/chat-server/tests/browser.mjs @@ -15,23 +15,88 @@ import assert from 'node:assert/strict'; import { createServer } from 'node:http'; -import { mkdtemp, readFile, writeFile, symlink, rm, mkdir, rename } from 'node:fs/promises'; +import { mkdtemp, readFile, writeFile, symlink, rm, mkdir, rename, stat, readdir } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { resolve, join } from 'node:path'; import { randomBytes } from 'node:crypto'; -import { spawn, execFileSync, spawnSync } from 'node:child_process'; +import { spawn, execFileSync, spawnSync, execFile } from 'node:child_process'; +import { promisify } from 'node:util'; import { chromium, firefox } from '../../../.tools/browser/node_modules/playwright/index.mjs'; -const root = await mkdtemp(join(tmpdir(), 'stack-chat-e2e-')); -const token = randomBytes(32).toString('hex'); -const origin = `http://127.0.0.1:${process.env.CHAT_TEST_PORT || '9081'}`; +const run = promisify(execFile); +const packaged = Boolean(process.env.CHAT_TEST_ROOT); +let root = packaged ? resolve(process.env.CHAT_TEST_ROOT) : await mkdtemp(join(tmpdir(), 'stack-chat-e2e-')); +const initialRoot = root; +const token = packaged + ? (await readFile(join(root,'.env'),'utf8')).split('\n').find(line => line.startsWith('CHAT_SETUP_TOKEN=')).slice('CHAT_SETUP_TOKEN='.length) + : randomBytes(32).toString('hex'); +const origin = process.env.CHAT_TEST_ORIGIN || `http://127.0.0.1:${process.env.CHAT_TEST_PORT || '9081'}`; +if (packaged) { + const fixture = await readFile(join(root,'.env'),'utf8'); + const address = new URL(origin); + assert.equal(address.protocol,'https:'); + assert.ok(address.hostname.endsWith('.localhost'),'Packaged tests require a disposable .localhost origin'); + assert.ok(fixture.split('\n').includes('WEB_BIND=127.0.0.1'),'Packaged tests require loopback-only publishing'); +} const password = 'browser-test-only-unique-passphrase'; -await writeFile(join(root, '.env'), `CHAT_ORIGIN=${origin}\nCHAT_SETUP_TOKEN=${token}\n`, {mode:0o600}); -for (const path of ['crates','target','docs','web','LICENSE']) await symlink(resolve(path), join(root,path)); -await mkdir(join(root,'var/build'), {recursive:true}); +const testHome = await mkdtemp(join(tmpdir(),'stack-chat-test-')); +const gpgHome = join(testHome,'gnupg'); +let restorations = 0; +if (packaged) { + await mkdir(gpgHome,{mode:0o700}); + await run('gpg',['--no-options','--homedir',gpgHome,'--batch','--pinentry-mode','loopback','--passphrase','','--quick-generate-key','Stack disposable recovery fixture','rsa2048','encr','1d']); +} else { + await writeFile(join(root, '.env'), `CHAT_ORIGIN=${origin}\nCHAT_SETUP_TOKEN=${token}\n`, {mode:0o600}); + for (const path of ['crates','target','docs','web','LICENSE']) await symlink(resolve(path), join(root,path)); + await mkdir(join(root,'var/build'), {recursive:true}); +} +async function stack(...args) { + return run(join(root,'bin/stack'),args,{cwd:root,env:{...process.env,GNUPGHOME:gpgHome},maxBuffer:8*1024*1024,timeout:300000}); +} +async function restorePackage() { + const listed = await run('gpg',['--no-options','--homedir',gpgHome,'--with-colons','--list-keys']); + const fingerprint = listed.stdout.split('\n').find(line => line.startsWith('fpr:')).split(':')[9]; + const key = join(testHome,'recipient.asc'); + const exported = await run('gpg',['--no-options','--homedir',gpgHome,'--armor','--export',fingerprint]); + await writeFile(key,exported.stdout); + const backup = await stack('backup','--include-images','--recipient',fingerprint,'--key',key); + const archive = backup.stdout.match(/Encrypted export: (.+)/)?.[1]; + assert.ok(archive,backup.stdout); + assert.equal((await stat(archive)).mode & 0o777,0o600); + assert.equal((await stat(archive + '.sha256')).mode & 0o777,0o600); + await stopServer(); + const destination = `${initialRoot}-restored-${++restorations}`; + await stack('restore',archive,destination); + root = destination; + assert.equal(await readFile(join(root,'.env'),'utf8'),await readFile(join(initialRoot,'.env'),'utf8')); + for (const name of ['target','.tools']) assert.equal(spawnSync('test',['-e',join(root,name)]).status,1); + await launchServer(); + console.log(`PASS: compiler-free HTTPS stack recovered from encrypted export ${restorations}.`); +} let server; let serverErrors = ''; +async function verifyGateway() { + const url = new URL(origin); + const args = ['--noproxy','*','--silent','--show-error','--fail']; + if (url.hostname.endsWith('.localhost')) { + const state = JSON.parse(await readFile(join(root,'var/state.json'),'utf8')); + const certificate = join(testHome,'gateway-ca.crt'); + await run('podman',['--root',join(root,'var/podman/storage'),'--runroot',join(root,'var/podman/run'),'cp',`${state.project}-caddy:/data/caddy/pki/authorities/local/root.crt`,certificate]); + args.push('--cacert',certificate,'--resolve',`${url.hostname}:${url.port}:127.0.0.1`); + } + const health = await run('curl',[...args,origin + '/healthz']); + assert.equal(health.stdout,'ok\n'); +} async function launchServer() { + if (packaged) { + await stack('up','--production'); + let failure; + for (let attempt = 0; attempt < 20; attempt += 1) { + try { await verifyGateway(); return; } + catch (error) { failure = error; await new Promise(done => setTimeout(done,500)); } + } + throw failure; + } server = spawn(resolve('target/debug/stack-chat-server'), ['--root',root,'--bind',new URL(origin).host], {stdio:['ignore','pipe','pipe']}); server.stderr.on('data', bytes => { serverErrors += bytes.toString(); }); await Promise.race([ @@ -40,6 +105,7 @@ async function launchServer() { ]); } async function stopServer() { + if (packaged) { await stack('down'); return; } if (server.exitCode !== null || server.signalCode !== null) return; const stopped = new Promise(done => server.once('exit',done)); server.kill('SIGINT'); @@ -50,10 +116,10 @@ const libre = process.env.CHAT_LIBREJS === '1'; let persistent; let browser; if (libre) { - const profile = join(root,'firefox-profile'); + const profile = join(testHome,'firefox-profile'); await mkdir(join(profile,'extensions'),{recursive:true}); await writeFile(join(profile,'extensions','jid1-KtlZuoiikVfFew@jetpack.xpi'),await readFile(resolve('.tools/librejs.xpi'))); - persistent = await firefox.launchPersistentContext(profile, {headless:true,acceptDownloads:true,firefoxUserPrefs:{ + persistent = await firefox.launchPersistentContext(profile, {headless:true,acceptDownloads:true,ignoreHTTPSErrors:packaged,firefoxUserPrefs:{ 'extensions.autoDisableScopes':0,'extensions.enabledScopes':15,'extensions.startupScanScopes':15, 'extensions.update.enabled':false,'app.update.auto':false,'datareporting.healthreport.uploadEnabled':false, 'browser.sessionstore.resume_from_crash':false, @@ -66,11 +132,18 @@ const external = []; const errors = []; const bodies = []; async function page() { - const context = persistent || await browser.newContext({acceptDownloads:true}); + const context = persistent || await browser.newContext({acceptDownloads:true,ignoreHTTPSErrors:packaged}); contexts.push(context); const page = await context.newPage(); page.setDefaultTimeout(90000); page.on('pageerror', error => errors.push(error.message)); + const navigate = page.goto.bind(page); + page.goto = async (...args) => { + for (let attempt = 0; ; attempt += 1) { + try { return await navigate(...args); } + catch (error) { if (attempt >= 2 || !error.message.includes('ERR_NETWORK_CHANGED')) throw error; } + } + }; page.on('request', request => { if (!request.url().startsWith(origin + '/')) external.push(request.url()); @@ -90,6 +163,13 @@ async function create(page, name, owner = false) { await page.click('#create-form button'); const download = await downloaded; const bytes = await readFile(await download.path()); + const envelope = JSON.parse(bytes.toString()); + assert.ok(envelope.format.startsWith('stack-chat-file/v2;')); + assert.ok(bytes.toString().startsWith('{\n "format"')); + assert.ok(Array.isArray(envelope.ciphertext)); + assert.ok(envelope.ciphertext.length > 1); + assert.ok(envelope.ciphertext.slice(0,-1).every(chunk => chunk.length === 80)); + assert.ok(envelope.ciphertext.at(-1).length > 0 && envelope.ciphertext.at(-1).length <= 80); await waitStatus(page,'Reopen your downloaded'); assert.equal(await page.locator('#chat').isVisible(), false); assert.equal(bodies.filter(body => body.op === 'authenticate').length,previousAuthentications); @@ -129,7 +209,7 @@ try { if (libre) await verifyLibreJS(); const owner = await page(); const response = await owner.goto(origin); - await owner.locator('#owner-choice').waitFor({state:'visible'}); + await owner.locator('#owner-choice').waitFor({state:'visible'}).catch(async error => { throw new Error(`${error.message}; status=${await owner.locator('#status').textContent()}`); }); await owner.click('#owner-choice'); assert.match(response.headers()['cache-control'], /no-store/); assert.match(response.headers()['content-security-policy'], /frame-ancestors 'none'/); @@ -211,12 +291,15 @@ try { await visitor.click('#history-form button'); await waitStatus(visitor,'No message history loaded'); assert.equal(await visitor.locator('#messages li').count(),0); - const snapshot = join(root,'snapshot.sqlite'); - execFileSync(resolve('target/debug/stack-chat-server'),['--root',root,'--snapshot',snapshot],{stdio:'pipe'}); - await stopServer(); - await rm(join(root,'var/data/chat/chat.sqlite')); - await rename(snapshot,join(root,'var/data/chat/chat.sqlite')); - await launchServer(); + if (packaged) await restorePackage(); + else { + const snapshot = join(root,'snapshot.sqlite'); + execFileSync(resolve('target/debug/stack-chat-server'),['--root',root,'--snapshot',snapshot],{stdio:'pipe'}); + await stopServer(); + await rm(join(root,'var/data/chat/chat.sqlite')); + await rename(snapshot,join(root,'var/data/chat/chat.sqlite')); + await launchServer(); + } await visitor.reload(); await waitEntry(visitor); await visitor.click('#open-choice'); @@ -279,11 +362,15 @@ try { for (const forbidden of ['file','password','secret','name','text']) assert.ok(!(forbidden in body),`Leaked ${forbidden}`); assert.ok(!JSON.stringify(body).includes(malicious)); } - const database = await readFile(join(root,'var/data/chat/chat.sqlite')); - for (const plaintext of [malicious,'A friend','Reply from owner','Safe retry after a lost response','Batch fixture 51',password]) assert.ok(!database.includes(Buffer.from(plaintext)),`Plaintext persisted: ${plaintext}`); + for (const filename of (await readdir(join(root,'var/data/chat'))).filter(name => name.startsWith('chat.sqlite'))) { + const database = await readFile(join(root,'var/data/chat',filename)); + for (const plaintext of [malicious,'A friend','Reply from owner','Safe retry after a lost response','Batch fixture 51',password]) assert.ok(!database.includes(Buffer.from(plaintext)),`Plaintext persisted in ${filename}`); + } const blocked = await visitor.request.post(origin + '/api',{data:{op:'config'},headers:{Origin:'https://foreign.invalid'}}); assert.equal(blocked.status(),403); - const administrative = (...args) => spawnSync(resolve('target/debug/stack-chat-server'),['--root',root,'--bind',new URL(origin).host,...args],{encoding:'utf8'}); + const administrative = (...args) => packaged + ? spawnSync(join(root,'bin/stack'),['chat',...args],{cwd:root,encoding:'utf8'}) + : spawnSync(resolve('target/debug/stack-chat-server'),['--root',root,'--bind',new URL(origin).host,...args],{encoding:'utf8'}); const registered = administrative('--owner-fingerprint'); assert.equal(registered.status,0); const fingerprint = registered.stdout.trim(); @@ -317,7 +404,7 @@ try { assert.equal(serverErrors,''); if (process.env.CHAT_SCREENSHOT) await visitor.screenshot({path:resolve('.tools/chat-preview.png'),fullPage:true}); if (libre) { - const extensions = JSON.parse(await readFile(join(root,'firefox-profile','extensions.json'),'utf8')); + const extensions = JSON.parse(await readFile(join(testHome,'firefox-profile','extensions.json'),'utf8')); const addon = extensions.addons.find(addon => addon.id === 'jid1-KtlZuoiikVfFew@jetpack'); assert.ok(addon?.active,'LibreJS must actually be active'); console.log(`LibreJS ${addon.version} is active in Firefox; complete chat workflow succeeded.`); @@ -327,5 +414,7 @@ try { clearTimeout(deadline); await browser.close(); await stopServer(); - await rm(root,{recursive:true,force:true}); + if (!packaged) await rm(root,{recursive:true,force:true}); + else await run('gpgconf',['--homedir',gpgHome,'--kill','all']); + await rm(testHome,{recursive:true,force:true}); } diff --git a/crates/chat-server/tests/progress.mjs b/crates/chat-server/tests/progress.mjs new file mode 100644 index 0000000..0ee5614 --- /dev/null +++ b/crates/chat-server/tests/progress.mjs @@ -0,0 +1,127 @@ +// 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/>. + +import assert from 'node:assert/strict'; +import { createServer } from 'node:http'; +import { readFile, writeFile, mkdir, mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join, resolve } from 'node:path'; +import { chromium, firefox } from '../../../.tools/browser/node_modules/playwright/index.mjs'; + +const libre = process.env.CHAT_LIBREJS === '1'; +const profile = await mkdtemp(join(tmpdir(),'stack-chat-progress-')); +const bytes = await readFile(resolve('target/wasm32-wasip1/release/stack-chat-crypto.wasm')); +let mode = 'known'; +let transfer; +let configuration; +let configReady; +let signalConfig; +let origin; +const errors = []; +const server = createServer(async (request,response) => { + response.setHeader('Cache-Control','no-store'); + if (request.url === '/crypto.wasm') { + if (mode === 'failure') { response.writeHead(503); response.end(); return; } + response.setHeader('Content-Type','application/wasm'); + if (mode !== 'unknown') response.setHeader('Content-Length',bytes.length); + transfer = response; + response.write(bytes.subarray(0,1048576)); + return; + } + if (request.url === '/api') { configuration = response; signalConfig(); return; } + const assets = { '/':'crates/chat-server/web/index.html','/app.js':'crates/chat-server/web/app.js','/style.css':'crates/chat-server/web/style.css' }; + const path = assets[request.url]; + if (!path) { response.writeHead(404); response.end(); return; } + const mime = request.url === '/' ? 'text/html' : request.url.endsWith('.js') ? 'text/javascript' : 'text/css'; + response.setHeader('Content-Type',mime); + let source = await readFile(resolve(path),'utf8'); + if (request.url === '/') source = source.replaceAll('{{SITE_ORIGIN}}',origin); + response.end(source); +}); +await new Promise(done => server.listen(0,'127.0.0.1',done)); +origin = `http://127.0.0.1:${server.address().port}`; +let browser; +let context; +if (libre) { + await mkdir(join(profile,'extensions')); + const addon = await readFile(resolve('.tools/librejs.xpi')); + await writeFile(join(profile,'extensions','jid1-KtlZuoiikVfFew@jetpack.xpi'),addon); + context = await firefox.launchPersistentContext(profile,{headless:true,firefoxUserPrefs:{'extensions.autoDisableScopes':0,'extensions.enabledScopes':15,'extensions.startupScanScopes':15,'extensions.update.enabled':false}}); + browser = {close:() => context.close()}; +} else { + browser = await chromium.launch({headless:true,channel:'chromium'}); + context = await browser.newContext(); +} +async function open(selected) { + mode = selected; + configuration = undefined; + configReady = new Promise(done => { signalConfig = done; }); + const page = await context.newPage(); + page.on('pageerror',error => errors.push(error.message)); + await page.clock.install(); + await page.goto(origin); + return page; +} +async function waitStatus(page,part) { + await page.waitForFunction(value => document.getElementById('status').textContent.includes(value),part); + return page.locator('#status').textContent(); +} +async function complete(page,offset) { + transfer.end(bytes.subarray(offset)); + await waitStatus(page,'Connecting to chat'); + await configReady; + configuration.setHeader('Content-Type','application/json'); + configuration.end(JSON.stringify({origin,owner:''})); + await page.locator('#owner-choice').waitFor({state:'visible'}); + assert.equal(await page.locator('#status').textContent(),''); + assert.equal(await page.evaluate(async () => (await wasm).byteLength),bytes.length); + await page.close(); +} +try { + const known = await open('known'); + assert.match(await waitStatus(known,'1.0 /'),/\[#{3}-{17}\] 19%/); + await known.clock.fastForward(44000); + transfer.write(bytes.subarray(1048576,2097152)); + assert.match(await waitStatus(known,'2.0 /'),/39%/); + await known.screenshot({path:resolve('.tools/chat-download-progress.png'),fullPage:true}); + await known.clock.fastForward(44000); + assert.ok(!(await known.locator('#status').textContent()).includes('stalled')); + await complete(known,2097152); + const unknown = await open('unknown'); + assert.equal(await waitStatus(unknown,'1.0 MiB received'),'Downloading cryptographic module... 1.0 MiB received'); + await complete(unknown,1048576); + const failure = await open('failure'); + await waitStatus(failure,'HTTP 503'); + assert.equal(await failure.locator('#owner-choice').isVisible(),false); + await failure.close(); + const stalled = await open('known'); + await waitStatus(stalled,'1.0 /'); + await stalled.clock.fastForward(45001); + await waitStatus(stalled,'stalled for 45 seconds'); + assert.match(await stalled.locator('#status').textContent(),/reload/); + assert.equal(await stalled.locator('#owner-choice').isVisible(),false); + await stalled.close(); + assert.deepEqual(errors,[]); + if (libre) { + const state = JSON.parse(await readFile(join(profile,'extensions.json'),'utf8')); + assert.ok(state.addons.find(addon => addon.id === 'jid1-KtlZuoiikVfFew@jetpack')?.active); + } + console.log(`PASS: ${libre ? 'Firefox/LibreJS' : 'Chromium'} streamed percentage/bar/MiB, unknown length, exact byte recovery, slow continuous transfer, idle timeout, HTTP failure, and connection phase.`); +} finally { + await browser.close(); + server.closeAllConnections(); + await new Promise(done => server.close(done)); + await rm(profile,{recursive:true,force:true}); +} diff --git a/crates/chat-server/web/app.js b/crates/chat-server/web/app.js index 3c97f66..4b0da63 100644 --- a/crates/chat-server/web/app.js +++ b/crates/chat-server/web/app.js @@ -356,12 +356,58 @@ element('lock').addEventListener('click', () => { if (session) api({ op: 'lock' window.addEventListener('pagehide', clearState); window.addEventListener('pageshow', event => { if (event.persisted) clearState(); }); +async function downloadCrypto() { + const controller = new AbortController(); + let timer; + let reader; + const refreshTimeout = () => { + clearTimeout(timer); + timer = setTimeout(() => controller.abort(), 45000); + }; + try { + refreshTimeout(); + const response = await fetch('/crypto.wasm', { cache: 'no-store', credentials: 'omit', signal: controller.signal }); + if (!response.ok) throw new Error(`Cryptographic module download failed (HTTP ${response.status}). Reload to try again.`); + const length = response.headers.get('content-encoding') ? 0 : Number(response.headers.get('content-length')); + const total = Number.isSafeInteger(length) && length > 0 ? length : 0; + const chunks = []; + let received = 0; + const progress = () => { + const loaded = (received / 1048576).toFixed(1); + if (total) { + const percent = Math.min(100, Math.floor(received * 100 / total)); + const filled = Math.floor(percent / 5); + status(`Downloading cryptographic module... [${'#'.repeat(filled)}${'-'.repeat(20 - filled)}] ${percent}% (${loaded} / ${(total / 1048576).toFixed(1)} MiB)`); + } else status(`Downloading cryptographic module... ${loaded} MiB received`); + }; + progress(); + reader = response.body.getReader(); + for (;;) { + const { done, value } = await reader.read(); + if (done) break; + refreshTimeout(); + chunks.push(value); + received += value.byteLength; + progress(); + } + if (!received) throw new Error('The cryptographic module download was empty. Reload to try again.'); + const bytes = new Uint8Array(received); + let offset = 0; + for (const chunk of chunks) { bytes.set(chunk, offset); offset += chunk.byteLength; } + return bytes.buffer; + } catch (error) { + if (controller.signal.aborted) throw new Error('Cryptographic module download stalled for 45 seconds. Check your connection and reload to try again.'); + throw error; + } finally { + clearTimeout(timer); + reader?.releaseLock(); + } +} + async function start() { - wasm = fetch('/crypto.wasm', { cache: 'no-store', credentials: 'omit' }).then(response => { - if (!response.ok) throw new Error('Build the WASI cryptographic module first'); - return response.arrayBuffer(); - }); + wasm = downloadCrypto(); await wasm; + status('Cryptographic module downloaded. Connecting to chat...'); config = await api({ op: 'config' }); if (config.origin !== location.origin) throw new Error('Server origin mismatch'); show('owner-entry'); diff --git a/crates/chat-server/web/index.html b/crates/chat-server/web/index.html index 79026b0..70f3ebb 100644 --- a/crates/chat-server/web/index.html +++ b/crates/chat-server/web/index.html @@ -41,7 +41,7 @@ along with this program. If not, see <https://www.gnu.org/licenses/>. <div class="ascii-panel introduction"> <div class="ascii-bar" aria-hidden="true"><span>+</span><span class="ascii-rule">--------------------------------------------------------------------------------</span><span>+</span></div> <div class="panel-body"> -<p class="build-status">EXPERIMENTAL / LOCAL TEST</p> +<p class="build-status">EXPERIMENTAL</p> <p>Keep your file and password. There is no account recovery.<br><a href="/LIMITATIONS.txt">Read the limitations</a> before using this experimental messenger.</p> </div> <div class="ascii-bar" aria-hidden="true"><span>+</span><span class="ascii-rule">--------------------------------------------------------------------------------</span><span>+</span></div> @@ -100,7 +100,7 @@ along with this program. If not, see <https://www.gnu.org/licenses/>. <noscript>This chat requires free JavaScript and WebAssembly for local cryptography. The main website remains JavaScript-free.</noscript> <footer class="source-footer" aria-label="Licensing and source"> <a class="project-license" href="https://www.gnu.org/licenses/agpl-3.0.html">[ LICENSE ]</a> -<a class="dependency-licenses" href="/THIRD-PARTY-LICENSES.html">[ THIRD PARTY LICENSES ]</a> +<a class="dependency-licenses" href="{{SITE_ORIGIN}}/THIRD-PARTY-LICENSES.html">[ THIRD PARTY LICENSES ]</a> <a href="/SOURCE.tar.gz">[ SOURCE CODE ]</a> </footer> </main> diff --git a/crates/chat-server/web/javascript.html b/crates/chat-server/web/javascript.html index 205a103..644e5a1 100644 --- a/crates/chat-server/web/javascript.html +++ b/crates/chat-server/web/javascript.html @@ -24,5 +24,5 @@ along with this program. If not, see <https://www.gnu.org/licenses/>. <tr><td><a href="/wasi.mjs">wasi.mjs</a></td><td><a href="https://www.gnu.org/licenses/agpl-3.0.html">GNU-AGPL-3.0</a></td><td><a href="/SOURCE.tar.gz">Complete corresponding source</a></td></tr> </tbody></table></div> <p>The WebAssembly module is compiled from the Rust workspace and unmodified OpenSSL 3.6.5, under their respective free software licenses. The source archive includes first-party sources, Cargo dependencies, the OpenSSL source archive, build scripts, and notices. General-purpose Rust and WASI SDK tools are documented with verified download locations in CHAT-OPENSSL-MIGRATION.txt.</p> -<p><a href="/THIRD-PARTY-LICENSES.html">Third-party licenses</a> / <a href="/SOURCE.tar.gz">Source archive</a> / <a href="/">Return</a></p> +<p><a href="{{SITE_ORIGIN}}/THIRD-PARTY-LICENSES.html">Third-party licenses</a> / <a href="/SOURCE.tar.gz">Source archive</a> / <a href="/">Return</a></p> </main></body></html> diff --git a/crates/config/src/lib.rs b/crates/config/src/lib.rs index 0a648a2..ce509b6 100644 --- a/crates/config/src/lib.rs +++ b/crates/config/src/lib.rs @@ -32,6 +32,7 @@ const HOSTS: &[&str] = &[ "SEARCH_HOST", "WKD_HOST", "CHAT_HOST", + "CHAT_LOCAL_HOST", ]; const PUBLIC: &[&str] = &[ "BASE_DOMAIN", @@ -40,6 +41,9 @@ const PUBLIC: &[&str] = &[ "SEARCH_HOST", "WKD_HOST", "CHAT_HOST", + "CHAT_LOCAL_HOST", + "WEB_BIND", + "CHAT_UPSTREAM", "HTTP_PORT", "HTTPS_PORT", "LOCAL_HTTP_PORT", @@ -103,6 +107,32 @@ impl Config { } else { BTreeMap::new() }; + Self::from_values(explicit) + } + + #[doc = "Loads only declared public settings and the chat token from a container environment."] + #[doc = "# Errors"] + #[doc = "Rejects invalid configuration without importing unrelated host secrets."] + pub fn from_environment() -> Result<Self> { + let mut explicit = BTreeMap::new(); + for key in PUBLIC + .iter() + .copied() + .chain(std::iter::once("CHAT_SETUP_TOKEN")) + { + if let Some(value) = std::env::var_os(key) { + explicit.insert( + key.to_owned(), + value + .into_string() + .map_err(|_| anyhow::anyhow!("Invalid {key} environment value"))?, + ); + } + } + Self::from_values(explicit) + } + + fn from_values(explicit: BTreeMap<String, String>) -> Result<Self> { let mut values = defaults()?; values.retain(|key, _| PUBLIC.contains(&key.as_str())); values.extend(explicit.clone()); @@ -160,9 +190,16 @@ impl Config { (1024..=65_534).contains(&self.port("LOCAL_HTTP_PORT")?), "LOCAL_HTTP_PORT must be between 1024 and 65534" ); - for key in ["CGIT_UPSTREAM", "SEARXNG_UPSTREAM"] { + for key in ["CGIT_UPSTREAM", "SEARXNG_UPSTREAM", "CHAT_UPSTREAM"] { ensure!(endpoint(self.value(key)?), "Invalid {key} in root .env"); } + self.value("WEB_BIND")? + .parse::<std::net::IpAddr>() + .context("Invalid WEB_BIND")?; + ensure!( + self.value("CHAT_LOCAL_HOST")?.ends_with(".localhost"), + "CHAT_LOCAL_HOST must be a dedicated .localhost name" + ); self.chat_bind()?; let origin = self.value("CHAT_ORIGIN")?; ensure!( @@ -222,9 +259,10 @@ impl Config { values.insert("GIT_ORIGIN".into(), "/git".into()); values.insert("SEARCH_ORIGIN".into(), "/search".into()); values.insert("WKD_ORIGIN".into(), String::new()); + values.insert("LOCAL_HTTP_PORT".into(), port.to_string()); values.insert( "CHAT_PUBLIC_ORIGIN".into(), - self.value("CHAT_ORIGIN")?.into(), + format!("http://{}:{port}", self.value("CHAT_LOCAL_HOST")?), ); } Ok(values) @@ -271,6 +309,9 @@ pub fn initialize_public(root: &Path) -> Result<()> { writeln!(appended, "{key}={value}")?; } } + if path.exists() { + fs::set_permissions(&path, fs::Permissions::from_mode(0o600))?; + } if appended.is_empty() { return Ok(()); } diff --git a/docs/CHAT-APPLICATION.txt b/docs/CHAT-APPLICATION.txt index 7e58051..28c576b 100644 --- a/docs/CHAT-APPLICATION.txt +++ b/docs/CHAT-APPLICATION.txt @@ -16,9 +16,10 @@ along with this program. If not, see <https://www.gnu.org/licenses/>. EXPERIMENTAL PRIVATE CHAT - Branch: feature/private-chat. This application is for local testing. - Production remains on main with its under-construction Message Me page. - No public chat service, TLS route, or new production identity was deployed. + Available on main. Direct development and a prebuilt Podman/HTTPS + deployment path are implemented. See CHAT-PRODUCTION.txt for the release, + production setup, and recovery procedure; feature work does not itself merge + main or activate the VPS. This is working experimental software, not an independently audited messenger. WHAT EXISTS @@ -28,6 +29,13 @@ WHAT EXISTS a capability-limited WASI host. Text is displayed through textContent. SearXNG and the other existing services are unchanged. + Startup shows an ASCII download bar, percentage, and received/total MiB + while fetching the cryptographic module. If its size is unavailable, it shows + received MiB. A 45-second gap without download progress aborts with a reload + instruction; slow transfers continue while bytes arrive. After download, + a separate status identifies the connection to chat. No browser cache or + storage was added. + Visitors choose CREATE NEW CHAT or OPEN CHAT FILE. Creation generates a purpose-specific identity locally and downloads its protected file. Reopening the exact downloaded file and entering its password is required before the @@ -49,7 +57,7 @@ WHAT EXISTS LOCAL PREREQUISITES AND COMMANDS - Start in the root of the stack repository on feature/private-chat. Native + Start in the root of the stack repository on main. Native development needs Rust, libclang, OpenSSL 3.5 or newer development libraries, a C compiler, pkg-config, make, Perl, curl, and tar. The checked configuration uses OpenSSL 3.6.5. The WASI build script currently targets an x86_64 GNU/Linux @@ -97,12 +105,11 @@ LOCAL PREREQUISITES AND COMMANDS Stop the development server with Control-C. A server restart invalidates all sessions but preserves identities, conversations, counts, and ciphertext. - The server currently refuses a non-loopback bind or an origin that does not - exactly match its development bind address. HTTP is allowed only for the - explicit loopback development origin. The intended production origin remains - https://chat.doan.luuduct.ai/. Production Podman packaging, TLS configuration, - operational review, and a main-branch merge remain separate work. Do not - expose this experimental development service through a public proxy. + The direct development server refuses a non-loopback bind or an origin that + does not exactly match its development bind address. Packaged production uses + a private container listener behind Caddy HTTPS, with an explicit external + CHAT_HOST/HTTPS_PORT origin. Forwarded request headers do not choose that + origin. Do not expose the direct development server through a public proxy. OWNER FILE LOSS AND BACKEND RESET @@ -116,9 +123,11 @@ OWNER FILE LOSS AND BACKEND RESET Run these commands locally at the stack root for the current development build. There is no reset operation in the HTTP API. Filesystem access to - the private chat directory is the administrative boundary. A future production - package must expose the same options through its prebuilt server binary; - do not install Rust on the VPS for these development commands. + the private chat directory is the administrative boundary. Production uses + ./bin/stack chat --owner-fingerprint and ./bin/stack chat --reset-owner + through an isolated maintenance container. Stop production with ./bin/stack + down first; restart with ./bin/stack up --production. No Rust is needed on + the VPS. See CHAT-PRODUCTION.txt before a destructive reset. Read the current public fingerprint, even if you have lost the private file: @@ -130,8 +139,8 @@ OWNER FILE LOSS AND BACKEND RESET cargo stack chat --reset-owner PASTE_CURRENT_OWNER_FINGERPRINT_HERE A fingerprint mismatch refuses without deleting data. The server and reset - process share a filesystem lock; reset also requires the loopback service - port to be free. This prevents resetting a running service, including a + process share a filesystem lock. Direct development additionally requires + the loopback service port to be free. This prevents resetting a running service, including a development process launched before the lock was added. Online snapshots and reading the public owner fingerprint remain available while chat is running. @@ -158,11 +167,24 @@ FILE AND MESSAGE CRYPTOGRAPHY a fixed purpose-only user ID. Names are not placed in public certificate IDs. The server rejects uploaded secret-key material and unsupported certificates. - The JSON identity file is an authenticated encrypted envelope. Its fixed - format identifier is authenticated as associated data: + New JSON identity files are indented and use an array of 80-character + Base64 ciphertext chunks; the final chunk may be shorter. JSON indentation, + quotes, and commas are additional to the ciphertext width. The reader joins + the chunks before Base64 decoding. No private fields become plaintext. + This is the application's own JSON envelope, not OpenPGP ASCII armor. + The new file-format identifier is authenticated as associated data: + + stack-chat-file/v2;argon2id-m65536-t3-p1;aes256-gcm + + Existing compact v1 files with one ciphertext string remain supported: stack-chat-file/v1;argon2id-m65536-t3-p1;aes256-gcm + v1 uses its original identifier as associated data. Changing a version label + or converting its field shape does not convert a file: the authentication tag + binds the version. No existing file needs rewriting; old readers do not support + new v2 exports. Cryptographic algorithms and KDF parameters are unchanged. + Argon2id version 0x13 uses 65536 KiB, three passes, one lane, and a 32-byte derived key. A fresh CSPRNG salt of 16 bytes and AES-256-GCM nonce of 12 bytes are generated for each file; the authentication tag is 16 bytes. These KDF @@ -287,7 +309,8 @@ BACKUP, EXPORT, AND RESTORE SQLite's online backup API for chat.sqlite. It does not copy a live SQLite file or journal with ordinary file-copy code. It can take a consistent chat snapshot while the local chat server runs. Existing Podman data still follows - the stack's previous pause/copy/resume process. A database snapshot is atomic + the stack's pause/copy/resume process; the chat container stays unpaused. + Separate snapshots cover both chat and chat-local. A snapshot is atomic for chat data; unrelated services do not share a global transaction with it. The backup includes the single root .env and must remain private/encrypted. @@ -339,6 +362,11 @@ LICENSES, SOURCE, AND VERIFICATION The local check used Alpine musl 1.2.6 headers with the host C compiler for the C object build and Rust's self-contained musl runtime for final linking. + The rendered dependency-license page is centralized on the main website at + https://doan.luuduct.ai/THIRD-PARTY-LICENSES.html. Chat links point there; its + previous local license-page URL redirects there. cgit keeps ordinary source + and raw-file views without a custom HTML renderer. + First-party software is GNU AGPL version 3 or later. Legal license headers are the exception to the no-comments convention. Documentation is plaintext. JavaScript has GNU LibreJS @license/@license-end markers and a linked @@ -368,14 +396,17 @@ LICENSES, SOURCE, AND VERIFICATION cargo stack check PLAYWRIGHT_BROWSERS_PATH="$PWD/.tools/browsers" node crates/chat-crypto/tests/browser.mjs PLAYWRIGHT_BROWSERS_PATH="$PWD/.tools/browsers" node crates/chat-server/tests/browser.mjs + PLAYWRIGHT_BROWSERS_PATH="$PWD/.tools/browsers" node crates/chat-server/tests/progress.mjs Browser automation additionally needs Node, Playwright under .tools/browser, and its browser downloads under .tools/browsers. These are development tools excluded from releases. The fixtures use temporary isolated installation roots with one temporary .env each, and remove their private data on completion. They listen on loopback port 9081 by default (override with CHAT_TEST_PORT), - leaving the normal local preview on 9080 available. They do not initialize - your real owner identity or touch production. + leaving the normal local preview on 9080 available. The optional packaged + HTTPS variant is documented in CHAT-PRODUCTION.txt and retains its disposable + installation roots for inspection. Tests do not initialize the real owner + identity or touch the VPS. LibreJS verification uses the Mozilla-distributed GNU LibreJS 7.21.1 in Playwright Firefox 157.0, without whitelisting the application. The full chat @@ -402,9 +433,11 @@ LICENSES, SOURCE, AND VERIFICATION live database snapshot restoration with both original identity files, owner setup/access controls, refusing reset while running or with a mismatched fingerprint, successful backend reset and fresh registration, rejecting old - visitor key pins, in-flight - lock, back/forward restoration, and checks for plaintext persistence or private - credential uploads. + visitor key pins, in-flight lock, back/forward restoration, and checks for + plaintext persistence in the database/journals or private credential uploads. + The packaged variant additionally verifies certificate trust/hostname through + curl, restrictive export permissions, and full encrypted recovery with saved + images and no build directories inside the recovered installation. Native tests cover authorization, challenge reuse, expiry, locked sessions, quotas, idempotency, and SQLite snapshot recovery. See CHAT-DESIGN.txt and CHAT-OPENSSL-MIGRATION.txt for the design and historical backend review. diff --git a/docs/CHAT-DESIGN.txt b/docs/CHAT-DESIGN.txt index d85ad44..fa2ad1b 100644 --- a/docs/CHAT-DESIGN.txt +++ b/docs/CHAT-DESIGN.txt @@ -16,10 +16,10 @@ along with this program. If not, see <https://www.gnu.org/licenses/>. PRIVATE CHAT DESIGN AND LIMITATIONS - Status: experimental local implementation on feature/private-chat. + Status: experimental implementation on feature/private-chat. CHAT-APPLICATION.txt records the implemented formats, behavior, commands, tests, and current limitations. This document preserves the original design. - Nothing is deployed to production. + CHAT-PRODUCTION.txt records the later packaged deployment path. USER EXPERIENCE diff --git a/docs/CHAT-OPENSSL-MIGRATION.txt b/docs/CHAT-OPENSSL-MIGRATION.txt index 17149ac..9e63730 100644 --- a/docs/CHAT-OPENSSL-MIGRATION.txt +++ b/docs/CHAT-OPENSSL-MIGRATION.txt @@ -17,8 +17,9 @@ along with this program. If not, see <https://www.gnu.org/licenses/>. OPENSSL BACKEND MIGRATION RECORD Date: 2026-10-30. Branch: feature/private-chat. - Scope: a compatibility and dependency-security experiment, not a messenger. - Main and production are unchanged. No real identity files are created. + Scope at this checkpoint: a compatibility and dependency-security experiment. + The later messenger and packaged deployment are described separately in + CHAT-APPLICATION.txt and CHAT-PRODUCTION.txt. Tests use disposable identities. DECISION AND FIXED ALGORITHMS diff --git a/docs/CHAT-PRODUCTION.txt b/docs/CHAT-PRODUCTION.txt new file mode 100644 index 0000000..c1a2402 --- /dev/null +++ b/docs/CHAT-PRODUCTION.txt @@ -0,0 +1,247 @@ +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/>. + + +PRODUCTION CHAT INTEGRATION + + Implemented on main after merging the original feature/private-chat branch. + Building and testing a release does not activate the VPS. This is experimental software without an + independent cryptographic or application security audit. Read the limitations + in CHAT-APPLICATION.txt before using it for private conversations. + + Four services run through rootless Podman: Caddy, cgit, SearXNG, and chat. + The release contains statically linked x86_64 GNU/Linux management and chat + binaries, the WASM browser module, exact corresponding sources, and saved + native container images. The chat image starts FROM scratch: no compiler, + shell, package manager, or first-party Python. Its root filesystem is read + only, capabilities are dropped, and only its private data directory is writable. + + Caddy terminates HTTPS at CHAT_HOST and redirects www.CHAT_HOST while keeping + the requested path. CHAT_UPSTREAM defaults to chat:8080 on the private + container network. Chat publishes no host ports. Production publishes only + the gateway HTTP and TCP/UDP HTTPS ports. Existing host SSH policy is separate. + + The gateway supplies an explicit external origin selected from root .env. + The server accepts that exact HTTPS origin or the dedicated local gateway + origin. Forwarded client headers never select the trusted origin. Origin + checks supplement cryptographic authentication; they are not authentication. + +BUILD ON THE WORKSTATION + + Current releases require an x86_64 GNU/Linux build host and matching target. + Use the pinned Rust toolchain, Podman, Git, GnuPG, a GCC-compatible C compiler, + Linux UAPI headers, libclang, pkg-config, make, Perl, curl, and tar. Native + workspace checks need OpenSSL 3.5+ development libraries. Browser testing + additionally needs Node, Playwright, Chromium, and Firefox. These tools remain + on the workstation; production does not need Cargo or a compiler. + + cargo stack release + + This runs formatting, strict Clippy, all workspace tests, rustdoc, cargo-deny, + and cargo-about. It then builds static native binaries, the WASI module, + corresponding sources, the chat/cgit images, and the complete image export. + THIRD-PARTY-LICENSES.html is regenerated from the full workspace before + packaging. Check the archive checksum and test recovery before deployment. + + infra/chat/build-native.sh verifies OpenSSL 3.6.5's original source archive: + a2157c2830efdec3788939b00c9b0638306d3f0bbb76dc4832ee503bb397df98 + It verifies Alpine musl-dev 1.2.6-r3 headers: + 310b51cff12e1d5ce5f135c558a82e2550427da5465bbd16c9ff9b05e63ee3d9 + Sources come from the official OpenSSL GitHub release and Alpine CDN. The + compiler uses isolated musl/GCC built-in headers plus host Linux UAPI headers; + glibc headers are excluded. OpenSSL is static, has no dynamic providers, and + seeds its RNG through getrandom. The native cryptographic compatibility probe + must pass after the build. WASI uses its separately verified SDK and OpenSSL + build recorded in CHAT-OPENSSL-MIGRATION.txt. No crypto algorithm was changed. + + Cached inputs live only under .tools/. Rebuild OpenSSL when its version, + source, or build flags change. An OpenSSL update requires reviewing upstream + security notices, updating every affected URL/hash, native and WASI rebuilds, + rerunning both browsers and cargo-deny, regenerating notices and exact source, + and repeating release recovery. Never approve a dependency advisory merely + to make the build pass. cargo-deny does not audit C/OpenSSL advisories. + + The source download includes first-party code, Cargo-vendored dependencies, + and unmodified OpenSSL source. Toolchain/sysroot development inputs are not + bundled as application sources. Saved images preserve the tested runtime. + An ARM host needs a separately supported native release; do not copy these + x86_64 binaries there and assume they will work. + +LOCAL MODES AND ORIGIN BINDING + + Direct Rust development: + cargo stack chat + http://127.0.0.1:9080/ + var/data/chat + + Container preview: + cargo stack dev + http://chat.localhost:8080/ + var/data/chat-local + + Production: + ./bin/stack up --production + https://CHAT_HOST/ with the configured HTTPS_PORT + var/data/chat + + These are distinct browser origins and require separate identity files. The + direct process and production deliberately use the same relative database + name on their respective machines. Do not switch an installation containing + valuable identities between direct development and production. Use a fresh + restored release directory for integration tests. Rebuild the release after + edits to refresh the packaged browser, server, sources, notices, and image. + + The single root .env holds public hostnames, ports, upstreams, backup settings, + SEARXNG_SECRET, and CHAT_SETUP_TOKEN. The example is public; actual .env is + ignored and never included in public releases or source downloads. Caddy keys + and chat databases are generated private runtime state under var/data. + Initialization preserves configured values and generates missing secrets. + +DEPLOY AND CREATE THE REAL OWNER FILE + + Use the release upload/activation instructions in OPERATIONS.txt. Extract + the new prebuilt management binary into var/incoming/bootstrap and invoke + it with --root pointing at the current installation. The older three-service + management binary cannot parse the new chat service schema; do not use that + older binary to activate this release. The new binary verifies the archive + and installs packaged chat artifacts before starting services. + The host needs rootless Podman prerequisites, user linger, low-port permission, + DNS A/AAAA records for CHAT_HOST and www.CHAT_HOST, and the normal 80/443 + firewall rules. Caddy obtains and renews public certificates automatically. + Public certificate validation requires standard reachable ACME ports. + + The systemd user service runs ./bin/stack up --production at boot and + ./bin/stack down on stop. Podman restart policies restart failed containers. + Neither mechanism compiles code or requests a registry pull for a complete + release on the matching architecture. Check all four services after activation: + + ./bin/stack status + + Check HTTPS, HTTP-to-HTTPS and www redirects, the homepage Services entry, + /LIMITATIONS.txt, /javascript.html, and /SOURCE.tar.gz on the chat origin. + The displayed limitations are CHAT-APPLICATION.txt. Chat has its own origin; + the main website remains JavaScript-free. Its Message Me links lead to chat. + + In the browser on the production HTTPS origin, choose OWNER SETUP. Read the + setup token from the production root .env locally; never publish it or send it + through chat. Choose a unique owner-file password, save the downloaded file, + reopen it, and unlock it. Store that file and password in KeePassXC and keep + a protected backup of the database. The setup token cannot replace this file. + Never copy a development owner file and silently change its pinned origin. + +BACKUP, RESTORE, AND KEY LOSS + + A complete encrypted application export: + ./bin/stack backup --include-images + + Download the .gpg and .sha256 files off the VPS. A backup on the same VPS is + vulnerable to loss of that VPS. Existing configured backup recipients apply. + This export includes the single .env, consistent SQLite snapshots for chat + and chat-local, Caddy certificate state, repositories, static binaries, WASM, + sources, and exact container images. SQLite uses its online backup API; chat + is left unpaused so a pending writer cannot block snapshot completion. + The database contains ciphertext and public keys, plus the metadata described + in CHAT-APPLICATION.txt. The owner private file and GnuPG secret key are not + server data; back them up separately. + + Restore to a new directory, verify, then explicitly start production mode: + ./bin/stack restore /path/export.tar.gz.gpg /path/stack-restored + cd /path/stack-restored + ./bin/stack up --production + + Restore defaults to local mode and changes container project names to avoid + accidentally replacing another installation. up --production selects the + production database and HTTPS origin again. Keep the same hostname/origin, + point DNS at the replacement host, and use unchanged owner/visitor files and + passwords. A new IP does not change an origin. A changed hostname or HTTPS + port does. Do not expose two restored copies simultaneously under one origin. + Server restart invalidates sessions; reopening identity files authenticates + again. Historical ciphertext and counters persist across restart and recovery. + + Losing the owner private file cannot be repaired by CHAT_SETUP_TOKEN. If its + backups are also lost, reset deliberately deletes every stored conversation + and message. It does not decrypt or recover anything. This backend operation + has no HTTP endpoint and requires filesystem/Podman access: + + ./bin/stack chat --owner-fingerprint + ./bin/stack down + ./bin/stack chat --reset-owner PASTE_CURRENT_OWNER_FINGERPRINT_HERE + ./bin/stack up --production + + A running server holds a filesystem lock; reset refuses until it stops. The + full fingerprint must match. Root .env and previous exports are preserved. + Create a new production owner file afterward. Old visitor files reject its + changed pinned owner key and need a new chat identity. A reset does not erase + copies already present in exports or external snapshots. + +VERIFICATION + + The browser harness supports packaged HTTPS installations without Rust or + target/ inside them. It creates disposable identities and a separate temporary + GnuPG key, takes a complete encrypted export with images during live chat, + restores into a new directory, starts the saved images, and reopens the same + files to decrypt history. It also exercises wrong passwords, safe text, + counters, 0-to-N history, 50-row batches, retry deduplication, origin rejection, + browser storage checks, and backend reset guards. It never resets real data. + + Example after restoring a release to a disposable staging directory and + configuring fixture .localhost hostnames, WEB_BIND=127.0.0.1, HTTP_PORT=8181, + HTTPS_PORT=8443, and LOCAL_HTTP_PORT=8180: + + ./bin/stack up --production + CHAT_TEST_ROOT=/absolute/disposable/stack CHAT_TEST_ORIGIN=https://chat.fixture.localhost:8443 PLAYWRIGHT_BROWSERS_PATH=.tools/browsers node crates/chat-server/tests/browser.mjs + + Run from the source checkout for the harness and Playwright installation. + Add CHAT_LIBREJS=1 for Firefox with the actual GNU LibreJS add-on. That test + verifies the add-on is active and blocks an unlicensed control script. + Disposable staging needs a fresh database for each complete run. + + Browser automation permits the fixture's private CA only for functional tests. + Separately verify its chain and hostname with curl --cacert and --resolve; + test the canonical route and www redirect without disabling verification. + Production uses ordinary trusted public certificates with no TLS bypass. + Passing these checks demonstrates recovery and functional compatibility; it + does not establish absence of attacks or constitute an independent audit. + +RECORDED RESULTS, 2026-10-31 + + The complete native workspace check passed: formatting, strict Clippy, + 14 tests, private rustdoc, cargo-deny with no ignored advisories, and + cargo-about generation. Static musl and WASI OpenSSL 3.6.5 probes passed + without changing the required RFC 9980 algorithms. + + Direct development passed in Chromium. Packaged HTTPS passed independently + in Chromium and Firefox with GNU LibreJS 7.21.1 active; its unlicensed-script + control was rejected. Both packaged tests recovered a complete encrypted + application export, including saved images, into a new directory, then + reopened the unchanged owner/visitor files and decrypted the history. + Export and checksum permissions were verified as 0600. + + Runtime inspection verified read-only root filesystem, zero effective + capabilities, no chat host ports, and unless-stopped restart policy. Killing + the disposable chat process with SIGKILL caused an automatic restart, and + its owner fingerprint persisted. The supplied systemd user unit passed + systemd-analyze verification; this did not reboot the real VPS. + + curl independently verified the fixture CA chain and hostname for canonical + chat, www redirect, and homepage. The homepage Services limitations link + resolved correctly. The source download matched the packaged source archive + byte-for-byte and contained only .env.example, without actual .env. + + Upgrade testing started the 2026-10-30 three-service release, then activated + the chat-enabled release through its new prebuilt management binary. All + four services started, existing data and SearXNG secret persisted, and the + new owner setup token was generated in the same root .env. These tests used + disposable local data; they did not register a real production owner. diff --git a/docs/CHAT-SECURITY-REVIEW.txt b/docs/CHAT-SECURITY-REVIEW.txt index 4567322..6257f3c 100644 --- a/docs/CHAT-SECURITY-REVIEW.txt +++ b/docs/CHAT-SECURITY-REVIEW.txt @@ -18,8 +18,9 @@ CHAT CRYPTOGRAPHY SECURITY REVIEW Date: 2026-10-30. Historical review of the initial Rust backend. The current OpenSSL migration is recorded in CHAT-OPENSSL-MIGRATION.txt. - Private-message use and production deployment are still not ready. - Branch: feature/private-chat. Production and main remain unchanged. + At that checkpoint, private-message use and deployment were not ready. + This historical review is not the current implementation status; see + CHAT-APPLICATION.txt and CHAT-PRODUCTION.txt for the implemented application. FIXED REQUIREMENT diff --git a/docs/CONFIGURATION.txt b/docs/CONFIGURATION.txt index 9660e20..fc18c41 100644 --- a/docs/CONFIGURATION.txt +++ b/docs/CONFIGURATION.txt @@ -33,10 +33,17 @@ PUBLIC SETTINGS Root web/WKD domain. www aliases are derived from each configured host. SITE_HOST, GIT_HOST, SEARCH_HOST, WKD_HOST, CHAT_HOST - Fully qualified DNS names without schemes, paths, or ports. The first - four configure production Caddy routing. CHAT_HOST supplies the intended - public chat link; production chat routing remains unimplemented on this - experimental feature branch. + Fully qualified DNS names without schemes, paths, or ports. These + configure production HTTPS routes and their canonical www redirects. + CHAT_HOST also selects the exact trusted external chat origin. + + CHAT_LOCAL_HOST + Dedicated development gateway hostname ending in .localhost. Default: + chat.localhost. Browser identity files bind to this origin and local port. + + WEB_BIND + Production host publish address. Default :: serves IPv4 and IPv6. Use + 127.0.0.1 for isolated staging; development mode always uses loopback. HTTP_PORT, HTTPS_PORT Production Caddy listener and Podman published ports. HTTPS also publishes @@ -53,16 +60,18 @@ PUBLIC SETTINGS image state remain in var/state.json; an explicit .env local port takes precedence over the previously saved port. - CGIT_UPSTREAM, SEARXNG_UPSTREAM - Proxy connection targets such as cgit:8080 and searxng:8080. These are + CGIT_UPSTREAM, SEARXNG_UPSTREAM, CHAT_UPSTREAM + Proxy connection targets such as cgit:8080, searxng:8080, and chat:8080. These are addresses, not upstream bind settings. The bundled images' internal 8080 listeners and health checks remain image contracts. Changing a target to another host/port requires an actual service at that address. CHAT_BIND, CHAT_ORIGIN - Chat socket and exact browser origin. Default: 127.0.0.1:9080 and - http://127.0.0.1:9080. The experimental server requires loopback and an - exact HTTP origin match. --bind overrides CHAT_BIND for testing. + Direct development socket and exact browser origin. Defaults: + 127.0.0.1:9080 and http://127.0.0.1:9080. Direct development requires + loopback and an exact HTTP origin match. --bind overrides CHAT_BIND. + The container binds privately to 0.0.0.0:8080; its trusted origin comes + from CHAT_HOST/HTTPS_PORT or CHAT_LOCAL_HOST/LOCAL_HTTP_PORT instead. Identity files pin their origin; editing these values does not migrate encrypted identities or grant access on another origin. @@ -77,7 +86,7 @@ PRIVATE SETTINGS Generated on first stack initialization. Existing values are preserved. CHAT_SETUP_TOKEN - Generated by cargo stack chat --init. Used only to authorize first owner + Generated by chat initialization or first stack startup. Used only to authorize first owner registration, including a fresh registration after an explicit backend reset. It cannot recover a lost owner private key. @@ -104,7 +113,7 @@ RENDER AND APPLY Do not run Caddy directly on an unrendered template. Podman mounts the generated files. Public host/origin changes reach Caddy routes, www redirects, website links, the cgit clone prefix, and the chat page's navigation. Local rendering - uses /git and /search paths and the configured local chat origin. + uses /git and /search paths and http://CHAT_LOCAL_HOST:LOCAL_HTTP_PORT for chat. To apply changes to the existing services: @@ -125,8 +134,8 @@ PUBLICATION AND LIMITS The shared stack-config crate uses an explicit public allowlist for template substitution. A {{CHAT_SETUP_TOKEN}} or other private/unknown placeholder causes an error rather than publishing its value. Generated public pages and - proxy configuration never receive a full environment dump. Each container - still receives only the environment keys listed for that service; Caddy uses + proxy configuration never receive a full environment dump. Chat receives the public configuration allowlist plus CHAT_SETUP_TOKEN; + other containers receive only their declared service secrets. Caddy uses rendered public configuration and does not need the .env or private tokens. The root .env, runtime files, local render outputs, exports, and tool caches diff --git a/docs/OPERATIONS.txt b/docs/OPERATIONS.txt index 2cf4693..5b1da25 100644 --- a/docs/OPERATIONS.txt +++ b/docs/OPERATIONS.txt @@ -103,8 +103,11 @@ EXPORT AND BACK UP PRODUCTION cd ~/stack ./bin/stack backup --include-images - The command briefly pauses the three services while copying consistent + The command briefly pauses Caddy, cgit, and SearXNG while copying their application files, then resumes them before compression and encryption. + Chat remains unpaused so SQLite online backups can complete during active + writes. Consistent snapshots cover both chat and chat-local databases; raw + SQLite journals and server lock files are excluded. It uses the published public key, verifies the recipient fingerprint, checks bare Git repositories, saves exact images, and produces: @@ -261,8 +264,9 @@ MAKE AND DEPLOY A RELEASE cd ~/stack cargo stack release - Release reruns the full check, builds a static Rust binary locally, - builds the cgit container locally, and packages the configured image set. + Release reruns the full check, builds static Rust management and chat + binaries plus the browser WASM module, packages corresponding sources, + builds cgit/chat containers locally, and saves the configured image set. Outputs are dist/stack-release-TIMESTAMP.tar.gz and its .sha256 file. No .env, live data, .git history, or previous backup is included. @@ -291,8 +295,16 @@ MAKE AND DEPLOY A RELEASE cd ~/stack/var/incoming sha256sum -c stack-release-TIMESTAMP.tar.gz.sha256 cd ~/stack - ./bin/stack activate var/incoming/stack-release-TIMESTAMP.tar.gz + mkdir -p var/incoming/bootstrap + tar -xzf var/incoming/stack-release-TIMESTAMP.tar.gz -C var/incoming/bootstrap stack/bin/stack + var/incoming/bootstrap/stack/bin/stack --root "$PWD" activate var/incoming/stack-release-TIMESTAMP.tar.gz ./bin/stack status + rm -rf -- var/incoming/bootstrap + + Use the management binary from the verified new release for activation. + Older binaries can reject a new service schema before installing it. This + bootstrap path handles the upgrade from three services to the chat-enabled + release without installing a compiler or overwriting private configuration. Activation verifies the release and platform, replaces public source and the binary, loads exact images, preserves .env and var/data, and replaces @@ -310,9 +322,11 @@ RECOVERY LIMITS AND SECRET ROTATION Changing CPU architecture requires a native release build. Registry access is needed only when images are omitted or rebuilding for another target. - Current persistent state is files, Git repositories, and Caddy state. - When adding a database, add its supported flush/dump procedure to the - export workflow and test recovery before relying on that database's backup. + Current persistent state includes files, Git repositories, Caddy state, + and encrypted chat records in SQLite. Chat uses the SQLite online backup + API; recovery testing must reopen both participants' unchanged identity + files and decrypt history. See CHAT-PRODUCTION.txt for this release's tests. + Future databases need their own supported snapshot and recovery procedure. If changing the OpenPGP backup recipient, publish its public key, update the command's default fingerprint, and test encryption and restoration. @@ -324,11 +338,14 @@ RECOVERY LIMITS AND SECRET ROTATION The current command intentionally accepts only its verified format-2 archives. Fresh format-2 production backups replace the normal workflow. -LICENSE PAGE IN CGIT +CENTRAL THIRD-PARTY LICENSE PAGE + + cargo-about generates web/THIRD-PARTY-LICENSES.html. Caddy serves the rendered + document at https://doan.luuduct.ai/THIRD-PARTY-LICENSES.html, using the + configured SITE_HOST for other deployments. Website and chat license links + use that same canonical page. Local previews use the local main-site origin. - The stack repository's About tab renders only - web/THIRD-PARTY-LICENSES.html. Source and Raw links retain the normal cgit - views and preserve revision queries. Other files are not rendered as HTML. - The rendered document is isolated in a sandboxed frame, with scripts and - external resources blocked. The standalone website license page remains - available at /THIRD-PARTY-LICENSES.html. + cgit no longer has a custom license renderer, Lua filter, or license About + tab. The generated file remains accessible through its normal Source and Raw + views in the repository. The old chat /THIRD-PARTY-LICENSES.html URL redirects + to the canonical site page. Source exports retain license texts for offline use. diff --git a/docs/POLICY.txt b/docs/POLICY.txt index f662dd4..16d97c2 100644 --- a/docs/POLICY.txt +++ b/docs/POLICY.txt @@ -72,7 +72,8 @@ PORTABILITY Host SSH keys, firewall policy, accounts, and disks are host administration, not application export contents. - Releases currently contain a static x86_64 Linux command and native + Releases contain static x86_64 Linux management/chat commands, the WASM + browser crypto module, exact corresponding sources, and native linux/amd64 container images. They are portable across compatible x86_64 Linux machines. An ARM machine needs a native release build; the command rejects mismatched production images. Development mode may fetch/build @@ -82,3 +83,12 @@ PORTABILITY Generated third-party legal texts retain upstream whitespace and line endings. .gitattributes exempts only THIRD-PARTY-LICENSES.html from whitespace errors; first-party source checks remain strict. Workspace audits include every member. + +STATIC ASSET CACHING + + Main-site stylesheet links include the SHA-256 of web/styles.css as a query + version. Update both website templates when CSS changes. The render test + checks that the published URL matches the current stylesheet, preventing + old cached CSS from being paired with new markup. Tooltip text carries the + native hidden attribute as well as CSS visibility rules, so a stylesheet + failure cannot expose it as ordinary page text. diff --git a/infra/Caddyfile b/infra/Caddyfile index 424e129..7975f68 100644 --- a/infra/Caddyfile +++ b/infra/Caddyfile @@ -96,3 +96,13 @@ https://www.{{WKD_HOST}}:{{HTTPS_PORT}} { import https_security redir {{WKD_ORIGIN}}{uri} permanent } + +https://{{CHAT_HOST}}:{{HTTPS_PORT}} { + import https_security + reverse_proxy {{CHAT_UPSTREAM}} +} + +https://www.{{CHAT_HOST}}:{{HTTPS_PORT}} { + import https_security + redir {{CHAT_PUBLIC_ORIGIN}}{uri} permanent +} diff --git a/infra/Caddyfile.local b/infra/Caddyfile.local index 6e3508c..6f81a0a 100644 --- a/infra/Caddyfile.local +++ b/infra/Caddyfile.local @@ -34,3 +34,7 @@ file_server } } + +http://{{CHAT_LOCAL_HOST}}:80 { + reverse_proxy {{CHAT_UPSTREAM}} +} diff --git a/infra/chat/build-native.sh b/infra/chat/build-native.sh new file mode 100644 index 0000000..f882fef --- /dev/null +++ b/infra/chat/build-native.sh @@ -0,0 +1,63 @@ +#!/bin/sh +# 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/>. + +set -eu +ROOT="$(CDPATH= cd -- "$(dirname -- "$0")/../.." && pwd)" +TOOLS="$ROOT/.tools" +SOURCE="$TOOLS/openssl-native-source" +PREFIX="$TOOLS/openssl-musl" +HEADERS="$TOOLS/alpine/usr/include" +KERNEL="$TOOLS/kernel-headers" +mkdir -p "$SOURCE" "$TOOLS/wasi-source" "$TOOLS/alpine" "$KERNEL" +fetch() { + file="$1" + digest="$2" + url="$3" + if ! test -f "$file"; then + curl --fail --location --retry 2 "$url" --output "$file.download" + printf '%s %s\n' "$digest" "$file.download" | sha256sum --check --status + mv "$file.download" "$file" + fi + printf '%s %s\n' "$digest" "$file" | sha256sum --check +} +fetch "$TOOLS/wasi-source/openssl.tar.gz" a2157c2830efdec3788939b00c9b0638306d3f0bbb76dc4832ee503bb397df98 https://github.com/openssl/openssl/releases/download/openssl-3.6.5/openssl-3.6.5.tar.gz +fetch "$TOOLS/musl-dev.apk" 310b51cff12e1d5ce5f135c558a82e2550427da5465bbd16c9ff9b05e63ee3d9 https://dl-cdn.alpinelinux.org/alpine/v3.24/main/x86_64/musl-dev-1.2.6-r3.apk +if ! test -f "$HEADERS/stdio.h"; then tar -xzf "$TOOLS/musl-dev.apk" -C "$TOOLS/alpine" usr/include; fi +if ! test -f "$SOURCE/openssl-3.6.5/Configure"; then tar -xzf "$TOOLS/wasi-source/openssl.tar.gz" -C "$SOURCE"; fi +GCC_INCLUDE="$(cc -print-file-name=include)" +KERNEL_ASM=/usr/include/asm +if ! test -d "$KERNEL_ASM"; then KERNEL_ASM="/usr/include/$(cc -dumpmachine)/asm"; fi +test -f /usr/include/linux/mman.h +test -d "$KERNEL_ASM" +ln -sfn /usr/include/linux "$KERNEL/linux" +ln -sfn "$KERNEL_ASM" "$KERNEL/asm" +ln -sfn /usr/include/asm-generic "$KERNEL/asm-generic" +MUSL_CC="cc -nostdinc -isystem $HEADERS -isystem $GCC_INCLUDE -idirafter $KERNEL" +STAMP="$(sha256sum "$ROOT/infra/chat/build-native.sh" | cut -d' ' -f1)" +if ! test -f "$PREFIX/.stack-build" || ! test "$(cat "$PREFIX/.stack-build")" = "$STAMP"; then + cd "$SOURCE/openssl-3.6.5" + env CC="$MUSL_CC" ./Configure linux-x86_64 no-shared no-module no-dso no-tests no-autoload-config --with-rand-seed=getrandom --prefix="$PREFIX" --libdir=lib + make -j"${CHAT_BUILD_JOBS:-8}" build_libs + make install_dev + printf '%s\n' "$STAMP" > "$PREFIX/.stack-build" +fi +cd "$ROOT" +rustup target add x86_64-unknown-linux-musl +env OPENSSL_DIR="$PREFIX" OPENSSL_STATIC=1 CC_x86_64_unknown_linux_musl="$MUSL_CC" \ + BINDGEN_EXTRA_CLANG_ARGS_x86_64_unknown_linux_musl="-nostdinc -isystem $HEADERS -isystem $GCC_INCLUDE -idirafter $KERNEL" \ + cargo build --locked --release --target x86_64-unknown-linux-musl -p stack -p stack-chat-server -p stack-chat-crypto +"$ROOT/target/x86_64-unknown-linux-musl/release/stack-chat-crypto" +file "$ROOT/target/x86_64-unknown-linux-musl/release/stack-chat-server" diff --git a/infra/containers/cgit/cgitrc b/infra/containers/cgit/cgitrc index 0dacb99..fb9cae8 100644 --- a/infra/containers/cgit/cgitrc +++ b/infra/containers/cgit/cgitrc @@ -24,6 +24,4 @@ defbranch=main cache-root=/var/cache/cgit cache-size=1000 cache-scanrc-ttl=1 -readme=:web/THIRD-PARTY-LICENSES.html -about-filter=lua:/etc/cgit-license.lua scan-path=/srv/git diff --git a/infra/containers/cgit/license.lua b/infra/containers/cgit/license.lua deleted file mode 100644 index ff5685f..0000000 --- a/infra/containers/cgit/license.lua +++ /dev/null @@ -1,47 +0,0 @@ --- 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/>. - -local chunks = {} -local rendered = false - -function filter_open(filename) - chunks = {} - rendered = filename == "web/THIRD-PARTY-LICENSES.html" -end - -function filter_write(buffer) - if rendered then - chunks[#chunks + 1] = buffer - else - html_txt(buffer) - end -end - -function filter_close() - if rendered then - local query = os.getenv("QUERY_STRING") or "" - local suffix = query ~= "" and ("?" .. query) or "" - html('<nav aria-label="License views"><strong>Rendered</strong> | <a href="') - html_attr("../tree/web/THIRD-PARTY-LICENSES.html" .. suffix) - html('">Source</a> | <a href="') - html_attr("../plain/web/THIRD-PARTY-LICENSES.html" .. suffix) - html('">Raw</a></nav>') - html('<iframe title="Third-party licenses" sandbox="" referrerpolicy="no-referrer" style="width:100%;height:80vh;border:0" srcdoc="') - html_attr([[<!doctype html><meta http-equiv="Content-Security-Policy" content="default-src 'none'; style-src 'unsafe-inline'">]]) - html_attr(table.concat(chunks)) - html('"></iframe>') - end - return 0 -end diff --git a/infra/containers/chat/Containerfile b/infra/containers/chat/Containerfile new file mode 100644 index 0000000..a63683e --- /dev/null +++ b/infra/containers/chat/Containerfile @@ -0,0 +1,27 @@ +# 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/>. + +FROM scratch +COPY server /app/bin/server +COPY crypto.wasm /app/bin/chat/crypto.wasm +COPY SOURCE.tar.gz /app/bin/chat/SOURCE.tar.gz +COPY web /app/crates/chat-server/web +COPY wasi.mjs /app/crates/chat-crypto/browser/wasi.mjs +COPY CHAT-APPLICATION.txt /app/docs/CHAT-APPLICATION.txt +COPY CHAT-PRODUCTION.txt /app/docs/CHAT-PRODUCTION.txt +COPY LICENSE /app/LICENSE +WORKDIR /app +EXPOSE 8080 +ENTRYPOINT ["/app/bin/server", "--root", "/app", "--proxy"] diff --git a/infra/services.toml b/infra/services.toml index f352d68..7fea853 100644 --- a/infra/services.toml +++ b/infra/services.toml @@ -33,7 +33,6 @@ health = ["curl", "--fail", "--silent", "--max-time", "3", "http://127.0.0.1:808 mounts = [ { source = "var/data/repos", target = "/srv/git", read_only = true }, { source = "@cgit", target = "/etc/cgitrc", read_only = true }, - { source = "infra/containers/cgit/license.lua", target = "/etc/cgit-license.lua", read_only = true }, ] tmpfs = ["/run", "/var/cache/cgit"] @@ -46,3 +45,14 @@ mounts = [ { source = "var/data/searxng", target = "/etc/searxng" }, ] tmpfs = ["/var/cache/searxng:mode=1777"] + +[services.chat] +image = "localhost/stack-chat:0.1.0" +build = "bin/chat" +drop_capabilities = true +read_only = true +health = ["/app/bin/server", "--root", "/app", "--proxy", "--healthcheck"] +environment = ["CHAT_SETUP_TOKEN"] +mounts = [ + { source = "@chat", target = "/app/var/data/chat" }, +] 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") diff --git a/web/THIRD-PARTY-LICENSES.html b/web/THIRD-PARTY-LICENSES.html index 8187aeb..ab8e687 100644 --- a/web/THIRD-PARTY-LICENSES.html +++ b/web/THIRD-PARTY-LICENSES.html @@ -32,7 +32,7 @@ applications and their operating-system packages have their own notices.</p> <p>Headings follow <a href="https://www.gnu.org/licenses/license-list.html">GNU's license names</a> where listed. SPDX identifiers remain available for exact identification. The original license texts below are unchanged.</p> -<ul><li><a href="#MIT">Expat License</a> (206)</li><li><a href="#Unicode-3.0">Unicode License v3</a> (19)</li><li><a href="#Apache-2.0">Apache License, Version 2.0</a> (4)</li><li><a href="#BSD-3-Clause">Modified BSD License</a> (3)</li><li><a href="#LGPL-2.0-or-later">GNU Library General Public License, version 2 or later</a> (2)</li><li><a href="#BSL-1.0">Boost Software License</a> (1)</li><li><a href="#ISC">ISC License</a> (1)</li><li><a href="#Zlib">License of ZLib</a> (1)</li></ul> +<ul><li><a href="#MIT">Expat License</a> (208)</li><li><a href="#Unicode-3.0">Unicode License v3</a> (19)</li><li><a href="#Apache-2.0">Apache License, Version 2.0</a> (4)</li><li><a href="#BSD-3-Clause">Modified BSD License</a> (3)</li><li><a href="#LGPL-2.0-or-later">GNU Library General Public License, version 2 or later</a> (2)</li><li><a href="#BSL-1.0">Boost Software License</a> (1)</li><li><a href="#ISC">ISC License</a> (1)</li><li><a href="#Zlib">License of ZLib</a> (1)</li></ul> <section id="license-0" data-spdx="Apache-2.0"> <h2 id="Apache-2.0">Apache License, Version 2.0</h2> <ul><li><a href="https://github.com/KyleMayes/clang-sys">clang-sys 1.9.1</a></li></ul> @@ -1930,7 +1930,7 @@ THE SOFTWARE. </section> <section id="license-35" data-spdx="MIT"> -<ul><li><a href="https://github.com/rust-lang/futures-rs">futures-core 0.3.34</a></li><li><a href="https://github.com/rust-lang/futures-rs">futures-task 0.3.34</a></li><li><a href="https://github.com/rust-lang/futures-rs">futures-util 0.3.34</a></li></ul> +<ul><li><a href="https://github.com/rust-lang/futures-rs">futures-core 0.3.34</a></li><li><a href="https://github.com/rust-lang/futures-rs">futures-sink 0.3.34</a></li><li><a href="https://github.com/rust-lang/futures-rs">futures-task 0.3.34</a></li><li><a href="https://github.com/rust-lang/futures-rs">futures-util 0.3.34</a></li></ul> <pre>Copyright (c) 2016 Alex Crichton Copyright (c) 2017 The Tokio Authors @@ -3639,7 +3639,7 @@ USE OR OTHER DEALINGS IN THE SOFTWARE. </section> <section id="license-94" data-spdx="MIT"> -<ul><li><a href="https://github.com/tokio-rs/tokio">tokio 1.54.1</a></li></ul> +<ul><li><a href="https://github.com/tokio-rs/tokio">tokio-util 0.7.20</a></li><li><a href="https://github.com/tokio-rs/tokio">tokio 1.54.1</a></li></ul> <pre>MIT License Copyright (c) Tokio Contributors diff --git a/web/index.html b/web/index.html index db8e819..0c7ff51 100644 --- a/web/index.html +++ b/web/index.html @@ -22,7 +22,7 @@ along with this program. If not, see <https://www.gnu.org/licenses/>. <title>Doan Luu Duc Tai</title> - <link rel="stylesheet" href="styles.css"> + <link rel="stylesheet" href="/styles.css?v=ef54dd8d9908ae8682c273d84377d322935874c3d491d9cc656f4485c5e795a1"> </head> <body> @@ -51,7 +51,7 @@ along with this program. If not, see <https://www.gnu.org/licenses/>. | <a href="{{CHAT_PUBLIC_ORIGIN}}/">Message Me</a> | | | | Email: <a href="mailto:doan@luuduct.ai">doan@luuduct.ai</a> | -| GnuPG: <a href="{{WKD_ORIGIN}}/.well-known/openpgpkey/luuduct.ai/hu/t76q8xqkeassgy1kr38re1ppj6cpc1dp?l=doan">3346AA3150F8F16828BB0D14F8C66DF77AFECEA6</a> | +| OpenPGP: <a href="{{WKD_ORIGIN}}/.well-known/openpgpkey/luuduct.ai/hu/t76q8xqkeassgy1kr38re1ppj6cpc1dp?l=doan">3346AA3150F8F16828BB0D14F8C66DF77AFECEA6</a> | | | | <a href="https://smp11.simplex.im/a#ikrTZ8sfleSZEifKV2wI8foAVwkbKfOoKc6TO2P4ZT8">SimpleX Chat</a> | | | @@ -76,13 +76,15 @@ ________________________________________________________________________________ Private search engine +<a href="{{CHAT_PUBLIC_ORIGIN}}/">{{CHAT_HOST}}</a> +* A home-grown experimental messenger. <span class="librejs-note"><a href="https://www.gnu.org/software/librejs/" aria-describedby="chat-js-warning">LibreJS-compliant</a><span class="librejs-warning" id="chat-js-warning" role="tooltip" hidden>LibreJS compliance concerns software freedom, not a guarantee of privacy or security. Review the source code, dependencies, and scripts delivered to your browser. Server-delivered code can change between visits; free software can still track users or behave maliciously.</span></span>. Know its <a href="{{CHAT_PUBLIC_ORIGIN}}/LIMITATIONS.txt">limitations</a>. </pre> <nav class="source-footer" aria-label="Licensing and source"> <a class="project-license" href="https://www.gnu.org/licenses/agpl-3.0.html">[ LICENSE ]</a> - <a class="dependency-licenses" href="{{GIT_ORIGIN}}/stack/about/">[ THIRD PARTY LICENSES ]</a> + <a class="dependency-licenses" href="{{SITE_ORIGIN}}/THIRD-PARTY-LICENSES.html">[ THIRD PARTY LICENSES ]</a> <a href="{{GIT_ORIGIN}}/stack/tree/web/index.html">[ SOURCE CODE ]</a> </nav> diff --git a/web/message/index.html b/web/message/index.html index d1739b0..cc64787 100644 --- a/web/message/index.html +++ b/web/message/index.html @@ -20,7 +20,7 @@ along with this program. If not, see <https://www.gnu.org/licenses/>. <meta charset="utf-8"> <meta name="viewport" content="width=device-width,initial-scale=1"> <title>Message Me — Doan Luu Duc Tai</title> - <link rel="stylesheet" href="/styles.css"> + <link rel="stylesheet" href="/styles.css?v=ef54dd8d9908ae8682c273d84377d322935874c3d491d9cc656f4485c5e795a1"> </head> <body> <div id="page"> @@ -44,7 +44,7 @@ For now, you can reach me by <a href="mailto:doan@luuduct.ai">email</a> or use t </pre> <nav class="source-footer" aria-label="Licensing and source"> <a class="project-license" href="https://www.gnu.org/licenses/agpl-3.0.html">[ LICENSE ]</a> - <a class="dependency-licenses" href="{{GIT_ORIGIN}}/stack/about/">[ THIRD PARTY LICENSES ]</a> + <a class="dependency-licenses" href="{{SITE_ORIGIN}}/THIRD-PARTY-LICENSES.html">[ THIRD PARTY LICENSES ]</a> <a href="{{GIT_ORIGIN}}/stack/tree/web/message/index.html">[ SOURCE CODE ]</a> </nav> </div> diff --git a/web/styles.css b/web/styles.css index a50bc37..20e469d 100644 --- a/web/styles.css +++ b/web/styles.css @@ -137,3 +137,45 @@ a { color: #a3c7a8; } } + +.librejs-note { + position: relative; +} + +.librejs-note > a { + color: #806000; + text-decoration: underline wavy; + text-underline-offset: .2em; +} + +.librejs-warning { + display: none; + position: absolute; + bottom: 100%; + right: 0; + width: 48ch; + max-width: calc(100vw - 32px); + padding: 1em 2ch; + white-space: normal; + background: #fff; + color: #000; + border: 1px dashed #806000; + z-index: 1; +} + +.librejs-note:hover .librejs-warning, +.librejs-note:focus-within .librejs-warning { + display: block; +} + +@media (prefers-color-scheme: dark) { + .librejs-note > a { + color: #ffdf60; + } + + .librejs-warning { + background: #000; + color: #fff; + border-color: #ffdf60; + } +} |
