diff options
Diffstat (limited to 'crates/chat-server/src')
| -rw-r--r-- | crates/chat-server/src/app.rs | 199 | ||||
| -rw-r--r-- | crates/chat-server/src/app/cli.rs | 167 | ||||
| -rw-r--r-- | crates/chat-server/src/app/http.rs (renamed from crates/chat-server/src/http.rs) | 4 | ||||
| -rw-r--r-- | crates/chat-server/src/app/maintenance.rs (renamed from crates/chat-server/src/maintenance.rs) | 8 | ||||
| -rw-r--r-- | crates/chat-server/src/app/service.rs (renamed from crates/chat-server/src/service.rs) | 16 | ||||
| -rw-r--r-- | crates/chat-server/src/app/store.rs (renamed from crates/chat-server/src/store.rs) | 24 | ||||
| -rw-r--r-- | crates/chat-server/src/main.rs | 202 |
7 files changed, 397 insertions, 223 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 + } + } +} diff --git a/crates/chat-server/src/app/cli.rs b/crates/chat-server/src/app/cli.rs new file mode 100644 index 0000000..1296df4 --- /dev/null +++ b/crates/chat-server/src/app/cli.rs @@ -0,0 +1,167 @@ +// 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/>. + +use clap::{Arg, ArgAction, Command}; +use std::ffi::OsString; +use std::net::SocketAddr; +use std::path::PathBuf; + +#[derive(Debug)] + +pub(super) struct Arguments { + pub(super) root: PathBuf, + + pub(super) listener: Listener, + + pub(super) healthcheck: bool, + + pub(super) init: bool, + + pub(super) reset_owner: Option<String>, + + pub(super) owner_fingerprint: bool, + + pub(super) snapshot: Option<PathBuf>, +} + +#[derive(Debug)] +pub(super) struct Listener { + pub(super) bind: Option<SocketAddr>, + + pub(super) proxy: bool, +} + +fn flag(name: &'static str) -> Arg { + Arg::new(name).long(name).action(ArgAction::SetTrue) +} + +fn path(name: &'static str) -> Arg { + Arg::new(name) + .long(name) + .value_parser(clap::builder::PathBufValueParser::new()) +} + +fn command() -> Command { + Command::new("stack-chat-server").about("Run private chat directly or behind the stack gateway") + .args([ + path("root").default_value("."), + Arg::new("bind").long("bind").value_parser(str::parse::<SocketAddr>), + flag("proxy").help("Run behind the stack gateway using its explicit container environment"), + flag("healthcheck").help("Check the local HTTP health endpoint without opening the database"), + flag("init").conflicts_with_all(["reset-owner", "owner-fingerprint", "snapshot"]), + Arg::new("reset-owner").long("reset-owner").value_name("FINGERPRINT").conflicts_with_all(["owner-fingerprint", "snapshot"]) + .help("Permanently clear the matching owner registration and all chat history after key loss"), + flag("owner-fingerprint").conflicts_with("snapshot").help("Print the registered owner's public fingerprint"), + path("snapshot"), + ]) +} + +impl Arguments { + pub(super) fn try_parse() -> Result<Self, clap::Error> { + Self::try_parse_from(std::env::args_os()) + } + + fn try_parse_from( + arguments: impl IntoIterator<Item = impl Into<OsString> + Clone>, + ) -> Result<Self, clap::Error> { + let mut values = command().try_get_matches_from(arguments)?; + let root = values.remove_one("root").ok_or_else(|| { + clap::Error::raw( + clap::error::ErrorKind::MissingRequiredArgument, + "Missing application root", + ) + })?; + Ok(Self { + root, + listener: Listener { + bind: values.remove_one("bind"), + proxy: values.get_flag("proxy"), + }, + healthcheck: values.get_flag("healthcheck"), + init: values.get_flag("init"), + reset_owner: values.remove_one("reset-owner"), + owner_fingerprint: values.get_flag("owner-fingerprint"), + snapshot: values.remove_one("snapshot"), + }) + } +} + +#[cfg(test)] +mod tests { + use super::{Arguments, command}; + use anyhow::{Result, ensure}; + use std::path::Path; + + #[test] + fn checked_command_schema() { + command().debug_assert(); + } + + #[test] + fn listener_defaults_and_maintenance_conflicts() -> Result<()> { + let defaults = Arguments::try_parse_from(["stack-chat-server"])?; + anyhow::ensure!((defaults.root) == (Path::new(".")), "Test invariant failed"); + anyhow::ensure!( + !defaults.listener.proxy && defaults.listener.bind.is_none(), + "Test invariant failed" + ); + let parsed = Arguments::try_parse_from([ + "stack-chat-server", + "--root", + "/tmp/stack", + "--bind", + "127.0.0.1:9180", + "--proxy", + "--healthcheck", + ])?; + anyhow::ensure!( + (parsed.root) == (Path::new("/tmp/stack")), + "Test invariant failed" + ); + ensure!( + parsed + .listener + .bind + .is_some_and(|address| address.port() == 9180), + "Listener port differs" + ); + anyhow::ensure!( + parsed.listener.proxy && parsed.healthcheck, + "Test invariant failed" + ); + for args in [ + vec!["stack-chat-server", "--bind", "garbage"], + vec!["stack-chat-server", "--init", "--snapshot", "snapshot"], + vec![ + "stack-chat-server", + "--reset-owner", + "abc", + "--owner-fingerprint", + ], + vec![ + "stack-chat-server", + "--owner-fingerprint", + "--snapshot", + "snapshot", + ], + ] { + anyhow::ensure!( + Arguments::try_parse_from(args).is_err(), + "Test invariant failed" + ); + } + Ok(()) + } +} diff --git a/crates/chat-server/src/http.rs b/crates/chat-server/src/app/http.rs index cb21b1f..5b1941a 100644 --- a/crates/chat-server/src/http.rs +++ b/crates/chat-server/src/app/http.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/>. -use crate::service::Service; +use crate::app::service::Service; use anyhow::Result; use axum::{ Json, Router, @@ -212,7 +212,7 @@ async fn asset(State(app): State<App>, uri: axum::http::Uri) -> Response { secured(response) } -pub(crate) async fn serve( +pub(super) async fn serve( listener: tokio::net::TcpListener, service: Service, root: &Path, diff --git a/crates/chat-server/src/maintenance.rs b/crates/chat-server/src/app/maintenance.rs index d416d73..647dff9 100644 --- a/crates/chat-server/src/maintenance.rs +++ b/crates/chat-server/src/app/maintenance.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/>. -use crate::store::Store; +use crate::app::store::Store; use anyhow::{Context, Result, ensure}; use std::fs::{File, OpenOptions}; use std::io::Write; @@ -21,7 +21,7 @@ use std::net::{SocketAddr, TcpListener}; use std::os::unix::fs::OpenOptionsExt; use std::path::Path; -pub(crate) fn lock(directory: &Path) -> Result<File> { +pub(super) fn lock(directory: &Path) -> Result<File> { let file = OpenOptions::new() .read(true) .write(true) @@ -35,7 +35,7 @@ pub(crate) fn lock(directory: &Path) -> Result<File> { Ok(file) } -pub(crate) fn fingerprint(store: &Store) -> Result<()> { +pub(super) fn fingerprint(store: &Store) -> Result<()> { let certificate = store.owner()?.context("No owner identity is registered")?; writeln!( std::io::stdout().lock(), @@ -45,7 +45,7 @@ pub(crate) fn fingerprint(store: &Store) -> Result<()> { Ok(()) } -pub(crate) fn reset( +pub(super) fn reset( store: &mut Store, expected: &str, address: SocketAddr, diff --git a/crates/chat-server/src/service.rs b/crates/chat-server/src/app/service.rs index 3f9b6ee..d5433e7 100644 --- a/crates/chat-server/src/service.rs +++ b/crates/chat-server/src/app/service.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/>. -use crate::store::Store; +use crate::app::store::Store; use anyhow::{Context, Result, ensure}; use base64::{Engine, prelude::BASE64_STANDARD}; use serde_json::{Value, json}; @@ -32,9 +32,9 @@ struct Session { expiry: Instant, } -pub(crate) struct Service { - pub(crate) store: Store, - pub(crate) origin: String, +pub(super) struct Service { + pub(super) store: Store, + pub(super) origin: String, setup_token: String, challenges: HashMap<String, Challenge>, sessions: HashMap<String, Session>, @@ -42,13 +42,13 @@ pub(crate) struct Service { attempts: u16, } -pub(crate) fn token() -> Result<String> { +pub(super) fn token() -> Result<String> { let mut bytes = [0; 32]; openssl::rand::rand_bytes(&mut bytes)?; Ok(hex::encode(bytes)) } -pub(crate) fn field<'a>(request: &'a Value, key: &str) -> Result<&'a str> { +pub(super) fn field<'a>(request: &'a Value, key: &str) -> Result<&'a str> { request .get(key) .and_then(Value::as_str) @@ -73,7 +73,7 @@ fn identifier(value: &str) -> Result<()> { } impl Service { - pub(crate) fn new(store: Store, origin: String, setup_token: String) -> Self { + pub(super) fn new(store: Store, origin: String, setup_token: String) -> Self { Self { store, origin, @@ -201,7 +201,7 @@ impl Service { Ok(conversation.into()) } - pub(crate) fn call(&mut self, request: &Value) -> Result<Value> { + pub(super) fn call(&mut self, request: &Value) -> Result<Value> { match field(request, "op")? { "config" => Ok( json!({"owner":self.store.owner()?,"origin":self.origin,"protocol":"stack-chat/v1"}), diff --git a/crates/chat-server/src/store.rs b/crates/chat-server/src/app/store.rs index 0e09de7..23e8ba1 100644 --- a/crates/chat-server/src/store.rs +++ b/crates/chat-server/src/app/store.rs @@ -18,18 +18,18 @@ use rusqlite::{Connection, OptionalExtension, params}; use serde_json::{Value, json}; use std::path::Path; -pub(crate) struct Store { +pub(super) struct Store { connection: Connection, } impl Store { - pub(crate) fn open(path: &Path) -> Result<Self> { + pub(super) fn open(path: &Path) -> Result<Self> { let connection = Connection::open(path)?; connection.execute_batch("PRAGMA foreign_keys=ON; PRAGMA journal_mode=DELETE; PRAGMA synchronous=FULL; PRAGMA secure_delete=ON; CREATE TABLE IF NOT EXISTS metadata (key TEXT PRIMARY KEY, value TEXT NOT NULL); CREATE TABLE IF NOT EXISTS conversations (id TEXT PRIMARY KEY, cert TEXT NOT NULL, profile TEXT); CREATE TABLE IF NOT EXISTS messages (seq INTEGER PRIMARY KEY AUTOINCREMENT, conversation TEXT NOT NULL REFERENCES conversations(id), id TEXT NOT NULL UNIQUE, sender TEXT NOT NULL, ciphertext TEXT NOT NULL);")?; Ok(Self { connection }) } - pub(crate) fn owner(&self) -> Result<Option<String>> { + pub(super) fn owner(&self) -> Result<Option<String>> { Ok(self .connection .query_row("SELECT value FROM metadata WHERE key='owner'", [], |row| { @@ -38,14 +38,14 @@ impl Store { .optional()?) } - pub(crate) fn install_owner(&self, cert: &str) -> Result<()> { + pub(super) fn install_owner(&self, cert: &str) -> Result<()> { ensure!(self.owner()?.is_none(), "Already initialized"); self.connection .execute("INSERT INTO metadata VALUES ('owner', ?1)", [cert])?; Ok(()) } - pub(crate) fn reset_owner(&mut self, expected: &str) -> Result<()> { + pub(super) fn reset_owner(&mut self, expected: &str) -> Result<()> { let owner = self.owner()?.context("No owner identity is registered")?; let fingerprint = stack_chat_crypto::public_identity(&owner)?; ensure!( @@ -58,7 +58,7 @@ impl Store { Ok(()) } - pub(crate) fn register(&self, id: &str, cert: &str) -> Result<()> { + pub(super) fn register(&self, id: &str, cert: &str) -> Result<()> { if self.connection.query_row( "SELECT EXISTS(SELECT 1 FROM conversations WHERE id=?1)", [id], @@ -77,7 +77,7 @@ impl Store { Ok(()) } - pub(crate) fn conversation(&self, id: &str) -> Result<Value> { + pub(super) fn conversation(&self, id: &str) -> Result<Value> { let (cert, profile): (String, Option<String>) = self.connection.query_row( "SELECT cert,profile FROM conversations WHERE id=?1", [id], @@ -93,7 +93,7 @@ impl Store { ) } - pub(crate) fn inbox(&self) -> Result<Value> { + pub(super) fn inbox(&self) -> Result<Value> { let mut statement = self .connection .prepare("SELECT id FROM conversations ORDER BY id")?; @@ -107,7 +107,7 @@ impl Store { Ok(json!({"conversations":conversations})) } - pub(crate) fn profile(&self, id: &str, ciphertext: &str) -> Result<()> { + pub(super) fn profile(&self, id: &str, ciphertext: &str) -> Result<()> { self.connection.execute( "UPDATE conversations SET profile=?2 WHERE id=?1 AND profile IS NULL", params![id, ciphertext], @@ -115,7 +115,7 @@ impl Store { Ok(()) } - pub(crate) fn messages(&self, id: &str, count: u64, before: u64) -> Result<Value> { + pub(super) fn messages(&self, id: &str, count: u64, before: u64) -> Result<Value> { ensure!( count <= 50 && before <= i64::MAX.unsigned_abs(), "Invalid history window" @@ -125,7 +125,7 @@ impl Store { Ok(json!({"conversation":self.conversation(id)?,"messages":messages})) } - pub(crate) fn send( + pub(super) fn send( &mut self, conversation: &str, id: &str, @@ -173,7 +173,7 @@ impl Store { Ok(json!({"seq":sequence,"conversation":self.conversation(conversation)?})) } - pub(crate) fn snapshot(&self, destination: &Path) -> Result<()> { + pub(super) fn snapshot(&self, destination: &Path) -> Result<()> { ensure!(!destination.exists(), "Snapshot destination already exists"); self.connection .backup("main", destination, None::<fn(rusqlite::backup::Progress)>)?; diff --git a/crates/chat-server/src/main.rs b/crates/chat-server/src/main.rs index 1d600b7..942a379 100644 --- a/crates/chat-server/src/main.rs +++ b/crates/chat-server/src/main.rs @@ -13,204 +13,12 @@ // 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."] +#![doc = "Checked command-line entry point for the personal infrastructure stack."] -mod http; -mod maintenance; -mod service; -mod store; +mod app; -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; +pub use app::run; -#[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<String>, - #[arg( - long, - conflicts_with = "snapshot", - help = "Print the registered owner's public fingerprint" - )] - owner_fingerprint: bool, - #[arg(long)] - 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() { - 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 - } - } +fn main() -> std::process::ExitCode { + run() } |
