summaryrefslogtreecommitdiff
path: root/crates/chat-server/src/app.rs
diff options
context:
space:
mode:
Diffstat (limited to 'crates/chat-server/src/app.rs')
-rw-r--r--crates/chat-server/src/app.rs199
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
+ }
+ }
+}