summaryrefslogtreecommitdiff
path: root/crates
diff options
context:
space:
mode:
authorDoan Luu Duc Tai <doan@luuduct.ai>2026-10-31 07:39:18 -0500
committerDoan Luu Duc Tai <doan@luuduct.ai>2026-10-31 07:39:18 -0500
commitd809d86182ab802472ae7d5e74cab66ba7bfa397 (patch)
tree36718e8d91b13b2714cbfe7877353ac7625f2f44 /crates
parentdd1d5863ef865ebe29dc93d03ca6abc0aaf48bee (diff)
Package private chat for HTTPS and verify portable encrypted recovery
Diffstat (limited to 'crates')
-rw-r--r--crates/chat-crypto/README.txt6
-rw-r--r--crates/chat-server/Cargo.toml1
-rw-r--r--crates/chat-server/README.txt3
-rw-r--r--crates/chat-server/src/http.rs43
-rw-r--r--crates/chat-server/src/main.rs105
-rw-r--r--crates/chat-server/src/maintenance.rs25
-rw-r--r--crates/chat-server/tests/browser.mjs128
-rw-r--r--crates/chat-server/web/index.html2
-rw-r--r--crates/config/src/lib.rs45
9 files changed, 297 insertions, 61 deletions
diff --git a/crates/chat-crypto/README.txt b/crates/chat-crypto/README.txt
index e04246c..2ee3f01 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 on feature/private-chat;
+ 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-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..324c593 100644
--- a/crates/chat-server/src/http.rs
+++ b/crates/chat-server/src/http.rs
@@ -17,6 +17,7 @@ use crate::service::Service;
use anyhow::Result;
use axum::{
Json, Router,
+ body::Body,
extract::{DefaultBodyLimit, State},
http::{HeaderMap, HeaderValue, StatusCode},
response::{IntoResponse, Response},
@@ -115,14 +116,25 @@ 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"),
+ "/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",
+ ),
"/THIRD-PARTY-LICENSES.html" => {
("web/THIRD-PARTY-LICENSES.html", "text/html; charset=utf-8")
}
@@ -130,10 +142,10 @@ 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()),
};
- 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 +154,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 +186,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..7a359e7 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());
@@ -129,7 +202,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 +284,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 +355,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 +397,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 +407,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/web/index.html b/crates/chat-server/web/index.html
index 79026b0..b2a4363 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>
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(());
}