// 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", "CHAT_LOCAL_HOST", ]; const PUBLIC: &[&str] = &[ "BASE_DOMAIN", "SITE_HOST", "GIT_HOST", "SEARCH_HOST", "WKD_HOST", "CHAT_HOST", "CHAT_LOCAL_HOST", "WEB_BIND", "CHAT_UPSTREAM", "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() }; Self::from_values(explicit) } #[doc = "Loads only declared public settings and the chat token from a container environment."] #[doc = "# Errors"] #[doc = "Rejects invalid configuration without importing unrelated host secrets."] pub fn from_environment() -> Result { let mut explicit = BTreeMap::new(); for key in PUBLIC .iter() .copied() .chain(std::iter::once("CHAT_SETUP_TOKEN")) { if let Some(value) = std::env::var_os(key) { explicit.insert( key.to_owned(), value .into_string() .map_err(|_| anyhow::anyhow!("Invalid {key} environment value"))?, ); } } Self::from_values(explicit) } fn from_values(explicit: BTreeMap) -> Result { 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", "CHAT_UPSTREAM"] { ensure!(endpoint(self.value(key)?), "Invalid {key} in root .env"); } self.value("WEB_BIND")? .parse::() .context("Invalid WEB_BIND")?; ensure!( self.value("CHAT_LOCAL_HOST")?.ends_with(".localhost"), "CHAT_LOCAL_HOST must be a dedicated .localhost name" ); 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("LOCAL_HTTP_PORT".into(), port.to_string()); values.insert( "CHAT_PUBLIC_ORIGIN".into(), format!("http://{}:{port}", self.value("CHAT_LOCAL_HOST")?), ); } 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 path.exists() { fs::set_permissions(&path, fs::Permissions::from_mode(0o600))?; } 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(()) } }