diff options
32 files changed, 1477 insertions, 296 deletions
diff --git a/.env.example b/.env.example index b8fad96..521bcd8 100644 --- a/.env.example +++ b/.env.example @@ -13,7 +13,43 @@ # 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/>. -SEARXNG_SECRET=GENERATE_RANDOM_HEX_KEY +# Public hostnames: DNS names only, without a scheme, path, or port. +# www aliases are derived from these names. These settings are public, not secrets. +BASE_DOMAIN=luuduct.ai +SITE_HOST=doan.luuduct.ai +GIT_HOST=git.doan.luuduct.ai +SEARCH_HOST=search.doan.luuduct.ai +WKD_HOST=openpgpkey.luuduct.ai +CHAT_HOST=chat.doan.luuduct.ai + +# 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. +HTTP_PORT=80 +HTTPS_PORT=443 + +# Development Caddy binds only to loopback. Its HTTPS host port is this value + 1. +# A --port argument overrides this setting for that invocation. +LOCAL_HTTP_PORT=8080 + +# Caddy connection targets, not upstream application bind settings. +# The bundled cgit and SearXNG images listen internally on 8080. +CGIT_UPSTREAM=cgit:8080 +SEARXNG_UPSTREAM=searxng:8080 +# Chat remains a separate local experimental process. --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 CHAT_ORIGIN=http://127.0.0.1:9080 + +# Public backup recipient and a project-relative public key file. +# Private GnuPG keys stay outside the project. CLI --recipient/--key override these. +BACKUP_RECIPIENT=3346AA3150F8F16828BB0D14F8C66DF77AFECEA6 +BACKUP_PUBLIC_KEY=web/.well-known/openpgpkey/luuduct.ai/hu/t76q8xqkeassgy1kr38re1ppj6cpc1dp + +# Private values: generate locally; never commit the actual .env. +# The stack creates SEARXNG_SECRET on first initialization. +SEARXNG_SECRET=GENERATE_RANDOM_HEX_KEY +# cargo stack chat --init generates the owner registration token. CHAT_SETUP_TOKEN=GENERATE_WITH_CHAT_SERVER_INIT @@ -1628,13 +1628,13 @@ dependencies = [ "anyhow", "chrono", "clap", - "dotenvy", "flate2", "hex", "rusqlite", "serde", "serde_json", "sha2", + "stack-config", "tar", "tempfile", "toml", @@ -1663,17 +1663,26 @@ dependencies = [ "axum", "base64", "clap", - "dotenvy", "hex", "openssl", "rusqlite", "serde_json", "stack-chat-crypto", + "stack-config", "tempfile", "tokio", ] [[package]] +name = "stack-config" +version = "0.1.0" +dependencies = [ + "anyhow", + "dotenvy", + "tempfile", +] + +[[package]] name = "string_cache" version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -29,10 +29,10 @@ publish = false exclude = ["var", ".tools", "target", "bin", "dist"] [dependencies] +stack-config = { path = "crates/config", version = "0.1.0" } anyhow = "1.0" chrono = { version = "0.4", default-features = false, features = ["now", "std"] } clap = { version = "4.6", features = ["derive"] } -dotenvy = "0.15" hex = "0.4" flate2 = { version = "1", default-features = false, features = ["rust_backend"] } serde = { version = "1.0", features = ["derive"] } @@ -54,7 +54,7 @@ incremental = false strip = false [workspace] -members = [".", "crates/chat-crypto", "crates/chat-server"] +members = [".", "crates/chat-crypto", "crates/chat-server", "crates/config"] default-members = ["."] resolver = "3" @@ -45,7 +45,9 @@ DEVELOPMENT AND PRODUCTION and the production user service. web/ holds the public website and WKD. docs/ contains plaintext operations and policy documentation. - Only .env holds configured secrets. .env.example documents the keys. + The single root .env holds deployment settings and configured secrets. + The commented .env.example documents hostnames, ports, upstreams, chat, + backup recipients, and secret-setting names. See docs/CONFIGURATION.txt. var/ contains private persistent data, container storage, temporary work, and exports. Generated TLS private keys are runtime data under var/. .env, var/, .tools/, target/, bin/, and dist/ are excluded from Git. diff --git a/crates/chat-server/Cargo.toml b/crates/chat-server/Cargo.toml index eecebf6..709c37f 100644 --- a/crates/chat-server/Cargo.toml +++ b/crates/chat-server/Cargo.toml @@ -27,11 +27,11 @@ categories = ["web-programming"] publish = false [dependencies] +stack-config = { path = "../config", version = "0.1.0" } anyhow = "1" axum = { version = "0.8", default-features = false, features = ["http1", "tokio", "json"] } base64 = "0.22" clap = { version = "4.6", features = ["derive"] } -dotenvy = "0.15" hex = "0.4" openssl = "0.10" rusqlite = { version = "0.41", default-features = false, features = ["bundled", "backup"] } diff --git a/crates/chat-server/src/http.rs b/crates/chat-server/src/http.rs index bb618d9..1c7286f 100644 --- a/crates/chat-server/src/http.rs +++ b/crates/chat-server/src/http.rs @@ -33,6 +33,7 @@ struct App { permits: Arc<Semaphore>, root: PathBuf, origin: String, + public: std::collections::BTreeMap<String, String>, } fn secured(mut response: Response) -> Response { @@ -129,22 +130,33 @@ async fn asset(State(app): State<App>, uri: axum::http::Uri) -> Response { "/LICENSE" => ("LICENSE", "text/plain; charset=utf-8"), _ => return secured(StatusCode::NOT_FOUND.into_response()), }; - tokio::fs::read(app.root.join(path)).await.map_or_else( - |_| secured(StatusCode::NOT_FOUND.into_response()), - |bytes| secured(([("content-type", mime)], bytes).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 rendered = String::from_utf8(bytes) + .map_err(anyhow::Error::from) + .and_then(|source| stack_config::render(&source, &app.public)); + return rendered.map_or_else( + |_| secured(StatusCode::INTERNAL_SERVER_ERROR.into_response()), + |html| secured(([("content-type", mime)], html).into_response()), + ); + } + secured(([("content-type", mime)], bytes).into_response()) } pub(crate) async fn serve( listener: tokio::net::TcpListener, service: Service, root: &Path, + public: std::collections::BTreeMap<String, String>, ) -> Result<()> { let app = App { origin: service.origin.clone(), service: Arc::new(Mutex::new(service)), permits: Arc::new(Semaphore::new(4)), root: root.to_path_buf(), + public, }; let routes = Router::new() .route("/api", post(api)) diff --git a/crates/chat-server/src/main.rs b/crates/chat-server/src/main.rs index 6c7f4e1..cfd3e7a 100644 --- a/crates/chat-server/src/main.rs +++ b/crates/chat-server/src/main.rs @@ -16,6 +16,7 @@ #![doc = "Local experimental chat server with ciphertext-only durable storage."] mod http; +mod maintenance; mod service; mod store; @@ -31,10 +32,18 @@ use std::process::ExitCode; struct Arguments { #[arg(long, default_value = ".")] root: PathBuf, - #[arg(long, default_value = "127.0.0.1:9080")] - bind: SocketAddr, #[arg(long)] + bind: Option<SocketAddr>, + #[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")] + reset_owner: Option<String>, + #[arg( + long, + conflicts_with = "snapshot", + help = "Print the registered owner's public fingerprint" + )] + owner_fingerprint: bool, #[arg(long)] snapshot: Option<PathBuf>, } @@ -64,7 +73,7 @@ fn initialize(root: &std::path::Path) -> Result<()> { { writeln!(file, "CHAT_ORIGIN=http://127.0.0.1:9080")?; } - Ok(()) + stack_config::initialize_public(root) } async fn run(arguments: Arguments) -> Result<()> { @@ -72,15 +81,28 @@ async fn run(arguments: Arguments) -> Result<()> { if arguments.init { return initialize(&root); } + let configuration = stack_config::Config::load(&root)?; + let bind = arguments.bind.unwrap_or(configuration.chat_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))?; let database = data.join("chat.sqlite"); - let store = store::Store::open(&database)?; + let _server_lock = if arguments.snapshot.is_none() && !arguments.owner_fingerprint { + Some(maintenance::lock(&data)?) + } else { + None + }; + let mut store = store::Store::open(&database)?; std::fs::set_permissions( &database, std::os::unix::fs::PermissionsExt::from_mode(0o600), )?; + if arguments.owner_fingerprint { + return maintenance::fingerprint(&store); + } + if let Some(expected) = arguments.reset_owner { + return maintenance::reset(&mut store, &expected, bind); + } if let Some(destination) = arguments.snapshot { store.snapshot(&destination)?; std::fs::set_permissions( @@ -90,28 +112,24 @@ async fn run(arguments: Arguments) -> Result<()> { return Ok(()); } ensure!( - arguments.bind.ip().is_loopback(), + bind.ip().is_loopback(), "Experimental server must bind to loopback" ); - let settings = dotenvy::from_path_iter(root.join(".env"))? - .collect::<std::result::Result<std::collections::HashMap<_, _>, _>>()?; - let origin = settings - .get("CHAT_ORIGIN") - .context("Run --init to configure chat")?; + let origin = configuration.value("CHAT_ORIGIN")?; ensure!( - origin == &format!("http://{}", arguments.bind), - "This development server requires CHAT_ORIGIN to match its loopback bind address" + origin == format!("http://{bind}"), + "CHAT_ORIGIN must match the loopback CHAT_BIND or --bind address" ); - let setup = settings - .get("CHAT_SETUP_TOKEN") - .context("Missing CHAT_SETUP_TOKEN")?; - let state = service::Service::new(store, origin.clone(), setup.clone()); - let listener = tokio::net::TcpListener::bind(arguments.bind).await?; + let setup = configuration + .value("CHAT_SETUP_TOKEN") + .context("Run --init to configure chat")?; + let state = service::Service::new(store, origin.to_owned(), setup.to_owned()); + let listener = tokio::net::TcpListener::bind(bind).await?; writeln!( std::io::stdout().lock(), "Experimental chat: {origin} (owner setup: /#setup)" )?; - http::serve(listener, state, &root).await + http::serve(listener, state, &root, configuration.public_values(None)?).await } #[tokio::main] diff --git a/crates/chat-server/src/maintenance.rs b/crates/chat-server/src/maintenance.rs new file mode 100644 index 0000000..eac689d --- /dev/null +++ b/crates/chat-server/src/maintenance.rs @@ -0,0 +1,61 @@ +// 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::store::Store; +use anyhow::{Context, Result, ensure}; +use std::fs::{File, OpenOptions}; +use std::io::Write; +use std::net::{SocketAddr, TcpListener}; +use std::os::unix::fs::OpenOptionsExt; +use std::path::Path; + +pub(crate) fn lock(directory: &Path) -> Result<File> { + let file = OpenOptions::new() + .read(true) + .write(true) + .create(true) + .truncate(false) + .mode(0o600) + .open(directory.join("server.lock"))?; + file.try_lock().context( + "Chat is already running. Stop it before starting another server or resetting the owner.", + )?; + Ok(file) +} + +pub(crate) fn fingerprint(store: &Store) -> Result<()> { + let certificate = store.owner()?.context("No owner identity is registered")?; + writeln!( + std::io::stdout().lock(), + "{}", + stack_chat_crypto::public_identity(&certificate)? + )?; + 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")?; + store.reset_owner(expected)?; + writeln!( + std::io::stdout().lock(), + "Owner registration and chat history cleared. Restart chat and use OWNER SETUP to create a new owner file. Existing backup archives were not changed." + )?; + Ok(()) +} diff --git a/crates/chat-server/src/store.rs b/crates/chat-server/src/store.rs index fce3bb3..0e09de7 100644 --- a/crates/chat-server/src/store.rs +++ b/crates/chat-server/src/store.rs @@ -45,6 +45,19 @@ impl Store { Ok(()) } + pub(crate) fn reset_owner(&mut self, expected: &str) -> Result<()> { + let owner = self.owner()?.context("No owner identity is registered")?; + let fingerprint = stack_chat_crypto::public_identity(&owner)?; + ensure!( + expected.len() == 64 && expected.eq_ignore_ascii_case(&fingerprint), + "Owner fingerprint does not match; no data was cleared" + ); + let transaction = self.connection.transaction()?; + transaction.execute_batch("DELETE FROM messages; DELETE FROM conversations; DELETE FROM metadata WHERE key='owner'; DELETE FROM sqlite_sequence WHERE name='messages';")?; + transaction.commit()?; + Ok(()) + } + pub(crate) fn register(&self, id: &str, cert: &str) -> Result<()> { if self.connection.query_row( "SELECT EXISTS(SELECT 1 FROM conversations WHERE id=?1)", diff --git a/crates/chat-server/tests/browser.mjs b/crates/chat-server/tests/browser.mjs index a56c7cc..e12c3da 100644 --- a/crates/chat-server/tests/browser.mjs +++ b/crates/chat-server/tests/browser.mjs @@ -19,12 +19,12 @@ import { mkdtemp, readFile, writeFile, symlink, rm, mkdir, rename } from 'node:f import { tmpdir } from 'node:os'; import { resolve, join } from 'node:path'; import { randomBytes } from 'node:crypto'; -import { spawn, execFileSync } from 'node:child_process'; +import { spawn, execFileSync, spawnSync } from 'node:child_process'; 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:9080'; +const origin = `http://127.0.0.1:${process.env.CHAT_TEST_PORT || '9081'}`; 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)); @@ -32,7 +32,7 @@ await mkdir(join(root,'var/build'), {recursive:true}); let server; let serverErrors = ''; async function launchServer() { - server = spawn(resolve('target/debug/stack-chat-server'), ['--root',root], {stdio:['ignore','pipe','pipe']}); + 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([ new Promise((ready, reject) => { server.stdout.once('data', ready); server.once('exit', () => reject(new Error(serverErrors || 'Server exited'))); }), @@ -78,8 +78,10 @@ async function page() { }); return page; } +async function waitEntry(page) { await page.locator('#create-choice').waitFor({state:'visible'}); } async function waitStatus(page, text) { await page.waitForFunction(value => document.getElementById('status').textContent.includes(value), text); } async function create(page, name, owner = false) { + const previousAuthentications = bodies.filter(body => body.op === 'authenticate').length; if (owner) await page.fill('#setup-token', token); else await page.click('#create-choice'); await page.fill('#name', name); await page.fill('#new-password', password); @@ -90,7 +92,7 @@ async function create(page, name, owner = false) { const bytes = await readFile(await download.path()); await waitStatus(page,'Reopen your downloaded'); assert.equal(await page.locator('#chat').isVisible(), false); - assert.equal(bodies.some(body => body.op === 'authenticate'), owner ? false : true); + assert.equal(bodies.filter(body => body.op === 'authenticate').length,previousAuthentications); return {name:'identity.chat',mimeType:'application/octet-stream',buffer:bytes}; } async function unlock(page, file, phrase = password) { @@ -126,17 +128,24 @@ async function verifyLibreJS() { try { if (libre) await verifyLibreJS(); const owner = await page(); - const response = await owner.goto(origin + '/#setup'); + const response = await owner.goto(origin); + await owner.locator('#owner-choice').waitFor({state:'visible'}); + await owner.click('#owner-choice'); assert.match(response.headers()['cache-control'], /no-store/); assert.match(response.headers()['content-security-policy'], /frame-ancestors 'none'/); - await waitStatus(owner,'Owner setup.'); + await owner.locator('#setup-token').waitFor({state:'visible'}); + assert.match(await owner.locator('#create-title').textContent(),/owner only/); + assert.equal(await owner.locator('.introduction h1').count(),0); + assert.equal(await owner.locator('.libre-badge a[rel=jslicense]').count(),1); + assert.equal(await owner.locator('.source-footer a[rel=jslicense]').count(),0); + assert.equal(await owner.evaluate(() => document.scrollingElement === document.documentElement),true); const ownerFile = await create(owner,'Doan',true); await unlock(owner,ownerFile); await waitStatus(owner,'Unlocked in memory only'); assert.match(await owner.locator('#who').textContent(), /OWNER/); const visitor = await page(); await visitor.goto(origin); - await waitStatus(visitor,'Choose CREATE'); + await waitEntry(visitor); const visitorFile = await create(visitor,'A friend'); assert.ok(!visitorFile.buffer.toString().includes('A friend')); await unlock(visitor,visitorFile,'wrong-but-long-password'); @@ -182,7 +191,7 @@ try { await waitStatus(visitor,'Encrypted message stored'); assert.match(await visitor.locator('#counts').textContent(),/Total: 3/); await visitor.reload(); - await waitStatus(visitor,'Choose CREATE'); + await waitEntry(visitor); assert.equal(await visitor.locator('#messages li').count(),0); assert.equal(await visitor.locator('#file').inputValue(),''); assert.equal(await visitor.locator('#password').inputValue(),''); @@ -209,7 +218,7 @@ try { await rename(snapshot,join(root,'var/data/chat/chat.sqlite')); await launchServer(); await visitor.reload(); - await waitStatus(visitor,'Choose CREATE'); + await waitEntry(visitor); await visitor.click('#open-choice'); await unlock(visitor,visitorFile); await waitStatus(visitor,'Unlocked in memory only'); @@ -219,9 +228,11 @@ try { await waitStatus(visitor,'Selected history decrypted'); assert.equal(await visitor.locator('#messages li').count(),3); assert.ok((await visitor.locator('#messages p').allTextContents()).includes(malicious)); - await owner.reload(); - await waitStatus(owner,'Choose CREATE'); - await owner.click('#open-choice'); + await owner.goto(origin); + await waitEntry(owner); + await owner.click('#owner-choice'); + await owner.locator('#owner-access').waitFor({state:'visible'}); + await owner.click('#owner-open'); await unlock(owner,ownerFile); await waitStatus(owner,'Unlocked in memory only'); assert.match(await owner.locator('#who').textContent(),/OWNER/); @@ -236,6 +247,30 @@ try { assert.equal(await visitor.locator('#messages li').count(),0); assert.equal(await visitor.locator('#password').inputValue(),''); assert.equal(await visitor.locator('#chat').isVisible(),false); + await visitor.click('#open-choice'); + await unlock(visitor,visitorFile); + await waitStatus(visitor,'Unlocked in memory only'); + for (let index = 1; index <= 51; index += 1) { + await visitor.fill('#text',`Batch fixture ${index}`); + await visitor.click('#send-form button'); + await waitStatus(visitor,'Encrypted message stored'); + } + assert.equal(await visitor.locator('#history-count').getAttribute('max'),'54'); + assert.equal(await visitor.locator('#history-range').textContent(),'Latest messages (0-54)'); + const historyStart = bodies.length; + await visitor.fill('#history-count','54'); + await visitor.click('#history-form button'); + await waitStatus(visitor,'Selected history decrypted'); + assert.equal(await visitor.locator('#messages li').count(),54); + const batches = bodies.slice(historyStart).filter(body => body.op === 'history' && body.count > 0).map(body => body.count); + assert.deepEqual(batches,[50,4]); + assert.equal(await visitor.locator('#messages p').last().textContent(),'Batch fixture 51'); + assert.equal(await visitor.locator('#older').isVisible(),false); + await visitor.fill('#history-count','0'); + await visitor.click('#history-form button'); + await waitStatus(visitor,'No message history loaded'); + assert.equal(await visitor.locator('#messages li').count(),0); + await visitor.click('#lock'); await storageEmpty(visitor); await storageEmpty(owner); assert.deepEqual(external,[]); @@ -245,9 +280,40 @@ try { 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',password]) assert.ok(!database.includes(Buffer.from(plaintext)),`Plaintext persisted: ${plaintext}`); + 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}`); 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 registered = administrative('--owner-fingerprint'); + assert.equal(registered.status,0); + const fingerprint = registered.stdout.trim(); + assert.match(fingerprint,/^[0-9A-F]{64}$/); + const runningReset = administrative('--reset-owner',fingerprint); + assert.notEqual(runningReset.status,0,'Reset must refuse while the server runs'); + await stopServer(); + const mismatch = administrative('--reset-owner','0'.repeat(64)); + assert.notEqual(mismatch.status,0,'Wrong fingerprint must not reset the owner'); + assert.equal(administrative('--owner-fingerprint').stdout.trim(),fingerprint); + const preservedEnv = await readFile(join(root,'.env')); + const reset = administrative('--reset-owner',fingerprint); + assert.equal(reset.status,0); + assert.deepEqual(await readFile(join(root,'.env')),preservedEnv); + assert.notEqual(administrative('--owner-fingerprint').status,0); + await launchServer(); + await owner.goto(origin); + await owner.locator('#owner-choice').waitFor({state:'visible'}); + await owner.click('#owner-choice'); + const replacement = await create(owner,'Replacement owner',true); + await unlock(owner,replacement); + await waitStatus(owner,'Unlocked in memory only'); + assert.equal(await owner.locator('#conversations button').count(),0,'Reset must clear conversations'); + assert.notEqual(administrative('--owner-fingerprint').stdout.trim(),fingerprint); + await visitor.goto(origin); + await waitEntry(visitor); + await visitor.click('#open-choice'); + await unlock(visitor,visitorFile); + await waitStatus(visitor,'Pinned owner key differs'); + assert.equal(await visitor.locator('#chat').isVisible(),false); assert.equal(serverErrors,''); if (process.env.CHAT_SCREENSHOT) await visitor.screenshot({path:resolve('.tools/chat-preview.png'),fullPage:true}); if (libre) { @@ -256,7 +322,7 @@ try { assert.ok(addon?.active,'LibreJS must actually be active'); console.log(`LibreJS ${addon.version} is active in Firefox; complete chat workflow succeeded.`); } - console.log('PASS: real browser/WASI owner setup, protected file recovery, wrong password, visitor/owner encrypted messages, safe text rendering, N=0, counts, pagination, retry deduplication, reload, live SQLite snapshot restoration and both identity recoveries, explicit lock, origin rejection, no browser storage or external requests, no private uploads or database plaintext.'); + console.log('PASS: real browser/WASI owner setup, protected file recovery, wrong password, visitor/owner encrypted messages, safe text rendering, N=0, counts, dynamic 0-to-total selection and 50-row batching, pagination, retry deduplication, reload, live SQLite snapshot restoration and both identity recoveries, explicit lock, owner setup/access, backend-only reset guards and fresh registration, old key-pin rejection, origin rejection, no browser storage or external requests, no private uploads or database plaintext.'); } finally { clearTimeout(deadline); await browser.close(); diff --git a/crates/chat-server/web/app.js b/crates/chat-server/web/app.js index c06116b..3c97f66 100644 --- a/crates/chat-server/web/app.js +++ b/crates/chat-server/web/app.js @@ -86,12 +86,15 @@ function clearState() { pendingSend = undefined; setupToken = undefined; before = 0; + element('history-count').max = '0'; + element('history-range').textContent = 'Latest messages (0-0)'; for (const input of document.querySelectorAll('input,textarea')) { if (input.id === 'history-count') input.value = '0'; else input.value = ''; } for (const id of ['messages', 'conversations', 'who', 'fingerprint', 'counts', 'thread-name', 'conversation-fingerprint']) element(id).replaceChildren(); - for (const id of ['create', 'unlock', 'chat', 'thread', 'inbox', 'older', 'retry']) show(id, false); + for (const id of ['create', 'unlock', 'chat', 'thread', 'inbox', 'older', 'retry', 'owner-access']) show(id, false); show('entry', Boolean(config?.owner)); + show('owner-entry'); status('Locked. Select your file and enter its password to return.'); } @@ -127,7 +130,7 @@ function download(file) { async function createIdentity() { const password = element('new-password').value; if (password !== element('confirm-password').value) throw new Error('Passwords do not match'); - if (!config.owner && location.hash !== '#setup') throw new Error('Owner setup is required first'); + if (!config.owner && element('setup-label').hidden) throw new Error('Owner setup is required first'); setupToken = !config.owner ? element('setup-token').value : undefined; status('Generating your purpose-specific identity and protecting its file...'); const result = await cryptoOperation({ op: 'create', origin: location.origin, name: element('name').value, password, owner: config.owner || '' }); @@ -160,6 +163,8 @@ async function unlockIdentity() { setupToken = undefined; config = await api({ op: 'config' }); show('entry', false); + show('owner-entry', false); + show('owner-access', false); show('unlock', false); show('chat'); element('who').textContent = `${identity.name} [${session.owner === true ? 'OWNER' : 'VISITOR'}]`; @@ -176,7 +181,7 @@ async function unlockIdentity() { element('conversation-fingerprint').textContent = `Conversation: ${session.id}`; show('thread'); element('thread-name').textContent = 'You <-> Doan'; - await history(0); + await loadHistory(0); } status('Unlocked in memory only. No history loaded automatically. Lock or leave to forget this session.'); } @@ -217,7 +222,7 @@ async function inbox() { name = profile.name; } element('thread-name').textContent = `Doan <-> ${name}`; - await history(0); + await loadHistory(0); status('Conversation opened. Choose how many messages to load.'); } catch (error) { status(error.message); } finally { if (generation === current) busy = false; } @@ -227,42 +232,74 @@ async function inbox() { } function counters(conversation) { + if (!Number.isSafeInteger(conversation.total) || conversation.total < 0 || conversation.total > 10000) throw new Error('Invalid conversation count'); + element('history-count').max = String(conversation.total); + element('history-range').textContent = `Latest messages (0-${conversation.total})`; const sent = session.owner ? conversation.owner_sent : conversation.visitor_sent; const received = session.owner ? conversation.visitor_sent : conversation.owner_sent; element('counts').textContent = `Total: ${conversation.total} | Sent: ${sent} | Received: ${received}`; } -async function history(count, cursor = 0) { +async function renderHistoryBatch(messages) { + const requests = messages.map(message => ({ sender: message.sender === selected.id ? selected.public : config.owner, ciphertext: message.ciphertext })); + const decrypted = await cryptoOperation(protectRequest('decrypt_batch', { messages: requests })); + for (let index = 0; index < messages.length; index += 1) { + const message = messages[index]; + const item = document.createElement('li'); + const label = document.createElement('small'); + label.textContent = `#${message.seq} / ${message.sender === session.id ? 'YOU' : 'THEM'}`; + const text = document.createElement('p'); + try { + const entry = decrypted.messages[index]; + if (entry.signer !== message.sender) throw new Error('Sender fingerprint mismatch'); + if (entry.error) throw new Error('Invalid ciphertext or signature'); + const payload = checkedPayload(entry.payload, 'message', selected.id, message.sender); + if (payload.id !== message.id || typeof payload.text !== 'string' || new TextEncoder().encode(payload.text).length > 8192) throw new Error('Message binding mismatch'); + text.textContent = payload.text; + } catch { text.textContent = '[Message rejected: invalid ciphertext, signature, or conversation binding.]'; } + item.append(label, text); + element('messages').prepend(item); + } +} + +async function refreshCounts() { + const conversation = selected.id; + const result = await api({ op: 'history', conversation, count: 0, before: 0 }); + if (selected?.id === conversation) counters(result.conversation); + return result.conversation; +} + +async function loadHistory(count, cursor = 0) { if (!selected) throw new Error('Choose a conversation first'); - if (!Number.isSafeInteger(count) || count < 0 || count > 50) throw new Error('Choose 0 to 50 messages'); - const result = await api({ op: 'history', conversation: selected.id, count, before: cursor }); - counters(result.conversation); - if (!cursor) element('messages').replaceChildren(); - if (result.messages.length) { - status('Decrypting and verifying selected messages locally...'); - const requests = result.messages.map(message => ({ sender: message.sender === selected.id ? selected.public : config.owner, ciphertext: message.ciphertext })); - const decrypted = await cryptoOperation(protectRequest('decrypt_batch', { messages: requests })); - for (let index = 0; index < result.messages.length; index += 1) { - const message = result.messages[index]; - const item = document.createElement('li'); - const label = document.createElement('small'); - label.textContent = `#${message.seq} / ${message.sender === session.id ? 'YOU' : 'THEM'}`; - const text = document.createElement('p'); - try { - const entry = decrypted.messages[index]; - if (entry.signer !== message.sender) throw new Error('Sender fingerprint mismatch'); - if (entry.error) throw new Error('Invalid ciphertext or signature'); - const payload = checkedPayload(entry.payload, 'message', selected.id, message.sender); - if (payload.id !== message.id || typeof payload.text !== 'string' || new TextEncoder().encode(payload.text).length > 8192) throw new Error('Message binding mismatch'); - text.textContent = payload.text; - } catch { text.textContent = '[Message rejected: invalid ciphertext, signature, or conversation binding.]'; } - item.append(label, text); - element('messages').prepend(item); - } - before = Math.min(...result.messages.map(message => message.seq)); + const conversation = await refreshCounts(); + if (!Number.isSafeInteger(count) || count < 0 || count > conversation.total) throw new Error(`Choose 0 to ${conversation.total} messages`); + if (!cursor) { element('messages').replaceChildren(); before = 0; } + if (count === 0) { + element('history-count').value = '0'; + show('older', false); + status('No message history loaded.'); + return; + } + let remaining = count; + let next = cursor; + let exhausted = false; + while (remaining > 0) { + const size = Math.min(50, remaining); + const result = await api({ op: 'history', conversation: selected.id, count: size, before: next }); + counters(result.conversation); + if (!Array.isArray(result.messages) || result.messages.length > size) throw new Error('Invalid history response'); + if (!result.messages.length) { exhausted = true; break; } + const sequence = Math.min(...result.messages.map(message => message.seq)); + if (!Number.isSafeInteger(sequence) || sequence <= 0 || (next > 0 && sequence >= next)) throw new Error('Invalid history cursor'); + status(`Decrypting and verifying selected messages locally (${count - remaining + result.messages.length}/${count})...`); + await renderHistoryBatch(result.messages); + remaining -= result.messages.length; + next = sequence; + before = next; + if (result.messages.length < size) { exhausted = true; break; } } - show('older', count > 0 && result.messages.length === count); - status(count === 0 ? 'No message history loaded.' : 'Selected history decrypted and verified locally.'); + show('older', !exhausted && element('messages').children.length < Number(element('history-count').max)); + status('Selected history decrypted and verified locally.'); } async function submitPending() { @@ -289,15 +326,32 @@ async function send() { await submitPending(); } -action('create-choice', async () => { show('entry', false); show('create'); status('Create a purpose-specific protected identity file.'); }); -action('open-choice', async () => { show('entry', false); show('unlock'); status('Select your saved identity file.'); }); +function ownerSetup() { + show('entry', false); + show('owner-entry', false); + show('unlock', false); + show('create', !config.owner); + show('owner-access', Boolean(config.owner)); + show('setup-label', !config.owner); + element('create-title').textContent = 'Create the owner identity (owner only)'; + status(''); +} + +action('create-choice', async () => { show('entry', false); show('owner-entry', false); show('setup-label', false); element('create-title').textContent = 'Create a new chat'; show('create'); status(''); }); +action('open-choice', async () => { show('entry', false); show('owner-entry', false); show('unlock'); status(''); }); +action('owner-choice', async () => { window.history.replaceState(null, '', '#setup'); ownerSetup(); }); +action('owner-open', async () => { show('owner-access', false); show('unlock'); status(''); }); action('create-form', createIdentity); action('unlock-form', unlockIdentity); -action('history-form', () => history(Number(element('history-count').value))); +element('history-count').addEventListener('focus', () => { + const current = generation; + if (session && selected && !busy) refreshCounts().catch(error => { if (current === generation) status(error.message); }); +}); +action('history-form', () => loadHistory(Number(element('history-count').value))); action('send-form', send); action('retry', submitPending); action('refresh-inbox', inbox); -action('older', () => history(Number(element('history-count').value), before)); +action('older', () => loadHistory(Number(element('history-count').value), before)); element('lock').addEventListener('click', () => { if (session) api({ op: 'lock' }).catch(() => {}); clearState(); }); window.addEventListener('pagehide', clearState); window.addEventListener('pageshow', event => { if (event.persisted) clearState(); }); @@ -310,14 +364,10 @@ async function start() { await wasm; config = await api({ op: 'config' }); if (config.origin !== location.origin) throw new Error('Server origin mismatch'); - if (!config.owner) { - if (location.hash === '#setup') { - show('create'); - show('setup-label'); - element('create-title').textContent = 'Create the dedicated owner identity'; - status('Owner setup. Keep this identity separate from your canonical OpenPGP key.'); - } else { status('Owner setup is not complete. Please return later.'); } - } else { show('entry'); status('Choose CREATE NEW CHAT or OPEN CHAT FILE.'); } + show('owner-entry'); + show('entry', Boolean(config.owner)); + status(''); + if (location.hash === '#setup') ownerSetup(); } start().catch(error => status(error.message)); diff --git a/crates/chat-server/web/index.html b/crates/chat-server/web/index.html index 9783789..79026b0 100644 --- a/crates/chat-server/web/index.html +++ b/crates/chat-server/web/index.html @@ -25,32 +25,59 @@ along with this program. If not, see <https://www.gnu.org/licenses/>. </head> <body> <main> -<header><a href="https://doan.luuduct.ai/">~doan</a> / message me <span class="status">EXPERIMENTAL / LOCAL TEST</span></header> -<h1>[ private chat ]</h1> -<p>Text only. A dedicated identity file. Encrypted for you and Doan.</p> -<p class="notice">This is an experimental application, not an audited messenger. <a href="/LIMITATIONS.txt">Read its limitations</a> before use. Keep your file and password safe: there is no account recovery.</p> +<h1 class="sr-only">Message Me</h1> +<div class="libre-badge">[ <a href="https://www.gnu.org/software/librejs/" aria-describedby="js-warning">GNU LibreJS-compliant</a><span id="js-warning" role="tooltip">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> ] +<a class="script-licenses" href="/javascript.html" rel="jslicense">[ JAVASCRIPT LICENSES ]</a></div> +<header> +<pre class="logo-art"><a href="{{SITE_ORIGIN}}/" class="logo" aria-label="Doan Luu Duc Tai home"> + ____ ___ _ _ _ +| _ \ / _ \ / \ | \ | | +| | | | | | |/ _ \ | \| | +| |_| | |_| / ___ \| |\ | +|____/ \___/_/ \_\_| \_| LUU DUC TAI +</a></pre> +<nav class="site-nav" aria-label="Site navigation"><a href="{{SITE_ORIGIN}}/">Home</a><a href="{{SITE_ORIGIN}}/#services">Services</a><a href="/" aria-current="page">Message Me</a></nav> +</header> +<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>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> +</div> <p id="status" role="status" aria-live="polite">Loading cryptographic module...</p> -<section id="entry" hidden> +<section id="entry" class="actions" hidden> <button id="create-choice">CREATE NEW CHAT</button> <button id="open-choice">OPEN CHAT FILE</button> </section> +<section id="owner-entry" class="owner-entry" hidden> +<button id="owner-choice">OWNER SETUP</button> +<span class="owner-note">/* Owner (Doan Luu Duc Tai) only. */</span> +</section> +<section id="owner-access" hidden> +<h2>Owner access (owner only)</h2> +<p>An owner identity is already registered. Open your saved owner file with its password.</p> +<p>If you lost that file, <a href="/LIMITATIONS.txt">reset the chat service from the backend</a> before creating a new owner identity. A reset clears the chat history; it cannot recover the lost key.</p> +<button id="owner-open">OPEN OWNER FILE</button> +</section> <section id="create" hidden> <h2 id="create-title">Create a new chat</h2> <form id="create-form" autocomplete="off"> -<label>Display name <input id="name" maxlength="80" required autocomplete="off"></label> -<label>Password <input id="new-password" type="password" minlength="12" maxlength="1024" required autocomplete="off"></label> -<label>Confirm password <input id="confirm-password" type="password" minlength="12" required autocomplete="off"></label> -<label id="setup-label" hidden>Owner setup token from the root .env <input id="setup-token" type="password" autocomplete="off"></label> +<div class="ascii-field"><div class="ascii-bar field-header"><span aria-hidden="true">+--[ </span><label class="field-heading" for="name">Display name</label><span aria-hidden="true"> ]</span><span class="ascii-rule" aria-hidden="true">--------------------------------------------------------------------------------</span><span aria-hidden="true">+</span></div><div class="field-row"><input id="name" maxlength="80" required autocomplete="off"></div><div class="ascii-bar field-end" aria-hidden="true"><span>+</span><span class="ascii-rule">--------------------------------------------------------------------------------</span><span>+</span></div></div> +<div class="ascii-field"><div class="ascii-bar field-header"><span aria-hidden="true">+--[ </span><label class="field-heading" for="new-password">Password</label><span aria-hidden="true"> ]</span><span class="ascii-rule" aria-hidden="true">--------------------------------------------------------------------------------</span><span aria-hidden="true">+</span></div><div class="field-row"><input id="new-password" type="password" minlength="12" maxlength="1024" required autocomplete="off"></div><div class="ascii-bar field-end" aria-hidden="true"><span>+</span><span class="ascii-rule">--------------------------------------------------------------------------------</span><span>+</span></div></div> +<div class="ascii-field"><div class="ascii-bar field-header"><span aria-hidden="true">+--[ </span><label class="field-heading" for="confirm-password">Confirm password</label><span aria-hidden="true"> ]</span><span class="ascii-rule" aria-hidden="true">--------------------------------------------------------------------------------</span><span aria-hidden="true">+</span></div><div class="field-row"><input id="confirm-password" type="password" minlength="12" required autocomplete="off"></div><div class="ascii-bar field-end" aria-hidden="true"><span>+</span><span class="ascii-rule">--------------------------------------------------------------------------------</span><span>+</span></div></div> +<div class="ascii-field" id="setup-label" hidden><p id="setup-help">Read CHAT_SETUP_TOKEN from your root .env.</p><div class="ascii-bar field-header"><span aria-hidden="true">+--[ </span><label class="field-heading" for="setup-token">Owner setup token</label><span aria-hidden="true"> ]</span><span class="ascii-rule" aria-hidden="true">--------------------------------------------------------------------------------</span><span aria-hidden="true">+</span></div><div class="field-row"><input aria-describedby="setup-help" id="setup-token" type="password" autocomplete="off"></div><div class="ascii-bar field-end" aria-hidden="true"><span>+</span><span class="ascii-rule">--------------------------------------------------------------------------------</span><span>+</span></div></div> <p>Use a strong, unique passphrase. This identity is solely for this chat; do not reuse it elsewhere.</p> -<button type="submit">GENERATE AND DOWNLOAD IDENTITY FILE</button> +<button type="submit">DOWNLOAD IDENTITY FILE</button> </form> </section> <section id="unlock" hidden> <h2>Open your saved identity file</h2> <p id="reopen-note">Your file is opened locally. The file and password are never uploaded.</p> <form id="unlock-form" autocomplete="off"> -<label>Identity file <input id="file" type="file" required></label> -<label>Password <input id="password" type="password" required autocomplete="off"></label> +<div class="ascii-field"><div class="ascii-bar field-header"><span aria-hidden="true">+--[ </span><label class="field-heading" for="file">Identity file</label><span aria-hidden="true"> ]</span><span class="ascii-rule" aria-hidden="true">--------------------------------------------------------------------------------</span><span aria-hidden="true">+</span></div><div class="field-row"><input id="file" type="file" required></div><div class="ascii-bar field-end" aria-hidden="true"><span>+</span><span class="ascii-rule">--------------------------------------------------------------------------------</span><span>+</span></div></div> +<div class="ascii-field"><div class="ascii-bar field-header"><span aria-hidden="true">+--[ </span><label class="field-heading" for="password">Password</label><span aria-hidden="true"> ]</span><span class="ascii-rule" aria-hidden="true">--------------------------------------------------------------------------------</span><span aria-hidden="true">+</span></div><div class="field-row"><input id="password" type="password" required autocomplete="off"></div><div class="ascii-bar field-end" aria-hidden="true"><span>+</span><span class="ascii-rule">--------------------------------------------------------------------------------</span><span>+</span></div></div> <button type="submit">UNLOCK CHAT</button> </form> </section> @@ -62,20 +89,19 @@ along with this program. If not, see <https://www.gnu.org/licenses/>. <h3 id="thread-name"></h3> <p id="conversation-fingerprint"></p> <p id="counts"></p> -<form id="history-form"><label>Latest messages to load (0–50) <input id="history-count" type="number" min="0" max="50" step="1" value="0" required></label><button type="submit">LOAD HISTORY</button></form> +<form id="history-form" novalidate><div class="ascii-field"><div class="ascii-bar field-header"><span aria-hidden="true">+--[ </span><label id="history-range" class="field-heading" for="history-count">Latest messages (0-0)</label><span aria-hidden="true"> ]</span><span class="ascii-rule" aria-hidden="true">--------------------------------------------------------------------------------</span><span aria-hidden="true">+</span></div><div class="field-row"><input id="history-count" type="number" min="0" max="0" step="1" value="0" required></div><div class="ascii-bar field-end" aria-hidden="true"><span>+</span><span class="ascii-rule">--------------------------------------------------------------------------------</span><span>+</span></div></div><button type="submit">LOAD HISTORY</button></form> <button id="older" hidden>LOAD OLDER</button> <ol id="messages" aria-label="Messages"></ol> -<form id="send-form" autocomplete="off"><label>Plain text message (up to 8192 UTF-8 bytes) <textarea id="text" rows="5" required maxlength="8192" autocomplete="off"></textarea></label><button type="submit">ENCRYPT AND SEND</button></form> -<button id="retry" hidden>RETRY THE SAME ENCRYPTED MESSAGE</button> +<p id="message-limit">Plain text only. Up to 8192 UTF-8 bytes per message.</p> +<form id="send-form" autocomplete="off"><div class="ascii-field"><div class="ascii-bar field-header"><span aria-hidden="true">+--[ </span><label class="field-heading" for="text">Message body</label><span aria-hidden="true"> ]</span><span class="ascii-rule" aria-hidden="true">--------------------------------------------------------------------------------</span><span aria-hidden="true">+</span></div><div class="field-row"><textarea aria-describedby="message-limit" id="text" rows="5" required maxlength="8192" autocomplete="off"></textarea></div><div class="ascii-bar field-end" aria-hidden="true"><span>+</span><span class="ascii-rule">--------------------------------------------------------------------------------</span><span>+</span></div></div><button type="submit">ENCRYPT AND SEND</button></form> +<button id="retry" hidden>RETRY MESSAGE</button> </section> </section> <noscript>This chat requires free JavaScript and WebAssembly for local cryptography. The main website remains JavaScript-free.</noscript> -<footer> -<p><a class="badge" href="https://www.gnu.org/software/librejs/" aria-describedby="js-warning">GNU LibreJS-compliant</a><span id="js-warning" role="tooltip">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></p> -<a href="https://www.gnu.org/licenses/agpl-3.0.html">LICENSE</a> -<a href="/THIRD-PARTY-LICENSES.html">THIRD PARTY LICENSES</a> -<a href="/SOURCE.tar.gz">CORRESPONDING SOURCE CODE</a> -<a href="/javascript.html" rel="jslicense">JAVASCRIPT LICENSES / RAW SOURCE</a> +<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 href="/SOURCE.tar.gz">[ SOURCE CODE ]</a> </footer> </main> </body> diff --git a/crates/chat-server/web/javascript.html b/crates/chat-server/web/javascript.html index 927a678..205a103 100644 --- a/crates/chat-server/web/javascript.html +++ b/crates/chat-server/web/javascript.html @@ -15,14 +15,14 @@ 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/>. --> <!doctype html> -<html lang="en"><head><meta charset="utf-8"><title>JavaScript licenses</title><link rel="stylesheet" href="/style.css"></head><body><main> +<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>JavaScript licenses</title><link rel="stylesheet" href="/style.css"></head><body><main class="license-page"> <h1>JavaScript licenses and source</h1> <p>All first-party scripts are available under the GNU Affero General Public License, version 3 or, at your option, any later version. The table uses the license identifiers recognized by GNU LibreJS.</p> -<table id="jslicense-labels1"><thead><tr><th>Script / raw file</th><th>License</th><th>Corresponding source</th></tr></thead><tbody> +<div class="license-table"><table id="jslicense-labels1"><thead><tr><th>Script / raw file</th><th>License</th><th>Corresponding source</th></tr></thead><tbody> <tr><td><a href="/app.js">app.js</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> <tr><td><a href="/worker.js">worker.js</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> <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> +</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> </main></body></html> diff --git a/crates/chat-server/web/style.css b/crates/chat-server/web/style.css index 0983b2e..5587f58 100644 --- a/crates/chat-server/web/style.css +++ b/crates/chat-server/web/style.css @@ -14,36 +14,108 @@ 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/>. */ + +:root { + color-scheme: light dark; + --background: #fff; + --foreground: #000; + --link: #06c; + --logo: #a00; + --good: #090; + --warning: #806000; + --license: #923900; + --third-party: #355b3c; + --muted: #555; +} + * { box-sizing: border-box; } -:root { color-scheme: dark; font-family: "Courier New", monospace; background: #131715; color: #d4d8cd; line-height: 1.6; } -body { margin: 0; } -main { max-width: 58rem; margin: 2rem auto; padding: 0 1.25rem; } -header, footer { border-top: 1px solid #667367; padding-top: 1rem; } -header { display: flex; flex-wrap: wrap; gap: .6rem; } -h1 { font-size: 1.8rem; color: #e3e3ce; } -h2, h3 { font-size: 1.05rem; } -a { color: #aec2a5; text-underline-offset: .2rem; } -.status, .badge { color: #e0c46d; } -.status { margin-left: auto; } -.notice { border-left: 2px solid #c5ad68; padding-left: 1rem; } -button, input, textarea { font: inherit; color: inherit; background: #1c231e; border: 1px solid #667367; border-radius: 0; padding: .55rem .7rem; } -button { cursor: pointer; margin: .4rem .6rem .4rem 0; } -button:hover, button:focus-visible { border-color: #d7c884; color: #f0e5b0; } +html { overflow-y: scroll; scrollbar-gutter: stable; } +body { overflow: visible; font: 16px/1.5 monospace, monospace; margin: 0; background: var(--background); color: var(--foreground); } +main { width: 80ch; max-width: calc(100% - 32px); margin: 16px auto; position: relative; } +a { color: var(--link); text-decoration: none; } +a:hover { text-decoration: underline; } +a:focus-visible, button:focus-visible, input:focus-visible, textarea:focus-visible { outline: 2px solid var(--link); outline-offset: 4px; } +.logo-art { font: inherit; line-height: 1.2; margin: 0 0 2em; overflow: visible; } +.logo { color: var(--logo); display: inline-block; } +.logo:hover { text-decoration: none; } +.site-nav { display: flex; flex-wrap: wrap; gap: 2ch; margin: 0 0 2em; } +.site-nav [aria-current] { color: inherit; } +h1, h2, h3 { font: inherit; font-weight: bold; } +h1 { margin: 0; } +h2, h3 { margin: 0 0 1em; } +p { margin: 0 0 1em; } +.introduction { text-align: center; } +.introduction p:last-child { margin-bottom: 0; } +.build-status, .notice { color: var(--warning); } +.ascii-bar { display: flex; white-space: pre; line-height: 1.5; user-select: none; } +.ascii-rule { flex: 1; min-width: 0; overflow: hidden; } +.panel-body { padding: 1em 2ch; background-image: repeating-linear-gradient(to bottom, currentColor 0 1em, transparent 1em 1.5em), repeating-linear-gradient(to bottom, currentColor 0 1em, transparent 1em 1.5em); background-size: 1px 100%; background-position: .5ch 0, calc(100% - .5ch) 0; background-repeat: no-repeat; } +.ascii-panel { margin: 1.5em 0; } +.ascii-field { margin: 1.5em 0; } +.field-heading { white-space: nowrap; font-weight: normal; margin: 0; } +.field-row { background-image: repeating-linear-gradient(to bottom, currentColor 0 1em, transparent 1em 1.5em), repeating-linear-gradient(to bottom, currentColor 0 1em, transparent 1em 1.5em); background-size: 1px 100%; background-position: .5ch 0, calc(100% - .5ch) 0; background-repeat: no-repeat; padding: .25em 2ch; display: flex; gap: 1ch; align-items: baseline; } +.field-row::before { content: '>'; color: var(--good); font-weight: bold; } +.field-end { height: 1.5em; } +button, input, textarea { font: inherit; color: inherit; background: transparent; border-radius: 0; } +button { appearance: none; border: 0; padding: 0; margin: .5em 2ch .5em 0; color: var(--link); font-weight: bold; cursor: pointer; } +button::before { content: '[ < '; } +button::after { content: ' > ]'; } +button:hover { text-decoration: underline; } button:disabled { opacity: .5; cursor: wait; } -input, textarea { display: block; width: 100%; margin: .35rem 0 1rem; } -input[type=number] { width: 8rem; } +#create-choice, #create-form button, #unlock-form button, #send-form button { color: var(--good); } +#lock { color: var(--logo); } +#retry { color: var(--warning); } +input, textarea { appearance: none; border: 0; padding: 0; margin: 0; min-width: 0; width: 100%; line-height: 1.5; } +input[type=file] { font-size: .875em; } +input[type=file]::file-selector-button { font: inherit; color: var(--link); background: transparent; border: 1px dashed currentColor; margin-right: 1ch; padding: .2em 1ch; cursor: pointer; } +input[type=number] { width: 7ch; } +textarea { resize: vertical; min-height: 7.5em; } label { display: block; } -section { margin: 1.5rem 0; } +section { margin: 2em 0; } [hidden] { display: none !important; } -.chat-heading { display: flex; flex-wrap: wrap; justify-content: space-between; align-items: center; } -#fingerprint, #conversation-fingerprint { overflow-wrap: anywhere; font-size: .8rem; } -#messages { padding: 0; list-style: none; } -#messages li { border-top: 1px dashed #48564a; padding: 1rem 0; } -#messages p { white-space: pre-wrap; overflow-wrap: anywhere; margin: .4rem 0; } -#messages small { color: #a8b39e; } -#status { min-height: 1.6rem; color: #e0c46d; } -footer { margin-top: 3rem; padding-bottom: 2rem; } -footer > a { display: block; width: fit-content; } -footer p { position: relative; } -#js-warning { display: none; max-width: 48rem; border: 1px solid #c5ad68; background: #1c231e; padding: 1rem; margin: .8rem 0; } -.badge:hover + #js-warning, .badge:focus-visible + #js-warning { display: block; } +.actions { display: flex; flex-wrap: wrap; gap: 0 2ch; } +.actions button { margin-right: 0; } +.chat-heading { display: flex; flex-wrap: wrap; justify-content: space-between; align-items: baseline; gap: 1ch; } +.chat-heading h2 { margin-bottom: 0; } +#fingerprint, #conversation-fingerprint { overflow-wrap: anywhere; font-size: .875em; color: var(--muted); margin-top: 1em; } +#thread { margin-top: 3em; } +#counts { color: var(--good); } +#history-form { display: flex; flex-wrap: wrap; align-items: baseline; gap: 1ch 2ch; } +#history-form .ascii-field { flex: 0 1 31ch; min-width: 24ch; margin: .5em 0; } +#messages { padding: 0; list-style: none; margin: 2em 0; } +#messages li::before { content: '--------------------------------------------------------------------------------'; display: block; overflow: hidden; white-space: pre; margin: 1em 0; } +#messages p { white-space: pre-wrap; overflow-wrap: anywhere; margin: .5em 0 1em; } +#messages small { color: var(--link); font: inherit; } +#status:empty { display: none; } +#status { min-height: 1.5em; color: var(--link); margin: 2em 0 1em; padding-left: 2ch; position: relative; } +#status::before { content: '>'; color: var(--good); position: absolute; left: 0; font-weight: bold; } +.libre-badge { position: absolute; top: 0; right: 0; color: var(--warning); font-weight: bold; } +.libre-badge > a[aria-describedby] { color: inherit; text-decoration: underline wavy; text-underline-offset: .2em; } +#js-warning { display: none; position: absolute; top: 2em; right: 0; width: 48ch; max-width: calc(100vw - 32px); padding: 1em 2ch; background: var(--background); color: var(--foreground); border: 1px dashed var(--warning); z-index: 1; font-weight: normal; } +.libre-badge > a[aria-describedby]:hover + #js-warning, .libre-badge > a[aria-describedby]:focus-visible + #js-warning { display: block; } +.script-licenses { display: block; color: var(--link); font-size: .875em; text-align: right; margin-top: .5em; } +.owner-entry { display: flex; flex-wrap: wrap; align-items: baseline; gap: .5em 1ch; margin: 1em 0; } +.owner-entry button { margin-right: 0; } +.owner-note { color: var(--muted); font-size: .875em; } +.sr-only { position: absolute; width: 1px; height: 1px; padding: 0; overflow: hidden; clip-path: inset(50%); white-space: nowrap; } +.source-footer { display: flex; flex-direction: column; align-items: flex-end; gap: .5em; text-align: right; margin: 3em 0 1em; font-weight: bold; } +.source-footer::before { content: '________________________________________________________________________________'; display: block; width: 100%; overflow: hidden; white-space: pre; margin-bottom: 1em; font-weight: normal; } +.project-license { color: var(--license); } +.dependency-licenses { color: var(--third-party); } +.license-page h1 { margin: 2em 0 1em; } +.license-table { overflow-x: auto; } +table { border-collapse: collapse; width: 100%; font: inherit; } +th, td { padding: .75em 1ch; text-align: left; border-bottom: 1px dashed currentColor; vertical-align: top; } + +@media (prefers-color-scheme: dark) { + :root { --background: #000; --foreground: #fff; --link: #6cf; --logo: #f33; --good: #0f0; --warning: #fc3; --license: #efb366; --third-party: #a3c7a8; --muted: #aaa; } +} + +@media (max-width: 600px) { + .libre-badge { position: relative; width: fit-content; margin: 0 0 2em auto; } + .logo-art { font-size: clamp(10px, 2.4vw, 16px); } + .field-heading { font-size: .875em; } + .panel-body { padding-inline: 2ch; } + .source-footer { align-items: flex-start; text-align: left; } + .actions { flex-direction: column; align-items: flex-start; } +} diff --git a/infra/containers/cgit/cgitrc.local b/crates/config/Cargo.toml index 20b533a..b0c0c8d 100644 --- a/infra/containers/cgit/cgitrc.local +++ b/crates/config/Cargo.toml @@ -13,17 +13,23 @@ # 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/>. -root-title=Doan Luu Duc Tai's repositories (local) -root-desc=Restored local copy -virtual-root=/git/ -clone-prefix=http://localhost:8080/git -css=/git/cgit.css -favicon=/git/favicon.ico -remove-suffix=1 -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 +[package] +name = "stack-config" +version = "0.1.0" +edition = "2024" +authors = ["Doan Luu Duc Tai <doan@luuduct.ai>"] +license = "AGPL-3.0-or-later" +description = "Validated shared deployment configuration for the personal stack." +repository = "https://git.doan.luuduct.ai/stack" +readme = "README.txt" +keywords = ["configuration", "self-hosting"] +categories = ["config"] +publish = false + +[dependencies] +anyhow = "1" +dotenvy = "0.15" +tempfile = "3" + +[lints] +workspace = true diff --git a/crates/config/README.txt b/crates/config/README.txt new file mode 100644 index 0000000..8648852 --- /dev/null +++ b/crates/config/README.txt @@ -0,0 +1,20 @@ +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/>. + +SHARED STACK CONFIGURATION + + Public defaults are documented in the root .env.example. Each installation + overrides them in the single root .env alongside private settings. Rendering + uses an explicit public allowlist. See docs/CONFIGURATION.txt for details. diff --git a/crates/config/src/lib.rs b/crates/config/src/lib.rs new file mode 100644 index 0000000..0a648a2 --- /dev/null +++ b/crates/config/src/lib.rs @@ -0,0 +1,332 @@ +// 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/>. + +#![doc = "Validated root environment configuration and public-only template rendering."] + +use anyhow::{Context, Result, ensure}; +use std::collections::BTreeMap; +use std::fmt; +use std::fs; +use std::io::Write; +use std::net::SocketAddr; +use std::os::unix::fs::PermissionsExt; +use std::path::{Component, Path}; + +const EXAMPLE: &str = include_str!("../../../.env.example"); +const HOSTS: &[&str] = &[ + "BASE_DOMAIN", + "SITE_HOST", + "GIT_HOST", + "SEARCH_HOST", + "WKD_HOST", + "CHAT_HOST", +]; +const PUBLIC: &[&str] = &[ + "BASE_DOMAIN", + "SITE_HOST", + "GIT_HOST", + "SEARCH_HOST", + "WKD_HOST", + "CHAT_HOST", + "HTTP_PORT", + "HTTPS_PORT", + "LOCAL_HTTP_PORT", + "CGIT_UPSTREAM", + "SEARXNG_UPSTREAM", + "CHAT_BIND", + "CHAT_ORIGIN", + "BACKUP_RECIPIENT", + "BACKUP_PUBLIC_KEY", +]; + +#[doc = "One installation's configuration; formatting deliberately excludes all values."] +pub struct Config { + values: BTreeMap<String, String>, + explicit: BTreeMap<String, String>, +} + +impl fmt::Debug for Config { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.debug_struct("Config").finish_non_exhaustive() + } +} + +fn defaults() -> Result<BTreeMap<String, String>> { + Ok(dotenvy::from_read_iter(EXAMPLE.as_bytes()).collect::<std::result::Result<_, _>>()?) +} + +fn hostname(value: &str) -> bool { + value.len() <= 253 + && value.split('.').all(|label| { + !label.is_empty() + && label.len() <= 63 + && !label.starts_with('-') + && !label.ends_with('-') + && label + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || byte == b'-') + }) +} + +fn endpoint(value: &str) -> bool { + if value.parse::<SocketAddr>().is_ok() { + return true; + } + value.rsplit_once(':').is_some_and(|(host, port)| { + hostname(host) && port.parse::<u16>().is_ok_and(|number| number > 0) + }) +} + +impl Config { + #[doc = "Loads the single root .env over public defaults from the published example."] + #[doc = "# Errors"] + #[doc = "Rejects malformed files and invalid public deployment values without printing secrets."] + pub fn load(root: &Path) -> Result<Self> { + let path = root.join(".env"); + let explicit: BTreeMap<String, String> = if path.exists() { + dotenvy::from_path_iter(path) + .context("Cannot parse root .env")? + .collect::<std::result::Result<_, _>>() + .map_err(|_| anyhow::anyhow!("Cannot parse root .env"))? + } else { + BTreeMap::new() + }; + let mut values = defaults()?; + values.retain(|key, _| PUBLIC.contains(&key.as_str())); + values.extend(explicit.clone()); + let config = Self { values, explicit }; + config.validate()?; + Ok(config) + } + + #[doc = "Returns a configured value; callers must not log private values."] + #[doc = "# Errors"] + #[doc = "Returns only the missing key name when a value is unavailable."] + pub fn value(&self, key: &str) -> Result<&str> { + self.values + .get(key) + .map(String::as_str) + .with_context(|| format!("Missing {key} in root .env")) + } + + #[doc = "Returns whether a setting is present in the installation file rather than defaults."] + #[must_use] + pub fn is_explicit(&self, key: &str) -> bool { + self.explicit.contains_key(key) + } + + #[doc = "Reads a nonzero TCP/UDP port."] + #[doc = "# Errors"] + #[doc = "Rejects invalid ports without displaying their raw values."] + pub fn port(&self, key: &str) -> Result<u16> { + let port = self + .value(key)? + .parse::<u16>() + .with_context(|| format!("Invalid {key} in root .env"))?; + ensure!(port > 0, "Invalid {key} in root .env"); + Ok(port) + } + + #[doc = "Reads the configured local chat socket address."] + #[doc = "# Errors"] + #[doc = "Rejects malformed socket addresses."] + pub fn chat_bind(&self) -> Result<SocketAddr> { + self.value("CHAT_BIND")? + .parse() + .context("Invalid CHAT_BIND in root .env") + } + + fn validate(&self) -> Result<()> { + for key in HOSTS { + ensure!(hostname(self.value(key)?), "Invalid {key} in root .env"); + } + ensure!( + self.port("HTTP_PORT")? != self.port("HTTPS_PORT")?, + "HTTP_PORT and HTTPS_PORT must differ" + ); + ensure!( + (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"] { + ensure!(endpoint(self.value(key)?), "Invalid {key} in root .env"); + } + self.chat_bind()?; + let origin = self.value("CHAT_ORIGIN")?; + ensure!( + origin.len() <= 256 + && (origin.starts_with("http://") || origin.starts_with("https://")) + && origin + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || b":/.-[]".contains(&byte)), + "Invalid CHAT_ORIGIN in root .env" + ); + let recipient = self.value("BACKUP_RECIPIENT")?; + ensure!( + [40, 64].contains(&recipient.len()) + && recipient.bytes().all(|byte| byte.is_ascii_hexdigit()), + "Invalid BACKUP_RECIPIENT in root .env" + ); + let key = Path::new(self.value("BACKUP_PUBLIC_KEY")?); + ensure!( + !key.as_os_str().is_empty() + && self + .value("BACKUP_PUBLIC_KEY")? + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || b"/._-".contains(&byte)) + && key + .components() + .all(|part| matches!(part, Component::Normal(_))), + "BACKUP_PUBLIC_KEY must be project-relative" + ); + Ok(()) + } + + #[doc = "Builds the explicit public-only template map for production or a local HTTP port."] + #[doc = "# Errors"] + #[doc = "Rejects invalid configuration or port arithmetic overflow."] + pub fn public_values(&self, local: Option<u16>) -> Result<BTreeMap<String, String>> { + let mut values = PUBLIC + .iter() + .map(|key| Ok(((*key).to_owned(), self.value(key)?.to_owned()))) + .collect::<Result<BTreeMap<_, _>>>()?; + let https = self.port("HTTPS_PORT")?; + let suffix = if https == 443 { + String::new() + } else { + format!(":{https}") + }; + for (name, key) in [ + ("SITE_ORIGIN", "SITE_HOST"), + ("GIT_ORIGIN", "GIT_HOST"), + ("SEARCH_ORIGIN", "SEARCH_HOST"), + ("WKD_ORIGIN", "WKD_HOST"), + ("CHAT_PUBLIC_ORIGIN", "CHAT_HOST"), + ] { + values.insert(name.into(), format!("https://{}{suffix}", self.value(key)?)); + } + if let Some(port) = local { + values.insert("SITE_ORIGIN".into(), format!("http://localhost:{port}")); + values.insert("GIT_ORIGIN".into(), "/git".into()); + values.insert("SEARCH_ORIGIN".into(), "/search".into()); + values.insert("WKD_ORIGIN".into(), String::new()); + values.insert( + "CHAT_PUBLIC_ORIGIN".into(), + self.value("CHAT_ORIGIN")?.into(), + ); + } + Ok(values) + } + + #[doc = "Renders only whitelisted public variables; private or unknown placeholders fail closed."] + #[doc = "# Errors"] + #[doc = "Rejects unknown placeholders without substituting private configuration."] + pub fn render(&self, source: &str, local: Option<u16>) -> Result<String> { + render(source, &self.public_values(local)?) + } +} + +#[doc = "Substitutes a previously filtered public configuration map into a source template."] +#[doc = "# Errors"] +#[doc = "Rejects remaining unknown placeholders."] +pub fn render(source: &str, public: &BTreeMap<String, String>) -> Result<String> { + let mut result = source.to_owned(); + for (key, value) in public { + result = result.replace(&format!("{{{{{key}}}}}"), value); + } + ensure!( + !result.contains("{{"), + "Unknown or private configuration placeholder" + ); + Ok(result) +} + +#[doc = "Appends missing public settings to the same root .env, preserving existing values."] +#[doc = "# Errors"] +#[doc = "Returns a filesystem/configuration error without printing environment contents."] +pub fn initialize_public(root: &Path) -> Result<()> { + let path = root.join(".env"); + let existing = if path.exists() { + fs::read_to_string(&path)? + } else { + String::new() + }; + let config = Config::load(root)?; + let mut appended = String::new(); + for (key, value) in defaults()? { + if PUBLIC.contains(&key.as_str()) && !config.is_explicit(&key) { + use std::fmt::Write as _; + writeln!(appended, "{key}={value}")?; + } + } + if appended.is_empty() { + return Ok(()); + } + let mut file = tempfile::NamedTempFile::new_in(root)?; + file.as_file() + .set_permissions(fs::Permissions::from_mode(0o600))?; + write!(file, "{existing}\n{appended}")?; + file.as_file().sync_all()?; + file.persist(path).map_err(|error| error.error)?; + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn public_rendering_rejects_secrets_and_host_injection() -> Result<()> { + let root = tempfile::tempdir()?; + fs::write( + root.path().join(".env"), + "CHAT_SETUP_TOKEN=\"private-malformed-sentinel\n", + )?; + let error = Config::load(root.path()) + .err() + .context("Malformed secret must fail")?; + assert!(!format!("{error:#}").contains("private-malformed-sentinel")); + fs::write( + root.path().join(".env"), + "SITE_HOST=home.example.net\nGIT_HOST=source.example.net\nHTTPS_PORT=8443\nLOCAL_HTTP_PORT=8180\nCHAT_SETUP_TOKEN=private-token-sentinel\nSEARXNG_SECRET=private-search-sentinel\n", + )?; + let config = Config::load(root.path())?; + assert_eq!( + config.render("{{SITE_ORIGIN}} {{GIT_ORIGIN}}", None)?, + "https://home.example.net:8443 https://source.example.net:8443" + ); + assert_eq!( + config.render("{{SITE_ORIGIN}} {{GIT_ORIGIN}}", Some(8180))?, + "http://localhost:8180 /git" + ); + assert!(config.render("{{CHAT_SETUP_TOKEN}}", None).is_err()); + assert!(!format!("{config:?}").contains("private-token")); + initialize_public(root.path())?; + assert_eq!( + Config::load(root.path())?.value("SITE_HOST")?, + "home.example.net" + ); + assert_eq!( + Config::load(root.path())?.value("CHAT_SETUP_TOKEN")?, + "private-token-sentinel" + ); + fs::write( + root.path().join(".env"), + "SITE_HOST=\"valid.example { respond bad }\"\n", + )?; + assert!(Config::load(root.path()).is_err()); + Ok(()) + } +} @@ -21,6 +21,10 @@ name = "stack" allow = ["AGPL-3.0-or-later"] [[licenses.exceptions]] +name = "stack-config" +allow = ["AGPL-3.0-or-later"] + +[[licenses.exceptions]] name = "stack-chat-server" allow = ["AGPL-3.0-or-later"] diff --git a/docs/CHAT-APPLICATION.txt b/docs/CHAT-APPLICATION.txt index 2a06649..7e58051 100644 --- a/docs/CHAT-APPLICATION.txt +++ b/docs/CHAT-APPLICATION.txt @@ -37,7 +37,11 @@ WHAT EXISTS and inbox; the canonical RSA OpenPGP key is not used for chat encryption. History defaults to zero messages. Authenticated counters show total, sent, - and received messages. Load 0 to 50 recent messages, then page backwards. + and received messages. Choose 0 to N recent messages, where N is the current + conversation total, or page backwards for older messages. Counts refresh when + the history input is focused, history is requested, or a send is acknowledged. + Large selections are fetched and verified in batches of at most 50; that is + a per-request resource bound, not a user-visible 50-message selection limit. Reload, lock, or leaving clears the application's in-memory references and visible messages. A separate authenticated session expires after 30 minutes. There is no automatic background polling, notification, or remote logout. @@ -63,6 +67,8 @@ LOCAL PREREQUISITES AND COMMANDS .env, creating it privately if necessary. It refuses to overwrite an existing setup token. The root .env.example lists both settings. Never commit .env. Existing SearXNG settings stay in the same file. No crate has its own .env. + CHAT_BIND and public navigation now use the shared root configuration loader; + see CONFIGURATION.txt for the documented deployment keys. For subsequent Rust-only edits, sh infra/chat/build-browser.sh reuses the previously built, hash-verified OpenSSL/SDK inputs. Rebuild OpenSSL itself @@ -73,15 +79,16 @@ LOCAL PREREQUISITES AND COMMANDS cargo stack chat - Open: - - http://127.0.0.1:9080/#setup + Open http://127.0.0.1:9080/ and choose OWNER SETUP, labeled for the owner + only. The direct http://127.0.0.1:9080/#setup link also works. Read CHAT_SETUP_TOKEN from your root .env locally and paste it into the owner setup form. Choose an owner display name and a strong unique password. Save the downloaded file, reopen it, and unlock it. That establishes the owner public identity. The setup token stops authorizing setup once an owner exists. - It does not become an owner login or a key recovery mechanism. Never publish + It does not become an owner login or a key recovery mechanism. OWNER SETUP + stays visible; once an owner exists, it explains how to open the existing + owner file or reset after loss instead of silently replacing the key. Never publish the owner private file or its password. Keep both in your private KeePassXC database, along with a separately protected backup of that database. @@ -97,6 +104,52 @@ LOCAL PREREQUISITES AND COMMANDS operational review, and a main-branch merge remain separate work. Do not expose this experimental development service through a public proxy. +OWNER FILE LOSS AND BACKEND RESET + + A setup token grants registration permission. It cannot recover the private + key inside a lost owner file. If that file and its backups are gone, existing + messages cannot be decrypted with a newly generated identity. The reset below + intentionally starts a fresh chat service and clears all stored messages, + encrypted profiles, conversations, counters, and the owner registration. + It preserves the root .env and any previously exported backup archives. + It does not claim forensic erasure of backups, snapshots, or OS copies. + + 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. + + Read the current public fingerprint, even if you have lost the private file: + + cargo stack chat --owner-fingerprint + + Stop the chat server with Control-C in its terminal, then explicitly pass + that full fingerprint to the destructive reset command: + + 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 + development process launched before the lock was added. Online snapshots and + reading the public owner fingerprint remain available while chat is running. + + Restart: + + cargo stack chat + + Choose OWNER SETUP and use the existing CHAT_SETUP_TOKEN from the same root + .env to generate, save, and reopen a fresh owner file. Back that file and its + password up in KeePassXC. Visitors must create fresh chat files too: their + previous files pin the former owner key and are correctly rejected after + replacement. A reset cannot prove continuity with the lost key. + + If an old owner file is found later, an old encrypted backup may still allow + recovering its original conversations in an isolated restored installation. + The reset does not remove existing exported archives, and it never substitutes + a new key into old ciphertext or old visitor files. + FILE AND MESSAGE CRYPTOGRAPHY OpenPGP v6, ML-KEM-1024 + X448 encryption, ML-DSA-87 + Ed448 signatures, @@ -178,7 +231,7 @@ AUTHENTICATION AND STORAGE Send retries are deliberate; messages are not automatically resent. Limits: 8192 UTF-8 bytes per text, 16384 bytes per signed payload, 65536 base64 - characters per stored ciphertext, 50 history rows per request, 1000 visitor + characters per stored ciphertext, 50 history rows per API request, 1000 visitor identities, 10000 messages per conversation, and a one-billion-character total message-ciphertext budget. At most 60 authentication challenges per global minute window, 128 pending challenges, 256 active sessions, and four @@ -187,7 +240,8 @@ AUTHENTICATION AND STORAGE protection against determined traffic, account creation, CPU, or disk attacks. Public-key and profile storage, indexes, journals, and backups add overhead to the ciphertext budget. Monitor actual disk use. No public abuse controls, - deletion UI, rotation UI, blocklist, or password-change UI are implemented. + deletion UI, in-place key rotation UI, blocklist, or password-change UI are + implemented. Lost-owner recovery uses the explicit backend reset above. BROWSER STATE AND LIMITATIONS @@ -263,6 +317,20 @@ BACKUP, EXPORT, AND RESTORE archive. Deleting a message locally or losing a file does not delete stored ciphertext, old exports, or other participants' copies. +INTERFACE AND SITE STYLE + + The interface follows web/styles.css and the previous www/message.html and + www/form.css from the parent of e373f15, the commit that removed the original + Message Me form. It reuses the red ASCII logo, 80-column desktop width, + black/white light and dark themes, blue links, green prompts/actions, ASCII + form frames, and bracketed controls. Licensing links retain the main site's + amber and muted green accents. The LibreJS badge stays yellow with its + keyboard-accessible warning. Small screens keep readable form controls and + fit the ASCII rules to the available width instead of shrinking all text. + Scrolling belongs to the root viewport at the screen edge; the logo has no + inner scroll container. JavaScript licenses sit below the LibreJS badge. + No remote fonts, images, or additional scripts are required. + LICENSES, SOURCE, AND VERIFICATION Portable root CLI builds now also need a musl C compiler because consistent @@ -305,7 +373,9 @@ LICENSES, SOURCE, AND VERIFICATION 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 do not initialize your real owner identity or touch production. + 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. LibreJS verification uses the Mozilla-distributed GNU LibreJS 7.21.1 in Playwright Firefox 157.0, without whitelisting the application. The full chat @@ -326,9 +396,13 @@ LICENSES, SOURCE, AND VERIFICATION The crypto browser probe fails closed without browser CSPRNG entropy and on unknown WASI imports. The application browser test uses real WASI/OpenSSL cryptography, protected file downloads and reopen, incorrect passwords, - signed sends/replies, safe text display, counts, zero-history, pagination, + signed sends/replies, safe text display, counts, zero-history, loading all + 54 fixture messages through 50+4 batches, pagination, lost-response retry deduplication, reload, explicit lock, origin rejection, - live database snapshot restoration with both original identity files, in-flight + 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. Native tests cover authorization, challenge reuse, expiry, locked sessions, diff --git a/docs/CONFIGURATION.txt b/docs/CONFIGURATION.txt new file mode 100644 index 0000000..9660e20 --- /dev/null +++ b/docs/CONFIGURATION.txt @@ -0,0 +1,142 @@ +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/>. + +CENTRAL CONFIGURATION + + Each installation has one root .env for deployment settings and secrets. + The published .env.example documents every supported setting with public + defaults, private placeholders, and comments. Hostnames are public information; + this arrangement centralizes configuration, not secrecy or access control. + Public values remain visible in generated pages, DNS, certificates, and URLs. + + Existing installations keep their configured secrets and settings. Missing + public defaults are appended to the same .env by cargo stack config, service + initialization, or chat initialization. No second environment file is created. + The example is also the shared Rust configuration library's default source, + so missing public settings remain compatible with older exports. + +PUBLIC SETTINGS + + BASE_DOMAIN + 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. + + HTTP_PORT, HTTPS_PORT + Production Caddy listener and Podman published ports. HTTPS also publishes + UDP for HTTP/3. Defaults are 80 and 443. Generated HTTPS links include a + nonstandard HTTPS port when configured. Ordinary public certificate + validation still needs the CA's standard external validation ports or an + appropriate alternative certificate/routing arrangement. Setting ports + alone does not configure DNS, firewall rules, or external port forwarding. + + LOCAL_HTTP_PORT + Local Caddy HTTP port on 127.0.0.1. HTTPS host port is this value plus one. + Local Caddy still listens on container ports 80/443. --port overrides the + local port for that invocation. Existing installation/project names and + 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 + 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. + Identity files pin their origin; editing these values does not migrate + encrypted identities or grant access on another origin. + + BACKUP_RECIPIENT, BACKUP_PUBLIC_KEY + Public OpenPGP fingerprint and project-relative public key path used for + encrypted exports. --recipient and --key override them. The secret GnuPG + key and revocation certificate remain outside the project. + +PRIVATE SETTINGS + + SEARXNG_SECRET + Generated on first stack initialization. Existing values are preserved. + + CHAT_SETUP_TOKEN + Generated by cargo stack chat --init. Used only to authorize first owner + registration, including a fresh registration after an explicit backend + reset. It cannot recover a lost owner private key. + +RENDER AND APPLY + + Edit .env locally. Never commit or publish it. Inspect the commented example + for exact keys. Hostnames, ports, socket addresses, fingerprints, and paths + are validated before use. Diagnostic formatting never dumps the config map. + Invalid environment-file syntax is reported without echoing its raw contents. + + Render public deployment files without starting services: + + cargo stack config + cargo stack config --production + + Outputs are var/local/ or var/production/: + + Caddyfile + cgitrc + web/ + + infra/Caddyfile, infra/Caddyfile.local, infra/containers/cgit/cgitrc, and the + first-party website HTML are templates using {{PUBLIC_KEY}} placeholders. + 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. + + To apply changes to the existing services: + + cargo stack up --local + + Production uses its prebuilt management command and images: + + ./bin/stack up --production + + Rendered website files can be read by an already running file server, while + listeners, container environment, and loaded Caddy configuration require the + service update above. Restart the local chat server with cargo stack chat to + load its new bind, origin, or public navigation settings. Reopen your identity + file afterward, as with any chat restart. + +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 + rendered public configuration and does not need the .env or private tokens. + + The root .env, runtime files, local render outputs, exports, and tool caches + remain ignored by Git. Source templates, the commented example, validated + defaults, and this configuration implementation are public. Full encrypted + backups include the single .env; source-only releases do not include it. + + Personal content and attribution remain source content: copyright headers, + author names, canonical contact information, public key material/WKD hashes, + project repository metadata, and GNU license links. Changing a deployment + hostname does not create a new email identity or regenerate WKD keys. + Image digests, source hashes, and fixed crypto/protocol/resource bounds remain + reviewed reproducible code/configuration rather than private deployment knobs. diff --git a/docs/GIT-HISTORY.txt b/docs/GIT-HISTORY.txt new file mode 100644 index 0000000..2dfc335 --- /dev/null +++ b/docs/GIT-HISTORY.txt @@ -0,0 +1,85 @@ +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/>. + +GIT IDENTITY AND SIGNATURE REWRITE + + Requested to replace the historical ops <ops@localhost> identity with + Doan Luu Duc Tai <doan@luuduct.ai> and sign the history with the canonical + RSA-2048 key 3346AA3150F8F16828BB0D14F8C66DF77AFECEA6. + + Ten historical author identities and their corresponding committer identities + were corrected. All 27 commits reachable from main and feature/private-chat + at the time of the rewrite were recreated and signed because rewritten + parents change descendant commit IDs. Every new signature was verified. + + Every corresponding tree, message, original author timestamp/time zone, and + original committer timestamp/time zone was preserved. Merge topology was + preserved. Signature creation times reflect the new signing operation. + main's file tree is unchanged. Configuration/chat development stays on + feature/private-chat; rewriting metadata does not deploy that code to main. + + Remote updates use an atomic push and exact force-with-lease expectations + for both branches, preventing overwrite of an unexpected remote update. + Existing objects and reflogs may still contain old IDs: this is an authorship + correction, not a claim of private-data removal or historical erasure. + +EXISTING CLONES + + The active development checkout is already aligned. Other clones must fetch + the new history instead of using an ordinary pull across divergent histories. + Preserve local-only commits and working changes first. A fresh development + clone is also possible; keep private .env and runtime data out of Git. + + For an existing clone with no local-only commits: + + git fetch origin + git switch main + git reset --keep origin/main + + For a feature checkout, use feature/private-chat in the final two commands. + --keep refuses updates that would overwrite conflicting working changes. + Do not blindly reset a clone with independent local work; use the mapping + below to identify the corresponding rewritten base before rebasing that work. + Git changes do not remove or replace ignored .env or var/ data. + +ORIGINAL TO REWRITTEN COMMITS + + 47fcc566884d7078c153cdce9fb62399fcca5d9e c4b2f892a1c6508360a683c90e8c4d596c521336 + 37d5e4a152df67d247349d2ed289bf65859ead53 020d3454cad6619bf25c6ecb0a293502f40364bd + 8a327a41cdce40933b977f7c6662929488bcf83e 64a1b484abb96c91da9aa23e0e45576749f6cdfe + 3a8956f9cb85184ae2b8202d7a733124a0d3f95f 9599590f67c24f70c400b967d162095c63f6af61 + 2f5c323be4a44b80cf9e39d51210776146a77cbd 1632c88ba08c62966c1f0b1578741d8af007e6fd + 118ba081389cfd80b38c9c691fd6d75f6497dcb5 274e70679b83bcac3dee3599bd9389a3332462ec + 38aa5d1f2a8dfc8ea97c0fdce8feae7e37795533 5317fa43b79e4251fcbcff2b4f1243a961ac00fe + 902cfd395fb03d6147813560ac5fcd7146702a1d 463c6f48a06730c29f89b2f376e68dc805df5cc9 + e373f154dbeacc5a0e83b4ec73c3aa316239c029 5386a63bfb2f14cab99874e7c414e1a418b01f67 + 006a1e94075e90fccfe248cd66bf7d0586a86279 84960925108eb2b5950d4c299b15b18893e0c0d0 + 19a0280d7dabd4ca637d8070cc7d3691a0e85bd1 909dce458d4bbef6710c2b4a67cd906902bb7f3f + c06e29dae4bb290fd2849f69da1bfa940c5a8222 5bed750baa5d782427e98550d34124f569e2fff8 + 714866be200b882be437375006180456284bab1c 9c7dd654512b8f51af15d50dd75a515566723be7 + 9f070f7ad0952c61b4ee66d2a01ff1bdf7ade0a8 cad7ab1e1ab1dde8ff4ba349cd5b1a05bbd32b05 + faaac8558431c80a6b8fbcf2c8f17f16d8602ab6 1386bd16b0fd796314d9ad70aede0946bd5c46bd + 92bf96961c09d7df6b1bfea72eb8a107bcf9e221 d546193ee543a5b1a0a097d0a7deca76e81b7de2 + 0a0cdc0c44dd8b4fb44d8a859962c40c593e98eb f196233257d247c94550cf822b91bc3c9d8a7f8f + 1206813d47e8602f08cbeaf27e73453e089e7c52 834a21a7cadce64889856a2a7521440a1519e244 + d611c869eb12e7fff5754be31775f82200ec958b b46ece18a424eaea3f396ddae7e45e91d1c0e742 + 436e217c7858888702d2b6d9e655597ccadb7281 f196c27faf265cd14d8d7accf58e87b4df3c8a91 + 7a0ce2805258e8ac061202904379692d53d5dc95 cf2f0e8c6752c401f152a8e534ba166839d4307e + 774074bb2bbd3bc07f7094cdce0935eca7520725 a0057b13fa4b6bed8aee3132001e79a245efabc8 + 143880db086c617b843340ca7023a879507761f8 0a4da3b86eb69f0f09b802a55e36fa9cf1bbf38c + 556eaa133e9cfe7f4c8e542708119fdfd311357e e6b350a1d48ca0490b7a3447bb8d0a4235514b76 + b7374fe09306c495131cf3673addb76a1dcf017d 3034705b6679bff1802c4180b7b4711b94fed7f9 + be16d00a8a4750028073e018ab2c58d54af192c4 c3b361c45ac8dd898d5c83a161abed5fc138b6c0 + 28a772cab50b1064e317aedbe4689aea286b1ec4 6933ec28be3dac0dc17cd634c663db3b4818caad diff --git a/docs/OPERATIONS.txt b/docs/OPERATIONS.txt index 826c4cf..2cf4693 100644 --- a/docs/OPERATIONS.txt +++ b/docs/OPERATIONS.txt @@ -218,7 +218,9 @@ SET UP A NEW PRODUCTION HOST Point public A records to the new IPv4 address, and AAAA records only to a working new IPv6 address. Remove stale AAAA records. Public hostnames are - listed in infra/Caddyfile. Update canonical hosts there if changing domains. + configured in the root .env. Edit those values and reapply the service + configuration; infra/Caddyfile is now a public rendering template. + See CONFIGURATION.txt for all keys and the render-only commands. Keep the www aliases and openpgpkey names pointing to the canonical hosts. Start production: diff --git a/docs/POLICY.txt b/docs/POLICY.txt index 4c8d0bd..f662dd4 100644 --- a/docs/POLICY.txt +++ b/docs/POLICY.txt @@ -30,10 +30,13 @@ PROJECT CONVENTIONS documentation. The generated HTML license page is an explicit exception. Rust documentation attributes provide crate metadata without comments. - Keep configured secrets only in the root .env. Never commit .env or var/. + Keep deployment overrides and configured secrets in the single root .env. + Never commit .env or var/. Only the root .env.example is published; environment variants and nested - examples are ignored. .env.example lists required keys with placeholders. Public hostnames, - public GPG keys, paths, configuration, and source belong in Git. + examples are ignored. The commented .env.example publishes supported public + defaults and private placeholders. Public GPG keys, paths, templates, shared + configuration logic, and source belong in Git. Actual public hostnames remain + visible in rendered pages; .env centralization is not an access boundary. TLS account/certificate private keys, databases, and other generated private state are persistent data in var/data, not .env configuration. The GPG secret key and revocation certificate remain outside this project. diff --git a/infra/Caddyfile b/infra/Caddyfile index 3146529..424e129 100644 --- a/infra/Caddyfile +++ b/infra/Caddyfile @@ -13,11 +13,16 @@ # 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/>. +{ + http_port {{HTTP_PORT}} + https_port {{HTTPS_PORT}} +} + (https_security) { header >Strict-Transport-Security "max-age=31536000" } -luuduct.ai { +https://{{BASE_DOMAIN}}:{{HTTPS_PORT}} { import https_security handle /.well-known/openpgpkey/* { root * /srv @@ -26,16 +31,16 @@ luuduct.ai { } handle { - redir https://doan.luuduct.ai{uri} permanent + redir {{SITE_ORIGIN}}{uri} permanent } } -www.luuduct.ai { +https://www.{{BASE_DOMAIN}}:{{HTTPS_PORT}} { import https_security - redir https://doan.luuduct.ai{uri} permanent + redir {{SITE_ORIGIN}}{uri} permanent } -doan.luuduct.ai { +https://{{SITE_HOST}}:{{HTTPS_PORT}} { import https_security root * /srv file_server @@ -43,15 +48,15 @@ doan.luuduct.ai { redir /git /git/ handle /cgit* { - reverse_proxy cgit:8080 + reverse_proxy {{CGIT_UPSTREAM}} } handle /git/* { - reverse_proxy cgit:8080 + reverse_proxy {{CGIT_UPSTREAM}} } } -openpgpkey.luuduct.ai { +https://{{WKD_HOST}}:{{HTTPS_PORT}} { import https_security handle /.well-known/openpgpkey/* { root * /srv @@ -60,34 +65,34 @@ openpgpkey.luuduct.ai { } } -git.doan.luuduct.ai { +https://{{GIT_HOST}}:{{HTTPS_PORT}} { import https_security redir /{path}.git /{path} permanent - reverse_proxy cgit:8080 + reverse_proxy {{CGIT_UPSTREAM}} } -search.doan.luuduct.ai { +https://{{SEARCH_HOST}}:{{HTTPS_PORT}} { import https_security - reverse_proxy searxng:8080 + reverse_proxy {{SEARXNG_UPSTREAM}} } -www.doan.luuduct.ai { +https://www.{{SITE_HOST}}:{{HTTPS_PORT}} { import https_security - redir https://doan.luuduct.ai{uri} permanent + redir {{SITE_ORIGIN}}{uri} permanent } -www.git.doan.luuduct.ai { +https://www.{{GIT_HOST}}:{{HTTPS_PORT}} { import https_security - redir https://git.doan.luuduct.ai{uri} permanent + redir {{GIT_ORIGIN}}{uri} permanent } -www.search.doan.luuduct.ai { +https://www.{{SEARCH_HOST}}:{{HTTPS_PORT}} { import https_security - redir https://search.doan.luuduct.ai{uri} permanent + redir {{SEARCH_ORIGIN}}{uri} permanent } -www.openpgpkey.luuduct.ai { +https://www.{{WKD_HOST}}:{{HTTPS_PORT}} { import https_security - redir https://openpgpkey.luuduct.ai{uri} permanent + redir {{WKD_ORIGIN}}{uri} permanent } diff --git a/infra/Caddyfile.local b/infra/Caddyfile.local index 4d3bf11..6e3508c 100644 --- a/infra/Caddyfile.local +++ b/infra/Caddyfile.local @@ -19,12 +19,12 @@ @git path /git /git/* /cgit.js /cgit.png handle @git { uri strip_prefix /git - reverse_proxy cgit:8080 + reverse_proxy {{CGIT_UPSTREAM}} } @search path /search /search/* handle @search { - reverse_proxy searxng:8080 { + reverse_proxy {{SEARXNG_UPSTREAM}} { header_up X-Script-Name /search } } diff --git a/infra/containers/cgit/cgitrc b/infra/containers/cgit/cgitrc index 9aaf8d5..0dacb99 100644 --- a/infra/containers/cgit/cgitrc +++ b/infra/containers/cgit/cgitrc @@ -16,7 +16,7 @@ root-title=Doan Luu Duc Tai's repositories root-desc=Personal git repos virtual-root=/ -clone-prefix=https://git.doan.luuduct.ai +clone-prefix={{GIT_ORIGIN}} css=/cgit.css favicon=/favicon.ico remove-suffix=1 diff --git a/src/archive.rs b/src/archive.rs index 03ec643..4d2c01c 100644 --- a/src/archive.rs +++ b/src/archive.rs @@ -24,9 +24,6 @@ use serde_json::Value; use crate::project::{Mode, Project, Settings, field}; use crate::{files, process}; -pub(crate) const RECIPIENT: &str = "3346AA3150F8F16828BB0D14F8C66DF77AFECEA6"; -const KEY: &str = "web/.well-known/openpgpkey/luuduct.ai/hu/t76q8xqkeassgy1kr38re1ppj6cpc1dp"; - #[derive(Debug)] struct Paused<'a> { project: &'a Project, @@ -192,7 +189,11 @@ pub(crate) fn backup( .tempdir_in(destination)?; let home = temp.path().join("gnupg"); files::private_dir(&home)?; - let key = public_key.map_or_else(|| project.root.join(KEY), Path::to_path_buf); + let configuration = stack_config::Config::load(&project.root)?; + let key = public_key.map_or_else( + || Ok(project.root.join(configuration.value("BACKUP_PUBLIC_KEY")?)), + |path| Ok::<_, anyhow::Error>(path.to_path_buf()), + )?; let operation = backup_inner( project, destination, diff --git a/src/main.rs b/src/main.rs index 08c88f5..7f5bcdc 100644 --- a/src/main.rs +++ b/src/main.rs @@ -21,6 +21,7 @@ pub(crate) mod images; pub(crate) mod process; pub(crate) mod project; pub(crate) mod release; +pub(crate) mod render; use std::path::{Path, PathBuf}; use std::process::ExitCode; @@ -51,13 +52,22 @@ struct Cli { enum Action { #[command(about = "Run the experimental local chat or initialize its single .env")] Chat { - #[arg(long)] + #[arg(long, conflicts_with_all = ["reset_owner", "owner_fingerprint"])] init: bool, + #[arg( + long, + value_name = "FINGERPRINT", + conflicts_with = "owner_fingerprint", + help = "Permanently clear owner registration and chat history after key loss" + )] + reset_owner: Option<String>, + #[arg(long, help = "Print the registered owner's public fingerprint")] + owner_fingerprint: bool, }, #[command(about = "Start the local development services")] Dev { - #[arg(long, default_value_t = 8080)] - port: u16, + #[arg(long)] + port: Option<u16>, }, #[command(about = "Start or update services in the selected mode")] Up { @@ -70,6 +80,15 @@ enum Action { #[arg(long)] online: bool, }, + #[command( + about = "Render public Caddy, website, and cgit configuration without starting services" + )] + Config { + #[arg(long)] + production: bool, + #[arg(long)] + port: Option<u16>, + }, #[command(about = "Show service status")] Status, #[command(about = "Stop services while retaining data")] @@ -79,8 +98,8 @@ enum Action { destination: Option<PathBuf>, #[arg(long)] include_images: bool, - #[arg(long, default_value = archive::RECIPIENT)] - recipient: String, + #[arg(long)] + recipient: Option<String>, #[arg(long)] key: Option<PathBuf>, }, @@ -132,22 +151,23 @@ fn execute(arguments: Cli) -> Result<()> { return archive::restore(&absolute(archive)?, &absolute(destination)?); } let project = Project::discover(arguments.root.as_deref())?; - if let Action::Chat { init } = arguments.command { - let mut command = project.command("cargo")?; - command - .args(["run", "--locked", "-p", "stack-chat-server", "--", "--root"]) - .arg(&project.root); - if init { - command.arg("--init"); - } - return process::run(&mut command); + if let Action::Chat { + init, + reset_owner, + owner_fingerprint, + } = arguments.command + { + return chat(&project, init, reset_owner.as_deref(), owner_fingerprint); } + let _operation = project.lock()?; match arguments.command { Action::Dev { port } => { let mut settings = project.settings()?; settings.mode = Mode::Local; - settings.port = port; + if let Some(selected_port) = port { + settings.port = selected_port; + } images::prepare(&project, &mut settings)?; project.up(&settings) } @@ -173,6 +193,23 @@ fn execute(arguments: Cli) -> Result<()> { images::prepare(&project, &mut settings)?; project.up(&settings) } + Action::Config { production, port } => { + let mut settings = project.settings()?; + settings.mode = if production { + Mode::Production + } else { + Mode::Local + }; + if let Some(selected_port) = port { + settings.port = selected_port; + } + stack_config::initialize_public(&project.root)?; + render::prepare(&project, &settings)?; + process::line(format_args!( + "Public configuration: {}", + render::directory(&project, &settings).display() + )) + } Action::Status => project.status(&project.settings()?), Action::Down => project.down(&project.settings()?), Action::Backup { @@ -185,11 +222,14 @@ fn execute(arguments: Cli) -> Result<()> { || Ok(project.root.join("var/exports")), |path| absolute(&path), )?; + let configuration = stack_config::Config::load(&project.root)?; + let selected_recipient = + recipient.unwrap_or(configuration.value("BACKUP_RECIPIENT")?.to_owned()); archive::backup( &project, &output, include_images, - &recipient, + &selected_recipient, key.as_deref(), ) } @@ -206,6 +246,28 @@ fn execute(arguments: Cli) -> Result<()> { } } +fn chat( + project: &Project, + init: bool, + reset_owner: Option<&str>, + owner_fingerprint: bool, +) -> Result<()> { + let mut command = project.command("cargo")?; + command + .args(["run", "--locked", "-p", "stack-chat-server", "--", "--root"]) + .arg(&project.root); + if init { + command.arg("--init"); + } + if let Some(fingerprint) = reset_owner { + command.arg("--reset-owner").arg(fingerprint); + } + if owner_fingerprint { + command.arg("--owner-fingerprint"); + } + process::run(&mut command) +} + fn absolute(path: &Path) -> Result<PathBuf> { Ok(if path.is_absolute() { path.to_path_buf() diff --git a/src/project.rs b/src/project.rs index 0dac2ef..6ef619a 100644 --- a/src/project.rs +++ b/src/project.rs @@ -16,7 +16,7 @@ use std::collections::BTreeMap; use std::env; use std::fs::{self, File, OpenOptions, Permissions}; -use std::io::Read; +use std::io::{Read, Write}; use std::os::unix::fs::{OpenOptionsExt, PermissionsExt}; use std::path::{Component, Path, PathBuf}; use std::process::Command; @@ -25,6 +25,7 @@ use std::time::Duration; use anyhow::{Context, Result, bail, ensure}; use serde::{Deserialize, Serialize}; use serde_json::Value; +use stack_config::Config; use crate::{files, process}; @@ -116,11 +117,16 @@ impl Project { pub(crate) fn settings(&self) -> Result<Settings> { let path = self.root.join("var/state.json"); if path.exists() { - return Ok(serde_json::from_reader(File::open(path)?)?); + let mut settings: Settings = serde_json::from_reader(File::open(path)?)?; + let config = Config::load(&self.root)?; + if config.is_explicit("LOCAL_HTTP_PORT") { + settings.port = config.port("LOCAL_HTTP_PORT")?; + } + return Ok(settings); } Ok(Settings { mode: Mode::Local, - port: 8080, + port: Config::load(&self.root)?.port("LOCAL_HTTP_PORT")?, project: format!( "stack-{}", files::hash_bytes(self.root.as_os_str().as_encoded_bytes()) @@ -230,16 +236,17 @@ 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 !env_file.exists() { + if !Config::load(&self.root)?.is_explicit("SEARXNG_SECRET") { let mut random = [0; 32]; File::open("/dev/urandom")?.read_exact(&mut random)?; - let secret = hex::encode(random); - files::write( - &env_file, - format!("SEARXNG_SECRET={secret}\n").as_bytes(), - 0o600, - )?; + let mut file = OpenOptions::new() + .create(true) + .append(true) + .mode(0o600) + .open(&env_file)?; + writeln!(file, "\nSEARXNG_SECRET={}", hex::encode(random))?; } + stack_config::initialize_public(&self.root)?; fs::set_permissions(&env_file, Permissions::from_mode(0o600))?; for name in ["repos", "caddy/data", "caddy/config", "searxng"] { fs::create_dir_all(self.root.join("var/data").join(name))?; @@ -268,42 +275,7 @@ impl Project { &self.root.join("infra/searxng"), &self.root.join("var/data/searxng"), )?; - if settings.mode == Mode::Local { - self.local_site(settings.port)?; - } - Ok(()) - } - - fn local_site(&self, port: u16) -> Result<()> { - let generated = self.root.join("var/local/web"); - if generated.exists() { - files::regular_tree(&generated)?; - fs::remove_dir_all(&generated)?; - } - files::copy_tree(&self.root.join("web"), &generated)?; - let index = generated.join("index.html"); - let html = fs::read_to_string(&index)? - .replace("https://git.doan.luuduct.ai", "/git") - .replace("https://search.doan.luuduct.ai", "/search") - .replace("https://openpgpkey.luuduct.ai", ""); - files::write(&index, html.as_bytes(), 0o644)?; - let template = self.root.join("infra/containers/cgit/cgitrc.local"); - let content = fs::read_to_string(&template)? - .lines() - .map(|line| { - if line.starts_with("clone-prefix=") { - format!("clone-prefix=http://localhost:{port}/git") - } else { - line.to_owned() - } - }) - .collect::<Vec<_>>() - .join("\n"); - files::write( - &self.root.join("var/local/cgitrc"), - format!("{content}\n").as_bytes(), - 0o644, - ) + crate::render::prepare(self, settings) } pub(crate) fn container_name(settings: &Settings, service: &str) -> String { @@ -343,9 +315,7 @@ impl Project { .args(["network", "create", &network]), )?; } - let environment: BTreeMap<String, String> = - dotenvy::from_path_iter(self.root.join(".env"))? - .collect::<std::result::Result<_, _>>()?; + let environment = Config::load(&self.root)?; for (name, service) in config .services .iter() @@ -364,7 +334,11 @@ impl Project { process::line(format_args!( "Running {}", if settings.mode == Mode::Production { - "https://doan.luuduct.ai".to_owned() + environment + .public_values(None)? + .get("SITE_ORIGIN") + .context("Missing public site origin")? + .to_owned() } else { format!("http://localhost:{}", settings.port) } @@ -401,7 +375,7 @@ impl Project { network: &str, name: &str, service: &Service, - environment: &BTreeMap<String, String>, + environment: &Config, ) -> Result<()> { let container = Self::container_name(settings, name); let mut command = self.command("podman")?; @@ -439,39 +413,29 @@ impl Project { ] .to_vec() } else { - ["[::]:80:80", "[::]:443:443", "[::]:443:443/udp"] - .map(str::to_owned) - .to_vec() + let http = environment.port("HTTP_PORT")?; + let https = environment.port("HTTPS_PORT")?; + vec![ + format!("[::]:{http}:{http}"), + format!("[::]:{https}:{https}"), + format!("[::]:{https}:{https}/udp"), + ] }; for port in ports { command.args(["--publish", &port]); } } for key in &service.environment { - command.arg("--env").arg(key).env( - key, - environment - .get(key) - .with_context(|| format!("Missing secret {key} in .env"))?, - ); + command + .arg("--env") + .arg(key) + .env(key, environment.value(key)?); } for mount in &service.mounts { let source = match mount.source.as_str() { - "@caddy" => self.root.join(if settings.mode == Mode::Local { - "infra/Caddyfile.local" - } else { - "infra/Caddyfile" - }), - "@web" => self.root.join(if settings.mode == Mode::Local { - "var/local/web" - } else { - "web" - }), - "@cgit" => self.root.join(if settings.mode == Mode::Local { - "var/local/cgitrc" - } else { - "infra/containers/cgit/cgitrc" - }), + "@caddy" => crate::render::directory(self, settings).join("Caddyfile"), + "@web" => crate::render::directory(self, settings).join("web"), + "@cgit" => crate::render::directory(self, settings).join("cgitrc"), other => self.path(other)?, }; let mode = if mount.read_only { "ro,z" } else { "rw,z" }; diff --git a/src/render.rs b/src/render.rs new file mode 100644 index 0000000..8e1ee1e --- /dev/null +++ b/src/render.rs @@ -0,0 +1,116 @@ +// 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::files; +use crate::project::{Mode, Project, Settings}; +use anyhow::Result; +use stack_config::Config; +use std::fs; +use std::path::PathBuf; + +pub(crate) fn directory(project: &Project, settings: &Settings) -> PathBuf { + project.root.join(match settings.mode { + Mode::Local => "var/local", + Mode::Production => "var/production", + }) +} + +pub(crate) fn prepare(project: &Project, settings: &Settings) -> Result<()> { + let config = Config::load(&project.root)?; + let local = (settings.mode == Mode::Local).then_some(settings.port); + let output = directory(project, settings); + let web = output.join("web"); + if web.exists() { + files::regular_tree(&web)?; + fs::remove_dir_all(&web)?; + } + files::copy_tree(&project.root.join("web"), &web)?; + for name in ["index.html", "message/index.html"] { + let file = web.join(name); + files::write( + &file, + config + .render(&fs::read_to_string(&file)?, local)? + .as_bytes(), + 0o644, + )?; + } + let template = project.root.join(if settings.mode == Mode::Local { + "infra/Caddyfile.local" + } else { + "infra/Caddyfile" + }); + files::write( + &output.join("Caddyfile"), + config + .render(&fs::read_to_string(template)?, local)? + .as_bytes(), + 0o644, + )?; + let cgit = fs::read_to_string(project.root.join("infra/containers/cgit/cgitrc"))?; + let mut rendered = config.render(&cgit, local)?; + if let Some(port) = local { + rendered = rendered.replace( + "clone-prefix=/git", + &format!("clone-prefix=http://localhost:{port}/git"), + ); + } + files::write(&output.join("cgitrc"), rendered.as_bytes(), 0o644) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn custom_domains_and_ports_reach_public_outputs_without_secrets() -> Result<()> { + let root = tempfile::tempdir()?; + for name in ["web", "infra"] { + files::copy_tree( + &PathBuf::from(env!("CARGO_MANIFEST_DIR")).join(name), + &root.path().join(name), + )?; + } + fs::write( + root.path().join(".env"), + "SITE_HOST=home.example.net\nGIT_HOST=git.example.net\nHTTP_PORT=8181\nHTTPS_PORT=8443\nSEARXNG_SECRET=do-not-publish-this-sentinel\n", + )?; + let project = Project::at(root.path())?; + let mut settings = project.settings()?; + settings.mode = Mode::Production; + prepare(&project, &settings)?; + let output = directory(&project, &settings); + let caddy = fs::read_to_string(output.join("Caddyfile"))?; + assert!(caddy.contains("https://home.example.net:8443")); + assert!(caddy.contains("http_port 8181")); + assert!(!caddy.contains("do-not-publish")); + assert!( + fs::read_to_string(output.join("web/index.html"))? + .contains("https://git.example.net:8443") + ); + 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/\"") + ); + assert!( + fs::read_to_string(directory(&project, &settings).join("cgitrc"))? + .contains("clone-prefix=http://localhost:8180/git") + ); + Ok(()) + } +} diff --git a/web/index.html b/web/index.html index 48ef508..db8e819 100644 --- a/web/index.html +++ b/web/index.html @@ -42,16 +42,16 @@ along with this program. If not, see <https://www.gnu.org/licenses/>. |____/ \___/_/ \_\_| \_| LUU DUC TAI </a> -<a href="/">Home</a> <a href="#services">Services</a> <a href="https://chat.doan.luuduct.ai/">Message Me</a> +<a href="/">Home</a> <a href="#services">Services</a> <a href="{{CHAT_PUBLIC_ORIGIN}}/">Message Me</a> +------------------------------------------------------------------------------+ | | | Doan Luu Duc Tai | -| <a href="https://chat.doan.luuduct.ai/">Message Me</a> | +| <a href="{{CHAT_PUBLIC_ORIGIN}}/">Message Me</a> | | | | Email: <a href="mailto:doan@luuduct.ai">doan@luuduct.ai</a> | -| GnuPG: <a href="https://openpgpkey.luuduct.ai/.well-known/openpgpkey/luuduct.ai/hu/t76q8xqkeassgy1kr38re1ppj6cpc1dp?l=doan">3346AA3150F8F16828BB0D14F8C66DF77AFECEA6</a> | +| GnuPG: <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> | | | @@ -64,13 +64,13 @@ along with this program. If not, see <https://www.gnu.org/licenses/>. ________________________________________________________________________________ -<a href="https://git.doan.luuduct.ai/">git.doan.luuduct.ai</a> +<a href="{{GIT_ORIGIN}}/">{{GIT_HOST}}</a> * A <a href="https://git.zx2c4.com/cgit">cgit</a> instance, <span class="good">functional without JS</span>. Personal Git repositories -<a href="https://search.doan.luuduct.ai/">search.doan.luuduct.ai</a> +<a href="{{SEARCH_ORIGIN}}/">{{SEARCH_HOST}}</a> * A <a href="https://github.com/searxng/searxng">SearXNG</a> instance, <span class="good">functional without JS</span>. Private search engine @@ -82,8 +82,8 @@ ________________________________________________________________________________ <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="https://git.doan.luuduct.ai/stack/about/">[ THIRD PARTY LICENSES ]</a> - <a href="https://git.doan.luuduct.ai/stack/tree/web/index.html">[ SOURCE CODE ]</a> + <a class="dependency-licenses" href="{{GIT_ORIGIN}}/stack/about/">[ THIRD PARTY LICENSES ]</a> + <a href="{{GIT_ORIGIN}}/stack/tree/web/index.html">[ SOURCE CODE ]</a> </nav> </div> diff --git a/web/message/index.html b/web/message/index.html index ea64d40..d1739b0 100644 --- a/web/message/index.html +++ b/web/message/index.html @@ -44,8 +44,8 @@ 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="https://git.doan.luuduct.ai/stack/about/">[ THIRD PARTY LICENSES ]</a> - <a href="https://git.doan.luuduct.ai/stack/tree/web/message/index.html">[ SOURCE CODE ]</a> + <a class="dependency-licenses" href="{{GIT_ORIGIN}}/stack/about/">[ THIRD PARTY LICENSES ]</a> + <a href="{{GIT_ORIGIN}}/stack/tree/web/message/index.html">[ SOURCE CODE ]</a> </nav> </div> </div> |
