diff options
Diffstat (limited to 'src')
| -rw-r--r-- | src/archive.rs | 9 | ||||
| -rw-r--r-- | src/main.rs | 94 | ||||
| -rw-r--r-- | src/project.rs | 114 | ||||
| -rw-r--r-- | src/render.rs | 116 |
4 files changed, 238 insertions, 95 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 08c88f5..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; @@ -51,13 +52,22 @@ struct Cli { enum Action { #[command(about = "Run the experimental local chat or initialize its single .env")] Chat { - #[arg(long)] + #[arg(long, conflicts_with_all = ["reset_owner", "owner_fingerprint"])] init: bool, + #[arg( + long, + value_name = "FINGERPRINT", + conflicts_with = "owner_fingerprint", + help = "Permanently clear owner registration and chat history after key loss" + )] + reset_owner: Option<String>, + #[arg(long, help = "Print the registered owner's public fingerprint")] + owner_fingerprint: bool, }, #[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 { @@ -70,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")] @@ -79,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>, }, @@ -132,22 +151,23 @@ fn execute(arguments: Cli) -> Result<()> { return archive::restore(&absolute(archive)?, &absolute(destination)?); } let project = Project::discover(arguments.root.as_deref())?; - if let Action::Chat { init } = 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"); - } - return process::run(&mut command); + if let Action::Chat { + init, + reset_owner, + owner_fingerprint, + } = arguments.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) } @@ -173,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 { @@ -185,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(), ) } @@ -206,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(()) + } +} |
