summaryrefslogtreecommitdiff
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
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.
-rw-r--r--.env.example38
-rw-r--r--Cargo.lock13
-rw-r--r--Cargo.toml4
-rw-r--r--README.txt4
-rw-r--r--crates/chat-server/Cargo.toml2
-rw-r--r--crates/chat-server/src/http.rs20
-rw-r--r--crates/chat-server/src/main.rs34
-rw-r--r--crates/chat-server/web/index.html4
-rw-r--r--crates/config/Cargo.toml (renamed from infra/containers/cgit/cgitrc.local)34
-rw-r--r--crates/config/README.txt20
-rw-r--r--crates/config/src/lib.rs332
-rw-r--r--deny.toml4
-rw-r--r--docs/CHAT-APPLICATION.txt2
-rw-r--r--docs/CONFIGURATION.txt142
-rw-r--r--docs/OPERATIONS.txt4
-rw-r--r--docs/POLICY.txt9
-rw-r--r--infra/Caddyfile45
-rw-r--r--infra/Caddyfile.local4
-rw-r--r--infra/containers/cgit/cgitrc2
-rw-r--r--src/archive.rs9
-rw-r--r--src/main.rs82
-rw-r--r--src/project.rs114
-rw-r--r--src/render.rs116
-rw-r--r--web/index.html14
-rw-r--r--web/message/index.html4
25 files changed, 876 insertions, 180 deletions
diff --git a/.env.example b/.env.example
index b8fad96..521bcd8 100644
--- a/.env.example
+++ b/.env.example
@@ -13,7 +13,43 @@
# 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/>.
-SEARXNG_SECRET=GENERATE_RANDOM_HEX_KEY
+# Public hostnames: DNS names only, without a scheme, path, or port.
+# www aliases are derived from these names. These settings are public, not secrets.
+BASE_DOMAIN=luuduct.ai
+SITE_HOST=doan.luuduct.ai
+GIT_HOST=git.doan.luuduct.ai
+SEARCH_HOST=search.doan.luuduct.ai
+WKD_HOST=openpgpkey.luuduct.ai
+CHAT_HOST=chat.doan.luuduct.ai
+
+# Production TCP HTTP and TCP/UDP HTTPS listener/published ports.
+# Keep 80/443 for ordinary public HTTPS and ACME validation. Nonstandard ports
+# also require matching external routing and a suitable certificate setup.
+HTTP_PORT=80
+HTTPS_PORT=443
+
+# Development Caddy binds only to loopback. Its HTTPS host port is this value + 1.
+# A --port argument overrides this setting for that invocation.
+LOCAL_HTTP_PORT=8080
+
+# Caddy connection targets, not upstream application bind settings.
+# The bundled cgit and SearXNG images listen internally on 8080.
+CGIT_UPSTREAM=cgit:8080
+SEARXNG_UPSTREAM=searxng:8080
+# Chat remains a separate local experimental process. --bind overrides CHAT_BIND.
+# CHAT_ORIGIN must match the browser's exact origin; existing identity files pin it.
+# Changing an origin is not a private-key or identity-file migration.
+CHAT_BIND=127.0.0.1:9080
CHAT_ORIGIN=http://127.0.0.1:9080
+
+# Public backup recipient and a project-relative public key file.
+# Private GnuPG keys stay outside the project. CLI --recipient/--key override these.
+BACKUP_RECIPIENT=3346AA3150F8F16828BB0D14F8C66DF77AFECEA6
+BACKUP_PUBLIC_KEY=web/.well-known/openpgpkey/luuduct.ai/hu/t76q8xqkeassgy1kr38re1ppj6cpc1dp
+
+# Private values: generate locally; never commit the actual .env.
+# The stack creates SEARXNG_SECRET on first initialization.
+SEARXNG_SECRET=GENERATE_RANDOM_HEX_KEY
+# cargo stack chat --init generates the owner registration token.
CHAT_SETUP_TOKEN=GENERATE_WITH_CHAT_SERVER_INIT
diff --git a/Cargo.lock b/Cargo.lock
index a286226..64b9759 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -1628,13 +1628,13 @@ dependencies = [
"anyhow",
"chrono",
"clap",
- "dotenvy",
"flate2",
"hex",
"rusqlite",
"serde",
"serde_json",
"sha2",
+ "stack-config",
"tar",
"tempfile",
"toml",
@@ -1663,17 +1663,26 @@ dependencies = [
"axum",
"base64",
"clap",
- "dotenvy",
"hex",
"openssl",
"rusqlite",
"serde_json",
"stack-chat-crypto",
+ "stack-config",
"tempfile",
"tokio",
]
[[package]]
+name = "stack-config"
+version = "0.1.0"
+dependencies = [
+ "anyhow",
+ "dotenvy",
+ "tempfile",
+]
+
+[[package]]
name = "string_cache"
version = "0.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
diff --git a/Cargo.toml b/Cargo.toml
index cea6d40..327372d 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -29,10 +29,10 @@ publish = false
exclude = ["var", ".tools", "target", "bin", "dist"]
[dependencies]
+stack-config = { path = "crates/config", version = "0.1.0" }
anyhow = "1.0"
chrono = { version = "0.4", default-features = false, features = ["now", "std"] }
clap = { version = "4.6", features = ["derive"] }
-dotenvy = "0.15"
hex = "0.4"
flate2 = { version = "1", default-features = false, features = ["rust_backend"] }
serde = { version = "1.0", features = ["derive"] }
@@ -54,7 +54,7 @@ incremental = false
strip = false
[workspace]
-members = [".", "crates/chat-crypto", "crates/chat-server"]
+members = [".", "crates/chat-crypto", "crates/chat-server", "crates/config"]
default-members = ["."]
resolver = "3"
diff --git a/README.txt b/README.txt
index 5613a05..9dac54b 100644
--- a/README.txt
+++ b/README.txt
@@ -45,7 +45,9 @@ DEVELOPMENT AND PRODUCTION
and the production user service. web/ holds the public website and WKD.
docs/ contains plaintext operations and policy documentation.
- Only .env holds configured secrets. .env.example documents the keys.
+ The single root .env holds deployment settings and configured secrets.
+ The commented .env.example documents hostnames, ports, upstreams, chat,
+ backup recipients, and secret-setting names. See docs/CONFIGURATION.txt.
var/ contains private persistent data, container storage, temporary work,
and exports. Generated TLS private keys are runtime data under var/.
.env, var/, .tools/, target/, bin/, and dist/ are excluded from Git.
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<Semaphore>,
root: PathBuf,
origin: String,
+ public: std::collections::BTreeMap<String, String>,
}
fn secured(mut response: Response) -> Response {
@@ -129,22 +130,33 @@ 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()),
};
- 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<String, String>,
) -> 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<SocketAddr>,
#[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::<std::result::Result<std::collections::HashMap<_, _>, _>>()?;
- 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 <https://www.gnu.org/licenses/>.
<div class="libre-badge">[ <a href="https://www.gnu.org/software/librejs/" aria-describedby="js-warning">GNU LibreJS-compliant</a><span id="js-warning" role="tooltip">LibreJS 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.</span> ]
<a class="script-licenses" href="/javascript.html" rel="jslicense">[ JAVASCRIPT LICENSES ]</a></div>
<header>
-<pre class="logo-art"><a href="https://doan.luuduct.ai/" class="logo" aria-label="Doan Luu Duc Tai home">
+<pre class="logo-art"><a href="{{SITE_ORIGIN}}/" class="logo" aria-label="Doan Luu Duc Tai home">
____ ___ _ _ _
| _ \ / _ \ / \ | \ | |
| | | | | | |/ _ \ | \| |
| |_| | |_| / ___ \| |\ |
|____/ \___/_/ \_\_| \_| LUU DUC TAI
</a></pre>
-<nav class="site-nav" aria-label="Site navigation"><a href="https://doan.luuduct.ai/">Home</a><a href="https://doan.luuduct.ai/#services">Services</a><a href="/" aria-current="page">Message Me</a></nav>
+<nav class="site-nav" aria-label="Site navigation"><a href="{{SITE_ORIGIN}}/">Home</a><a href="{{SITE_ORIGIN}}/#services">Services</a><a href="/" aria-current="page">Message Me</a></nav>
</header>
<div class="ascii-panel introduction">
<div class="ascii-bar" aria-hidden="true"><span>+</span><span class="ascii-rule">--------------------------------------------------------------------------------</span><span>+</span></div>
diff --git a/infra/containers/cgit/cgitrc.local b/crates/config/Cargo.toml
index 20b533a..b0c0c8d 100644
--- a/infra/containers/cgit/cgitrc.local
+++ b/crates/config/Cargo.toml
@@ -13,17 +13,23 @@
# 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/>.
-root-title=Doan Luu Duc Tai's repositories (local)
-root-desc=Restored local copy
-virtual-root=/git/
-clone-prefix=http://localhost:8080/git
-css=/git/cgit.css
-favicon=/git/favicon.ico
-remove-suffix=1
-defbranch=main
-cache-root=/var/cache/cgit
-cache-size=1000
-cache-scanrc-ttl=1
-readme=:web/THIRD-PARTY-LICENSES.html
-about-filter=lua:/etc/cgit-license.lua
-scan-path=/srv/git
+[package]
+name = "stack-config"
+version = "0.1.0"
+edition = "2024"
+authors = ["Doan Luu Duc Tai <doan@luuduct.ai>"]
+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 <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/>.
+
+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 <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(())
+ }
+}
diff --git a/deny.toml b/deny.toml
index c80da7b..4dd3a57 100644
--- a/deny.toml
+++ b/deny.toml
@@ -21,6 +21,10 @@ name = "stack"
allow = ["AGPL-3.0-or-later"]
[[licenses.exceptions]]
+name = "stack-config"
+allow = ["AGPL-3.0-or-later"]
+
+[[licenses.exceptions]]
name = "stack-chat-server"
allow = ["AGPL-3.0-or-later"]
diff --git a/docs/CHAT-APPLICATION.txt b/docs/CHAT-APPLICATION.txt
index 7b4dc40..7e58051 100644
--- a/docs/CHAT-APPLICATION.txt
+++ b/docs/CHAT-APPLICATION.txt
@@ -67,6 +67,8 @@ LOCAL PREREQUISITES AND COMMANDS
.env, creating it privately if necessary. It refuses to overwrite an existing
setup token. The root .env.example lists both settings. Never commit .env.
Existing SearXNG settings stay in the same file. No crate has its own .env.
+ CHAT_BIND and public navigation now use the shared root configuration loader;
+ see CONFIGURATION.txt for the documented deployment keys.
For subsequent Rust-only edits, sh infra/chat/build-browser.sh reuses the
previously built, hash-verified OpenSSL/SDK inputs. Rebuild OpenSSL itself
diff --git a/docs/CONFIGURATION.txt b/docs/CONFIGURATION.txt
new file mode 100644
index 0000000..9660e20
--- /dev/null
+++ b/docs/CONFIGURATION.txt
@@ -0,0 +1,142 @@
+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/>.
+
+CENTRAL CONFIGURATION
+
+ Each installation has one root .env for deployment settings and secrets.
+ The published .env.example documents every supported setting with public
+ defaults, private placeholders, and comments. Hostnames are public information;
+ this arrangement centralizes configuration, not secrecy or access control.
+ Public values remain visible in generated pages, DNS, certificates, and URLs.
+
+ Existing installations keep their configured secrets and settings. Missing
+ public defaults are appended to the same .env by cargo stack config, service
+ initialization, or chat initialization. No second environment file is created.
+ The example is also the shared Rust configuration library's default source,
+ so missing public settings remain compatible with older exports.
+
+PUBLIC SETTINGS
+
+ BASE_DOMAIN
+ Root web/WKD domain. www aliases are derived from each configured host.
+
+ SITE_HOST, GIT_HOST, SEARCH_HOST, WKD_HOST, CHAT_HOST
+ Fully qualified DNS names without schemes, paths, or ports. The first
+ four configure production Caddy routing. CHAT_HOST supplies the intended
+ public chat link; production chat routing remains unimplemented on this
+ experimental feature branch.
+
+ HTTP_PORT, HTTPS_PORT
+ Production Caddy listener and Podman published ports. HTTPS also publishes
+ UDP for HTTP/3. Defaults are 80 and 443. Generated HTTPS links include a
+ nonstandard HTTPS port when configured. Ordinary public certificate
+ validation still needs the CA's standard external validation ports or an
+ appropriate alternative certificate/routing arrangement. Setting ports
+ alone does not configure DNS, firewall rules, or external port forwarding.
+
+ LOCAL_HTTP_PORT
+ Local Caddy HTTP port on 127.0.0.1. HTTPS host port is this value plus one.
+ Local Caddy still listens on container ports 80/443. --port overrides the
+ local port for that invocation. Existing installation/project names and
+ image state remain in var/state.json; an explicit .env local port takes
+ precedence over the previously saved port.
+
+ CGIT_UPSTREAM, SEARXNG_UPSTREAM
+ Proxy connection targets such as cgit:8080 and searxng:8080. These are
+ addresses, not upstream bind settings. The bundled images' internal 8080
+ listeners and health checks remain image contracts. Changing a target
+ to another host/port requires an actual service at that address.
+
+ CHAT_BIND, CHAT_ORIGIN
+ Chat socket and exact browser origin. Default: 127.0.0.1:9080 and
+ http://127.0.0.1:9080. The experimental server requires loopback and an
+ exact HTTP origin match. --bind overrides CHAT_BIND for testing.
+ Identity files pin their origin; editing these values does not migrate
+ encrypted identities or grant access on another origin.
+
+ BACKUP_RECIPIENT, BACKUP_PUBLIC_KEY
+ Public OpenPGP fingerprint and project-relative public key path used for
+ encrypted exports. --recipient and --key override them. The secret GnuPG
+ key and revocation certificate remain outside the project.
+
+PRIVATE SETTINGS
+
+ SEARXNG_SECRET
+ Generated on first stack initialization. Existing values are preserved.
+
+ CHAT_SETUP_TOKEN
+ Generated by cargo stack chat --init. Used only to authorize first owner
+ registration, including a fresh registration after an explicit backend
+ reset. It cannot recover a lost owner private key.
+
+RENDER AND APPLY
+
+ Edit .env locally. Never commit or publish it. Inspect the commented example
+ for exact keys. Hostnames, ports, socket addresses, fingerprints, and paths
+ are validated before use. Diagnostic formatting never dumps the config map.
+ Invalid environment-file syntax is reported without echoing its raw contents.
+
+ Render public deployment files without starting services:
+
+ cargo stack config
+ cargo stack config --production
+
+ Outputs are var/local/ or var/production/:
+
+ Caddyfile
+ cgitrc
+ web/
+
+ infra/Caddyfile, infra/Caddyfile.local, infra/containers/cgit/cgitrc, and the
+ first-party website HTML are templates using {{PUBLIC_KEY}} placeholders.
+ Do not run Caddy directly on an unrendered template. Podman mounts the generated
+ files. Public host/origin changes reach Caddy routes, www redirects, website
+ links, the cgit clone prefix, and the chat page's navigation. Local rendering
+ uses /git and /search paths and the configured local chat origin.
+
+ To apply changes to the existing services:
+
+ cargo stack up --local
+
+ Production uses its prebuilt management command and images:
+
+ ./bin/stack up --production
+
+ Rendered website files can be read by an already running file server, while
+ listeners, container environment, and loaded Caddy configuration require the
+ service update above. Restart the local chat server with cargo stack chat to
+ load its new bind, origin, or public navigation settings. Reopen your identity
+ file afterward, as with any chat restart.
+
+PUBLICATION AND LIMITS
+
+ The shared stack-config crate uses an explicit public allowlist for template
+ substitution. A {{CHAT_SETUP_TOKEN}} or other private/unknown placeholder
+ causes an error rather than publishing its value. Generated public pages and
+ proxy configuration never receive a full environment dump. Each container
+ still receives only the environment keys listed for that service; Caddy uses
+ rendered public configuration and does not need the .env or private tokens.
+
+ The root .env, runtime files, local render outputs, exports, and tool caches
+ remain ignored by Git. Source templates, the commented example, validated
+ defaults, and this configuration implementation are public. Full encrypted
+ backups include the single .env; source-only releases do not include it.
+
+ Personal content and attribution remain source content: copyright headers,
+ author names, canonical contact information, public key material/WKD hashes,
+ project repository metadata, and GNU license links. Changing a deployment
+ hostname does not create a new email identity or regenerate WKD keys.
+ Image digests, source hashes, and fixed crypto/protocol/resource bounds remain
+ reviewed reproducible code/configuration rather than private deployment knobs.
diff --git a/docs/OPERATIONS.txt b/docs/OPERATIONS.txt
index 826c4cf..2cf4693 100644
--- a/docs/OPERATIONS.txt
+++ b/docs/OPERATIONS.txt
@@ -218,7 +218,9 @@ SET UP A NEW PRODUCTION HOST
Point public A records to the new IPv4 address, and AAAA records only to a
working new IPv6 address. Remove stale AAAA records. Public hostnames are
- listed in infra/Caddyfile. Update canonical hosts there if changing domains.
+ configured in the root .env. Edit those values and reapply the service
+ configuration; infra/Caddyfile is now a public rendering template.
+ See CONFIGURATION.txt for all keys and the render-only commands.
Keep the www aliases and openpgpkey names pointing to the canonical hosts.
Start production:
diff --git a/docs/POLICY.txt b/docs/POLICY.txt
index 4c8d0bd..f662dd4 100644
--- a/docs/POLICY.txt
+++ b/docs/POLICY.txt
@@ -30,10 +30,13 @@ PROJECT CONVENTIONS
documentation. The generated HTML license page is an explicit exception.
Rust documentation attributes provide crate metadata without comments.
- Keep configured secrets only in the root .env. Never commit .env or var/.
+ Keep deployment overrides and configured secrets in the single root .env.
+ Never commit .env or var/.
Only the root .env.example is published; environment variants and nested
- examples are ignored. .env.example lists required keys with placeholders. Public hostnames,
- public GPG keys, paths, configuration, and source belong in Git.
+ examples are ignored. The commented .env.example publishes supported public
+ defaults and private placeholders. Public GPG keys, paths, templates, shared
+ configuration logic, and source belong in Git. Actual public hostnames remain
+ visible in rendered pages; .env centralization is not an access boundary.
TLS account/certificate private keys, databases, and other generated
private state are persistent data in var/data, not .env configuration.
The GPG secret key and revocation certificate remain outside this project.
diff --git a/infra/Caddyfile b/infra/Caddyfile
index 3146529..424e129 100644
--- a/infra/Caddyfile
+++ b/infra/Caddyfile
@@ -13,11 +13,16 @@
# 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/>.
+{
+ http_port {{HTTP_PORT}}
+ https_port {{HTTPS_PORT}}
+}
+
(https_security) {
header >Strict-Transport-Security "max-age=31536000"
}
-luuduct.ai {
+https://{{BASE_DOMAIN}}:{{HTTPS_PORT}} {
import https_security
handle /.well-known/openpgpkey/* {
root * /srv
@@ -26,16 +31,16 @@ luuduct.ai {
}
handle {
- redir https://doan.luuduct.ai{uri} permanent
+ redir {{SITE_ORIGIN}}{uri} permanent
}
}
-www.luuduct.ai {
+https://www.{{BASE_DOMAIN}}:{{HTTPS_PORT}} {
import https_security
- redir https://doan.luuduct.ai{uri} permanent
+ redir {{SITE_ORIGIN}}{uri} permanent
}
-doan.luuduct.ai {
+https://{{SITE_HOST}}:{{HTTPS_PORT}} {
import https_security
root * /srv
file_server
@@ -43,15 +48,15 @@ doan.luuduct.ai {
redir /git /git/
handle /cgit* {
- reverse_proxy cgit:8080
+ reverse_proxy {{CGIT_UPSTREAM}}
}
handle /git/* {
- reverse_proxy cgit:8080
+ reverse_proxy {{CGIT_UPSTREAM}}
}
}
-openpgpkey.luuduct.ai {
+https://{{WKD_HOST}}:{{HTTPS_PORT}} {
import https_security
handle /.well-known/openpgpkey/* {
root * /srv
@@ -60,34 +65,34 @@ openpgpkey.luuduct.ai {
}
}
-git.doan.luuduct.ai {
+https://{{GIT_HOST}}:{{HTTPS_PORT}} {
import https_security
redir /{path}.git /{path} permanent
- reverse_proxy cgit:8080
+ reverse_proxy {{CGIT_UPSTREAM}}
}
-search.doan.luuduct.ai {
+https://{{SEARCH_HOST}}:{{HTTPS_PORT}} {
import https_security
- reverse_proxy searxng:8080
+ reverse_proxy {{SEARXNG_UPSTREAM}}
}
-www.doan.luuduct.ai {
+https://www.{{SITE_HOST}}:{{HTTPS_PORT}} {
import https_security
- redir https://doan.luuduct.ai{uri} permanent
+ redir {{SITE_ORIGIN}}{uri} permanent
}
-www.git.doan.luuduct.ai {
+https://www.{{GIT_HOST}}:{{HTTPS_PORT}} {
import https_security
- redir https://git.doan.luuduct.ai{uri} permanent
+ redir {{GIT_ORIGIN}}{uri} permanent
}
-www.search.doan.luuduct.ai {
+https://www.{{SEARCH_HOST}}:{{HTTPS_PORT}} {
import https_security
- redir https://search.doan.luuduct.ai{uri} permanent
+ redir {{SEARCH_ORIGIN}}{uri} permanent
}
-www.openpgpkey.luuduct.ai {
+https://www.{{WKD_HOST}}:{{HTTPS_PORT}} {
import https_security
- redir https://openpgpkey.luuduct.ai{uri} permanent
+ redir {{WKD_ORIGIN}}{uri} permanent
}
diff --git a/infra/Caddyfile.local b/infra/Caddyfile.local
index 4d3bf11..6e3508c 100644
--- a/infra/Caddyfile.local
+++ b/infra/Caddyfile.local
@@ -19,12 +19,12 @@
@git path /git /git/* /cgit.js /cgit.png
handle @git {
uri strip_prefix /git
- reverse_proxy cgit:8080
+ reverse_proxy {{CGIT_UPSTREAM}}
}
@search path /search /search/*
handle @search {
- reverse_proxy searxng:8080 {
+ reverse_proxy {{SEARXNG_UPSTREAM}} {
header_up X-Script-Name /search
}
}
diff --git a/infra/containers/cgit/cgitrc b/infra/containers/cgit/cgitrc
index 9aaf8d5..0dacb99 100644
--- a/infra/containers/cgit/cgitrc
+++ b/infra/containers/cgit/cgitrc
@@ -16,7 +16,7 @@
root-title=Doan Luu Duc Tai's repositories
root-desc=Personal git repos
virtual-root=/
-clone-prefix=https://git.doan.luuduct.ai
+clone-prefix={{GIT_ORIGIN}}
css=/cgit.css
favicon=/favicon.ico
remove-suffix=1
diff --git a/src/archive.rs b/src/archive.rs
index 03ec643..4d2c01c 100644
--- a/src/archive.rs
+++ b/src/archive.rs
@@ -24,9 +24,6 @@ use serde_json::Value;
use crate::project::{Mode, Project, Settings, field};
use crate::{files, process};
-pub(crate) const RECIPIENT: &str = "3346AA3150F8F16828BB0D14F8C66DF77AFECEA6";
-const KEY: &str = "web/.well-known/openpgpkey/luuduct.ai/hu/t76q8xqkeassgy1kr38re1ppj6cpc1dp";
-
#[derive(Debug)]
struct Paused<'a> {
project: &'a Project,
@@ -192,7 +189,11 @@ pub(crate) fn backup(
.tempdir_in(destination)?;
let home = temp.path().join("gnupg");
files::private_dir(&home)?;
- let key = public_key.map_or_else(|| project.root.join(KEY), Path::to_path_buf);
+ let configuration = stack_config::Config::load(&project.root)?;
+ let key = public_key.map_or_else(
+ || Ok(project.root.join(configuration.value("BACKUP_PUBLIC_KEY")?)),
+ |path| Ok::<_, anyhow::Error>(path.to_path_buf()),
+ )?;
let operation = backup_inner(
project,
destination,
diff --git a/src/main.rs b/src/main.rs
index 4c528da..7f5bcdc 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -21,6 +21,7 @@ pub(crate) mod images;
pub(crate) mod process;
pub(crate) mod project;
pub(crate) mod release;
+pub(crate) mod render;
use std::path::{Path, PathBuf};
use std::process::ExitCode;
@@ -65,8 +66,8 @@ enum Action {
},
#[command(about = "Start the local development services")]
Dev {
- #[arg(long, default_value_t = 8080)]
- port: u16,
+ #[arg(long)]
+ port: Option<u16>,
},
#[command(about = "Start or update services in the selected mode")]
Up {
@@ -79,6 +80,15 @@ enum Action {
#[arg(long)]
online: bool,
},
+ #[command(
+ about = "Render public Caddy, website, and cgit configuration without starting services"
+ )]
+ Config {
+ #[arg(long)]
+ production: bool,
+ #[arg(long)]
+ port: Option<u16>,
+ },
#[command(about = "Show service status")]
Status,
#[command(about = "Stop services while retaining data")]
@@ -88,8 +98,8 @@ enum Action {
destination: Option<PathBuf>,
#[arg(long)]
include_images: bool,
- #[arg(long, default_value = archive::RECIPIENT)]
- recipient: String,
+ #[arg(long)]
+ recipient: Option<String>,
#[arg(long)]
key: Option<PathBuf>,
},
@@ -147,27 +157,17 @@ fn execute(arguments: Cli) -> Result<()> {
owner_fingerprint,
} = arguments.command
{
- let mut command = project.command("cargo")?;
- command
- .args(["run", "--locked", "-p", "stack-chat-server", "--", "--root"])
- .arg(&project.root);
- if init {
- command.arg("--init");
- }
- if let Some(fingerprint) = reset_owner {
- command.arg("--reset-owner").arg(fingerprint);
- }
- if owner_fingerprint {
- command.arg("--owner-fingerprint");
- }
- return process::run(&mut command);
+ return chat(&project, init, reset_owner.as_deref(), owner_fingerprint);
}
+
let _operation = project.lock()?;
match arguments.command {
Action::Dev { port } => {
let mut settings = project.settings()?;
settings.mode = Mode::Local;
- settings.port = port;
+ if let Some(selected_port) = port {
+ settings.port = selected_port;
+ }
images::prepare(&project, &mut settings)?;
project.up(&settings)
}
@@ -193,6 +193,23 @@ fn execute(arguments: Cli) -> Result<()> {
images::prepare(&project, &mut settings)?;
project.up(&settings)
}
+ Action::Config { production, port } => {
+ let mut settings = project.settings()?;
+ settings.mode = if production {
+ Mode::Production
+ } else {
+ Mode::Local
+ };
+ if let Some(selected_port) = port {
+ settings.port = selected_port;
+ }
+ stack_config::initialize_public(&project.root)?;
+ render::prepare(&project, &settings)?;
+ process::line(format_args!(
+ "Public configuration: {}",
+ render::directory(&project, &settings).display()
+ ))
+ }
Action::Status => project.status(&project.settings()?),
Action::Down => project.down(&project.settings()?),
Action::Backup {
@@ -205,11 +222,14 @@ fn execute(arguments: Cli) -> Result<()> {
|| Ok(project.root.join("var/exports")),
|path| absolute(&path),
)?;
+ let configuration = stack_config::Config::load(&project.root)?;
+ let selected_recipient =
+ recipient.unwrap_or(configuration.value("BACKUP_RECIPIENT")?.to_owned());
archive::backup(
&project,
&output,
include_images,
- &recipient,
+ &selected_recipient,
key.as_deref(),
)
}
@@ -226,6 +246,28 @@ fn execute(arguments: Cli) -> Result<()> {
}
}
+fn chat(
+ project: &Project,
+ init: bool,
+ reset_owner: Option<&str>,
+ owner_fingerprint: bool,
+) -> Result<()> {
+ let mut command = project.command("cargo")?;
+ command
+ .args(["run", "--locked", "-p", "stack-chat-server", "--", "--root"])
+ .arg(&project.root);
+ if init {
+ command.arg("--init");
+ }
+ if let Some(fingerprint) = reset_owner {
+ command.arg("--reset-owner").arg(fingerprint);
+ }
+ if owner_fingerprint {
+ command.arg("--owner-fingerprint");
+ }
+ process::run(&mut command)
+}
+
fn absolute(path: &Path) -> Result<PathBuf> {
Ok(if path.is_absolute() {
path.to_path_buf()
diff --git a/src/project.rs b/src/project.rs
index 0dac2ef..6ef619a 100644
--- a/src/project.rs
+++ b/src/project.rs
@@ -16,7 +16,7 @@
use std::collections::BTreeMap;
use std::env;
use std::fs::{self, File, OpenOptions, Permissions};
-use std::io::Read;
+use std::io::{Read, Write};
use std::os::unix::fs::{OpenOptionsExt, PermissionsExt};
use std::path::{Component, Path, PathBuf};
use std::process::Command;
@@ -25,6 +25,7 @@ use std::time::Duration;
use anyhow::{Context, Result, bail, ensure};
use serde::{Deserialize, Serialize};
use serde_json::Value;
+use stack_config::Config;
use crate::{files, process};
@@ -116,11 +117,16 @@ impl Project {
pub(crate) fn settings(&self) -> Result<Settings> {
let path = self.root.join("var/state.json");
if path.exists() {
- return Ok(serde_json::from_reader(File::open(path)?)?);
+ let mut settings: Settings = serde_json::from_reader(File::open(path)?)?;
+ let config = Config::load(&self.root)?;
+ if config.is_explicit("LOCAL_HTTP_PORT") {
+ settings.port = config.port("LOCAL_HTTP_PORT")?;
+ }
+ return Ok(settings);
}
Ok(Settings {
mode: Mode::Local,
- port: 8080,
+ port: Config::load(&self.root)?.port("LOCAL_HTTP_PORT")?,
project: format!(
"stack-{}",
files::hash_bytes(self.root.as_os_str().as_encoded_bytes())
@@ -230,16 +236,17 @@ impl Project {
pub(crate) fn initialize(&self, settings: &Settings) -> Result<()> {
files::private_dir(&self.root.join("var/data"))?;
let env_file = self.root.join(".env");
- if !env_file.exists() {
+ if !Config::load(&self.root)?.is_explicit("SEARXNG_SECRET") {
let mut random = [0; 32];
File::open("/dev/urandom")?.read_exact(&mut random)?;
- let secret = hex::encode(random);
- files::write(
- &env_file,
- format!("SEARXNG_SECRET={secret}\n").as_bytes(),
- 0o600,
- )?;
+ let mut file = OpenOptions::new()
+ .create(true)
+ .append(true)
+ .mode(0o600)
+ .open(&env_file)?;
+ writeln!(file, "\nSEARXNG_SECRET={}", hex::encode(random))?;
}
+ stack_config::initialize_public(&self.root)?;
fs::set_permissions(&env_file, Permissions::from_mode(0o600))?;
for name in ["repos", "caddy/data", "caddy/config", "searxng"] {
fs::create_dir_all(self.root.join("var/data").join(name))?;
@@ -268,42 +275,7 @@ impl Project {
&self.root.join("infra/searxng"),
&self.root.join("var/data/searxng"),
)?;
- if settings.mode == Mode::Local {
- self.local_site(settings.port)?;
- }
- Ok(())
- }
-
- fn local_site(&self, port: u16) -> Result<()> {
- let generated = self.root.join("var/local/web");
- if generated.exists() {
- files::regular_tree(&generated)?;
- fs::remove_dir_all(&generated)?;
- }
- files::copy_tree(&self.root.join("web"), &generated)?;
- let index = generated.join("index.html");
- let html = fs::read_to_string(&index)?
- .replace("https://git.doan.luuduct.ai", "/git")
- .replace("https://search.doan.luuduct.ai", "/search")
- .replace("https://openpgpkey.luuduct.ai", "");
- files::write(&index, html.as_bytes(), 0o644)?;
- let template = self.root.join("infra/containers/cgit/cgitrc.local");
- let content = fs::read_to_string(&template)?
- .lines()
- .map(|line| {
- if line.starts_with("clone-prefix=") {
- format!("clone-prefix=http://localhost:{port}/git")
- } else {
- line.to_owned()
- }
- })
- .collect::<Vec<_>>()
- .join("\n");
- files::write(
- &self.root.join("var/local/cgitrc"),
- format!("{content}\n").as_bytes(),
- 0o644,
- )
+ crate::render::prepare(self, settings)
}
pub(crate) fn container_name(settings: &Settings, service: &str) -> String {
@@ -343,9 +315,7 @@ impl Project {
.args(["network", "create", &network]),
)?;
}
- let environment: BTreeMap<String, String> =
- dotenvy::from_path_iter(self.root.join(".env"))?
- .collect::<std::result::Result<_, _>>()?;
+ let environment = Config::load(&self.root)?;
for (name, service) in config
.services
.iter()
@@ -364,7 +334,11 @@ impl Project {
process::line(format_args!(
"Running {}",
if settings.mode == Mode::Production {
- "https://doan.luuduct.ai".to_owned()
+ environment
+ .public_values(None)?
+ .get("SITE_ORIGIN")
+ .context("Missing public site origin")?
+ .to_owned()
} else {
format!("http://localhost:{}", settings.port)
}
@@ -401,7 +375,7 @@ impl Project {
network: &str,
name: &str,
service: &Service,
- environment: &BTreeMap<String, String>,
+ environment: &Config,
) -> Result<()> {
let container = Self::container_name(settings, name);
let mut command = self.command("podman")?;
@@ -439,39 +413,29 @@ impl Project {
]
.to_vec()
} else {
- ["[::]:80:80", "[::]:443:443", "[::]:443:443/udp"]
- .map(str::to_owned)
- .to_vec()
+ let http = environment.port("HTTP_PORT")?;
+ let https = environment.port("HTTPS_PORT")?;
+ vec![
+ format!("[::]:{http}:{http}"),
+ format!("[::]:{https}:{https}"),
+ format!("[::]:{https}:{https}/udp"),
+ ]
};
for port in ports {
command.args(["--publish", &port]);
}
}
for key in &service.environment {
- command.arg("--env").arg(key).env(
- key,
- environment
- .get(key)
- .with_context(|| format!("Missing secret {key} in .env"))?,
- );
+ command
+ .arg("--env")
+ .arg(key)
+ .env(key, environment.value(key)?);
}
for mount in &service.mounts {
let source = match mount.source.as_str() {
- "@caddy" => self.root.join(if settings.mode == Mode::Local {
- "infra/Caddyfile.local"
- } else {
- "infra/Caddyfile"
- }),
- "@web" => self.root.join(if settings.mode == Mode::Local {
- "var/local/web"
- } else {
- "web"
- }),
- "@cgit" => self.root.join(if settings.mode == Mode::Local {
- "var/local/cgitrc"
- } else {
- "infra/containers/cgit/cgitrc"
- }),
+ "@caddy" => crate::render::directory(self, settings).join("Caddyfile"),
+ "@web" => crate::render::directory(self, settings).join("web"),
+ "@cgit" => crate::render::directory(self, settings).join("cgitrc"),
other => self.path(other)?,
};
let mode = if mount.read_only { "ro,z" } else { "rw,z" };
diff --git a/src/render.rs b/src/render.rs
new file mode 100644
index 0000000..8e1ee1e
--- /dev/null
+++ b/src/render.rs
@@ -0,0 +1,116 @@
+// 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/>.
+
+use crate::files;
+use crate::project::{Mode, Project, Settings};
+use anyhow::Result;
+use stack_config::Config;
+use std::fs;
+use std::path::PathBuf;
+
+pub(crate) fn directory(project: &Project, settings: &Settings) -> PathBuf {
+ project.root.join(match settings.mode {
+ Mode::Local => "var/local",
+ Mode::Production => "var/production",
+ })
+}
+
+pub(crate) fn prepare(project: &Project, settings: &Settings) -> Result<()> {
+ let config = Config::load(&project.root)?;
+ let local = (settings.mode == Mode::Local).then_some(settings.port);
+ let output = directory(project, settings);
+ let web = output.join("web");
+ if web.exists() {
+ files::regular_tree(&web)?;
+ fs::remove_dir_all(&web)?;
+ }
+ files::copy_tree(&project.root.join("web"), &web)?;
+ for name in ["index.html", "message/index.html"] {
+ let file = web.join(name);
+ files::write(
+ &file,
+ config
+ .render(&fs::read_to_string(&file)?, local)?
+ .as_bytes(),
+ 0o644,
+ )?;
+ }
+ let template = project.root.join(if settings.mode == Mode::Local {
+ "infra/Caddyfile.local"
+ } else {
+ "infra/Caddyfile"
+ });
+ files::write(
+ &output.join("Caddyfile"),
+ config
+ .render(&fs::read_to_string(template)?, local)?
+ .as_bytes(),
+ 0o644,
+ )?;
+ let cgit = fs::read_to_string(project.root.join("infra/containers/cgit/cgitrc"))?;
+ let mut rendered = config.render(&cgit, local)?;
+ if let Some(port) = local {
+ rendered = rendered.replace(
+ "clone-prefix=/git",
+ &format!("clone-prefix=http://localhost:{port}/git"),
+ );
+ }
+ files::write(&output.join("cgitrc"), rendered.as_bytes(), 0o644)
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn custom_domains_and_ports_reach_public_outputs_without_secrets() -> Result<()> {
+ let root = tempfile::tempdir()?;
+ for name in ["web", "infra"] {
+ files::copy_tree(
+ &PathBuf::from(env!("CARGO_MANIFEST_DIR")).join(name),
+ &root.path().join(name),
+ )?;
+ }
+ fs::write(
+ root.path().join(".env"),
+ "SITE_HOST=home.example.net\nGIT_HOST=git.example.net\nHTTP_PORT=8181\nHTTPS_PORT=8443\nSEARXNG_SECRET=do-not-publish-this-sentinel\n",
+ )?;
+ let project = Project::at(root.path())?;
+ let mut settings = project.settings()?;
+ settings.mode = Mode::Production;
+ prepare(&project, &settings)?;
+ let output = directory(&project, &settings);
+ let caddy = fs::read_to_string(output.join("Caddyfile"))?;
+ assert!(caddy.contains("https://home.example.net:8443"));
+ assert!(caddy.contains("http_port 8181"));
+ assert!(!caddy.contains("do-not-publish"));
+ assert!(
+ fs::read_to_string(output.join("web/index.html"))?
+ .contains("https://git.example.net:8443")
+ );
+ settings.mode = Mode::Local;
+ settings.port = 8180;
+ prepare(&project, &settings)?;
+ assert!(
+ fs::read_to_string(directory(&project, &settings).join("web/index.html"))?
+ .contains("href=\"/git/\"")
+ );
+ assert!(
+ fs::read_to_string(directory(&project, &settings).join("cgitrc"))?
+ .contains("clone-prefix=http://localhost:8180/git")
+ );
+ Ok(())
+ }
+}
diff --git a/web/index.html b/web/index.html
index 48ef508..db8e819 100644
--- a/web/index.html
+++ b/web/index.html
@@ -42,16 +42,16 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
|____/ \___/_/ \_\_| \_| LUU DUC TAI
</a>
-<a href="/">Home</a> <a href="#services">Services</a> <a href="https://chat.doan.luuduct.ai/">Message Me</a>
+<a href="/">Home</a> <a href="#services">Services</a> <a href="{{CHAT_PUBLIC_ORIGIN}}/">Message Me</a>
+------------------------------------------------------------------------------+
| |
| Doan Luu Duc Tai |
-| <a href="https://chat.doan.luuduct.ai/">Message Me</a> |
+| <a href="{{CHAT_PUBLIC_ORIGIN}}/">Message Me</a> |
| |
| Email: <a href="mailto:doan@luuduct.ai">doan@luuduct.ai</a> |
-| GnuPG: <a href="https://openpgpkey.luuduct.ai/.well-known/openpgpkey/luuduct.ai/hu/t76q8xqkeassgy1kr38re1ppj6cpc1dp?l=doan">3346AA3150F8F16828BB0D14F8C66DF77AFECEA6</a> |
+| GnuPG: <a href="{{WKD_ORIGIN}}/.well-known/openpgpkey/luuduct.ai/hu/t76q8xqkeassgy1kr38re1ppj6cpc1dp?l=doan">3346AA3150F8F16828BB0D14F8C66DF77AFECEA6</a> |
| |
| <a href="https://smp11.simplex.im/a#ikrTZ8sfleSZEifKV2wI8foAVwkbKfOoKc6TO2P4ZT8">SimpleX Chat</a> |
| |
@@ -64,13 +64,13 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
________________________________________________________________________________
-<a href="https://git.doan.luuduct.ai/">git.doan.luuduct.ai</a>
+<a href="{{GIT_ORIGIN}}/">{{GIT_HOST}}</a>
* A <a href="https://git.zx2c4.com/cgit">cgit</a> instance, <span class="good">functional without JS</span>.
Personal Git repositories
-<a href="https://search.doan.luuduct.ai/">search.doan.luuduct.ai</a>
+<a href="{{SEARCH_ORIGIN}}/">{{SEARCH_HOST}}</a>
* A <a href="https://github.com/searxng/searxng">SearXNG</a> instance, <span class="good">functional without JS</span>.
Private search engine
@@ -82,8 +82,8 @@ ________________________________________________________________________________
<nav class="source-footer" aria-label="Licensing and source">
<a class="project-license" href="https://www.gnu.org/licenses/agpl-3.0.html">[ LICENSE ]</a>
- <a class="dependency-licenses" href="https://git.doan.luuduct.ai/stack/about/">[ THIRD PARTY LICENSES ]</a>
- <a href="https://git.doan.luuduct.ai/stack/tree/web/index.html">[ SOURCE CODE ]</a>
+ <a class="dependency-licenses" href="{{GIT_ORIGIN}}/stack/about/">[ THIRD PARTY LICENSES ]</a>
+ <a href="{{GIT_ORIGIN}}/stack/tree/web/index.html">[ SOURCE CODE ]</a>
</nav>
</div>
diff --git a/web/message/index.html b/web/message/index.html
index ea64d40..d1739b0 100644
--- a/web/message/index.html
+++ b/web/message/index.html
@@ -44,8 +44,8 @@ For now, you can reach me by <a href="mailto:doan@luuduct.ai">email</a> or use t
</pre>
<nav class="source-footer" aria-label="Licensing and source">
<a class="project-license" href="https://www.gnu.org/licenses/agpl-3.0.html">[ LICENSE ]</a>
- <a class="dependency-licenses" href="https://git.doan.luuduct.ai/stack/about/">[ THIRD PARTY LICENSES ]</a>
- <a href="https://git.doan.luuduct.ai/stack/tree/web/message/index.html">[ SOURCE CODE ]</a>
+ <a class="dependency-licenses" href="{{GIT_ORIGIN}}/stack/about/">[ THIRD PARTY LICENSES ]</a>
+ <a href="{{GIT_ORIGIN}}/stack/tree/web/message/index.html">[ SOURCE CODE ]</a>
</nav>
</div>
</div>