// Copyright (C) 2026 Doan Luu Duc Tai // // 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 . #![doc = "Encrypted private chat server and backend maintenance commands."] mod http; mod maintenance; mod service; mod store; use anyhow::{Context, Result, ensure}; use clap::Parser; use std::io::{Read, Write}; use std::net::SocketAddr; use std::path::PathBuf; use std::process::ExitCode; #[derive(Debug, Parser)] #[command(about = "Run private chat directly or behind the stack gateway")] struct Arguments { #[arg(long, default_value = ".")] root: PathBuf, #[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")] reset_owner: Option, #[arg( long, conflicts_with = "snapshot", help = "Print the registered owner's public fingerprint" )] owner_fingerprint: bool, #[arg(long)] snapshot: Option, } #[derive(Debug, clap::Args)] struct Listener { #[arg(long)] bind: Option, #[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() { std::fs::read_to_string(&env)? } else { String::new() }; ensure!( !existing .lines() .any(|line| line.starts_with("CHAT_SETUP_TOKEN=")), "CHAT_SETUP_TOKEN already exists" ); let mut file = std::fs::OpenOptions::new() .create(true) .append(true) .open(&env)?; std::fs::set_permissions(&env, std::os::unix::fs::PermissionsExt::from_mode(0o600))?; writeln!(file, "\nCHAT_SETUP_TOKEN={}", service::token()?)?; if !existing .lines() .any(|line| line.starts_with("CHAT_ORIGIN=")) { writeln!(file, "CHAT_ORIGIN=http://127.0.0.1:9080")?; } 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 = 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))?; let database = data.join("chat.sqlite"); 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, arguments.listener.proxy); } if let Some(destination) = arguments.snapshot { store.snapshot(&destination)?; std::fs::set_permissions( destination, std::os::unix::fs::PermissionsExt::from_mode(0o600), )?; return Ok(()); } let origin = configuration.value("CHAT_ORIGIN")?; validate_origin(&configuration, bind, arguments.listener.proxy)?; 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(), "Chat ready: {origin} (owner setup: /#setup)" )?; 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] async fn main() -> ExitCode { match run(Arguments::parse()).await { Ok(()) => ExitCode::SUCCESS, Err(error) => { drop(writeln!( std::io::stderr().lock(), "Chat server failed: {error:#}" )); ExitCode::FAILURE } } }