diff options
Diffstat (limited to 'crates/chat-server/src')
| -rw-r--r-- | crates/chat-server/src/http.rs | 56 | ||||
| -rw-r--r-- | crates/chat-server/src/main.rs | 105 | ||||
| -rw-r--r-- | crates/chat-server/src/maintenance.rs | 25 |
3 files changed, 151 insertions, 35 deletions
diff --git a/crates/chat-server/src/http.rs b/crates/chat-server/src/http.rs index 1c7286f..7394b71 100644 --- a/crates/chat-server/src/http.rs +++ b/crates/chat-server/src/http.rs @@ -17,9 +17,10 @@ use crate::service::Service; use anyhow::Result; use axum::{ Json, Router, + body::Body, extract::{DefaultBodyLimit, State}, http::{HeaderMap, HeaderValue, StatusCode}, - response::{IntoResponse, Response}, + response::{IntoResponse, Redirect, Response}, routing::{get, post}, }; use serde_json::{Value, json}; @@ -93,6 +94,14 @@ async fn api(State(app): State<App>, headers: HeaderMap, Json(request): Json<Val } async fn asset(State(app): State<App>, uri: axum::http::Uri) -> Response { + if uri.path() == "/THIRD-PARTY-LICENSES.html" { + let Some(origin) = app.public.get("SITE_ORIGIN") else { + return secured(StatusCode::INTERNAL_SERVER_ERROR.into_response()); + }; + return secured( + Redirect::permanent(&format!("{origin}/THIRD-PARTY-LICENSES.html")).into_response(), + ); + } let (path, mime) = match uri.path() { "/" => ( "crates/chat-server/web/index.html", @@ -115,25 +124,33 @@ async fn asset(State(app): State<App>, uri: axum::http::Uri) -> Response { "text/css; charset=utf-8", ), "/crypto.wasm" => ( - "target/wasm32-wasip1/release/stack-chat-crypto.wasm", + if app.root.join("bin/chat/crypto.wasm").is_file() { + "bin/chat/crypto.wasm" + } else { + "target/wasm32-wasip1/release/stack-chat-crypto.wasm" + }, "application/wasm", ), "/javascript.html" => ( "crates/chat-server/web/javascript.html", "text/html; charset=utf-8", ), - "/SOURCE.tar.gz" => ("var/build/chat-source.tar.gz", "application/gzip"), - "/THIRD-PARTY-LICENSES.html" => { - ("web/THIRD-PARTY-LICENSES.html", "text/html; charset=utf-8") - } + "/SOURCE.tar.gz" => ( + if app.root.join("bin/chat/SOURCE.tar.gz").is_file() { + "bin/chat/SOURCE.tar.gz" + } else { + "var/build/chat-source.tar.gz" + }, + "application/gzip", + ), "/LIMITATIONS.txt" => ("docs/CHAT-APPLICATION.txt", "text/plain; charset=utf-8"), "/LICENSE" => ("LICENSE", "text/plain; charset=utf-8"), _ => return secured(StatusCode::NOT_FOUND.into_response()), }; - let Ok(bytes) = tokio::fs::read(app.root.join(path)).await else { - return secured(StatusCode::NOT_FOUND.into_response()); - }; if matches!(uri.path(), "/" | "/javascript.html") { + let Ok(bytes) = tokio::fs::read(app.root.join(path)).await else { + return secured(StatusCode::NOT_FOUND.into_response()); + }; let rendered = String::from_utf8(bytes) .map_err(anyhow::Error::from) .and_then(|source| stack_config::render(&source, &app.public)); @@ -142,7 +159,22 @@ async fn asset(State(app): State<App>, uri: axum::http::Uri) -> Response { |html| secured(([("content-type", mime)], html).into_response()), ); } - secured(([("content-type", mime)], bytes).into_response()) + let Ok(file) = tokio::fs::File::open(app.root.join(path)).await else { + return secured(StatusCode::NOT_FOUND.into_response()); + }; + let length = file.metadata().await.ok().map(|metadata| metadata.len()); + let mut response = Response::new(Body::from_stream(tokio_util::io::ReaderStream::new(file))); + response + .headers_mut() + .insert("content-type", HeaderValue::from_static(mime)); + if let Some(header_length) = + length.and_then(|value| HeaderValue::from_str(&value.to_string()).ok()) + { + response + .headers_mut() + .insert("content-length", header_length); + } + secured(response) } pub(crate) async fn serve( @@ -159,6 +191,10 @@ pub(crate) async fn serve( public, }; let routes = Router::new() + .route( + "/healthz", + get(|| async { secured("ok\n".into_response()) }), + ) .route("/api", post(api)) .fallback(get(asset)) .layer(DefaultBodyLimit::max(262_144)) diff --git a/crates/chat-server/src/main.rs b/crates/chat-server/src/main.rs index cfd3e7a..1d600b7 100644 --- a/crates/chat-server/src/main.rs +++ b/crates/chat-server/src/main.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/>. -#![doc = "Local experimental chat server with ciphertext-only durable storage."] +#![doc = "Encrypted private chat server and backend maintenance commands."] mod http; mod maintenance; @@ -22,18 +22,23 @@ mod store; use anyhow::{Context, Result, ensure}; use clap::Parser; -use std::io::Write; +use std::io::{Read, Write}; use std::net::SocketAddr; use std::path::PathBuf; use std::process::ExitCode; #[derive(Debug, Parser)] -#[command(about = "Run the experimental private chat locally; production is not enabled")] +#[command(about = "Run private chat directly or behind the stack gateway")] struct Arguments { #[arg(long, default_value = ".")] root: PathBuf, - #[arg(long)] - bind: Option<SocketAddr>, + #[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")] @@ -48,6 +53,17 @@ struct Arguments { 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() { @@ -76,13 +92,70 @@ fn initialize(root: &std::path::Path) -> Result<()> { 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 = stack_config::Config::load(&root)?; - let bind = arguments.bind.unwrap_or(configuration.chat_bind()?); + 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))?; @@ -101,7 +174,7 @@ async fn run(arguments: Arguments) -> Result<()> { return maintenance::fingerprint(&store); } if let Some(expected) = arguments.reset_owner { - return maintenance::reset(&mut store, &expected, bind); + return maintenance::reset(&mut store, &expected, bind, arguments.listener.proxy); } if let Some(destination) = arguments.snapshot { store.snapshot(&destination)?; @@ -111,15 +184,8 @@ async fn run(arguments: Arguments) -> Result<()> { )?; return Ok(()); } - ensure!( - bind.ip().is_loopback(), - "Experimental server must bind to loopback" - ); let origin = configuration.value("CHAT_ORIGIN")?; - ensure!( - origin == format!("http://{bind}"), - "CHAT_ORIGIN must match the loopback CHAT_BIND or --bind address" - ); + validate_origin(&configuration, bind, arguments.listener.proxy)?; let setup = configuration .value("CHAT_SETUP_TOKEN") .context("Run --init to configure chat")?; @@ -127,9 +193,12 @@ async fn run(arguments: Arguments) -> Result<()> { let listener = tokio::net::TcpListener::bind(bind).await?; writeln!( std::io::stdout().lock(), - "Experimental chat: {origin} (owner setup: /#setup)" + "Chat ready: {origin} (owner setup: /#setup)" )?; - http::serve(listener, state, &root, configuration.public_values(None)?).await + 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] diff --git a/crates/chat-server/src/maintenance.rs b/crates/chat-server/src/maintenance.rs index eac689d..d416d73 100644 --- a/crates/chat-server/src/maintenance.rs +++ b/crates/chat-server/src/maintenance.rs @@ -45,13 +45,24 @@ pub(crate) fn fingerprint(store: &Store) -> Result<()> { Ok(()) } -pub(crate) fn reset(store: &mut Store, expected: &str, address: SocketAddr) -> Result<()> { - ensure!( - address.ip().is_loopback(), - "Reset must use the local chat address" - ); - let _port = - TcpListener::bind(address).context("Stop the chat server before resetting its owner")?; +pub(crate) fn reset( + store: &mut Store, + expected: &str, + address: SocketAddr, + proxy: bool, +) -> Result<()> { + let _port = if proxy { + None + } else { + ensure!( + address.ip().is_loopback(), + "Reset must use the local chat address" + ); + Some( + TcpListener::bind(address) + .context("Stop the chat server before resetting its owner")?, + ) + }; store.reset_owner(expected)?; writeln!( std::io::stdout().lock(), |
