summaryrefslogtreecommitdiff
path: root/crates/config/src/lib.rs
diff options
context:
space:
mode:
authorDoan Luu Duc Tai <doan@luuduct.ai>2026-10-30 22:36:11 -0500
committerDoan Luu Duc Tai <doan@luuduct.ai>2026-10-30 22:36:11 -0500
commit6933ec28be3dac0dc17cd634c663db3b4818caad (patch)
tree22690260136d598d2719d97deb169b847b31424c /crates/config/src/lib.rs
parentc3b361c45ac8dd898d5c83a161abed5fc138b6c0 (diff)
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.
Diffstat (limited to 'crates/config/src/lib.rs')
-rw-r--r--crates/config/src/lib.rs332
1 files changed, 332 insertions, 0 deletions
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 <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 = "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<String, String>,
+ explicit: BTreeMap<String, String>,
+}
+
+impl fmt::Debug for Config {
+ fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
+ formatter.debug_struct("Config").finish_non_exhaustive()
+ }
+}
+
+fn defaults() -> Result<BTreeMap<String, String>> {
+ Ok(dotenvy::from_read_iter(EXAMPLE.as_bytes()).collect::<std::result::Result<_, _>>()?)
+}
+
+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::<SocketAddr>().is_ok() {
+ return true;
+ }
+ value.rsplit_once(':').is_some_and(|(host, port)| {
+ hostname(host) && port.parse::<u16>().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<Self> {
+ let path = root.join(".env");
+ let explicit: BTreeMap<String, String> = if path.exists() {
+ dotenvy::from_path_iter(path)
+ .context("Cannot parse root .env")?
+ .collect::<std::result::Result<_, _>>()
+ .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<u16> {
+ let port = self
+ .value(key)?
+ .parse::<u16>()
+ .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<SocketAddr> {
+ 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<u16>) -> Result<BTreeMap<String, String>> {
+ let mut values = PUBLIC
+ .iter()
+ .map(|key| Ok(((*key).to_owned(), self.value(key)?.to_owned())))
+ .collect::<Result<BTreeMap<_, _>>>()?;
+ 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<u16>) -> Result<String> {
+ 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<String, String>) -> Result<String> {
+ 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(())
+ }
+}