diff options
Diffstat (limited to 'crates')
| -rw-r--r-- | crates/chat-crypto/README.txt | 6 | ||||
| -rw-r--r-- | crates/chat-crypto/src/application.rs | 115 | ||||
| -rw-r--r-- | crates/chat-server/Cargo.toml | 1 | ||||
| -rw-r--r-- | crates/chat-server/README.txt | 3 | ||||
| -rw-r--r-- | crates/chat-server/src/http.rs | 56 | ||||
| -rw-r--r-- | crates/chat-server/src/main.rs | 105 | ||||
| -rw-r--r-- | crates/chat-server/src/maintenance.rs | 25 | ||||
| -rw-r--r-- | crates/chat-server/tests/browser.mjs | 135 | ||||
| -rw-r--r-- | crates/chat-server/tests/progress.mjs | 127 | ||||
| -rw-r--r-- | crates/chat-server/web/app.js | 54 | ||||
| -rw-r--r-- | crates/chat-server/web/index.html | 4 | ||||
| -rw-r--r-- | crates/chat-server/web/javascript.html | 2 | ||||
| -rw-r--r-- | crates/config/src/lib.rs | 45 |
13 files changed, 596 insertions, 82 deletions
diff --git a/crates/chat-crypto/README.txt b/crates/chat-crypto/README.txt index e04246c..a08d850 100644 --- a/crates/chat-crypto/README.txt +++ b/crates/chat-crypto/README.txt @@ -31,9 +31,9 @@ PRIVATE CHAT CRYPTOGRAPHY data must remain in local memory and must never be submitted to the server. See ../../docs/CHAT-APPLICATION.txt for implemented formats, local setup, - tests, backups, and limitations. Work remains experimental and local on - feature/private-chat. Production remains unchanged. Do not mistake the - compatibility probe for an application security audit. + tests, backups, and limitations. See ../../docs/CHAT-PRODUCTION.txt for + packaged HTTPS integration. Work remains experimental; + compatibility tests are not an application security audit. ../../docs/CHAT-OPENSSL-MIGRATION.txt records the complete backend decision, pinned source hashes, build flags, FFI/capability limitations, and reviewed diff --git a/crates/chat-crypto/src/application.rs b/crates/chat-crypto/src/application.rs index 98e8a20..963e1de 100644 --- a/crates/chat-crypto/src/application.rs +++ b/crates/chat-crypto/src/application.rs @@ -32,7 +32,8 @@ use std::io::{Read, Write}; use zeroize::Zeroizing; const PURPOSE: &str = "Stack private chat identity only; do not reuse"; -const FORMAT: &str = "stack-chat-file/v1;argon2id-m65536-t3-p1;aes256-gcm"; +const LEGACY_FORMAT: &str = "stack-chat-file/v1;argon2id-m65536-t3-p1;aes256-gcm"; +const FORMAT: &str = "stack-chat-file/v2;argon2id-m65536-t3-p1;aes256-gcm"; const LIMIT: usize = 262_144; #[derive(Deserialize, JsonSerialize)] @@ -50,10 +51,17 @@ struct Envelope { format: String, salt: String, nonce: String, - ciphertext: String, + ciphertext: FileCiphertext, tag: String, } +#[derive(Deserialize, JsonSerialize)] +#[serde(untagged)] +enum FileCiphertext { + Compact(String), + Wrapped(Vec<String>), +} + #[derive(Deserialize)] #[serde(tag = "op", rename_all = "snake_case", deny_unknown_fields)] enum Request { @@ -201,11 +209,17 @@ fn protect(identity: &Identity, password: &str) -> Result<String> { &plaintext, &mut tag, )?; - Ok(serde_json::to_string(&Envelope { + let encoded = BASE64_STANDARD.encode(ciphertext); + let chunks = encoded + .as_bytes() + .chunks(80) + .map(|chunk| std::str::from_utf8(chunk).map(str::to_owned)) + .collect::<std::result::Result<Vec<_>, _>>()?; + Ok(serde_json::to_string_pretty(&Envelope { format: FORMAT.into(), salt: BASE64_STANDARD.encode(salt), nonce: BASE64_STANDARD.encode(nonce), - ciphertext: BASE64_STANDARD.encode(ciphertext), + ciphertext: FileCiphertext::Wrapped(chunks), tag: BASE64_STANDARD.encode(tag), })?) } @@ -213,10 +227,20 @@ fn protect(identity: &Identity, password: &str) -> Result<String> { fn unlock(file: &str, password: &str, origin: &str) -> Result<(Identity, Cert)> { ensure!(file.len() <= LIMIT, "Identity file exceeds its limit"); let envelope: Envelope = serde_json::from_str(file)?; - ensure!( - envelope.format == FORMAT, - "Unsupported identity file format" - ); + let encoded = match (envelope.format.as_str(), &envelope.ciphertext) { + (LEGACY_FORMAT, FileCiphertext::Compact(value)) => value.clone(), + (FORMAT, FileCiphertext::Wrapped(chunks)) => { + ensure!( + !chunks.is_empty() + && chunks + .iter() + .all(|chunk| !chunk.is_empty() && chunk.len() <= 80), + "Invalid ciphertext wrapping" + ); + chunks.concat() + } + _ => anyhow::bail!("Unsupported identity file format"), + }; let salt = decode(&envelope.salt)?; let nonce = decode(&envelope.nonce)?; let tag = decode(&envelope.tag)?; @@ -224,13 +248,13 @@ fn unlock(file: &str, password: &str, origin: &str) -> Result<(Identity, Cert)> salt.len() == 16 && nonce.len() == 12 && tag.len() == 16, "Malformed envelope" ); - let ciphertext = decode(&envelope.ciphertext)?; + let ciphertext = decode(&encoded)?; let key = derive(password, &salt)?; let plaintext = Zeroizing::new(decrypt_aead( Cipher::aes_256_gcm(), key.as_ref(), Some(&nonce), - FORMAT.as_bytes(), + envelope.format.as_bytes(), &ciphertext, &tag, )?); @@ -643,7 +667,7 @@ mod tests { format: FORMAT.into(), salt: BASE64_STANDARD.encode([0; 15]), nonce: BASE64_STANDARD.encode([0; 12]), - ciphertext: String::new(), + ciphertext: FileCiphertext::Wrapped(Vec::new()), tag: BASE64_STANDARD.encode([0; 16]), })?; ensure!( @@ -657,6 +681,74 @@ mod tests { Ok(()) } + fn check_file_formats( + file: &str, + identity: &Identity, + password: &str, + origin: &str, + ) -> Result<()> { + ensure!( + file.starts_with("{\n \"format\""), + "Envelope is not indented" + ); + let envelope: Envelope = serde_json::from_str(file)?; + let FileCiphertext::Wrapped(chunks) = &envelope.ciphertext else { + anyhow::bail!("Ciphertext is not wrapped"); + }; + let (last, complete) = chunks.split_last().context("Missing ciphertext")?; + ensure!( + complete.iter().all(|chunk| chunk.len() == 80) && (1..=80).contains(&last.len()), + "Wrong ciphertext width" + ); + let salt = random::<16>()?; + let nonce = random::<12>()?; + let key = derive(password, &salt)?; + let mut tag = [0; 16]; + let plaintext = Zeroizing::new(serde_json::to_vec(identity)?); + let ciphertext = encrypt_aead( + Cipher::aes_256_gcm(), + key.as_ref(), + Some(&nonce), + LEGACY_FORMAT.as_bytes(), + &plaintext, + &mut tag, + )?; + let legacy = Envelope { + format: LEGACY_FORMAT.into(), + salt: BASE64_STANDARD.encode(salt), + nonce: BASE64_STANDARD.encode(nonce), + ciphertext: FileCiphertext::Compact(BASE64_STANDARD.encode(ciphertext)), + tag: BASE64_STANDARD.encode(tag), + }; + for historical in [ + serde_json::to_string(&legacy)?, + serde_json::to_string_pretty(&legacy)?, + ] { + let (restored, _certificate) = unlock(&historical, password, origin)?; + ensure!( + restored.secret.as_str() == identity.secret.as_str(), + "Legacy identity changed" + ); + ensure!( + unlock(&historical, "wrong-passphrase", origin).is_err(), + "Legacy password protection failed" + ); + } + let mut malformed = serde_json::to_value(&envelope)?; + *malformed.get_mut("ciphertext").context("Ciphertext")? = json!(["A".repeat(81)]); + ensure!( + unlock(&malformed.to_string(), password, origin).is_err(), + "Overlong ciphertext chunk accepted" + ); + *malformed.get_mut("format").context("Format")? = json!(LEGACY_FORMAT); + *malformed.get_mut("ciphertext").context("Ciphertext")? = json!(chunks.concat()); + ensure!( + unlock(&malformed.to_string(), password, origin).is_err(), + "Unauthenticated file-version downgrade accepted" + ); + Ok(()) + } + #[test] fn protected_file_and_authenticated_messages() -> Result<()> { let origin = "http://127.0.0.1:9080"; @@ -668,6 +760,7 @@ mod tests { .as_str() .context("File")?; let (owner_identity, owner_cert) = unlock(owner_file, password, origin)?; + check_file_formats(owner_file, &owner_identity, password, origin)?; let owner_public = public(&owner_cert)?; let visitor = create(origin.into(), "Visitor".into(), password, owner_public)?; let visitor_file = visitor diff --git a/crates/chat-server/Cargo.toml b/crates/chat-server/Cargo.toml index 709c37f..6ebb2df 100644 --- a/crates/chat-server/Cargo.toml +++ b/crates/chat-server/Cargo.toml @@ -37,6 +37,7 @@ openssl = "0.10" rusqlite = { version = "0.41", default-features = false, features = ["bundled", "backup"] } serde_json = "1" stack-chat-crypto = { path = "../chat-crypto", version = "0.1.0" } +tokio-util = { version = "0.7", features = ["io"] } tokio = { version = "1", features = ["rt-multi-thread", "net", "macros", "signal", "sync", "fs"] } [dev-dependencies] diff --git a/crates/chat-server/README.txt b/crates/chat-server/README.txt index a7aba5d..0562385 100644 --- a/crates/chat-server/README.txt +++ b/crates/chat-server/README.txt @@ -19,4 +19,5 @@ EXPERIMENTAL PRIVATE CHAT SERVER See docs/CHAT-APPLICATION.txt at the workspace root for local setup, file formats, API/security limits, backups, recovery, source licensing, and tests. Start with cargo stack chat --init, then cargo stack chat. - Production is intentionally not configured on this feature branch. + See docs/CHAT-PRODUCTION.txt for the prebuilt Podman/HTTPS deployment, + production owner setup, and full encrypted export/recovery procedure. diff --git a/crates/chat-server/src/http.rs b/crates/chat-server/src/http.rs index 1c7286f..7394b71 100644 --- a/crates/chat-server/src/http.rs +++ b/crates/chat-server/src/http.rs @@ -17,9 +17,10 @@ use crate::service::Service; use anyhow::Result; use axum::{ Json, Router, + body::Body, extract::{DefaultBodyLimit, State}, http::{HeaderMap, HeaderValue, StatusCode}, - response::{IntoResponse, Response}, + response::{IntoResponse, Redirect, Response}, routing::{get, post}, }; use serde_json::{Value, json}; @@ -93,6 +94,14 @@ async fn api(State(app): State<App>, headers: HeaderMap, Json(request): Json<Val } async fn asset(State(app): State<App>, uri: axum::http::Uri) -> Response { + if uri.path() == "/THIRD-PARTY-LICENSES.html" { + let Some(origin) = app.public.get("SITE_ORIGIN") else { + return secured(StatusCode::INTERNAL_SERVER_ERROR.into_response()); + }; + return secured( + Redirect::permanent(&format!("{origin}/THIRD-PARTY-LICENSES.html")).into_response(), + ); + } let (path, mime) = match uri.path() { "/" => ( "crates/chat-server/web/index.html", @@ -115,25 +124,33 @@ async fn asset(State(app): State<App>, uri: axum::http::Uri) -> Response { "text/css; charset=utf-8", ), "/crypto.wasm" => ( - "target/wasm32-wasip1/release/stack-chat-crypto.wasm", + if app.root.join("bin/chat/crypto.wasm").is_file() { + "bin/chat/crypto.wasm" + } else { + "target/wasm32-wasip1/release/stack-chat-crypto.wasm" + }, "application/wasm", ), "/javascript.html" => ( "crates/chat-server/web/javascript.html", "text/html; charset=utf-8", ), - "/SOURCE.tar.gz" => ("var/build/chat-source.tar.gz", "application/gzip"), - "/THIRD-PARTY-LICENSES.html" => { - ("web/THIRD-PARTY-LICENSES.html", "text/html; charset=utf-8") - } + "/SOURCE.tar.gz" => ( + if app.root.join("bin/chat/SOURCE.tar.gz").is_file() { + "bin/chat/SOURCE.tar.gz" + } else { + "var/build/chat-source.tar.gz" + }, + "application/gzip", + ), "/LIMITATIONS.txt" => ("docs/CHAT-APPLICATION.txt", "text/plain; charset=utf-8"), "/LICENSE" => ("LICENSE", "text/plain; charset=utf-8"), _ => return secured(StatusCode::NOT_FOUND.into_response()), }; - let Ok(bytes) = tokio::fs::read(app.root.join(path)).await else { - return secured(StatusCode::NOT_FOUND.into_response()); - }; if matches!(uri.path(), "/" | "/javascript.html") { + let Ok(bytes) = tokio::fs::read(app.root.join(path)).await else { + return secured(StatusCode::NOT_FOUND.into_response()); + }; let rendered = String::from_utf8(bytes) .map_err(anyhow::Error::from) .and_then(|source| stack_config::render(&source, &app.public)); @@ -142,7 +159,22 @@ async fn asset(State(app): State<App>, uri: axum::http::Uri) -> Response { |html| secured(([("content-type", mime)], html).into_response()), ); } - secured(([("content-type", mime)], bytes).into_response()) + let Ok(file) = tokio::fs::File::open(app.root.join(path)).await else { + return secured(StatusCode::NOT_FOUND.into_response()); + }; + let length = file.metadata().await.ok().map(|metadata| metadata.len()); + let mut response = Response::new(Body::from_stream(tokio_util::io::ReaderStream::new(file))); + response + .headers_mut() + .insert("content-type", HeaderValue::from_static(mime)); + if let Some(header_length) = + length.and_then(|value| HeaderValue::from_str(&value.to_string()).ok()) + { + response + .headers_mut() + .insert("content-length", header_length); + } + secured(response) } pub(crate) async fn serve( @@ -159,6 +191,10 @@ pub(crate) async fn serve( public, }; let routes = Router::new() + .route( + "/healthz", + get(|| async { secured("ok\n".into_response()) }), + ) .route("/api", post(api)) .fallback(get(asset)) .layer(DefaultBodyLimit::max(262_144)) diff --git a/crates/chat-server/src/main.rs b/crates/chat-server/src/main.rs index cfd3e7a..1d600b7 100644 --- a/crates/chat-server/src/main.rs +++ b/crates/chat-server/src/main.rs @@ -13,7 +13,7 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see <https://www.gnu.org/licenses/>. -#![doc = "Local experimental chat server with ciphertext-only durable storage."] +#![doc = "Encrypted private chat server and backend maintenance commands."] mod http; mod maintenance; @@ -22,18 +22,23 @@ mod store; use anyhow::{Context, Result, ensure}; use clap::Parser; -use std::io::Write; +use std::io::{Read, Write}; use std::net::SocketAddr; use std::path::PathBuf; use std::process::ExitCode; #[derive(Debug, Parser)] -#[command(about = "Run the experimental private chat locally; production is not enabled")] +#[command(about = "Run private chat directly or behind the stack gateway")] struct Arguments { #[arg(long, default_value = ".")] root: PathBuf, - #[arg(long)] - bind: Option<SocketAddr>, + #[command(flatten)] + listener: Listener, + #[arg( + long, + help = "Check the local HTTP health endpoint without opening the database" + )] + healthcheck: bool, #[arg(long, conflicts_with_all = ["reset_owner", "owner_fingerprint", "snapshot"])] init: bool, #[arg(long, value_name = "FINGERPRINT", conflicts_with_all = ["owner_fingerprint", "snapshot"], help = "Permanently clear the matching owner registration and all chat history after key loss")] @@ -48,6 +53,17 @@ struct Arguments { snapshot: Option<PathBuf>, } +#[derive(Debug, clap::Args)] +struct Listener { + #[arg(long)] + bind: Option<SocketAddr>, + #[arg( + long, + help = "Run behind the stack gateway using its explicit container environment" + )] + proxy: bool, +} + fn initialize(root: &std::path::Path) -> Result<()> { let env = root.join(".env"); let existing = if env.exists() { @@ -76,13 +92,70 @@ fn initialize(root: &std::path::Path) -> Result<()> { stack_config::initialize_public(root) } +fn healthcheck(mut address: SocketAddr) -> Result<()> { + if address.ip().is_unspecified() { + address.set_ip(std::net::IpAddr::V4(std::net::Ipv4Addr::LOCALHOST)); + } + let mut socket = + std::net::TcpStream::connect_timeout(&address, std::time::Duration::from_secs(2))?; + socket.set_read_timeout(Some(std::time::Duration::from_secs(2)))?; + socket.set_write_timeout(Some(std::time::Duration::from_secs(2)))?; + socket.write_all(b"GET /healthz HTTP/1.0\r\nHost: localhost\r\n\r\n")?; + let mut response = String::new(); + socket.take(8192).read_to_string(&mut response)?; + ensure!( + response.starts_with("HTTP/1.0 200") || response.starts_with("HTTP/1.1 200"), + "Chat health endpoint failed" + ); + Ok(()) +} + +fn validate_origin( + configuration: &stack_config::Config, + bind: SocketAddr, + proxy: bool, +) -> Result<()> { + let origin = configuration.value("CHAT_ORIGIN")?; + if proxy { + let public = configuration.public_values(None)?; + let canonical = public + .get("CHAT_PUBLIC_ORIGIN") + .context("Missing public chat origin")?; + let local = format!( + "http://{}:{}", + configuration.value("CHAT_LOCAL_HOST")?, + configuration.port("LOCAL_HTTP_PORT")? + ); + ensure!( + origin == canonical || origin == local, + "Gateway chat origin must match the configured HTTPS hostname or dedicated local hostname" + ); + } else { + ensure!( + bind.ip().is_loopback() && origin == format!("http://{bind}"), + "Direct development chat requires an exact loopback HTTP origin" + ); + } + Ok(()) +} + async fn run(arguments: Arguments) -> Result<()> { let root = arguments.root.canonicalize()?; if arguments.init { return initialize(&root); } - let configuration = stack_config::Config::load(&root)?; - let bind = arguments.bind.unwrap_or(configuration.chat_bind()?); + let configuration = if arguments.listener.proxy { + stack_config::Config::from_environment()? + } else { + stack_config::Config::load(&root)? + }; + let bind = arguments + .listener + .bind + .unwrap_or(configuration.chat_bind()?); + if arguments.healthcheck { + return healthcheck(bind); + } let data = root.join("var/data/chat"); std::fs::create_dir_all(&data)?; std::fs::set_permissions(&data, std::os::unix::fs::PermissionsExt::from_mode(0o700))?; @@ -101,7 +174,7 @@ async fn run(arguments: Arguments) -> Result<()> { return maintenance::fingerprint(&store); } if let Some(expected) = arguments.reset_owner { - return maintenance::reset(&mut store, &expected, bind); + return maintenance::reset(&mut store, &expected, bind, arguments.listener.proxy); } if let Some(destination) = arguments.snapshot { store.snapshot(&destination)?; @@ -111,15 +184,8 @@ async fn run(arguments: Arguments) -> Result<()> { )?; return Ok(()); } - ensure!( - bind.ip().is_loopback(), - "Experimental server must bind to loopback" - ); let origin = configuration.value("CHAT_ORIGIN")?; - ensure!( - origin == format!("http://{bind}"), - "CHAT_ORIGIN must match the loopback CHAT_BIND or --bind address" - ); + validate_origin(&configuration, bind, arguments.listener.proxy)?; let setup = configuration .value("CHAT_SETUP_TOKEN") .context("Run --init to configure chat")?; @@ -127,9 +193,12 @@ async fn run(arguments: Arguments) -> Result<()> { let listener = tokio::net::TcpListener::bind(bind).await?; writeln!( std::io::stdout().lock(), - "Experimental chat: {origin} (owner setup: /#setup)" + "Chat ready: {origin} (owner setup: /#setup)" )?; - http::serve(listener, state, &root, configuration.public_values(None)?).await + let local = (arguments.listener.proxy && origin.starts_with("http://")) + .then(|| configuration.port("LOCAL_HTTP_PORT")) + .transpose()?; + http::serve(listener, state, &root, configuration.public_values(local)?).await } #[tokio::main] diff --git a/crates/chat-server/src/maintenance.rs b/crates/chat-server/src/maintenance.rs index eac689d..d416d73 100644 --- a/crates/chat-server/src/maintenance.rs +++ b/crates/chat-server/src/maintenance.rs @@ -45,13 +45,24 @@ pub(crate) fn fingerprint(store: &Store) -> Result<()> { Ok(()) } -pub(crate) fn reset(store: &mut Store, expected: &str, address: SocketAddr) -> Result<()> { - ensure!( - address.ip().is_loopback(), - "Reset must use the local chat address" - ); - let _port = - TcpListener::bind(address).context("Stop the chat server before resetting its owner")?; +pub(crate) fn reset( + store: &mut Store, + expected: &str, + address: SocketAddr, + proxy: bool, +) -> Result<()> { + let _port = if proxy { + None + } else { + ensure!( + address.ip().is_loopback(), + "Reset must use the local chat address" + ); + Some( + TcpListener::bind(address) + .context("Stop the chat server before resetting its owner")?, + ) + }; store.reset_owner(expected)?; writeln!( std::io::stdout().lock(), diff --git a/crates/chat-server/tests/browser.mjs b/crates/chat-server/tests/browser.mjs index e12c3da..b0a9041 100644 --- a/crates/chat-server/tests/browser.mjs +++ b/crates/chat-server/tests/browser.mjs @@ -15,23 +15,88 @@ import assert from 'node:assert/strict'; import { createServer } from 'node:http'; -import { mkdtemp, readFile, writeFile, symlink, rm, mkdir, rename } from 'node:fs/promises'; +import { mkdtemp, readFile, writeFile, symlink, rm, mkdir, rename, stat, readdir } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { resolve, join } from 'node:path'; import { randomBytes } from 'node:crypto'; -import { spawn, execFileSync, spawnSync } from 'node:child_process'; +import { spawn, execFileSync, spawnSync, execFile } from 'node:child_process'; +import { promisify } from 'node:util'; import { chromium, firefox } from '../../../.tools/browser/node_modules/playwright/index.mjs'; -const root = await mkdtemp(join(tmpdir(), 'stack-chat-e2e-')); -const token = randomBytes(32).toString('hex'); -const origin = `http://127.0.0.1:${process.env.CHAT_TEST_PORT || '9081'}`; +const run = promisify(execFile); +const packaged = Boolean(process.env.CHAT_TEST_ROOT); +let root = packaged ? resolve(process.env.CHAT_TEST_ROOT) : await mkdtemp(join(tmpdir(), 'stack-chat-e2e-')); +const initialRoot = root; +const token = packaged + ? (await readFile(join(root,'.env'),'utf8')).split('\n').find(line => line.startsWith('CHAT_SETUP_TOKEN=')).slice('CHAT_SETUP_TOKEN='.length) + : randomBytes(32).toString('hex'); +const origin = process.env.CHAT_TEST_ORIGIN || `http://127.0.0.1:${process.env.CHAT_TEST_PORT || '9081'}`; +if (packaged) { + const fixture = await readFile(join(root,'.env'),'utf8'); + const address = new URL(origin); + assert.equal(address.protocol,'https:'); + assert.ok(address.hostname.endsWith('.localhost'),'Packaged tests require a disposable .localhost origin'); + assert.ok(fixture.split('\n').includes('WEB_BIND=127.0.0.1'),'Packaged tests require loopback-only publishing'); +} const password = 'browser-test-only-unique-passphrase'; -await writeFile(join(root, '.env'), `CHAT_ORIGIN=${origin}\nCHAT_SETUP_TOKEN=${token}\n`, {mode:0o600}); -for (const path of ['crates','target','docs','web','LICENSE']) await symlink(resolve(path), join(root,path)); -await mkdir(join(root,'var/build'), {recursive:true}); +const testHome = await mkdtemp(join(tmpdir(),'stack-chat-test-')); +const gpgHome = join(testHome,'gnupg'); +let restorations = 0; +if (packaged) { + await mkdir(gpgHome,{mode:0o700}); + await run('gpg',['--no-options','--homedir',gpgHome,'--batch','--pinentry-mode','loopback','--passphrase','','--quick-generate-key','Stack disposable recovery fixture','rsa2048','encr','1d']); +} else { + await writeFile(join(root, '.env'), `CHAT_ORIGIN=${origin}\nCHAT_SETUP_TOKEN=${token}\n`, {mode:0o600}); + for (const path of ['crates','target','docs','web','LICENSE']) await symlink(resolve(path), join(root,path)); + await mkdir(join(root,'var/build'), {recursive:true}); +} +async function stack(...args) { + return run(join(root,'bin/stack'),args,{cwd:root,env:{...process.env,GNUPGHOME:gpgHome},maxBuffer:8*1024*1024,timeout:300000}); +} +async function restorePackage() { + const listed = await run('gpg',['--no-options','--homedir',gpgHome,'--with-colons','--list-keys']); + const fingerprint = listed.stdout.split('\n').find(line => line.startsWith('fpr:')).split(':')[9]; + const key = join(testHome,'recipient.asc'); + const exported = await run('gpg',['--no-options','--homedir',gpgHome,'--armor','--export',fingerprint]); + await writeFile(key,exported.stdout); + const backup = await stack('backup','--include-images','--recipient',fingerprint,'--key',key); + const archive = backup.stdout.match(/Encrypted export: (.+)/)?.[1]; + assert.ok(archive,backup.stdout); + assert.equal((await stat(archive)).mode & 0o777,0o600); + assert.equal((await stat(archive + '.sha256')).mode & 0o777,0o600); + await stopServer(); + const destination = `${initialRoot}-restored-${++restorations}`; + await stack('restore',archive,destination); + root = destination; + assert.equal(await readFile(join(root,'.env'),'utf8'),await readFile(join(initialRoot,'.env'),'utf8')); + for (const name of ['target','.tools']) assert.equal(spawnSync('test',['-e',join(root,name)]).status,1); + await launchServer(); + console.log(`PASS: compiler-free HTTPS stack recovered from encrypted export ${restorations}.`); +} let server; let serverErrors = ''; +async function verifyGateway() { + const url = new URL(origin); + const args = ['--noproxy','*','--silent','--show-error','--fail']; + if (url.hostname.endsWith('.localhost')) { + const state = JSON.parse(await readFile(join(root,'var/state.json'),'utf8')); + const certificate = join(testHome,'gateway-ca.crt'); + await run('podman',['--root',join(root,'var/podman/storage'),'--runroot',join(root,'var/podman/run'),'cp',`${state.project}-caddy:/data/caddy/pki/authorities/local/root.crt`,certificate]); + args.push('--cacert',certificate,'--resolve',`${url.hostname}:${url.port}:127.0.0.1`); + } + const health = await run('curl',[...args,origin + '/healthz']); + assert.equal(health.stdout,'ok\n'); +} async function launchServer() { + if (packaged) { + await stack('up','--production'); + let failure; + for (let attempt = 0; attempt < 20; attempt += 1) { + try { await verifyGateway(); return; } + catch (error) { failure = error; await new Promise(done => setTimeout(done,500)); } + } + throw failure; + } server = spawn(resolve('target/debug/stack-chat-server'), ['--root',root,'--bind',new URL(origin).host], {stdio:['ignore','pipe','pipe']}); server.stderr.on('data', bytes => { serverErrors += bytes.toString(); }); await Promise.race([ @@ -40,6 +105,7 @@ async function launchServer() { ]); } async function stopServer() { + if (packaged) { await stack('down'); return; } if (server.exitCode !== null || server.signalCode !== null) return; const stopped = new Promise(done => server.once('exit',done)); server.kill('SIGINT'); @@ -50,10 +116,10 @@ const libre = process.env.CHAT_LIBREJS === '1'; let persistent; let browser; if (libre) { - const profile = join(root,'firefox-profile'); + const profile = join(testHome,'firefox-profile'); await mkdir(join(profile,'extensions'),{recursive:true}); await writeFile(join(profile,'extensions','jid1-KtlZuoiikVfFew@jetpack.xpi'),await readFile(resolve('.tools/librejs.xpi'))); - persistent = await firefox.launchPersistentContext(profile, {headless:true,acceptDownloads:true,firefoxUserPrefs:{ + persistent = await firefox.launchPersistentContext(profile, {headless:true,acceptDownloads:true,ignoreHTTPSErrors:packaged,firefoxUserPrefs:{ 'extensions.autoDisableScopes':0,'extensions.enabledScopes':15,'extensions.startupScanScopes':15, 'extensions.update.enabled':false,'app.update.auto':false,'datareporting.healthreport.uploadEnabled':false, 'browser.sessionstore.resume_from_crash':false, @@ -66,11 +132,18 @@ const external = []; const errors = []; const bodies = []; async function page() { - const context = persistent || await browser.newContext({acceptDownloads:true}); + const context = persistent || await browser.newContext({acceptDownloads:true,ignoreHTTPSErrors:packaged}); contexts.push(context); const page = await context.newPage(); page.setDefaultTimeout(90000); page.on('pageerror', error => errors.push(error.message)); + const navigate = page.goto.bind(page); + page.goto = async (...args) => { + for (let attempt = 0; ; attempt += 1) { + try { return await navigate(...args); } + catch (error) { if (attempt >= 2 || !error.message.includes('ERR_NETWORK_CHANGED')) throw error; } + } + }; page.on('request', request => { if (!request.url().startsWith(origin + '/')) external.push(request.url()); @@ -90,6 +163,13 @@ async function create(page, name, owner = false) { await page.click('#create-form button'); const download = await downloaded; const bytes = await readFile(await download.path()); + const envelope = JSON.parse(bytes.toString()); + assert.ok(envelope.format.startsWith('stack-chat-file/v2;')); + assert.ok(bytes.toString().startsWith('{\n "format"')); + assert.ok(Array.isArray(envelope.ciphertext)); + assert.ok(envelope.ciphertext.length > 1); + assert.ok(envelope.ciphertext.slice(0,-1).every(chunk => chunk.length === 80)); + assert.ok(envelope.ciphertext.at(-1).length > 0 && envelope.ciphertext.at(-1).length <= 80); await waitStatus(page,'Reopen your downloaded'); assert.equal(await page.locator('#chat').isVisible(), false); assert.equal(bodies.filter(body => body.op === 'authenticate').length,previousAuthentications); @@ -129,7 +209,7 @@ try { if (libre) await verifyLibreJS(); const owner = await page(); const response = await owner.goto(origin); - await owner.locator('#owner-choice').waitFor({state:'visible'}); + await owner.locator('#owner-choice').waitFor({state:'visible'}).catch(async error => { throw new Error(`${error.message}; status=${await owner.locator('#status').textContent()}`); }); await owner.click('#owner-choice'); assert.match(response.headers()['cache-control'], /no-store/); assert.match(response.headers()['content-security-policy'], /frame-ancestors 'none'/); @@ -211,12 +291,15 @@ try { await visitor.click('#history-form button'); await waitStatus(visitor,'No message history loaded'); assert.equal(await visitor.locator('#messages li').count(),0); - const snapshot = join(root,'snapshot.sqlite'); - execFileSync(resolve('target/debug/stack-chat-server'),['--root',root,'--snapshot',snapshot],{stdio:'pipe'}); - await stopServer(); - await rm(join(root,'var/data/chat/chat.sqlite')); - await rename(snapshot,join(root,'var/data/chat/chat.sqlite')); - await launchServer(); + if (packaged) await restorePackage(); + else { + const snapshot = join(root,'snapshot.sqlite'); + execFileSync(resolve('target/debug/stack-chat-server'),['--root',root,'--snapshot',snapshot],{stdio:'pipe'}); + await stopServer(); + await rm(join(root,'var/data/chat/chat.sqlite')); + await rename(snapshot,join(root,'var/data/chat/chat.sqlite')); + await launchServer(); + } await visitor.reload(); await waitEntry(visitor); await visitor.click('#open-choice'); @@ -279,11 +362,15 @@ try { for (const forbidden of ['file','password','secret','name','text']) assert.ok(!(forbidden in body),`Leaked ${forbidden}`); assert.ok(!JSON.stringify(body).includes(malicious)); } - const database = await readFile(join(root,'var/data/chat/chat.sqlite')); - for (const plaintext of [malicious,'A friend','Reply from owner','Safe retry after a lost response','Batch fixture 51',password]) assert.ok(!database.includes(Buffer.from(plaintext)),`Plaintext persisted: ${plaintext}`); + for (const filename of (await readdir(join(root,'var/data/chat'))).filter(name => name.startsWith('chat.sqlite'))) { + const database = await readFile(join(root,'var/data/chat',filename)); + for (const plaintext of [malicious,'A friend','Reply from owner','Safe retry after a lost response','Batch fixture 51',password]) assert.ok(!database.includes(Buffer.from(plaintext)),`Plaintext persisted in ${filename}`); + } const blocked = await visitor.request.post(origin + '/api',{data:{op:'config'},headers:{Origin:'https://foreign.invalid'}}); assert.equal(blocked.status(),403); - const administrative = (...args) => spawnSync(resolve('target/debug/stack-chat-server'),['--root',root,'--bind',new URL(origin).host,...args],{encoding:'utf8'}); + const administrative = (...args) => packaged + ? spawnSync(join(root,'bin/stack'),['chat',...args],{cwd:root,encoding:'utf8'}) + : spawnSync(resolve('target/debug/stack-chat-server'),['--root',root,'--bind',new URL(origin).host,...args],{encoding:'utf8'}); const registered = administrative('--owner-fingerprint'); assert.equal(registered.status,0); const fingerprint = registered.stdout.trim(); @@ -317,7 +404,7 @@ try { assert.equal(serverErrors,''); if (process.env.CHAT_SCREENSHOT) await visitor.screenshot({path:resolve('.tools/chat-preview.png'),fullPage:true}); if (libre) { - const extensions = JSON.parse(await readFile(join(root,'firefox-profile','extensions.json'),'utf8')); + const extensions = JSON.parse(await readFile(join(testHome,'firefox-profile','extensions.json'),'utf8')); const addon = extensions.addons.find(addon => addon.id === 'jid1-KtlZuoiikVfFew@jetpack'); assert.ok(addon?.active,'LibreJS must actually be active'); console.log(`LibreJS ${addon.version} is active in Firefox; complete chat workflow succeeded.`); @@ -327,5 +414,7 @@ try { clearTimeout(deadline); await browser.close(); await stopServer(); - await rm(root,{recursive:true,force:true}); + if (!packaged) await rm(root,{recursive:true,force:true}); + else await run('gpgconf',['--homedir',gpgHome,'--kill','all']); + await rm(testHome,{recursive:true,force:true}); } diff --git a/crates/chat-server/tests/progress.mjs b/crates/chat-server/tests/progress.mjs new file mode 100644 index 0000000..0ee5614 --- /dev/null +++ b/crates/chat-server/tests/progress.mjs @@ -0,0 +1,127 @@ +// Copyright (C) 2026 Doan Luu Duc Tai <doan@luuduct.ai> +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see <https://www.gnu.org/licenses/>. + +import assert from 'node:assert/strict'; +import { createServer } from 'node:http'; +import { readFile, writeFile, mkdir, mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join, resolve } from 'node:path'; +import { chromium, firefox } from '../../../.tools/browser/node_modules/playwright/index.mjs'; + +const libre = process.env.CHAT_LIBREJS === '1'; +const profile = await mkdtemp(join(tmpdir(),'stack-chat-progress-')); +const bytes = await readFile(resolve('target/wasm32-wasip1/release/stack-chat-crypto.wasm')); +let mode = 'known'; +let transfer; +let configuration; +let configReady; +let signalConfig; +let origin; +const errors = []; +const server = createServer(async (request,response) => { + response.setHeader('Cache-Control','no-store'); + if (request.url === '/crypto.wasm') { + if (mode === 'failure') { response.writeHead(503); response.end(); return; } + response.setHeader('Content-Type','application/wasm'); + if (mode !== 'unknown') response.setHeader('Content-Length',bytes.length); + transfer = response; + response.write(bytes.subarray(0,1048576)); + return; + } + if (request.url === '/api') { configuration = response; signalConfig(); return; } + const assets = { '/':'crates/chat-server/web/index.html','/app.js':'crates/chat-server/web/app.js','/style.css':'crates/chat-server/web/style.css' }; + const path = assets[request.url]; + if (!path) { response.writeHead(404); response.end(); return; } + const mime = request.url === '/' ? 'text/html' : request.url.endsWith('.js') ? 'text/javascript' : 'text/css'; + response.setHeader('Content-Type',mime); + let source = await readFile(resolve(path),'utf8'); + if (request.url === '/') source = source.replaceAll('{{SITE_ORIGIN}}',origin); + response.end(source); +}); +await new Promise(done => server.listen(0,'127.0.0.1',done)); +origin = `http://127.0.0.1:${server.address().port}`; +let browser; +let context; +if (libre) { + await mkdir(join(profile,'extensions')); + const addon = await readFile(resolve('.tools/librejs.xpi')); + await writeFile(join(profile,'extensions','jid1-KtlZuoiikVfFew@jetpack.xpi'),addon); + context = await firefox.launchPersistentContext(profile,{headless:true,firefoxUserPrefs:{'extensions.autoDisableScopes':0,'extensions.enabledScopes':15,'extensions.startupScanScopes':15,'extensions.update.enabled':false}}); + browser = {close:() => context.close()}; +} else { + browser = await chromium.launch({headless:true,channel:'chromium'}); + context = await browser.newContext(); +} +async function open(selected) { + mode = selected; + configuration = undefined; + configReady = new Promise(done => { signalConfig = done; }); + const page = await context.newPage(); + page.on('pageerror',error => errors.push(error.message)); + await page.clock.install(); + await page.goto(origin); + return page; +} +async function waitStatus(page,part) { + await page.waitForFunction(value => document.getElementById('status').textContent.includes(value),part); + return page.locator('#status').textContent(); +} +async function complete(page,offset) { + transfer.end(bytes.subarray(offset)); + await waitStatus(page,'Connecting to chat'); + await configReady; + configuration.setHeader('Content-Type','application/json'); + configuration.end(JSON.stringify({origin,owner:''})); + await page.locator('#owner-choice').waitFor({state:'visible'}); + assert.equal(await page.locator('#status').textContent(),''); + assert.equal(await page.evaluate(async () => (await wasm).byteLength),bytes.length); + await page.close(); +} +try { + const known = await open('known'); + assert.match(await waitStatus(known,'1.0 /'),/\[#{3}-{17}\] 19%/); + await known.clock.fastForward(44000); + transfer.write(bytes.subarray(1048576,2097152)); + assert.match(await waitStatus(known,'2.0 /'),/39%/); + await known.screenshot({path:resolve('.tools/chat-download-progress.png'),fullPage:true}); + await known.clock.fastForward(44000); + assert.ok(!(await known.locator('#status').textContent()).includes('stalled')); + await complete(known,2097152); + const unknown = await open('unknown'); + assert.equal(await waitStatus(unknown,'1.0 MiB received'),'Downloading cryptographic module... 1.0 MiB received'); + await complete(unknown,1048576); + const failure = await open('failure'); + await waitStatus(failure,'HTTP 503'); + assert.equal(await failure.locator('#owner-choice').isVisible(),false); + await failure.close(); + const stalled = await open('known'); + await waitStatus(stalled,'1.0 /'); + await stalled.clock.fastForward(45001); + await waitStatus(stalled,'stalled for 45 seconds'); + assert.match(await stalled.locator('#status').textContent(),/reload/); + assert.equal(await stalled.locator('#owner-choice').isVisible(),false); + await stalled.close(); + assert.deepEqual(errors,[]); + if (libre) { + const state = JSON.parse(await readFile(join(profile,'extensions.json'),'utf8')); + assert.ok(state.addons.find(addon => addon.id === 'jid1-KtlZuoiikVfFew@jetpack')?.active); + } + console.log(`PASS: ${libre ? 'Firefox/LibreJS' : 'Chromium'} streamed percentage/bar/MiB, unknown length, exact byte recovery, slow continuous transfer, idle timeout, HTTP failure, and connection phase.`); +} finally { + await browser.close(); + server.closeAllConnections(); + await new Promise(done => server.close(done)); + await rm(profile,{recursive:true,force:true}); +} diff --git a/crates/chat-server/web/app.js b/crates/chat-server/web/app.js index 3c97f66..4b0da63 100644 --- a/crates/chat-server/web/app.js +++ b/crates/chat-server/web/app.js @@ -356,12 +356,58 @@ element('lock').addEventListener('click', () => { if (session) api({ op: 'lock' window.addEventListener('pagehide', clearState); window.addEventListener('pageshow', event => { if (event.persisted) clearState(); }); +async function downloadCrypto() { + const controller = new AbortController(); + let timer; + let reader; + const refreshTimeout = () => { + clearTimeout(timer); + timer = setTimeout(() => controller.abort(), 45000); + }; + try { + refreshTimeout(); + const response = await fetch('/crypto.wasm', { cache: 'no-store', credentials: 'omit', signal: controller.signal }); + if (!response.ok) throw new Error(`Cryptographic module download failed (HTTP ${response.status}). Reload to try again.`); + const length = response.headers.get('content-encoding') ? 0 : Number(response.headers.get('content-length')); + const total = Number.isSafeInteger(length) && length > 0 ? length : 0; + const chunks = []; + let received = 0; + const progress = () => { + const loaded = (received / 1048576).toFixed(1); + if (total) { + const percent = Math.min(100, Math.floor(received * 100 / total)); + const filled = Math.floor(percent / 5); + status(`Downloading cryptographic module... [${'#'.repeat(filled)}${'-'.repeat(20 - filled)}] ${percent}% (${loaded} / ${(total / 1048576).toFixed(1)} MiB)`); + } else status(`Downloading cryptographic module... ${loaded} MiB received`); + }; + progress(); + reader = response.body.getReader(); + for (;;) { + const { done, value } = await reader.read(); + if (done) break; + refreshTimeout(); + chunks.push(value); + received += value.byteLength; + progress(); + } + if (!received) throw new Error('The cryptographic module download was empty. Reload to try again.'); + const bytes = new Uint8Array(received); + let offset = 0; + for (const chunk of chunks) { bytes.set(chunk, offset); offset += chunk.byteLength; } + return bytes.buffer; + } catch (error) { + if (controller.signal.aborted) throw new Error('Cryptographic module download stalled for 45 seconds. Check your connection and reload to try again.'); + throw error; + } finally { + clearTimeout(timer); + reader?.releaseLock(); + } +} + async function start() { - wasm = fetch('/crypto.wasm', { cache: 'no-store', credentials: 'omit' }).then(response => { - if (!response.ok) throw new Error('Build the WASI cryptographic module first'); - return response.arrayBuffer(); - }); + wasm = downloadCrypto(); await wasm; + status('Cryptographic module downloaded. Connecting to chat...'); config = await api({ op: 'config' }); if (config.origin !== location.origin) throw new Error('Server origin mismatch'); show('owner-entry'); diff --git a/crates/chat-server/web/index.html b/crates/chat-server/web/index.html index 79026b0..70f3ebb 100644 --- a/crates/chat-server/web/index.html +++ b/crates/chat-server/web/index.html @@ -41,7 +41,7 @@ along with this program. If not, see <https://www.gnu.org/licenses/>. <div class="ascii-panel introduction"> <div class="ascii-bar" aria-hidden="true"><span>+</span><span class="ascii-rule">--------------------------------------------------------------------------------</span><span>+</span></div> <div class="panel-body"> -<p class="build-status">EXPERIMENTAL / LOCAL TEST</p> +<p class="build-status">EXPERIMENTAL</p> <p>Keep your file and password. There is no account recovery.<br><a href="/LIMITATIONS.txt">Read the limitations</a> before using this experimental messenger.</p> </div> <div class="ascii-bar" aria-hidden="true"><span>+</span><span class="ascii-rule">--------------------------------------------------------------------------------</span><span>+</span></div> @@ -100,7 +100,7 @@ along with this program. If not, see <https://www.gnu.org/licenses/>. <noscript>This chat requires free JavaScript and WebAssembly for local cryptography. The main website remains JavaScript-free.</noscript> <footer class="source-footer" aria-label="Licensing and source"> <a class="project-license" href="https://www.gnu.org/licenses/agpl-3.0.html">[ LICENSE ]</a> -<a class="dependency-licenses" href="/THIRD-PARTY-LICENSES.html">[ THIRD PARTY LICENSES ]</a> +<a class="dependency-licenses" href="{{SITE_ORIGIN}}/THIRD-PARTY-LICENSES.html">[ THIRD PARTY LICENSES ]</a> <a href="/SOURCE.tar.gz">[ SOURCE CODE ]</a> </footer> </main> diff --git a/crates/chat-server/web/javascript.html b/crates/chat-server/web/javascript.html index 205a103..644e5a1 100644 --- a/crates/chat-server/web/javascript.html +++ b/crates/chat-server/web/javascript.html @@ -24,5 +24,5 @@ along with this program. If not, see <https://www.gnu.org/licenses/>. <tr><td><a href="/wasi.mjs">wasi.mjs</a></td><td><a href="https://www.gnu.org/licenses/agpl-3.0.html">GNU-AGPL-3.0</a></td><td><a href="/SOURCE.tar.gz">Complete corresponding source</a></td></tr> </tbody></table></div> <p>The WebAssembly module is compiled from the Rust workspace and unmodified OpenSSL 3.6.5, under their respective free software licenses. The source archive includes first-party sources, Cargo dependencies, the OpenSSL source archive, build scripts, and notices. General-purpose Rust and WASI SDK tools are documented with verified download locations in CHAT-OPENSSL-MIGRATION.txt.</p> -<p><a href="/THIRD-PARTY-LICENSES.html">Third-party licenses</a> / <a href="/SOURCE.tar.gz">Source archive</a> / <a href="/">Return</a></p> +<p><a href="{{SITE_ORIGIN}}/THIRD-PARTY-LICENSES.html">Third-party licenses</a> / <a href="/SOURCE.tar.gz">Source archive</a> / <a href="/">Return</a></p> </main></body></html> diff --git a/crates/config/src/lib.rs b/crates/config/src/lib.rs index 0a648a2..ce509b6 100644 --- a/crates/config/src/lib.rs +++ b/crates/config/src/lib.rs @@ -32,6 +32,7 @@ const HOSTS: &[&str] = &[ "SEARCH_HOST", "WKD_HOST", "CHAT_HOST", + "CHAT_LOCAL_HOST", ]; const PUBLIC: &[&str] = &[ "BASE_DOMAIN", @@ -40,6 +41,9 @@ const PUBLIC: &[&str] = &[ "SEARCH_HOST", "WKD_HOST", "CHAT_HOST", + "CHAT_LOCAL_HOST", + "WEB_BIND", + "CHAT_UPSTREAM", "HTTP_PORT", "HTTPS_PORT", "LOCAL_HTTP_PORT", @@ -103,6 +107,32 @@ impl Config { } else { BTreeMap::new() }; + Self::from_values(explicit) + } + + #[doc = "Loads only declared public settings and the chat token from a container environment."] + #[doc = "# Errors"] + #[doc = "Rejects invalid configuration without importing unrelated host secrets."] + pub fn from_environment() -> Result<Self> { + let mut explicit = BTreeMap::new(); + for key in PUBLIC + .iter() + .copied() + .chain(std::iter::once("CHAT_SETUP_TOKEN")) + { + if let Some(value) = std::env::var_os(key) { + explicit.insert( + key.to_owned(), + value + .into_string() + .map_err(|_| anyhow::anyhow!("Invalid {key} environment value"))?, + ); + } + } + Self::from_values(explicit) + } + + fn from_values(explicit: BTreeMap<String, String>) -> Result<Self> { let mut values = defaults()?; values.retain(|key, _| PUBLIC.contains(&key.as_str())); values.extend(explicit.clone()); @@ -160,9 +190,16 @@ impl Config { (1024..=65_534).contains(&self.port("LOCAL_HTTP_PORT")?), "LOCAL_HTTP_PORT must be between 1024 and 65534" ); - for key in ["CGIT_UPSTREAM", "SEARXNG_UPSTREAM"] { + for key in ["CGIT_UPSTREAM", "SEARXNG_UPSTREAM", "CHAT_UPSTREAM"] { ensure!(endpoint(self.value(key)?), "Invalid {key} in root .env"); } + self.value("WEB_BIND")? + .parse::<std::net::IpAddr>() + .context("Invalid WEB_BIND")?; + ensure!( + self.value("CHAT_LOCAL_HOST")?.ends_with(".localhost"), + "CHAT_LOCAL_HOST must be a dedicated .localhost name" + ); self.chat_bind()?; let origin = self.value("CHAT_ORIGIN")?; ensure!( @@ -222,9 +259,10 @@ impl Config { values.insert("GIT_ORIGIN".into(), "/git".into()); values.insert("SEARCH_ORIGIN".into(), "/search".into()); values.insert("WKD_ORIGIN".into(), String::new()); + values.insert("LOCAL_HTTP_PORT".into(), port.to_string()); values.insert( "CHAT_PUBLIC_ORIGIN".into(), - self.value("CHAT_ORIGIN")?.into(), + format!("http://{}:{port}", self.value("CHAT_LOCAL_HOST")?), ); } Ok(values) @@ -271,6 +309,9 @@ pub fn initialize_public(root: &Path) -> Result<()> { writeln!(appended, "{key}={value}")?; } } + if path.exists() { + fs::set_permissions(&path, fs::Permissions::from_mode(0o600))?; + } if appended.is_empty() { return Ok(()); } |
