Menu
AkurAI-Build
publicLatest change e9497c4d8313c394e907ae962fdfe3bb5025013e - Add mail-server backup, audit, and update operations by Ólafur Búi Ólafsson
//! Operational verbs ported from the `akurai-ec2` bash CLI and its Python helpers.
//!
//! Fidelity to the shell original is deliberate: argument order, defaults,
//! environment variable names, stdout text, and exit codes are reproduced
//! verbatim because seventeen live production services depend on them.
use std::{
collections::BTreeMap,
env,
fs::{self, DirBuilder, File, OpenOptions},
io::{Read, Write},
os::unix::fs::{DirBuilderExt, OpenOptionsExt, PermissionsExt},
path::{Path, PathBuf},
process::{Command, Stdio},
time::{Duration, Instant, SystemTime, UNIX_EPOCH},
};
use anyhow::{Context, Result, anyhow, bail};
use serde_json::{Map, Value, json};
use sha2::{Digest, Sha256};
use zeroize::Zeroizing;
use crate::ec2::{Ec2, say, shell_quote};
// ---------------------------------------------------------------------------
// shared helpers
// ---------------------------------------------------------------------------
fn home() -> Result<PathBuf> {
env::var_os("HOME")
.map(PathBuf::from)
.ok_or_else(|| anyhow!("HOME is not set"))
}
/// Resolve a helper script shipped inside this repository.
///
/// The bash CLI reached for `$HOME/Projects/AkurAI-Build/scripts/...`; a
/// deployed binary is usually installed elsewhere, so the working directory and
/// the executable's own neighbourhood are consulted first.
fn repo_script(name: &str) -> PathBuf {
let mut candidates: Vec<PathBuf> = Vec::new();
if let Ok(cwd) = env::current_dir() {
candidates.push(cwd.join("scripts").join(name));
}
if let Ok(exe) = env::current_exe()
&& let Some(directory) = exe.parent()
{
candidates.push(directory.join("scripts").join(name));
candidates.push(directory.join("..").join("scripts").join(name));
candidates.push(directory.join("../..").join("scripts").join(name));
}
if let Ok(root) = home() {
candidates.push(root.join("Projects/AkurAI-Build/scripts").join(name));
}
for candidate in &candidates {
if candidate.is_file() {
return candidate.clone();
}
}
candidates
.pop()
.unwrap_or_else(|| PathBuf::from("scripts").join(name))
}
/// The command used to re-enter this CLI for the DNS surface.
///
/// `AKURAI_EC2_CLI` keeps compatibility with the retired bash shim; otherwise
/// this binary calls itself with the `ec2` subcommand prefix.
fn self_cli() -> (PathBuf, Vec<String>) {
if let Some(cli) = env::var_os("AKURAI_EC2_CLI") {
return (PathBuf::from(cli), Vec::new());
}
let program = env::current_exe().unwrap_or_else(|_| PathBuf::from("akurai"));
(program, vec!["ec2".to_string()])
}
fn run_local(program: &Path, args: &[&str], operation: &str) -> Result<()> {
let status = Command::new(program)
.args(args)
.status()
.with_context(|| format!("failed to start {operation}"))?;
if !status.success() {
bail!("{operation} failed");
}
Ok(())
}
fn local_ok(program: &str, args: &[&str]) -> bool {
Command::new(program)
.args(args)
.stdout(Stdio::null())
.stderr(Stdio::null())
.status()
.map(|status| status.success())
.unwrap_or(false)
}
fn capture_local(program: &Path, args: &[&str], operation: &str) -> Result<String> {
let output = Command::new(program)
.args(args)
.stderr(Stdio::inherit())
.output()
.with_context(|| format!("failed to start {operation}"))?;
if !output.status.success() {
bail!("{operation} failed");
}
let mut text = String::from_utf8_lossy(&output.stdout).into_owned();
while text.ends_with('\n') || text.ends_with('\r') {
text.pop();
}
Ok(text)
}
fn capture_quiet(program: &str, args: &[&str]) -> Option<String> {
let output = Command::new(program)
.args(args)
.stderr(Stdio::null())
.output()
.ok()?;
if !output.status.success() {
return None;
}
Some(String::from_utf8_lossy(&output.stdout).trim().to_string())
}
fn matches_class(value: &str, allowed: impl Fn(char) -> bool) -> bool {
!value.is_empty() && value.chars().all(allowed)
}
fn write_private(path: &Path, contents: &str) -> Result<()> {
let directory = path
.parent()
.ok_or_else(|| anyhow!("no parent directory for {}", path.display()))?;
let mut temporary = tempfile::Builder::new()
.prefix(".akurai-ec2-")
.tempfile_in(directory)
.with_context(|| format!("create a temporary file in {}", directory.display()))?;
temporary.write_all(contents.as_bytes())?;
temporary.flush()?;
fs::set_permissions(temporary.path(), fs::Permissions::from_mode(0o600))?;
temporary
.persist(path)
.map_err(|error| anyhow!("install {}: {}", path.display(), error.error))?;
Ok(())
}
fn make_private_dir(path: &Path, mode: u32) -> Result<()> {
if path.is_dir() {
return Ok(());
}
DirBuilder::new()
.recursive(true)
.mode(mode)
.create(path)
.with_context(|| format!("create {}", path.display()))
}
// ---------------------------------------------------------------------------
// time (no date dependency; the journal format is ISO-8601 UTC)
// ---------------------------------------------------------------------------
fn epoch_now() -> i64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|value| value.as_secs() as i64)
.unwrap_or(0)
}
fn nanos_now() -> u32 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|value| value.subsec_nanos())
.unwrap_or(0)
}
/// Howard Hinnant's civil-from-days.
fn civil_from_days(days: i64) -> (i64, u32, u32) {
let z = days + 719_468;
let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
let day_of_era = z - era * 146_097;
let year_of_era =
(day_of_era - day_of_era / 1460 + day_of_era / 36_524 - day_of_era / 146_096) / 365;
let year = year_of_era + era * 400;
let day_of_year = day_of_era - (365 * year_of_era + year_of_era / 4 - year_of_era / 100);
let mp = (5 * day_of_year + 2) / 153;
let day = (day_of_year - (153 * mp + 2) / 5 + 1) as u32;
let month = if mp < 10 { mp + 3 } else { mp - 9 } as u32;
(if month <= 2 { year + 1 } else { year }, month, day)
}
/// Howard Hinnant's days-from-civil.
fn days_from_civil(year: i64, month: u32, day: u32) -> i64 {
let year = if month <= 2 { year - 1 } else { year };
let era = if year >= 0 { year } else { year - 399 } / 400;
let year_of_era = year - era * 400;
let month = month as i64;
let day = day as i64;
let day_of_year = (153 * (if month > 2 { month - 3 } else { month + 9 }) + 2) / 5 + day - 1;
let day_of_era = year_of_era * 365 + year_of_era / 4 - year_of_era / 100 + day_of_year;
era * 146_097 + day_of_era - 719_468
}
fn iso_utc(seconds: i64) -> String {
let days = seconds.div_euclid(86_400);
let remainder = seconds.rem_euclid(86_400);
let (year, month, day) = civil_from_days(days);
format!(
"{year:04}-{month:02}-{day:02}T{:02}:{:02}:{:02}Z",
remainder / 3600,
(remainder % 3600) / 60,
remainder % 60
)
}
fn compact_stamp(seconds: i64) -> String {
let days = seconds.div_euclid(86_400);
let remainder = seconds.rem_euclid(86_400);
let (year, month, day) = civil_from_days(days);
format!(
"{year:04}{month:02}{day:02}T{:02}{:02}{:02}",
remainder / 3600,
(remainder % 3600) / 60,
remainder % 60
)
}
fn parse_iso(value: &str) -> Result<i64> {
let text = value.trim();
let text = text.strip_suffix('Z').unwrap_or(text);
let text = text.strip_suffix("+00:00").unwrap_or(text);
let (date, time) = text
.split_once('T')
.ok_or_else(|| anyhow!("invalid timestamp: {value}"))?;
let mut date_parts = date.split('-');
let year: i64 = date_parts
.next()
.and_then(|part| part.parse().ok())
.ok_or_else(|| anyhow!("invalid timestamp: {value}"))?;
let month: u32 = date_parts
.next()
.and_then(|part| part.parse().ok())
.ok_or_else(|| anyhow!("invalid timestamp: {value}"))?;
let day: u32 = date_parts
.next()
.and_then(|part| part.parse().ok())
.ok_or_else(|| anyhow!("invalid timestamp: {value}"))?;
let time = time.split('.').next().unwrap_or(time);
let mut time_parts = time.split(':');
let hour: i64 = time_parts
.next()
.and_then(|part| part.parse().ok())
.ok_or_else(|| anyhow!("invalid timestamp: {value}"))?;
let minute: i64 = time_parts
.next()
.and_then(|part| part.parse().ok())
.ok_or_else(|| anyhow!("invalid timestamp: {value}"))?;
let second: i64 = time_parts
.next()
.and_then(|part| part.parse().ok())
.unwrap_or(0);
Ok(days_from_civil(year, month, day) * 86_400 + hour * 3600 + minute * 60 + second)
}
// ---------------------------------------------------------------------------
// JSON emitted with Python's `ensure_ascii=True` so digests and files match
// ---------------------------------------------------------------------------
fn ascii_escaped(text: &str) -> String {
if text.is_ascii() {
return text.to_string();
}
let mut out = String::with_capacity(text.len());
for character in text.chars() {
if character.is_ascii() {
out.push(character);
continue;
}
let mut buffer = [0u16; 2];
for unit in character.encode_utf16(&mut buffer) {
out.push_str(&format!("\\u{unit:04x}"));
}
}
out
}
fn compact_json(value: &Value) -> String {
ascii_escaped(&value.to_string())
}
fn pretty_json(value: &Value) -> String {
ascii_escaped(&serde_json::to_string_pretty(value).unwrap_or_else(|_| "{}".to_string()))
}
// ===========================================================================
// mariadb
// ===========================================================================
const MARIADB_VERBS: [&str; 12] = [
"status",
"health",
"up",
"down",
"restart",
"mode",
"sql",
"create-db",
"create-user",
"copy-db",
"dump",
"logs",
];
/// Delegate to the on-box `akurai-mariadbctl`, or install it from the repo copy.
pub fn mariadb(ec2: &Ec2, args: &[String]) -> Result<()> {
let default = ["status".to_string()];
let args = if args.is_empty() { &default[..] } else { args };
let verb = args[0].as_str();
if MARIADB_VERBS.contains(&verb) {
let mut command = String::from("akurai-mariadbctl");
for argument in args {
command.push(' ');
command.push_str(&shell_quote(argument));
}
return ec2.ssh_streaming(&command);
}
if verb == "install" {
say("installing akurai-mariadbctl from the repo copy…");
let source = repo_script("akurai-mariadbctl");
let payload = fs::read(&source)
.with_context(|| format!("read the repo copy: {}", source.display()))?;
return ec2.ssh_stdin_streaming(
"sudo tee /usr/local/bin/akurai-mariadbctl > /dev/null && sudo chmod +x /usr/local/bin/akurai-mariadbctl && akurai-mariadbctl health",
&payload,
);
}
bail!(
"usage: akurai-ec2 mariadb <status|health|up|down|restart|mode|sql|create-db|create-user|copy-db|dump|logs|install> (see akurai-mariadbctl --help)"
)
}
// ===========================================================================
// secret-sync
// ===========================================================================
/// Read a secret from stdin, atomically update a protected env file, restart.
///
/// The secret never reaches argv, a log line, or shell history: it travels
/// stdin → remote `IFS= read -r` → `printf` into the EnvironmentFile.
pub fn secret_sync(ec2: &Ec2, service: &str, key: &str, env_file: &str) -> Result<()> {
if !matches_class(service, |c| {
c.is_ascii_alphanumeric() || matches!(c, '_' | '.' | '@' | '-')
}) {
bail!("invalid service name");
}
let mut key_chars = key.chars();
let valid_key = match key_chars.next() {
Some(first) if first.is_ascii_uppercase() => {
key_chars.all(|c| c.is_ascii_uppercase() || c.is_ascii_digit() || c == '_')
}
_ => false,
};
if !valid_key {
bail!("invalid environment key");
}
if !env_file.starts_with("/etc/") {
bail!("environment file must be below /etc");
}
let mut raw = String::new();
std::io::stdin()
.read_to_string(&mut raw)
.context("read the secret from stdin")?;
// `$(cat)` strips every trailing newline.
let secret = Zeroizing::new(raw.trim_end_matches('\n').to_string());
if secret.is_empty() {
bail!("secret-sync requires a secret on stdin");
}
if secret.contains('\n') {
bail!("secret must be one line");
}
// Values are transported verbatim (stdin -> remote `IFS= read -r` ->
// printf) into a KEY=value systemd EnvironmentFile line. Excluded:
// whitespace, quotes, backslash, $, backtick, #, ;, &, |, <, > — the write
// path would carry them, but keeping them out avoids downstream ambiguity.
if !matches_class(&secret, |c| {
c.is_ascii_alphanumeric() || "._~!@%^*+=:,/-".contains(c)
}) {
bail!("secret contains unsupported characters");
}
let remote = format!(
r#"sudo sh -c 'set -eu
tmp=$(mktemp)
trap "rm -f $tmp" EXIT
if [ -f "{env_file}" ]; then
grep -v "^[[:space:]]*\(export[[:space:]]\+\)\?{key}=" "{env_file}" >"$tmp" || true
fi
IFS= read -r value
printf "%s=%s\n" "{key}" "$value" >>"$tmp"
install -D -o root -g root -m 600 "$tmp" "{env_file}"'"#
);
let payload = Zeroizing::new(format!("{}\n", secret.as_str()).into_bytes());
if ec2.ssh_stdin_streaming(&remote, &payload).is_err() {
bail!("failed to install secret");
}
drop(payload);
drop(secret);
if ec2
.ssh_streaming(&format!(
"sudo systemctl restart '{service}' && sudo systemctl is-active --quiet '{service}'"
))
.is_err()
{
bail!("secret installed but {service} failed to restart");
}
say(&format!("secret synchronized and {service} active"));
Ok(())
}
// ===========================================================================
// validate-config
// ===========================================================================
/// Minimal top-level TOML reader: enough for the flat `akurai-deploy.toml`
/// schema this verb validates, with no new dependency.
fn top_level_keys(text: &str) -> BTreeMap<String, (String, bool)> {
let mut keys = BTreeMap::new();
for line in text.lines() {
let trimmed = line.trim();
if trimmed.is_empty() || trimmed.starts_with('#') {
continue;
}
if trimmed.starts_with('[') {
break; // only the top-level table matters here
}
let Some((name, raw)) = trimmed.split_once('=') else {
continue;
};
let name = name.trim().trim_matches('"').to_string();
let mut value = String::new();
let mut quoted = false;
let mut in_string = false;
for character in raw.trim().chars() {
match character {
'"' | '\'' => {
quoted = true;
in_string = !in_string;
}
'#' if !in_string => break,
_ => value.push(character),
}
}
keys.insert(name, (value.trim().to_string(), quoted));
}
keys
}
/// Validate an `akurai-deploy.toml`. The bash arm swallowed the validator's
/// exit code (`2>&1 || true`), so a failing document still exits 0.
pub fn validate_config(_ec2: &Ec2, path: Option<&Path>) -> Result<()> {
let path = path.map(Path::to_path_buf).unwrap_or_else(|| {
env::current_dir()
.unwrap_or_else(|_| PathBuf::from("."))
.join("akurai-deploy.toml")
});
if !path.is_file() {
bail!("not found: {}", path.display());
}
let text = match fs::read_to_string(&path) {
Ok(text) => text,
Err(error) => {
println!("{error}");
return Ok(());
}
};
let config = top_level_keys(&text);
let mut errors: Vec<String> = Vec::new();
for required in ["name", "port"] {
if !config.contains_key(required) {
errors.push(format!("missing required key: {required}"));
}
}
if let Some((raw, quoted)) = config.get("port") {
let numeric = if *quoted {
None
} else {
raw.parse::<i64>().ok()
};
match numeric {
Some(port) if (1024..=65535).contains(&port) => {}
_ => errors.push(format!("invalid port: {raw} (must be 1024-65535)")),
}
}
if let Some((domain, _)) = config.get("domain")
&& domain != "-"
&& !domain.contains('.')
{
errors.push(format!("domain \"{domain}\" looks invalid"));
}
if !errors.is_empty() {
println!("VALIDATION FAILED:");
for error in errors {
println!(" - {error}");
}
return Ok(());
}
let name = config.get("name").map(|(v, _)| v.as_str()).unwrap_or("?");
let domain = config.get("domain").map(|(v, _)| v.as_str()).unwrap_or("-");
let port = config.get("port").map(|(v, _)| v.as_str()).unwrap_or("?");
let build = config
.get("build")
.map(|(v, _)| v.as_str())
.unwrap_or("(shared binary)");
let build: String = build.chars().take(60).collect();
println!("✅ {name} — config valid");
println!(" domain: {domain}");
println!(" port: {port}");
println!(" build: {build}");
Ok(())
}
// ===========================================================================
// repos / search
// ===========================================================================
fn project_dirs(patterns: &[&str]) -> Result<Vec<PathBuf>> {
let projects = home()?.join("Projects");
let mut found = Vec::new();
for pattern in patterns {
let full = projects.join(pattern);
let Some(text) = full.to_str() else { continue };
let Ok(entries) = glob::glob(text) else {
continue;
};
for entry in entries.flatten() {
found.push(entry);
}
}
Ok(found)
}
/// `repos status` — branch, dirt, and upstream drift across the AkurAI repos.
pub fn repos(_ec2: &Ec2, action: Option<&str>) -> Result<()> {
match action.unwrap_or("status") {
"status" => {}
_ => bail!("usage: akurai-ec2 repos status"),
}
println!("=== AkurAI Repos Status ===");
for directory in project_dirs(&["AkurAI-*", "akurai-*", "golfsetridak*"])? {
if !directory.join(".git").exists() {
continue;
}
let Some(path) = directory.to_str() else {
continue;
};
let name = directory
.file_name()
.and_then(|value| value.to_str())
.unwrap_or(path)
.to_string();
let branch = capture_quiet("git", &["-C", path, "rev-parse", "--abbrev-ref", "HEAD"])
.unwrap_or_else(|| "?".to_string());
let dirty = capture_quiet("git", &["-C", path, "status", "--porcelain"])
.map(|text| {
if text.is_empty() {
0
} else {
text.lines().count()
}
})
.unwrap_or(0);
let ahead = capture_quiet(
"git",
&["-C", path, "rev-list", "--count", "@{upstream}..HEAD"],
)
.unwrap_or_else(|| "0".to_string());
let behind = capture_quiet(
"git",
&["-C", path, "rev-list", "--count", "HEAD..@{upstream}"],
)
.unwrap_or_else(|| "0".to_string());
println!(" {name:<35} {branch:<22} dirty={dirty:<3} ahead={ahead:<3} behind={behind:<3}");
}
Ok(())
}
/// Cross-repo source search. The bash original passed grep's `--include` to
/// ripgrep, which rejects that flag, so every search silently found nothing;
/// the working equivalent (`--glob`) is used here.
pub fn search(_ec2: &Ec2, pattern: &str, include: Option<&str>) -> Result<()> {
let include = include.unwrap_or("*.rs");
println!("=== Searching AkurAI repos for: {pattern} ===");
for directory in project_dirs(&["AkurAI-*", "akurai-*"])? {
if !directory.is_dir() {
continue;
}
let Some(path) = directory.to_str() else {
continue;
};
let name = directory
.file_name()
.and_then(|value| value.to_str())
.unwrap_or(path)
.to_string();
let source = format!("{path}/src");
let crates = format!("{path}/crates");
let Some(matches) =
capture_quiet("rg", &["-l", "--glob", include, pattern, &source, &crates])
else {
continue;
};
let files: Vec<&str> = matches
.lines()
.filter(|line| !line.is_empty())
.take(5)
.collect();
if files.is_empty() {
continue;
}
println!(" [{name}]");
let prefix = format!("{path}/");
for file in files {
println!(" {}", file.strip_prefix(&prefix).unwrap_or(file));
}
}
Ok(())
}
// ===========================================================================
// provision-oidc-client
// ===========================================================================
/// Create an IDP client, store credentials in PassVault, merge a protected env.
pub fn provision_oidc_client(
_ec2: &Ec2,
name: &str,
redirect: &str,
env_prefix: &str,
email: &str,
folder: &str,
remote_env_file: Option<&str>,
) -> Result<()> {
let script = repo_script("provision-app-oidc.py");
if !script.is_file() {
bail!("provision helper not found: {}", script.display());
}
let mut command = Command::new(&script);
command.args([name, redirect, env_prefix, email, folder]);
if let Some(remote) = remote_env_file {
command.arg(remote);
}
let status = command
.status()
.with_context(|| format!("failed to start {}", script.display()))?;
if !status.success() {
std::process::exit(status.code().unwrap_or(1));
}
Ok(())
}
// ===========================================================================
// retire-app
// ===========================================================================
const RETIRE_APP_REMOTE: &str = r#"set -euo pipefail
name="$1"; domain="$2"; action="$3"; delete_data="$4"; delete_user="$5"; delete_cert="$6"
exists() { sudo test -e "$1" && printf true || printf false; }
unit=$(systemctl list-unit-files "$name.service" --no-legend 2>/dev/null | grep -q "$name.service" && printf true || printf false)
active=$(systemctl is-active --quiet "$name.service" 2>/dev/null && printf true || printf false)
user=$(id "$name" >/dev/null 2>&1 && printf true || printf false)
printf '{"name":"%s","domain":"%s","unit":%s,"active":%s,"app":%s,"data":%s,"config":%s,"backup":%s,"nginx_available":%s,"nginx_enabled":%s,"user":%s}\n' \
"$name" "$domain" "$unit" "$active" "$(exists "/opt/$name")" "$(exists "/var/lib/$name")" \
"$(exists "/etc/$name")" "$(exists "/var/backups/$name")" \
"$(exists "/etc/nginx/sites-available/$domain")" "$(exists "/etc/nginx/sites-enabled/$domain")" "$user"
[ "$action" = inventory ] && exit 0
sudo systemctl disable --now "$name.service" 2>/dev/null || true
sudo rm -f "/etc/systemd/system/$name.service"
sudo rm -rf "/opt/$name"
sudo rm -f "/etc/nginx/sites-enabled/$domain" "/etc/nginx/sites-available/$domain"
if [ "$delete_data" = true ]; then sudo rm -rf "/var/lib/$name" "/etc/$name" "/var/backups/$name"; fi
if [ "$delete_cert" = true ]; then sudo certbot delete --cert-name "$domain" --non-interactive 2>/dev/null || true; fi
if [ "$delete_user" = true ]; then sudo userdel "$name" 2>/dev/null || true; sudo groupdel "$name" 2>/dev/null || true; fi
sudo systemctl daemon-reload
sudo systemctl reset-failed
sudo nginx -t >/dev/null
sudo systemctl reload nginx
! systemctl list-unit-files "$name.service" --no-legend 2>/dev/null | grep -q "$name.service"
! sudo test -e "/opt/$name"
! sudo test -e "/etc/nginx/sites-enabled/$domain"
! sudo test -e "/etc/nginx/sites-available/$domain"
[ "$delete_data" != true ] || { ! sudo test -e "/var/lib/$name" && ! sudo test -e "/etc/$name" && ! sudo test -e "/var/backups/$name"; }
[ "$delete_user" != true ] || ! id "$name" >/dev/null 2>&1
printf '{"retired":"%s","domain":"%s","ok":true}\n' "$name" "$domain"
"#;
fn valid_app_name(value: &str) -> bool {
let mut chars = value.chars();
let Some(first) = chars.next() else {
return false;
};
if !(first.is_ascii_lowercase() || first.is_ascii_digit()) {
return false;
}
value.len() <= 63 && chars.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
}
fn valid_retire_domain(value: &str) -> bool {
let length = value.chars().count();
if !(3..=254).contains(&length) {
return false;
}
let bytes: Vec<char> = value.chars().collect();
let first = bytes[0];
let last = bytes[length - 1];
if !(first.is_ascii_alphanumeric() && last.is_ascii_alphanumeric()) {
return false;
}
bytes[1..length - 1]
.iter()
.all(|c| c.is_ascii_alphanumeric() || *c == '.' || *c == '-')
}
/// Inventory or retire one EC2 application with bounded, explicit deletion.
pub fn retire_app(ec2: &Ec2, args: &[String]) -> Result<()> {
let mut name: Option<String> = None;
let mut domain: Option<String> = None;
let mut inventory = false;
let mut apply = false;
let mut delete_data = false;
let mut delete_user = false;
let mut delete_certificate = false;
let mut delete_dns = false;
let mut dns_type = "A".to_string();
let mut yes = false;
let mut index = 0;
while index < args.len() {
let argument = args[index].as_str();
let mut value_for = |flag: &str| -> Result<String> {
index += 1;
args.get(index)
.cloned()
.ok_or_else(|| anyhow!("argument {flag}: expected one argument"))
};
match argument {
"--domain" => domain = Some(value_for("--domain")?),
"--dns-type" => dns_type = value_for("--dns-type")?,
"--inventory" => inventory = true,
"--apply" => apply = true,
"--delete-data" => delete_data = true,
"--delete-user" => delete_user = true,
"--delete-certificate" => delete_certificate = true,
"--delete-dns" => delete_dns = true,
"--yes" => yes = true,
other if other.starts_with('-') => {
eprintln!(
"usage: akurai-ec2 retire-app NAME --domain DOMAIN [--inventory | --apply]"
);
eprintln!("akurai-ec2 retire-app: error: unrecognized arguments: {other}");
std::process::exit(2);
}
other => {
if name.is_some() {
eprintln!(
"usage: akurai-ec2 retire-app NAME --domain DOMAIN [--inventory | --apply]"
);
eprintln!("akurai-ec2 retire-app: error: unrecognized arguments: {other}");
std::process::exit(2);
}
name = Some(other.to_string());
}
}
index += 1;
}
if inventory && apply {
eprintln!(
"akurai-ec2 retire-app: error: argument --apply: not allowed with argument --inventory"
);
std::process::exit(2);
}
let Some(name) = name else {
eprintln!("akurai-ec2 retire-app: error: the following arguments are required: name");
std::process::exit(2);
};
let Some(domain) = domain else {
eprintln!("akurai-ec2 retire-app: error: the following arguments are required: --domain");
std::process::exit(2);
};
if !valid_app_name(&name) {
eprintln!("invalid application name: '{name}'");
std::process::exit(1);
}
let domain = domain.trim_end_matches('.').to_string();
if !valid_retire_domain(&domain) {
eprintln!("invalid domain: '{domain}'");
std::process::exit(1);
}
if apply && !yes {
eprintln!("retire-app --apply requires --yes");
std::process::exit(1);
}
if !apply && (delete_data || delete_user || delete_certificate || delete_dns) {
eprintln!("deletion flags require --apply");
std::process::exit(1);
}
let action = if apply { "apply" } else { "inventory" };
let remote = format!(
"bash -s -- {name} {domain} {action} {delete_data} {delete_user} {delete_certificate}"
);
if ec2
.ssh_stdin_streaming(&remote, RETIRE_APP_REMOTE.as_bytes())
.is_err()
{
std::process::exit(1);
}
if apply && delete_dns {
let record_type = dns_type.to_uppercase();
let (program, prefix) = self_cli();
let mut delete = Command::new(&program);
delete.args(&prefix);
delete.args(["dns", "delete", &domain, "--type", &record_type, "--yes"]);
let status = delete
.status()
.with_context(|| format!("failed to start {}", program.display()))?;
if !status.success() {
std::process::exit(status.code().unwrap_or(1));
}
let mut verify = Command::new(&program);
verify.args(&prefix);
verify.args(["dns", "verify", &domain, "--type", &record_type, "--absent"]);
let status = verify
.status()
.with_context(|| format!("failed to start {}", program.display()))?;
if !status.success() {
eprintln!(
"{{\"ok\": false, \"error\": \"public DNS still resolves; propagation may be pending\"}}"
);
std::process::exit(status.code().unwrap_or(1));
}
}
Ok(())
}
// ===========================================================================
// workspace — durable, fenced lifecycle boundary
// ===========================================================================
const WORKSPACE_ACTIONS: [&str; 5] = [
"provision",
"deprovision",
"upgrade",
"health-register",
"dns-handoff",
];
const LEASE_SECONDS: i64 = 900;
struct OperationStore {
directory: PathBuf,
path: PathBuf,
lock_path: PathBuf,
}
impl OperationStore {
fn new() -> Result<Self> {
let directory = match env::var_os("AKURAI_WORKSPACE_STATE_DIR") {
Some(value) if !value.is_empty() => {
let raw = PathBuf::from(value);
if let Ok(stripped) = raw.strip_prefix("~") {
home()?.join(stripped)
} else {
raw
}
}
_ => {
let base = match env::var_os("XDG_STATE_HOME") {
Some(value) if !value.is_empty() => PathBuf::from(value),
_ => home()?.join(".local/state"),
};
base.join("akurai-ec2")
}
};
Ok(Self {
path: directory.join("workspace-lifecycle.json"),
lock_path: directory.join("workspace-lifecycle.lock"),
directory,
})
}
fn load(&self) -> Result<Value> {
if !self.path.exists() {
return Ok(json!({"schema": 1, "operations": {}}));
}
let text = fs::read_to_string(&self.path)
.map_err(|error| anyhow!("workspace lifecycle journal is unreadable: {error}"))?;
let value: Value = serde_json::from_str(&text)
.map_err(|error| anyhow!("workspace lifecycle journal is unreadable: {error}"))?;
let usable = value.get("schema") == Some(&json!(1))
&& value.get("operations").map(Value::is_object) == Some(true);
if !value.is_object() || !usable {
bail!("workspace lifecycle journal has an unsupported schema");
}
Ok(value)
}
fn save(&self, state: &Value) -> Result<()> {
make_private_dir(&self.directory, 0o700)?;
write_private(&self.path, &format!("{}\n", pretty_json(state)))
}
fn lock(&self, exclusive: bool) -> Result<File> {
make_private_dir(&self.directory, 0o700)?;
let file = OpenOptions::new()
.append(true)
.read(true)
.create(true)
.mode(0o600)
.open(&self.lock_path)
.with_context(|| format!("open {}", self.lock_path.display()))?;
fs::set_permissions(&self.lock_path, fs::Permissions::from_mode(0o600))?;
if exclusive {
file.lock()?;
} else {
file.lock_shared()?;
}
Ok(file)
}
fn mutate<T>(&self, operation: impl FnOnce(&mut Value) -> Result<T>) -> Result<T> {
let _guard = self.lock(true)?;
let mut state = self.load()?;
let result = operation(&mut state)?;
self.save(&state)?;
Ok(result)
}
fn read<T>(&self, operation: impl FnOnce(&Value) -> Result<T>) -> Result<T> {
let _guard = self.lock(false)?;
let state = self.load()?;
operation(&state)
}
}
fn now_iso() -> String {
iso_utc(epoch_now())
}
fn lease_expiry() -> String {
iso_utc(epoch_now() + LEASE_SECONDS)
}
fn append_audit(record: &mut Value, event: &str, details: Vec<(&str, Value)>) {
let entry_index = record
.get("audit")
.and_then(Value::as_array)
.map(Vec::len)
.unwrap_or(0);
let mut entry = Map::new();
entry.insert("sequence".into(), json!(entry_index + 1));
entry.insert("at".into(), json!(now_iso()));
entry.insert("event".into(), json!(event));
for (key, value) in details {
entry.insert(key.to_string(), value);
}
match record.get_mut("audit").and_then(Value::as_array_mut) {
Some(audit) => audit.push(Value::Object(entry)),
None => {
if let Some(object) = record.as_object_mut() {
object.insert("audit".into(), json!([Value::Object(entry)]));
}
}
}
}
fn operation_id(action: &str, idempotency_key: &str, request: &Value) -> String {
let source = compact_json(&json!({
"action": action,
"idempotency_key": idempotency_key,
"request": request,
}));
let digest = Sha256::digest(source.as_bytes());
let hex = digest
.iter()
.map(|byte| format!("{byte:02x}"))
.collect::<String>();
format!("wop_{}", &hex[..24])
}
fn validate_workspace_name(label: &str, value: &str) -> Result<String> {
let trimmed = value.trim();
let printable = !trimmed.is_empty()
&& trimmed.chars().count() <= 200
&& !trimmed
.chars()
.any(|c| (c as u32) < 0x20 || (c as u32) == 0x7f);
if !printable {
bail!("{label} must contain 1 through 200 printable characters");
}
Ok(trimmed.to_string())
}
fn validate_workspace_domain(value: &str) -> Result<String> {
let domain = value.trim_end_matches('.').to_lowercase();
let characters: Vec<char> = domain.chars().collect();
let shaped = match characters.len() {
0 => false,
1 => characters[0].is_ascii_alphanumeric(),
length => {
length <= 253
&& characters[0].is_ascii_alphanumeric()
&& characters[length - 1].is_ascii_alphanumeric()
&& characters[1..length - 1]
.iter()
.all(|c| c.is_ascii_alphanumeric() || *c == '.' || *c == '-')
}
};
if !shaped || domain.contains("..") || !domain.contains('.') {
bail!("domain must be a fully qualified DNS name");
}
Ok(domain)
}
fn pending_steps(action: &str) -> Value {
match action {
"provision" => json!([
{"name": "drive_workspace", "state": "pending", "reason": "Drive lacks an authenticated idempotent workspace create/read/delete write contract."},
{"name": "notes_workspace", "state": "pending", "reason": "Notes lacks an authenticated workspace tenancy write contract."},
{"name": "tasks_workspace", "state": "pending", "reason": "Tasks lacks a source-owned workspace tenancy write contract."},
{"name": "idp_tenant", "state": "pending", "reason": "IDP lacks an authenticated idempotent tenant create/read/delete write contract; provision-oidc-client only creates app clients."},
{"name": "workspace_install", "state": "pending", "reason": "The EC2 deploy surface is app-scoped, not workspace-scoped; no installation target contract exists."}
]),
"deprovision" => json!([
{"name": "retention_transition", "state": "pending", "reason": "No source-owned cancel-at-period-end or retention write contract exists."},
{"name": "source_resource_delete", "state": "pending", "reason": "No source-owned Drive, Notes, Tasks, or IDP deletion write contract exists."}
]),
"upgrade" => json!([
{"name": "workspace_upgrade", "state": "pending", "reason": "The maintained EC2 deployment contract has no workspace resource selector or installed-version readback."}
]),
"health-register" => json!([
{"name": "monitor_registration", "state": "pending", "reason": "The maintained Monitor MCP exposes reads only; it has no workspace health-registration write contract."}
]),
_ => json!([
{"name": "dns_handoff", "state": "pending", "reason": "Requires explicit --apply."}
]),
}
}
fn next_action(record: &Value) -> &'static str {
let state = record.get("state").and_then(Value::as_str).unwrap_or("");
let action = record.get("action").and_then(Value::as_str).unwrap_or("");
match state {
"approval_required" => {
"Approve the irreversible deletion in a separate command with workspace approve-delete."
}
"completed" => "No action required; the source-owned DNS handoff is complete.",
"compensation_pending" => {
"Inspect the recorded prior DNS resource, then resume after the source is reachable."
}
_ if action == "dns-handoff" => {
"Run workspace resume <operation_id> --apply only after confirming the DNS target."
}
_ => {
"Implement the listed source-owned write contracts, then resume with the returned operation_id and fence."
}
}
}
fn response(record: &Value) -> Value {
let mut result = record.clone();
let action = next_action(&result);
if let Some(object) = result.as_object_mut() {
object.insert("next_action".into(), json!(action));
}
result
}
fn find_operation<'a>(state: &'a Value, op_id: &str) -> Result<&'a Value> {
state
.get("operations")
.and_then(|operations| operations.get(op_id))
.ok_or_else(|| anyhow!("workspace operation not found: {op_id}"))
}
fn find_operation_mut<'a>(state: &'a mut Value, op_id: &str) -> Result<&'a mut Value> {
state
.get_mut("operations")
.and_then(|operations| operations.get_mut(op_id))
.ok_or_else(|| anyhow!("workspace operation not found: {op_id}"))
}
#[derive(Default)]
struct RequestArgs {
action: String,
idempotency_key: String,
organization: String,
workspace: String,
domain: Option<String>,
record_type: Option<String>,
record_value: Option<String>,
zone: Option<String>,
provider: String,
ttl: i64,
irreversible: bool,
apply: bool,
}
fn request_payload(args: &RequestArgs) -> Result<Value> {
let mut payload = Map::new();
payload.insert(
"organization".into(),
json!(validate_workspace_name("organization", &args.organization)?),
);
payload.insert(
"workspace".into(),
json!(validate_workspace_name("workspace", &args.workspace)?),
);
if args.action == "dns-handoff" {
let (Some(domain), Some(record_type), Some(record_value)) = (
args.domain.as_ref(),
args.record_type.as_ref(),
args.record_value.as_ref(),
) else {
bail!("dns-handoff requires --domain, --record-type, and --record-value");
};
let record_type = record_type.to_uppercase();
if !["A", "AAAA", "CNAME", "TXT"].contains(&record_type.as_str()) {
bail!("record type must be A, AAAA, CNAME, or TXT");
}
if record_value.contains('\n') || record_value.trim().is_empty() {
bail!("record value must be one non-empty line");
}
if !(60..=86_400).contains(&args.ttl) {
bail!("DNS TTL must be from 60 through 86400 seconds");
}
payload.insert("domain".into(), json!(validate_workspace_domain(domain)?));
payload.insert("record_type".into(), json!(record_type));
payload.insert("record_value".into(), json!(record_value));
payload.insert("ttl".into(), json!(args.ttl));
payload.insert(
"zone".into(),
match args.zone.as_ref() {
Some(zone) => json!(validate_workspace_domain(zone)?),
None => Value::Null,
},
);
payload.insert("provider".into(), json!(args.provider));
}
if args.action == "deprovision" {
payload.insert("irreversible".into(), json!(args.irreversible));
}
Ok(Value::Object(payload))
}
fn valid_idempotency_key(value: &str) -> bool {
let mut characters = value.chars();
let Some(first) = characters.next() else {
return false;
};
first.is_ascii_alphanumeric()
&& value.chars().count() <= 200
&& characters.all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | ':' | '-'))
}
fn create_or_get(store: &OperationStore, args: &RequestArgs) -> Result<Value> {
if !valid_idempotency_key(&args.idempotency_key) {
bail!("idempotency key must be 1 through 200 URL-safe characters");
}
if args.apply && args.action != "dns-handoff" {
bail!("--apply is currently supported only for dns-handoff");
}
let payload = request_payload(args)?;
let op_id = operation_id(&args.action, &args.idempotency_key, &payload);
let record = store.mutate(|state| {
let operations = state
.get_mut("operations")
.and_then(Value::as_object_mut)
.ok_or_else(|| anyhow!("workspace lifecycle journal has an unsupported schema"))?;
let existing_conflict = operations
.iter()
.find(|(_, existing)| {
existing.get("idempotency_key").and_then(Value::as_str)
== Some(args.idempotency_key.as_str())
})
.map(|(id, _)| id.clone());
if let Some(existing_id) = existing_conflict {
if existing_id == op_id {
let fence = operations
.get(&existing_id)
.and_then(|record| record.get("fence"))
.cloned()
.unwrap_or(json!(0));
let existing = operations
.get_mut(&existing_id)
.ok_or_else(|| anyhow!("workspace operation not found: {existing_id}"))?;
append_audit(existing, "idempotency_replayed", vec![("fence", fence)]);
return Ok(existing.clone());
}
bail!(
"idempotency key already owns {existing_id}; use a distinct key for a different request"
);
}
let irreversible = payload
.get("irreversible")
.and_then(Value::as_bool)
.unwrap_or(false);
let state_value = if args.action == "deprovision" && irreversible {
"approval_required"
} else {
"pending"
};
let mut record = json!({
"operation_id": op_id,
"action": args.action,
"idempotency_key": args.idempotency_key,
"request": payload,
"state": state_value,
"fence": 0,
"lease_expires_at": Value::Null,
"resource_ids": {},
"steps": pending_steps(&args.action),
"audit": [],
});
if args.action == "deprovision"
&& let Some(object) = record.as_object_mut()
{
object.insert(
"irreversible_delete".into(),
json!({
"requested": irreversible,
"approval_state": if irreversible { "required" } else { "not_requested" },
"approval_token_sha256": Value::Null,
}),
);
}
append_audit(&mut record, "requested", vec![("state", json!(state_value))]);
operations.insert(op_id.clone(), record.clone());
Ok(record)
})?;
let record = if args.apply
&& record.get("action").and_then(Value::as_str) == Some("dns-handoff")
&& record.get("state").and_then(Value::as_str) != Some("completed")
{
execute_dns(store, &op_id)?
} else {
record
};
Ok(response(&record))
}
fn approve_delete(store: &OperationStore, op_id: &str, approval_token: &str) -> Result<Value> {
if approval_token.chars().count() < 16 {
bail!("approval token must contain at least 16 characters");
}
let digest = Sha256::digest(approval_token.as_bytes())
.iter()
.map(|byte| format!("{byte:02x}"))
.collect::<String>();
let record = store.mutate(|state| {
let record = find_operation_mut(state, op_id)?;
let irreversible = record
.get("request")
.and_then(|request| request.get("irreversible"))
.and_then(Value::as_bool)
.unwrap_or(false);
if record.get("action").and_then(Value::as_str) != Some("deprovision") || !irreversible {
bail!("only an irreversible deprovision operation can be approved");
}
let approved = record
.get("irreversible_delete")
.and_then(|deletion| deletion.get("approval_state"))
.and_then(Value::as_str)
== Some("approved");
if approved {
let recorded = record
.get("irreversible_delete")
.and_then(|deletion| deletion.get("approval_token_sha256"))
.and_then(Value::as_str)
.unwrap_or("");
if recorded != digest {
bail!("a different irreversible-delete approval was already recorded");
}
append_audit(record, "approval_idempotency_replayed", Vec::new());
return Ok(record.clone());
}
if let Some(deletion) = record
.get_mut("irreversible_delete")
.and_then(Value::as_object_mut)
{
deletion.insert("approval_state".into(), json!("approved"));
deletion.insert("approval_token_sha256".into(), json!(digest));
}
if let Some(object) = record.as_object_mut() {
object.insert("state".into(), json!("pending"));
}
append_audit(record, "irreversible_delete_approved", Vec::new());
Ok(record.clone())
})?;
Ok(response(&record))
}
fn resume(store: &OperationStore, op_id: &str, apply: bool) -> Result<Value> {
let record = store.mutate(|state| {
let record = find_operation_mut(state, op_id)?;
let current = record
.get("state")
.and_then(Value::as_str)
.unwrap_or("")
.to_string();
if current == "completed" {
let fence = record.get("fence").cloned().unwrap_or(json!(0));
append_audit(
record,
"resume_idempotency_replayed",
vec![("fence", fence)],
);
return Ok(record.clone());
}
if current == "approval_required" {
bail!("irreversible deletion requires workspace approve-delete before resume");
}
if current == "running"
&& let Some(expiry) = record.get("lease_expires_at").and_then(Value::as_str)
&& parse_iso(expiry)? > epoch_now()
{
bail!("operation has a live execution lease; wait for it or retry after it expires");
}
let fence = record.get("fence").and_then(Value::as_i64).unwrap_or(0) + 1;
if let Some(object) = record.as_object_mut() {
object.insert("fence".into(), json!(fence));
object.insert("lease_expires_at".into(), Value::Null);
object.insert("state".into(), json!("pending"));
}
append_audit(record, "resumed", vec![("fence", json!(fence))]);
Ok(record.clone())
})?;
let record = if apply {
if record.get("action").and_then(Value::as_str) != Some("dns-handoff") {
bail!("--apply is currently supported only for dns-handoff");
}
execute_dns(store, op_id)?
} else {
record
};
Ok(response(&record))
}
fn dns_cli_args(request: &Value, action: &str) -> Vec<String> {
let string = |key: &str| -> String {
request
.get(key)
.and_then(Value::as_str)
.unwrap_or_default()
.to_string()
};
let mut args = vec!["dns".to_string(), action.to_string(), string("domain")];
if action == "upsert" {
let ttl = request
.get("ttl")
.and_then(Value::as_i64)
.unwrap_or(900)
.to_string();
args.extend([
string("record_type"),
string("record_value"),
"--ttl".to_string(),
ttl,
]);
} else {
args.extend(["--type".to_string(), string("record_type")]);
if action == "delete" {
args.extend(["--value".to_string(), string("record_value")]);
}
}
if let Some(zone) = request.get("zone").and_then(Value::as_str)
&& !zone.is_empty()
{
args.extend(["--zone".to_string(), zone.to_string()]);
}
args.extend(["--provider".to_string(), string("provider")]);
if action == "upsert" || action == "delete" {
args.push("--yes".to_string());
}
args
}
fn run_dns_cli(args: &[String]) -> Result<Value> {
let (program, prefix) = self_cli();
let label = args
.iter()
.take(2)
.cloned()
.collect::<Vec<String>>()
.join(" ");
let output = Command::new(&program)
.args(&prefix)
.args(args)
.output()
.map_err(|error| anyhow!("{label}: unable to execute {}: {error}", program.display()))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string();
let message = if !stderr.is_empty() {
stderr
} else if !stdout.is_empty() {
stdout
} else {
"akurai-ec2 command failed".to_string()
};
bail!("{label}: {message}");
}
serde_json::from_slice(&output.stdout).map_err(|_| anyhow!("{label}: expected JSON output"))
}
fn matching_record(
payload: &Value,
name: &str,
record_type: &str,
record_value: Option<&str>,
) -> Result<Option<Value>> {
let records = payload.get("records").cloned().unwrap_or(json!([]));
let Some(records) = records.as_array() else {
bail!("DNS source returned an invalid records payload");
};
let wanted = name.trim_end_matches('.').to_lowercase();
let candidates: Vec<Value> = records
.iter()
.filter(|value| value.is_object())
.filter(|value| {
value
.get("fqdn")
.and_then(Value::as_str)
.unwrap_or("")
.trim_end_matches('.')
.to_lowercase()
== wanted
&& value.get("type").and_then(Value::as_str) == Some(record_type)
&& match record_value {
None => true,
Some(expected) => value.get("value").and_then(Value::as_str) == Some(expected),
}
})
.cloned()
.collect();
if candidates.len() > 1 {
bail!(
"DNS handoff refuses multiple same-type records because exact compensation is ambiguous"
);
}
Ok(candidates.into_iter().next())
}
fn dns_lookup(request: &Value) -> Result<(Value, Option<Value>)> {
let payload = run_dns_cli(&dns_cli_args(request, "get"))?;
let name = request
.get("domain")
.and_then(Value::as_str)
.unwrap_or_default();
let record_type = request
.get("record_type")
.and_then(Value::as_str)
.unwrap_or_default();
let found = matching_record(&payload, name, record_type, None)?;
Ok((payload, found))
}
fn set_running(store: &OperationStore, op_id: &str) -> Result<(Value, i64)> {
store.mutate(|state| {
let record = find_operation_mut(state, op_id)?;
let current = record
.get("state")
.and_then(Value::as_str)
.unwrap_or("")
.to_string();
if current == "completed" {
let fence = record.get("fence").and_then(Value::as_i64).unwrap_or(0);
return Ok((record.clone(), fence));
}
if current == "approval_required" {
bail!("irreversible deletion requires explicit approval");
}
if current == "running"
&& let Some(expiry) = record.get("lease_expires_at").and_then(Value::as_str)
&& parse_iso(expiry)? > epoch_now()
{
bail!("operation already has a live execution lease");
}
let fence = record.get("fence").and_then(Value::as_i64).unwrap_or(0) + 1;
if let Some(object) = record.as_object_mut() {
object.insert("fence".into(), json!(fence));
object.insert("state".into(), json!("running"));
object.insert("lease_expires_at".into(), json!(lease_expiry()));
}
if let Some(step) = record
.get_mut("steps")
.and_then(Value::as_array_mut)
.and_then(|steps| steps.first_mut())
.and_then(Value::as_object_mut)
{
step.insert("state".into(), json!("running"));
}
append_audit(record, "dns_handoff_started", vec![("fence", json!(fence))]);
Ok((record.clone(), fence))
})
}
fn update_for_fence(
store: &OperationStore,
op_id: &str,
fence: i64,
update: impl FnOnce(&mut Value) -> Result<()>,
) -> Result<Value> {
store.mutate(|state| {
let record = find_operation_mut(state, op_id)?;
if record.get("fence").and_then(Value::as_i64) != Some(fence) {
bail!("stale execution fence; its source result was not recorded");
}
update(record)?;
Ok(record.clone())
})
}
fn compensate_dns(request: &Value, prior: Option<&Value>) -> (bool, String) {
let attempt = || -> Result<String> {
let (_, current) = dns_lookup(request)?;
if let Some(prior) = prior {
if let Some(current) = current.as_ref()
&& current.get("value") == prior.get("value")
{
return Ok("original DNS record remained in place".to_string());
}
let mut restore = request.clone();
if let Some(object) = restore.as_object_mut() {
let value = match prior.get("value") {
Some(Value::String(text)) => text.clone(),
Some(other) => other.to_string(),
None => String::new(),
};
object.insert("record_value".into(), json!(value));
let ttl = prior
.get("ttl")
.and_then(|value| {
value
.as_i64()
.or_else(|| value.as_str().and_then(|text| text.parse().ok()))
})
.filter(|ttl| *ttl != 0)
.or_else(|| request.get("ttl").and_then(Value::as_i64))
.unwrap_or(900);
object.insert("ttl".into(), json!(ttl));
}
run_dns_cli(&dns_cli_args(&restore, "upsert"))?;
return Ok("restored the prior DNS record".to_string());
}
if let Some(current) = current.as_ref()
&& current.get("value") == request.get("record_value")
{
run_dns_cli(&dns_cli_args(request, "delete"))?;
return Ok("removed the partially created DNS record".to_string());
}
Ok("no DNS compensation was needed".to_string())
};
match attempt() {
Ok(detail) => (true, detail),
Err(error) => (false, error.to_string()),
}
}
fn execute_dns(store: &OperationStore, op_id: &str) -> Result<Value> {
let (record, fence) = set_running(store, op_id)?;
if record.get("state").and_then(Value::as_str) == Some("completed") {
return Ok(record);
}
let request = record.get("request").cloned().unwrap_or(json!({}));
let mut prior: Option<Value> = None;
let outcome = (|| -> Result<Value> {
let (_, found) = dns_lookup(&request)?;
prior = found.clone();
let recorded = prior.clone().unwrap_or(Value::Null);
update_for_fence(store, op_id, fence, |current| {
if let Some(object) = current.as_object_mut() {
object.insert("prior_dns_record".into(), recorded);
}
Ok(())
})?;
run_dns_cli(&dns_cli_args(&request, "upsert"))?;
let (source, created) = dns_lookup(&request)?;
let created = match created {
Some(created) if created.get("value") == request.get("record_value") => created,
_ => bail!("DNS upsert returned without the requested record"),
};
update_for_fence(store, op_id, fence, |current| {
if let Some(object) = current.as_object_mut() {
object.insert("state".into(), json!("completed"));
object.insert("lease_expires_at".into(), Value::Null);
}
if let Some(step) = current
.get_mut("steps")
.and_then(Value::as_array_mut)
.and_then(|steps| steps.first_mut())
.and_then(Value::as_object_mut)
{
step.insert("state".into(), json!("completed"));
step.remove("reason");
}
let record_id = match created.get("id") {
Some(Value::String(text)) => text.clone(),
Some(other) => other.to_string(),
None => "None".to_string(),
};
if let Some(object) = current.as_object_mut() {
object.insert(
"resource_ids".into(),
json!({
"dns": {
"provider": source.get("provider").cloned().unwrap_or(Value::Null),
"zone": source.get("zone").cloned().unwrap_or(Value::Null),
"record_id": record_id,
"fqdn": created.get("fqdn").cloned().unwrap_or(Value::Null),
"type": created.get("type").cloned().unwrap_or(Value::Null),
}
}),
);
}
append_audit(
current,
"dns_handoff_completed",
vec![("fence", json!(fence))],
);
Ok(())
})
})();
match outcome {
Ok(record) => Ok(record),
Err(error) => {
let (compensated, detail) = compensate_dns(&request, prior.as_ref());
let message = error.to_string();
update_for_fence(store, op_id, fence, |current| {
let step_state = if compensated {
"rolled_back"
} else {
"compensation_pending"
};
if let Some(object) = current.as_object_mut() {
object.insert("lease_expires_at".into(), Value::Null);
}
if let Some(step) = current
.get_mut("steps")
.and_then(Value::as_array_mut)
.and_then(|steps| steps.first_mut())
.and_then(Value::as_object_mut)
{
step.insert("state".into(), json!(step_state));
step.insert("reason".into(), json!(message));
}
if let Some(object) = current.as_object_mut() {
object.insert("state".into(), json!(step_state));
}
append_audit(
current,
"dns_handoff_failed",
vec![
("fence", json!(fence)),
("error", json!(message)),
("compensation", json!(detail)),
],
);
Ok(())
})
}
}
}
fn workspace_usage() -> ! {
eprintln!("usage: akurai-ec2 workspace [-h] {{request,resume,approve-delete,status,list}} ...");
std::process::exit(2);
}
fn take_value(args: &[String], index: &mut usize, flag: &str) -> String {
*index += 1;
match args.get(*index) {
Some(value) => value.clone(),
None => {
eprintln!("akurai-ec2 workspace: error: argument {flag}: expected one argument");
std::process::exit(2);
}
}
}
fn workspace_dispatch(args: &[String]) -> Result<Value> {
let store = OperationStore::new()?;
let Some(command) = args.first().map(String::as_str) else {
workspace_usage();
};
match command {
"request" => {
let mut parsed = RequestArgs {
provider: "ec2".to_string(),
ttl: 900,
..RequestArgs::default()
};
let mut index = 1;
let mut action: Option<String> = None;
while index < args.len() {
match args[index].as_str() {
"--idempotency-key" => {
parsed.idempotency_key = take_value(args, &mut index, "--idempotency-key")
}
"--organization" => {
parsed.organization = take_value(args, &mut index, "--organization")
}
"--workspace" => parsed.workspace = take_value(args, &mut index, "--workspace"),
"--domain" => parsed.domain = Some(take_value(args, &mut index, "--domain")),
"--record-type" => {
parsed.record_type = Some(take_value(args, &mut index, "--record-type"))
}
"--record-value" => {
parsed.record_value = Some(take_value(args, &mut index, "--record-value"))
}
"--zone" => parsed.zone = Some(take_value(args, &mut index, "--zone")),
"--provider" => {
let value = take_value(args, &mut index, "--provider");
if value != "ec2" && value != "1984" {
eprintln!(
"akurai-ec2 workspace: error: argument --provider: invalid choice: '{value}' (choose from 'ec2', '1984')"
);
std::process::exit(2);
}
parsed.provider = value;
}
"--ttl" => {
let value = take_value(args, &mut index, "--ttl");
match value.parse::<i64>() {
Ok(ttl) => parsed.ttl = ttl,
Err(_) => {
eprintln!(
"akurai-ec2 workspace: error: argument --ttl: invalid int value: '{value}'"
);
std::process::exit(2);
}
}
}
"--irreversible" => parsed.irreversible = true,
"--apply" => parsed.apply = true,
other if other.starts_with("--") => workspace_usage(),
other => {
if action.is_some() {
workspace_usage();
}
if !WORKSPACE_ACTIONS.contains(&other) {
eprintln!(
"akurai-ec2 workspace: error: argument action: invalid choice: '{other}'"
);
std::process::exit(2);
}
action = Some(other.to_string());
}
}
index += 1;
}
let Some(action) = action else {
eprintln!(
"akurai-ec2 workspace: error: the following arguments are required: action"
);
std::process::exit(2);
};
if parsed.idempotency_key.is_empty()
|| parsed.organization.is_empty()
|| parsed.workspace.is_empty()
{
eprintln!(
"akurai-ec2 workspace: error: the following arguments are required: --idempotency-key, --organization, --workspace"
);
std::process::exit(2);
}
parsed.action = action;
create_or_get(&store, &parsed)
}
"resume" => {
let mut op_id: Option<String> = None;
let mut apply = false;
for argument in &args[1..] {
match argument.as_str() {
"--apply" => apply = true,
other if other.starts_with("--") => workspace_usage(),
other => op_id = Some(other.to_string()),
}
}
let Some(op_id) = op_id else {
eprintln!(
"akurai-ec2 workspace: error: the following arguments are required: operation_id"
);
std::process::exit(2);
};
resume(&store, &op_id, apply)
}
"approve-delete" => {
let mut op_id: Option<String> = None;
let mut token: Option<String> = None;
let mut index = 1;
while index < args.len() {
match args[index].as_str() {
"--approval-token" => {
token = Some(take_value(args, &mut index, "--approval-token"))
}
other if other.starts_with("--") => workspace_usage(),
other => op_id = Some(other.to_string()),
}
index += 1;
}
let (Some(op_id), Some(token)) = (op_id, token) else {
eprintln!(
"akurai-ec2 workspace: error: the following arguments are required: operation_id, --approval-token"
);
std::process::exit(2);
};
approve_delete(&store, &op_id, &token)
}
"status" => {
let Some(op_id) = args.get(1) else {
eprintln!(
"akurai-ec2 workspace: error: the following arguments are required: operation_id"
);
std::process::exit(2);
};
store.read(|state| Ok(response(find_operation(state, op_id)?)))
}
"list" => store.read(|state| {
let operations = state
.get("operations")
.and_then(Value::as_object)
.ok_or_else(|| anyhow!("workspace lifecycle journal has an unsupported schema"))?;
Ok(Value::Array(
operations.values().map(response).collect::<Vec<Value>>(),
))
}),
_ => workspace_usage(),
}
}
/// Durable, fenced workspace lifecycle control. Unsupported source operations
/// stay explicitly pending; DNS is the one applied mutation.
pub fn workspace(_ec2: &Ec2, args: &[String]) -> Result<()> {
match workspace_dispatch(args) {
Ok(result) => {
println!("{}", compact_json(&result));
Ok(())
}
Err(error) => {
let message = serde_json::to_string(&error.to_string())
.unwrap_or_else(|_| "\"lifecycle error\"".to_string());
eprintln!("{{\"ok\":false,\"error\":{}}}", ascii_escaped(&message));
std::process::exit(1);
}
}
}
// ===========================================================================
// backups and monitoring
// ===========================================================================
fn backup_dispatch(
kind: &str,
action: Option<&str>,
retention: Option<u32>,
default_retention: u32,
maximum: u32,
) -> Result<(String, u32)> {
let action = action.unwrap_or("verify").to_string();
if !matches!(action.as_str(), "install" | "run" | "verify") {
bail!("{kind} backup action must be install, run, or verify");
}
let retention = retention.unwrap_or(default_retention);
if !(2..=maximum).contains(&retention) {
bail!("{kind} backup retention must be an integer from 2 through {maximum}");
}
Ok((action, retention))
}
const BUNFORK_BACKUP_INSTALL: &str = include_str!("remote/bunfork-backup-install.sh");
const BUNFORK_BACKUP_VERIFY: &str = include_str!("remote/bunfork-backup-verify.sh");
const TASKS_BACKUP_INSTALL: &str = include_str!("remote/tasks-backup-install.sh");
const TASKS_BACKUP_VERIFY: &str = include_str!("remote/tasks-backup-verify.sh");
const WORKBENCH_KEY_INSTALL: &str = include_str!("remote/workbench-key-install.sh");
const WORKBENCH_UPLOAD: &str = include_str!("remote/workbench-upload.sh");
const WORKBENCH_VERIFY: &str = include_str!("remote/workbench-verify.sh");
const MONITOR_INSTALL: &str = include_str!("remote/bunfork-monitor-install.sh");
const MONITOR_VERIFY: &str = include_str!("remote/bunfork-monitor-verify.sh");
const MAIL_BACKUP_INSTALL: &str = include_str!("remote/mail-backup-install.sh");
const MAIL_BACKUP_VERIFY: &str = include_str!("remote/mail-backup-verify.sh");
const MAIL_AUDIT: &str = include_str!("remote/mail-audit.sh");
const MAIL_UPDATE: &str = include_str!("remote/mail-update.sh");
/// The mail server pushes its encrypted archives to Titan over the mesh overlay,
/// and only Titan holds the secret half of the recipient key.
const MAIL_OFFSITE_DEST: &str = "olibuijr@100.88.0.9";
const MAIL_OFFSITE_RECIPIENT: &str = "akurai-mail-offsite@olibuijr.com";
const MAIL_HOST: &str = "mail.olibuijr.com";
/// Install, run, or verify the encrypted Bunfork database backup timer.
pub fn bunfork_backup(ec2: &Ec2, action: Option<&str>, retention: Option<u32>) -> Result<()> {
let (action, retention) = backup_dispatch("bunfork", action, retention, 14, 365)?;
if action == "install" {
say("installing encrypted Bunfork backup service and timer");
ec2.ssh_stdin_streaming(
&format!("bash -s -- '{retention}'"),
BUNFORK_BACKUP_INSTALL.as_bytes(),
)?;
} else if action == "run" {
say("running an encrypted Bunfork backup");
ec2.ssh_streaming("sudo systemctl start bunfork-backup.service")?;
}
say("verifying encrypted Bunfork backup and recovery path");
ec2.ssh_stdin_streaming("bash -s", BUNFORK_BACKUP_VERIFY.as_bytes())
}
/// Install, run, or verify the hourly encrypted AkurAI-Tasks backup.
pub fn tasks_backup(ec2: &Ec2, action: Option<&str>, retention: Option<u32>) -> Result<()> {
let (action, retention) = backup_dispatch("AkurAI-Tasks", action, retention, 48, 720)?;
if action == "install" {
say("installing encrypted AkurAI-Tasks backup service and hourly timer");
ec2.ssh_stdin_streaming(
&format!("bash -s -- '{retention}'"),
TASKS_BACKUP_INSTALL.as_bytes(),
)?;
} else if action == "run" {
say("running an encrypted AkurAI-Tasks backup");
ec2.ssh_streaming("sudo systemctl start akurai-tasks-backup.service")?;
}
say("verifying encrypted AkurAI-Tasks backup and isolated recovery");
ec2.ssh_stdin_streaming("bash -s", TASKS_BACKUP_VERIFY.as_bytes())
}
fn gpg_fingerprint(homedir: &Path, recipient: &str) -> Result<String> {
let Some(home_text) = homedir.to_str() else {
bail!("invalid gnupg home path");
};
let listing = capture_local(
Path::new("gpg"),
&[
"--batch",
"--homedir",
home_text,
"--with-colons",
"--fingerprint",
recipient,
],
"gpg --fingerprint",
)?;
let fingerprint = listing
.lines()
.find(|line| line.starts_with("fpr:"))
.and_then(|line| line.split(':').nth(9))
.unwrap_or_default()
.to_string();
Ok(fingerprint)
}
fn valid_fingerprint(value: &str) -> bool {
value.len() == 40
&& value
.chars()
.all(|c| c.is_ascii_digit() || ('A'..='F').contains(&c))
}
fn first_bytes_hex(path: &Path, count: usize) -> Result<String> {
let mut file = File::open(path).with_context(|| format!("read {}", path.display()))?;
let mut buffer = vec![0u8; count];
let mut filled = 0;
while filled < count {
let read = file.read(&mut buffer[filled..])?;
if read == 0 {
break;
}
filled += read;
}
Ok(buffer[..filled]
.iter()
.map(|byte| format!("{byte:02x}"))
.collect())
}
/// Encrypt Workbench snapshots on this machine and store them on EC2.
pub fn workbench_backup(ec2: &Ec2, action: Option<&str>, retention: Option<u32>) -> Result<()> {
let (action, retention) = backup_dispatch("Workbench", action, retention, 14, 365)?;
let root = home()?;
let workbench_ctl = match env::var_os("OMP_WORKBENCH_CTL") {
Some(value) if !value.is_empty() => PathBuf::from(value),
_ => root.join(".local/bin/titan-omp-workbenchctl"),
};
let offsite_home = match env::var_os("OMP_WORKBENCH_OFFSITE_HOME") {
Some(value) if !value.is_empty() => PathBuf::from(value),
_ => root.join(".local/share/omp-workbench/offsite"),
};
let offsite_gnupg = offsite_home.join("gnupg");
let fingerprint_file = offsite_home.join("recipient.fingerprint");
let recipient = "titan-omp-workbench-offsite@olibuijr.com";
let service_path = root.join(".config/systemd/user/omp-workbench-offsite-backup.service");
let timer_path = root.join(".config/systemd/user/omp-workbench-offsite-backup.timer");
if action == "install" {
say("installing EC2 encryption identity and backup destination");
let public_key = ec2.ssh_stdin(
&format!("bash -s -- {}", shell_quote(recipient)),
WORKBENCH_KEY_INSTALL.as_bytes(),
)?;
if !public_key.contains("BEGIN PGP PUBLIC KEY BLOCK") {
bail!("EC2 did not return a valid backup public key");
}
make_private_dir(&offsite_home, 0o700)?;
make_private_dir(&offsite_gnupg, 0o700)?;
let public_key_file = offsite_home.join("recipient.asc");
write_private(&public_key_file, &format!("{}\n", public_key.trim_end()))?;
let Some(gnupg_text) = offsite_gnupg.to_str() else {
bail!("invalid offsite gnupg path");
};
let Some(key_text) = public_key_file.to_str() else {
bail!("invalid public key path");
};
if !local_ok(
"gpg",
&["--batch", "--homedir", gnupg_text, "--import", key_text],
) {
bail!("failed to import EC2 backup public key");
}
let fingerprint = gpg_fingerprint(&offsite_gnupg, recipient)?;
if !valid_fingerprint(&fingerprint) {
bail!("invalid EC2 backup key fingerprint");
}
let mut ownertrust = Command::new("gpg")
.args(["--batch", "--homedir", gnupg_text, "--import-ownertrust"])
.stdin(Stdio::piped())
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn()
.context("failed to start gpg --import-ownertrust")?;
if let Some(stdin) = ownertrust.stdin.as_mut() {
stdin.write_all(format!("{fingerprint}:6:\n").as_bytes())?;
}
let status = ownertrust
.wait()
.context("failed to wait for gpg --import-ownertrust")?;
if !status.success() {
bail!("failed to import EC2 backup key ownertrust");
}
write_private(&fingerprint_file, &format!("{fingerprint}\n"))?;
let unit_dir = service_path
.parent()
.ok_or_else(|| anyhow!("no systemd user unit directory"))?
.to_path_buf();
make_private_dir(&unit_dir, 0o700)?;
let exe = env::current_exe().unwrap_or_else(|_| PathBuf::from("akurai"));
let service = format!(
"[Unit]\nDescription=Create and send an encrypted Workbench backup to AkurAI EC2\nAfter=network-online.target omp-workbench.service\nWants=network-online.target\n\n[Service]\nType=oneshot\nExecStart={} ec2 workbench-backup run {retention}\nUMask=0077\nNoNewPrivileges=true\n\n[Install]\nWantedBy=default.target\n",
exe.display()
);
let timer = "[Unit]\nDescription=Daily off-machine Workbench backup\n\n[Timer]\nOnCalendar=*-*-* 04:15:00 UTC\nRandomizedDelaySec=15m\nPersistent=true\nUnit=omp-workbench-offsite-backup.service\n\n[Install]\nWantedBy=timers.target\n";
write_private(&service_path, &service)?;
write_private(&timer_path, timer)?;
run_local(
Path::new("systemctl"),
&["--user", "daemon-reload"],
"systemctl --user daemon-reload",
)?;
run_local(
Path::new("systemctl"),
&[
"--user",
"enable",
"--now",
"omp-workbench-offsite-backup.timer",
],
"systemctl --user enable",
)?;
run_local(
Path::new("systemctl"),
&["--user", "start", "omp-workbench-offsite-backup.service"],
"systemctl --user start",
)?;
} else if action == "run" {
if !workbench_ctl.is_file() {
bail!(
"Workbench controller not found: {}",
workbench_ctl.display()
);
}
if !offsite_gnupg.is_dir() {
bail!("offsite encryption identity is not installed");
}
say("creating and encrypting a Workbench snapshot");
let snapshot = capture_local(&workbench_ctl, &["backup"], "workbench backup")?;
let expected_prefix = root.join(".local/share/omp-workbench/backups/workbench-");
let matches_shape = expected_prefix
.to_str()
.map(|prefix| snapshot.starts_with(prefix) && snapshot.ends_with(".sqlite"))
.unwrap_or(false);
if !matches_shape {
bail!("Workbench controller returned an unexpected snapshot path");
}
let fingerprint = fs::read_to_string(&fingerprint_file)
.map_err(|_| anyhow!("backup recipient fingerprint is not installed"))?
.trim()
.to_string();
if !valid_fingerprint(&fingerprint) {
bail!("invalid backup recipient fingerprint");
}
let ciphertext = tempfile::Builder::new()
.prefix(".outgoing-")
.suffix(".sqlite.gpg")
.tempfile_in(&offsite_home)
.context("create the outgoing ciphertext")?;
let ciphertext_path = ciphertext.path().to_path_buf();
let (Some(gnupg_text), Some(cipher_text)) =
(offsite_gnupg.to_str(), ciphertext_path.to_str())
else {
bail!("invalid offsite paths");
};
run_local(
Path::new("gpg"),
&[
"--batch",
"--yes",
"--homedir",
gnupg_text,
"--recipient",
&format!("{fingerprint}!"),
"--output",
cipher_text,
"--encrypt",
&snapshot,
],
"gpg --encrypt",
)?;
if fs::metadata(&ciphertext_path)
.map(|meta| meta.len())
.unwrap_or(0)
== 0
{
bail!("encrypted backup is empty");
}
if first_bytes_hex(&ciphertext_path, 16)? == "53514c69746520666f726d6174203300" {
bail!("backup was not encrypted");
}
let name = format!(
"workbench-{}-{:09}.sqlite.gpg",
compact_stamp(epoch_now()),
nanos_now()
);
let remote_tmp = ec2
.ssh(
r#"find /tmp -maxdepth 1 -user "$USER" -type f -name "omp-workbench-backup-*.gpg" -mmin +1440 -delete; mktemp /tmp/omp-workbench-backup-XXXXXXXX.gpg"#,
)?
.trim()
.to_string();
ec2.ship(&ciphertext_path, &remote_tmp)?;
let upload = ec2.ssh_stdin_streaming(
&format!(
"bash -s -- {} {} {retention}",
shell_quote(&remote_tmp),
shell_quote(&name)
),
WORKBENCH_UPLOAD.as_bytes(),
);
let _ = fs::remove_file(&ciphertext_path);
if upload.is_err() {
let _ = ec2.ssh(&format!("rm -f -- {}", shell_quote(&remote_tmp)));
upload?;
}
}
say("verifying off-machine Workbench backup and isolated restore");
if action != "run" {
run_local(
Path::new("systemctl"),
&[
"--user",
"is-enabled",
"--quiet",
"omp-workbench-offsite-backup.timer",
],
"systemctl --user is-enabled",
)?;
run_local(
Path::new("systemctl"),
&[
"--user",
"is-active",
"--quiet",
"omp-workbench-offsite-backup.timer",
],
"systemctl --user is-active",
)?;
let result = capture_local(
Path::new("systemctl"),
&[
"--user",
"show",
"omp-workbench-offsite-backup.service",
"-p",
"Result",
"--value",
],
"systemctl --user show",
)?;
if result.trim() != "success" {
bail!("omp-workbench-offsite-backup.service last result is not success");
}
}
ec2.ssh_stdin_streaming(
&format!("bash -s -- {retention}"),
WORKBENCH_VERIFY.as_bytes(),
)
}
struct MailOffsite {
home: PathBuf,
gnupg: PathBuf,
archives: PathBuf,
}
fn mail_offsite() -> Result<MailOffsite> {
let root = home()?;
let home_dir = match env::var_os("AKURAI_MAIL_OFFSITE_HOME") {
Some(value) if !value.is_empty() => PathBuf::from(value),
_ => root.join(".local/share/akurai-mail-offsite"),
};
let archives = match env::var_os("AKURAI_MAIL_ARCHIVE_DIR") {
Some(value) if !value.is_empty() => PathBuf::from(value),
_ => root.join("backups/akurai-mail"),
};
Ok(MailOffsite {
gnupg: home_dir.join("gnupg"),
home: home_dir,
archives,
})
}
fn file_sha256(path: &Path) -> Result<String> {
let mut file = File::open(path).with_context(|| format!("read {}", path.display()))?;
let mut hasher = Sha256::new();
let mut buffer = vec![0u8; 65_536];
loop {
let read = file.read(&mut buffer)?;
if read == 0 {
break;
}
hasher.update(&buffer[..read]);
}
Ok(hasher
.finalize()
.iter()
.map(|byte| format!("{byte:02x}"))
.collect())
}
/// Restore the newest archive Titan holds, so `verify` proves recoverability
/// from this machine alone instead of trusting the mail server's own report.
fn verify_mail_offsite(offsite: &MailOffsite, retention: u32) -> Result<()> {
let mut archives: Vec<(SystemTime, PathBuf)> = fs::read_dir(&offsite.archives)
.with_context(|| format!("read {}", offsite.archives.display()))?
.flatten()
.filter(|entry| {
let name = entry.file_name().to_string_lossy().into_owned();
name.starts_with("akurai-mail-") && name.ends_with(".tar.gz.gpg")
})
.filter_map(|entry| {
let modified = entry.metadata().ok()?.modified().ok()?;
Some((modified, entry.path()))
})
.collect();
if archives.is_empty() {
bail!(
"no encrypted mail archive in {}",
offsite.archives.display()
);
}
archives.sort_by_key(|entry| std::cmp::Reverse(entry.0));
let (modified, latest) = archives[0].clone();
if archives.len() > retention as usize {
bail!(
"{} holds {} archives, retention is {retention}",
offsite.archives.display(),
archives.len()
);
}
let age = SystemTime::now()
.duration_since(modified)
.map(|value| value.as_secs())
.unwrap_or(0);
if age >= 172_800 {
bail!("newest Titan mail archive is {age}s old");
}
let digest_file = PathBuf::from(format!("{}.sha256", latest.display()));
let recorded = fs::read_to_string(&digest_file)
.with_context(|| format!("read {}", digest_file.display()))?;
let recorded = recorded.split_whitespace().next().unwrap_or_default();
let actual = file_sha256(&latest)?;
if recorded != actual {
bail!("{} does not match its recorded sha256", latest.display());
}
let scratch = tempfile::Builder::new()
.prefix(".verify-")
.tempdir_in(&offsite.home)
.context("create the restore scratch directory")?;
let payload = scratch.path().join("payload.tar.gz");
let (Some(gnupg_text), Some(payload_text), Some(latest_text)) =
(offsite.gnupg.to_str(), payload.to_str(), latest.to_str())
else {
bail!("invalid offsite paths");
};
run_local(
Path::new("gpg"),
&[
"--batch",
"--yes",
"--homedir",
gnupg_text,
"--output",
payload_text,
"--decrypt",
latest_text,
],
"gpg --decrypt",
)?;
let listing = capture_local(Path::new("tar"), &["-tzf", payload_text], "tar --list")?;
for member in [
"./manifest.json",
"./vmail/",
"./etc/postfix/main.cf",
"./etc/dovecot/dovecot.conf",
] {
if !listing.lines().any(|line| line.starts_with(member)) {
bail!("the restored archive is missing {member}");
}
}
let manifest_text = capture_local(
Path::new("tar"),
&["-xzOf", payload_text, "./manifest.json"],
"tar --extract manifest",
)?;
let manifest: Value =
serde_json::from_str(&manifest_text).context("parse the archive manifest")?;
let messages = manifest["messages"].as_i64().unwrap_or(0);
let bytes = manifest["vmail_bytes"].as_i64().unwrap_or(0);
let accounts = manifest["accounts"].as_i64().unwrap_or(0);
if messages <= 0 || bytes <= 0 {
bail!("the restored archive reports no mail");
}
println!(
"offsite={} latest={} age_secs={age} archives={} accounts={accounts} messages={messages} \
vmail_bytes={bytes} sha256=ok recovery=verified",
offsite.archives.display(),
latest
.file_name()
.map(|name| name.to_string_lossy().into_owned())
.unwrap_or_default(),
archives.len()
);
Ok(())
}
/// Install, run, or verify the encrypted mail-server backup Titan holds.
pub fn mail_backup(ec2: &Ec2, action: Option<&str>, retention: Option<u32>) -> Result<()> {
let (action, retention) = backup_dispatch("mail", action, retention, 14, 365)?;
let offsite = mail_offsite()?;
let fingerprint_file = offsite.home.join("recipient.fingerprint");
if action == "install" {
say("creating the Titan-held mail backup identity");
make_private_dir(&offsite.home, 0o700)?;
make_private_dir(&offsite.gnupg, 0o700)?;
make_private_dir(&offsite.archives, 0o700)?;
let Some(gnupg_text) = offsite.gnupg.to_str() else {
bail!("invalid offsite gnupg path");
};
let has_secret = capture_quiet(
"gpg",
&[
"--batch",
"--homedir",
gnupg_text,
"--with-colons",
"--list-secret-keys",
MAIL_OFFSITE_RECIPIENT,
],
)
.is_some_and(|listing| listing.lines().any(|line| line.starts_with("sec:")));
if !has_secret {
// Unattended restore intentionally uses an operator-only keyring
// without a passphrase; a stored passphrase adds no boundary here.
run_local(
Path::new("gpg"),
&[
"--batch",
"--homedir",
gnupg_text,
"--passphrase",
"",
"--quick-generate-key",
MAIL_OFFSITE_RECIPIENT,
"rsa3072",
"encrypt",
"0",
],
"gpg --quick-generate-key",
)?;
}
let fingerprint = gpg_fingerprint(&offsite.gnupg, MAIL_OFFSITE_RECIPIENT)?;
if !valid_fingerprint(&fingerprint) {
bail!("invalid mail backup key fingerprint");
}
write_private(&fingerprint_file, &format!("{fingerprint}\n"))?;
let public_key = capture_local(
Path::new("gpg"),
&[
"--batch",
"--homedir",
gnupg_text,
"--armor",
"--export",
MAIL_OFFSITE_RECIPIENT,
],
"gpg --export",
)?;
if !public_key.contains("BEGIN PGP PUBLIC KEY BLOCK") {
bail!("failed to export the mail backup public key");
}
let key_file = offsite.home.join("recipient.asc");
write_private(&key_file, &format!("{}\n", public_key.trim_end()))?;
say("installing the mail backup service and daily timer");
let Some(archive_text) = offsite.archives.to_str() else {
bail!("invalid mail archive directory");
};
let remote_key = ec2
.ssh("mktemp /tmp/akurai-mail-recipient-XXXXXXXX.asc")?
.trim()
.to_string();
ec2.ship(&key_file, &remote_key)?;
let install = ec2.ssh_stdin_streaming(
&format!(
"bash -s -- {} {} {retention} {} {}",
shell_quote(&remote_key),
shell_quote(&fingerprint),
shell_quote(MAIL_OFFSITE_DEST),
shell_quote(archive_text)
),
MAIL_BACKUP_INSTALL.as_bytes(),
);
if install.is_err() {
let _ = ec2.ssh(&format!("rm -f -- {}", shell_quote(&remote_key)));
install?;
}
} else if action == "run" {
say("running an encrypted mail backup");
ec2.ssh_streaming("sudo systemctl start akurai-mail-backup.service")?;
}
say("verifying the mail server's backup timer and encryption");
ec2.ssh_stdin_streaming(
&format!("bash -s -- {retention}"),
MAIL_BACKUP_VERIFY.as_bytes(),
)?;
say("restoring the newest Titan copy to prove recoverability");
verify_mail_offsite(&offsite, retention)
}
fn valid_mail_host(value: &str) -> bool {
matches_class(value, |c| c.is_ascii_alphanumeric() || c == '.' || c == '-')
}
fn valid_mail_update_action(value: &str) -> bool {
matches!(value, "check" | "apply" | "reboot" | "postverify")
}
/// Audit the mail server's security posture. Read-only; exits non-zero on findings.
pub fn mail_audit(ec2: &Ec2, host: Option<&str>, warn_days: Option<u32>) -> Result<()> {
let host = host.unwrap_or(MAIL_HOST);
if !valid_mail_host(host) {
bail!("mail audit host must be a hostname");
}
let warn_days = warn_days.unwrap_or(21);
if !(1..=90).contains(&warn_days) {
bail!("mail audit warn days must be an integer from 1 through 90");
}
say(&format!("auditing mail server security posture for {host}"));
ec2.ssh_stdin_streaming(
&format!("bash -s -- {} {warn_days}", shell_quote(host)),
MAIL_AUDIT.as_bytes(),
)
}
/// Report, install, or reboot into mail-server package updates.
pub fn mail_update(ec2: &Ec2, action: Option<&str>) -> Result<()> {
let action = action.unwrap_or("check");
if !valid_mail_update_action(action) {
bail!("mail update action must be check, apply, reboot, or postverify");
}
match action {
"check" | "apply" | "postverify" => {
say(&format!("mail server package update: {action}"));
ec2.ssh_stdin_streaming(&format!("bash -s -- {action}"), MAIL_UPDATE.as_bytes())
}
"reboot" => {
say("rebooting the mail server");
let _ = ec2.ssh_streaming_status("sudo systemctl reboot");
std::thread::sleep(Duration::from_secs(20));
let deadline = Instant::now() + Duration::from_secs(300);
while ec2.ssh("true").is_err() {
if Instant::now() >= deadline {
bail!("the mail server did not answer within 300s of the reboot");
}
std::thread::sleep(Duration::from_secs(10));
}
say("verifying mail services after the reboot");
ec2.ssh_stdin_streaming("bash -s -- postverify", MAIL_UPDATE.as_bytes())
}
_ => unreachable!("validated above"),
}
}
fn valid_monitor_recipient(value: &str) -> bool {
let Some((local, domain)) = value.split_once('@') else {
return false;
};
matches_class(local, |c| {
c.is_ascii_alphanumeric() || ".!#$%&'*+/=?^_`{|}~-".contains(c)
}) && matches_class(domain, |c| {
c.is_ascii_alphanumeric() || c == '.' || c == '-'
})
}
/// Install, run, or verify Bunfork HTTPS readiness and TLS-expiry monitoring.
pub fn bunfork_monitor(
ec2: &Ec2,
action: Option<&str>,
domain: Option<&str>,
warn_days: Option<u32>,
recipient: Option<&str>,
) -> Result<()> {
let action = action.unwrap_or("verify");
let domain = domain.unwrap_or("bunfork.olibuijr.com");
let warn_days = warn_days.unwrap_or(30);
let recipient = recipient.unwrap_or("olibuijr@olibuijr.com");
if !matches!(action, "install" | "run" | "verify") {
bail!("monitor action must be install, run, or verify");
}
if !matches_class(domain, |c| {
c.is_ascii_alphanumeric() || c == '.' || c == '-'
}) {
bail!("invalid domain: {domain}");
}
if !(1..=3650).contains(&warn_days) {
bail!("TLS warning threshold must be from 1 through 3650 days");
}
if !valid_monitor_recipient(recipient) {
bail!("invalid alert recipient");
}
if action == "install" {
say("installing Bunfork readiness and TLS monitor");
ec2.ssh_stdin_streaming(
&format!("bash -s -- '{domain}' '{warn_days}' '{recipient}'"),
MONITOR_INSTALL.as_bytes(),
)?;
} else if action == "run" {
say("running Bunfork production monitor");
ec2.ssh_streaming("sudo systemctl start bunfork-monitor.service")?;
}
say("verifying Bunfork production monitor");
ec2.ssh_stdin_streaming(
&format!("bash -s -- '{domain}' '{warn_days}'"),
MONITOR_VERIFY.as_bytes(),
)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn secret_character_class_matches_the_shell_contract() {
let allowed = |value: &str| {
matches_class(value, |c| {
c.is_ascii_alphanumeric() || "._~!@%^*+=:,/-".contains(c)
})
};
assert!(allowed("abc123._~!@%^*+=:,/-"));
assert!(!allowed(""));
assert!(!allowed("has space"));
assert!(!allowed("dollar$sign"));
assert!(!allowed("back\\slash"));
}
#[test]
fn operation_id_is_stable_and_prefixed() {
let request = json!({"organization": "acme", "workspace": "core"});
let first = operation_id("provision", "key-1", &request);
let second = operation_id("provision", "key-1", &request);
assert_eq!(first, second);
assert!(first.starts_with("wop_"));
assert_eq!(first.len(), 28);
assert_ne!(first, operation_id("upgrade", "key-1", &request));
}
#[test]
fn iso_round_trips_through_the_journal_format() {
let stamp = iso_utc(1_767_225_600);
assert_eq!(stamp, "2026-01-01T00:00:00Z");
assert_eq!(parse_iso(&stamp).expect("parses"), 1_767_225_600);
assert_eq!(compact_stamp(1_767_225_600), "20260101T000000");
}
#[test]
fn json_is_ascii_escaped_like_python() {
assert_eq!(compact_json(&json!({"a": "é"})), r#"{"a":"\u00e9"}"#);
}
#[test]
fn domain_validation_requires_a_fully_qualified_name() {
assert_eq!(
validate_workspace_domain("Example.COM.").expect("valid"),
"example.com"
);
assert!(validate_workspace_domain("localhost").is_err());
assert!(validate_workspace_domain("a..b.com").is_err());
}
#[test]
fn top_level_toml_keys_drop_comments_and_quotes() {
let keys = top_level_keys(
"# comment\nname = \"platform\"\nport = 8094 # inline\ndomain = \"a.example\"\n[section]\nport = 1\n",
);
assert_eq!(
keys.get("name").map(|(v, q)| (v.as_str(), *q)),
Some(("platform", true))
);
assert_eq!(
keys.get("port").map(|(v, q)| (v.as_str(), *q)),
Some(("8094", false))
);
assert_eq!(
keys.get("domain").map(|(v, _)| v.as_str()),
Some("a.example")
);
}
#[test]
fn retire_app_validators_match_the_python_patterns() {
assert!(valid_app_name("akurai-tasks"));
assert!(!valid_app_name("-leading"));
assert!(!valid_app_name("Upper"));
assert!(valid_retire_domain("tasks.olibuijr.com"));
assert!(!valid_retire_domain("-bad.example"));
}
#[test]
fn idempotency_keys_are_url_safe_and_bounded() {
assert!(valid_idempotency_key("Key.1:2_3-4"));
assert!(!valid_idempotency_key(""));
assert!(!valid_idempotency_key("-leading"));
assert!(!valid_idempotency_key(&"a".repeat(201)));
}
#[test]
fn mail_validators_bound_the_operator_surface() {
assert!(valid_mail_host("mail.olibuijr.com"));
assert!(!valid_mail_host("mail olibuijr.com"));
assert!(!valid_mail_host(""));
for action in ["check", "apply", "reboot", "postverify"] {
assert!(valid_mail_update_action(action));
}
assert!(!valid_mail_update_action("upgrade"));
let (action, retention) = backup_dispatch("mail", None, None, 14, 365).expect("defaults");
assert_eq!((action.as_str(), retention), ("verify", 14));
assert!(backup_dispatch("mail", Some("install"), Some(1), 14, 365).is_err());
assert!(backup_dispatch("mail", Some("install"), Some(366), 14, 365).is_err());
}
#[test]
fn offsite_restore_refuses_an_empty_archive_directory() {
let scratch = tempfile::Builder::new()
.prefix("akurai-mail-offsite-")
.tempdir()
.expect("scratch");
let offsite = MailOffsite {
home: scratch.path().to_path_buf(),
gnupg: scratch.path().join("gnupg"),
archives: scratch.path().to_path_buf(),
};
let error = verify_mail_offsite(&offsite, 14).expect_err("no archive");
assert!(error.to_string().contains("no encrypted mail archive"));
}
}