summaryrefslogtreecommitdiff
path: root/src
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 /src
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 'src')
-rw-r--r--src/archive.rs9
-rw-r--r--src/main.rs82
-rw-r--r--src/project.rs114
-rw-r--r--src/render.rs116
4 files changed, 222 insertions, 99 deletions
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(())
+ }
+}