From 6933ec28be3dac0dc17cd634c663db3b4818caad Mon Sep 17 00:00:00 2001 From: Doan Luu Duc Tai Date: Fri, 30 Oct 2026 22:36:11 -0500 Subject: Centralize deployment settings in the root environment file Document public domains, ports, upstreams, chat binding, and backup defaults alongside private settings. Validate shared configuration and render Caddy, cgit, and website links from an explicit public allowlist while preserving one installation environment file. --- crates/chat-server/Cargo.toml | 2 +- crates/chat-server/src/http.rs | 20 ++- crates/chat-server/src/main.rs | 34 ++-- crates/chat-server/web/index.html | 4 +- crates/config/Cargo.toml | 35 ++++ crates/config/README.txt | 20 +++ crates/config/src/lib.rs | 332 ++++++++++++++++++++++++++++++++++++++ 7 files changed, 422 insertions(+), 25 deletions(-) create mode 100644 crates/config/Cargo.toml create mode 100644 crates/config/README.txt create mode 100644 crates/config/src/lib.rs (limited to 'crates') diff --git a/crates/chat-server/Cargo.toml b/crates/chat-server/Cargo.toml index eecebf6..709c37f 100644 --- a/crates/chat-server/Cargo.toml +++ b/crates/chat-server/Cargo.toml @@ -27,11 +27,11 @@ categories = ["web-programming"] publish = false [dependencies] +stack-config = { path = "../config", version = "0.1.0" } anyhow = "1" axum = { version = "0.8", default-features = false, features = ["http1", "tokio", "json"] } base64 = "0.22" clap = { version = "4.6", features = ["derive"] } -dotenvy = "0.15" hex = "0.4" openssl = "0.10" rusqlite = { version = "0.41", default-features = false, features = ["bundled", "backup"] } diff --git a/crates/chat-server/src/http.rs b/crates/chat-server/src/http.rs index bb618d9..1c7286f 100644 --- a/crates/chat-server/src/http.rs +++ b/crates/chat-server/src/http.rs @@ -33,6 +33,7 @@ struct App { permits: Arc, root: PathBuf, origin: String, + public: std::collections::BTreeMap, } fn secured(mut response: Response) -> Response { @@ -129,22 +130,33 @@ async fn asset(State(app): State, uri: axum::http::Uri) -> Response { "/LICENSE" => ("LICENSE", "text/plain; charset=utf-8"), _ => return secured(StatusCode::NOT_FOUND.into_response()), }; - tokio::fs::read(app.root.join(path)).await.map_or_else( - |_| secured(StatusCode::NOT_FOUND.into_response()), - |bytes| secured(([("content-type", mime)], bytes).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 rendered = String::from_utf8(bytes) + .map_err(anyhow::Error::from) + .and_then(|source| stack_config::render(&source, &app.public)); + return rendered.map_or_else( + |_| secured(StatusCode::INTERNAL_SERVER_ERROR.into_response()), + |html| secured(([("content-type", mime)], html).into_response()), + ); + } + secured(([("content-type", mime)], bytes).into_response()) } pub(crate) async fn serve( listener: tokio::net::TcpListener, service: Service, root: &Path, + public: std::collections::BTreeMap, ) -> Result<()> { let app = App { origin: service.origin.clone(), service: Arc::new(Mutex::new(service)), permits: Arc::new(Semaphore::new(4)), root: root.to_path_buf(), + public, }; let routes = Router::new() .route("/api", post(api)) diff --git a/crates/chat-server/src/main.rs b/crates/chat-server/src/main.rs index c17a7f6..cfd3e7a 100644 --- a/crates/chat-server/src/main.rs +++ b/crates/chat-server/src/main.rs @@ -32,8 +32,8 @@ use std::process::ExitCode; struct Arguments { #[arg(long, default_value = ".")] root: PathBuf, - #[arg(long, default_value = "127.0.0.1:9080")] - bind: SocketAddr, + #[arg(long)] + bind: Option, #[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")] @@ -73,7 +73,7 @@ fn initialize(root: &std::path::Path) -> Result<()> { { writeln!(file, "CHAT_ORIGIN=http://127.0.0.1:9080")?; } - Ok(()) + stack_config::initialize_public(root) } async fn run(arguments: Arguments) -> Result<()> { @@ -81,6 +81,8 @@ async fn run(arguments: Arguments) -> Result<()> { if arguments.init { return initialize(&root); } + let configuration = stack_config::Config::load(&root)?; + let bind = arguments.bind.unwrap_or(configuration.chat_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))?; @@ -99,7 +101,7 @@ async fn run(arguments: Arguments) -> Result<()> { return maintenance::fingerprint(&store); } if let Some(expected) = arguments.reset_owner { - return maintenance::reset(&mut store, &expected, arguments.bind); + return maintenance::reset(&mut store, &expected, bind); } if let Some(destination) = arguments.snapshot { store.snapshot(&destination)?; @@ -110,28 +112,24 @@ async fn run(arguments: Arguments) -> Result<()> { return Ok(()); } ensure!( - arguments.bind.ip().is_loopback(), + bind.ip().is_loopback(), "Experimental server must bind to loopback" ); - let settings = dotenvy::from_path_iter(root.join(".env"))? - .collect::, _>>()?; - let origin = settings - .get("CHAT_ORIGIN") - .context("Run --init to configure chat")?; + let origin = configuration.value("CHAT_ORIGIN")?; ensure!( - origin == &format!("http://{}", arguments.bind), - "This development server requires CHAT_ORIGIN to match its loopback bind address" + origin == format!("http://{bind}"), + "CHAT_ORIGIN must match the loopback CHAT_BIND or --bind address" ); - let setup = settings - .get("CHAT_SETUP_TOKEN") - .context("Missing CHAT_SETUP_TOKEN")?; - let state = service::Service::new(store, origin.clone(), setup.clone()); - let listener = tokio::net::TcpListener::bind(arguments.bind).await?; + 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(), "Experimental chat: {origin} (owner setup: /#setup)" )?; - http::serve(listener, state, &root).await + http::serve(listener, state, &root, configuration.public_values(None)?).await } #[tokio::main] diff --git a/crates/chat-server/web/index.html b/crates/chat-server/web/index.html index 7c2fe8b..79026b0 100644 --- a/crates/chat-server/web/index.html +++ b/crates/chat-server/web/index.html @@ -29,14 +29,14 @@ along with this program. If not, see .
[ GNU LibreJS-compliantLibreJS compliance concerns software freedom, not a guarantee of privacy or security. Review the source code, dependencies, and scripts delivered to your browser. Server-delivered code can change between visits; free software can still track users or behave maliciously. ] [ JAVASCRIPT LICENSES ]
-
- +
diff --git a/crates/config/Cargo.toml b/crates/config/Cargo.toml new file mode 100644 index 0000000..b0c0c8d --- /dev/null +++ b/crates/config/Cargo.toml @@ -0,0 +1,35 @@ +# Copyright (C) 2026 Doan Luu Duc Tai +# +# 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 . + +[package] +name = "stack-config" +version = "0.1.0" +edition = "2024" +authors = ["Doan Luu Duc Tai "] +license = "AGPL-3.0-or-later" +description = "Validated shared deployment configuration for the personal stack." +repository = "https://git.doan.luuduct.ai/stack" +readme = "README.txt" +keywords = ["configuration", "self-hosting"] +categories = ["config"] +publish = false + +[dependencies] +anyhow = "1" +dotenvy = "0.15" +tempfile = "3" + +[lints] +workspace = true diff --git a/crates/config/README.txt b/crates/config/README.txt new file mode 100644 index 0000000..8648852 --- /dev/null +++ b/crates/config/README.txt @@ -0,0 +1,20 @@ +Copyright (C) 2026 Doan Luu Duc Tai + +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 . + +SHARED STACK CONFIGURATION + + Public defaults are documented in the root .env.example. Each installation + overrides them in the single root .env alongside private settings. Rendering + uses an explicit public allowlist. See docs/CONFIGURATION.txt for details. diff --git a/crates/config/src/lib.rs b/crates/config/src/lib.rs new file mode 100644 index 0000000..0a648a2 --- /dev/null +++ b/crates/config/src/lib.rs @@ -0,0 +1,332 @@ +// Copyright (C) 2026 Doan Luu Duc Tai +// +// 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 . + +#![doc = "Validated root environment configuration and public-only template rendering."] + +use anyhow::{Context, Result, ensure}; +use std::collections::BTreeMap; +use std::fmt; +use std::fs; +use std::io::Write; +use std::net::SocketAddr; +use std::os::unix::fs::PermissionsExt; +use std::path::{Component, Path}; + +const EXAMPLE: &str = include_str!("../../../.env.example"); +const HOSTS: &[&str] = &[ + "BASE_DOMAIN", + "SITE_HOST", + "GIT_HOST", + "SEARCH_HOST", + "WKD_HOST", + "CHAT_HOST", +]; +const PUBLIC: &[&str] = &[ + "BASE_DOMAIN", + "SITE_HOST", + "GIT_HOST", + "SEARCH_HOST", + "WKD_HOST", + "CHAT_HOST", + "HTTP_PORT", + "HTTPS_PORT", + "LOCAL_HTTP_PORT", + "CGIT_UPSTREAM", + "SEARXNG_UPSTREAM", + "CHAT_BIND", + "CHAT_ORIGIN", + "BACKUP_RECIPIENT", + "BACKUP_PUBLIC_KEY", +]; + +#[doc = "One installation's configuration; formatting deliberately excludes all values."] +pub struct Config { + values: BTreeMap, + explicit: BTreeMap, +} + +impl fmt::Debug for Config { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.debug_struct("Config").finish_non_exhaustive() + } +} + +fn defaults() -> Result> { + Ok(dotenvy::from_read_iter(EXAMPLE.as_bytes()).collect::>()?) +} + +fn hostname(value: &str) -> bool { + value.len() <= 253 + && value.split('.').all(|label| { + !label.is_empty() + && label.len() <= 63 + && !label.starts_with('-') + && !label.ends_with('-') + && label + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || byte == b'-') + }) +} + +fn endpoint(value: &str) -> bool { + if value.parse::().is_ok() { + return true; + } + value.rsplit_once(':').is_some_and(|(host, port)| { + hostname(host) && port.parse::().is_ok_and(|number| number > 0) + }) +} + +impl Config { + #[doc = "Loads the single root .env over public defaults from the published example."] + #[doc = "# Errors"] + #[doc = "Rejects malformed files and invalid public deployment values without printing secrets."] + pub fn load(root: &Path) -> Result { + let path = root.join(".env"); + let explicit: BTreeMap = if path.exists() { + dotenvy::from_path_iter(path) + .context("Cannot parse root .env")? + .collect::>() + .map_err(|_| anyhow::anyhow!("Cannot parse root .env"))? + } else { + BTreeMap::new() + }; + let mut values = defaults()?; + values.retain(|key, _| PUBLIC.contains(&key.as_str())); + values.extend(explicit.clone()); + let config = Self { values, explicit }; + config.validate()?; + Ok(config) + } + + #[doc = "Returns a configured value; callers must not log private values."] + #[doc = "# Errors"] + #[doc = "Returns only the missing key name when a value is unavailable."] + pub fn value(&self, key: &str) -> Result<&str> { + self.values + .get(key) + .map(String::as_str) + .with_context(|| format!("Missing {key} in root .env")) + } + + #[doc = "Returns whether a setting is present in the installation file rather than defaults."] + #[must_use] + pub fn is_explicit(&self, key: &str) -> bool { + self.explicit.contains_key(key) + } + + #[doc = "Reads a nonzero TCP/UDP port."] + #[doc = "# Errors"] + #[doc = "Rejects invalid ports without displaying their raw values."] + pub fn port(&self, key: &str) -> Result { + let port = self + .value(key)? + .parse::() + .with_context(|| format!("Invalid {key} in root .env"))?; + ensure!(port > 0, "Invalid {key} in root .env"); + Ok(port) + } + + #[doc = "Reads the configured local chat socket address."] + #[doc = "# Errors"] + #[doc = "Rejects malformed socket addresses."] + pub fn chat_bind(&self) -> Result { + self.value("CHAT_BIND")? + .parse() + .context("Invalid CHAT_BIND in root .env") + } + + fn validate(&self) -> Result<()> { + for key in HOSTS { + ensure!(hostname(self.value(key)?), "Invalid {key} in root .env"); + } + ensure!( + self.port("HTTP_PORT")? != self.port("HTTPS_PORT")?, + "HTTP_PORT and HTTPS_PORT must differ" + ); + ensure!( + (1024..=65_534).contains(&self.port("LOCAL_HTTP_PORT")?), + "LOCAL_HTTP_PORT must be between 1024 and 65534" + ); + for key in ["CGIT_UPSTREAM", "SEARXNG_UPSTREAM"] { + ensure!(endpoint(self.value(key)?), "Invalid {key} in root .env"); + } + self.chat_bind()?; + let origin = self.value("CHAT_ORIGIN")?; + ensure!( + origin.len() <= 256 + && (origin.starts_with("http://") || origin.starts_with("https://")) + && origin + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || b":/.-[]".contains(&byte)), + "Invalid CHAT_ORIGIN in root .env" + ); + let recipient = self.value("BACKUP_RECIPIENT")?; + ensure!( + [40, 64].contains(&recipient.len()) + && recipient.bytes().all(|byte| byte.is_ascii_hexdigit()), + "Invalid BACKUP_RECIPIENT in root .env" + ); + let key = Path::new(self.value("BACKUP_PUBLIC_KEY")?); + ensure!( + !key.as_os_str().is_empty() + && self + .value("BACKUP_PUBLIC_KEY")? + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || b"/._-".contains(&byte)) + && key + .components() + .all(|part| matches!(part, Component::Normal(_))), + "BACKUP_PUBLIC_KEY must be project-relative" + ); + Ok(()) + } + + #[doc = "Builds the explicit public-only template map for production or a local HTTP port."] + #[doc = "# Errors"] + #[doc = "Rejects invalid configuration or port arithmetic overflow."] + pub fn public_values(&self, local: Option) -> Result> { + let mut values = PUBLIC + .iter() + .map(|key| Ok(((*key).to_owned(), self.value(key)?.to_owned()))) + .collect::>>()?; + let https = self.port("HTTPS_PORT")?; + let suffix = if https == 443 { + String::new() + } else { + format!(":{https}") + }; + for (name, key) in [ + ("SITE_ORIGIN", "SITE_HOST"), + ("GIT_ORIGIN", "GIT_HOST"), + ("SEARCH_ORIGIN", "SEARCH_HOST"), + ("WKD_ORIGIN", "WKD_HOST"), + ("CHAT_PUBLIC_ORIGIN", "CHAT_HOST"), + ] { + values.insert(name.into(), format!("https://{}{suffix}", self.value(key)?)); + } + if let Some(port) = local { + values.insert("SITE_ORIGIN".into(), format!("http://localhost:{port}")); + values.insert("GIT_ORIGIN".into(), "/git".into()); + values.insert("SEARCH_ORIGIN".into(), "/search".into()); + values.insert("WKD_ORIGIN".into(), String::new()); + values.insert( + "CHAT_PUBLIC_ORIGIN".into(), + self.value("CHAT_ORIGIN")?.into(), + ); + } + Ok(values) + } + + #[doc = "Renders only whitelisted public variables; private or unknown placeholders fail closed."] + #[doc = "# Errors"] + #[doc = "Rejects unknown placeholders without substituting private configuration."] + pub fn render(&self, source: &str, local: Option) -> Result { + render(source, &self.public_values(local)?) + } +} + +#[doc = "Substitutes a previously filtered public configuration map into a source template."] +#[doc = "# Errors"] +#[doc = "Rejects remaining unknown placeholders."] +pub fn render(source: &str, public: &BTreeMap) -> Result { + let mut result = source.to_owned(); + for (key, value) in public { + result = result.replace(&format!("{{{{{key}}}}}"), value); + } + ensure!( + !result.contains("{{"), + "Unknown or private configuration placeholder" + ); + Ok(result) +} + +#[doc = "Appends missing public settings to the same root .env, preserving existing values."] +#[doc = "# Errors"] +#[doc = "Returns a filesystem/configuration error without printing environment contents."] +pub fn initialize_public(root: &Path) -> Result<()> { + let path = root.join(".env"); + let existing = if path.exists() { + fs::read_to_string(&path)? + } else { + String::new() + }; + let config = Config::load(root)?; + let mut appended = String::new(); + for (key, value) in defaults()? { + if PUBLIC.contains(&key.as_str()) && !config.is_explicit(&key) { + use std::fmt::Write as _; + writeln!(appended, "{key}={value}")?; + } + } + if appended.is_empty() { + return Ok(()); + } + let mut file = tempfile::NamedTempFile::new_in(root)?; + file.as_file() + .set_permissions(fs::Permissions::from_mode(0o600))?; + write!(file, "{existing}\n{appended}")?; + file.as_file().sync_all()?; + file.persist(path).map_err(|error| error.error)?; + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn public_rendering_rejects_secrets_and_host_injection() -> Result<()> { + let root = tempfile::tempdir()?; + fs::write( + root.path().join(".env"), + "CHAT_SETUP_TOKEN=\"private-malformed-sentinel\n", + )?; + let error = Config::load(root.path()) + .err() + .context("Malformed secret must fail")?; + assert!(!format!("{error:#}").contains("private-malformed-sentinel")); + fs::write( + root.path().join(".env"), + "SITE_HOST=home.example.net\nGIT_HOST=source.example.net\nHTTPS_PORT=8443\nLOCAL_HTTP_PORT=8180\nCHAT_SETUP_TOKEN=private-token-sentinel\nSEARXNG_SECRET=private-search-sentinel\n", + )?; + let config = Config::load(root.path())?; + assert_eq!( + config.render("{{SITE_ORIGIN}} {{GIT_ORIGIN}}", None)?, + "https://home.example.net:8443 https://source.example.net:8443" + ); + assert_eq!( + config.render("{{SITE_ORIGIN}} {{GIT_ORIGIN}}", Some(8180))?, + "http://localhost:8180 /git" + ); + assert!(config.render("{{CHAT_SETUP_TOKEN}}", None).is_err()); + assert!(!format!("{config:?}").contains("private-token")); + initialize_public(root.path())?; + assert_eq!( + Config::load(root.path())?.value("SITE_HOST")?, + "home.example.net" + ); + assert_eq!( + Config::load(root.path())?.value("CHAT_SETUP_TOKEN")?, + "private-token-sentinel" + ); + fs::write( + root.path().join(".env"), + "SITE_HOST=\"valid.example { respond bad }\"\n", + )?; + assert!(Config::load(root.path()).is_err()); + Ok(()) + } +} -- cgit v1.2.3