summaryrefslogtreecommitdiff
path: root/crates/chat-server/src
diff options
context:
space:
mode:
authorDoan Luu Duc Tai <doan@luuduct.ai>2026-10-31 13:08:08 -0500
committerDoan Luu Duc Tai <doan@luuduct.ai>2026-10-31 13:08:08 -0500
commita80df869a92c330fbad9695057720b11a18af901 (patch)
tree5307272821cb3adc3f0348413a3b904290890044 /crates/chat-server/src
parent9139398d465f5899cc31bc4cdd54daa7392ca550 (diff)
Forbid Rust lints and enforce strict typed TypeScript linting
Diffstat (limited to 'crates/chat-server/src')
-rw-r--r--crates/chat-server/src/app.rs199
-rw-r--r--crates/chat-server/src/app/cli.rs167
-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.rs202
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()
}