// 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 cli; mod http; mod maintenance; mod service; mod store; use crate::app::cli::Arguments; use anyhow::{Context, Result, ensure}; use std::io::{Read, Write}; use std::net::SocketAddr; use std::process::ExitCode; 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 serve(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 } fn execute(arguments: Arguments) -> Result<()> { let runtime = tokio::runtime::Builder::new_multi_thread() .enable_all() .build()?; runtime.block_on(serve(arguments)) } #[doc = "Runs the checked command-line entry point and returns its process status."] #[must_use] pub fn run() -> ExitCode { let arguments = match Arguments::try_parse() { Ok(arguments) => arguments, Err(error) => { let status = if error.use_stderr() { ExitCode::from(2) } else { ExitCode::SUCCESS }; drop(error.print()); return status; } }; match execute(arguments) { Ok(()) => ExitCode::SUCCESS, Err(error) => { drop(writeln!( std::io::stderr().lock(), "Chat server failed: {error:#}" )); ExitCode::FAILURE } } }