diff options
Diffstat (limited to 'crates/chat-server/src/app.rs')
| -rw-r--r-- | crates/chat-server/src/app.rs | 199 |
1 files changed, 199 insertions, 0 deletions
diff --git a/crates/chat-server/src/app.rs b/crates/chat-server/src/app.rs new file mode 100644 index 0000000..31efd28 --- /dev/null +++ b/crates/chat-server/src/app.rs @@ -0,0 +1,199 @@ +// 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 = "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 + } + } +} |
