summaryrefslogtreecommitdiff
path: root/crates/chat-server/src
diff options
context:
space:
mode:
Diffstat (limited to 'crates/chat-server/src')
-rw-r--r--crates/chat-server/src/http.rs43
-rw-r--r--crates/chat-server/src/main.rs105
-rw-r--r--crates/chat-server/src/maintenance.rs25
3 files changed, 142 insertions, 31 deletions
diff --git a/crates/chat-server/src/http.rs b/crates/chat-server/src/http.rs
index 1c7286f..324c593 100644
--- a/crates/chat-server/src/http.rs
+++ b/crates/chat-server/src/http.rs
@@ -17,6 +17,7 @@ use crate::service::Service;
use anyhow::Result;
use axum::{
Json, Router,
+ body::Body,
extract::{DefaultBodyLimit, State},
http::{HeaderMap, HeaderValue, StatusCode},
response::{IntoResponse, Response},
@@ -115,14 +116,25 @@ 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"),
+ "/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",
+ ),
"/THIRD-PARTY-LICENSES.html" => {
("web/THIRD-PARTY-LICENSES.html", "text/html; charset=utf-8")
}
@@ -130,10 +142,10 @@ async fn asset(State(app): State<App>, uri: axum::http::Uri) -> Response {
"/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 +154,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 +186,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(),