summaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
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(())
+ }
+}