AkurAI Build
Menu

AkurAI-Build

public

Latest change b2fb6810b8844f39a9752f65118c05ffecf28160 - Fold AkurAI EC2 operations into Build CLI by Ólafur Búi Ólafsson

use crate::ec2::Ec2;
use anyhow::{Context, Result, bail};
use serde::Serialize;
use serde_json::{Value, json};
use std::env;
use std::process::Command;

const BASE: &str = "https://1984.hosting";

#[derive(Clone, Serialize)]
struct Record {
    id: String,
    fqdn: String,
    #[serde(rename = "type")]
    rtype: String,
    ttl: Option<u32>,
    value: String,
}

#[derive(Clone, Copy, PartialEq)]
enum Provider {
    Ec2,
    Legacy,
}

struct Options {
    action: String,
    name: String,
    zone: Option<String>,
    rtype: Option<String>,
    value: Option<String>,
    ttl: u32,
    yes: bool,
    absent: bool,
    provider: Provider,
}

pub fn run(ec2: &Ec2, args: &[String]) -> Result<()> {
    match inner(ec2, args) {
        Ok(()) => Ok(()),
        Err(error) => {
            eprintln!("{}", json!({"ok": false, "error": error.to_string()}));
            Err(error)
        }
    }
}

fn inner(ec2: &Ec2, args: &[String]) -> Result<()> {
    let options = parse_args(args)?;
    let fqdn = options.name.trim_end_matches('.');
    if options.action == "verify" {
        let rtype = options.rtype.as_deref().unwrap_or("A").to_ascii_uppercase();
        let values = public_values(fqdn, &rtype)?;
        let ok = if options.absent {
            values.is_empty()
        } else {
            !values.is_empty()
        };
        println!(
            "{}",
            json!({"name": fqdn, "type": rtype, "values": values,
            "state": if values.is_empty() { "absent" } else { "present" }, "ok": ok})
        );
        if !ok {
            bail!("public DNS state did not match expectation")
        }
        return Ok(());
    }
    if matches!(options.action.as_str(), "delete" | "upsert") && !options.yes {
        bail!("{} requires --yes", options.action);
    }
    let zone = infer_zone(fqdn, options.zone.as_deref())?;
    let mut provider = DnsProvider::new(ec2, options.provider)?;
    let records = provider.records(&zone)?;
    let rtype = options.rtype.as_ref().map(|s| s.to_ascii_uppercase());
    let mut selected: Vec<Record> = records
        .into_iter()
        .filter(|record| {
            (options.action == "list" || record.fqdn == fqdn)
                && rtype.as_ref().is_none_or(|kind| record.rtype == *kind)
        })
        .collect();
    match options.action.as_str() {
        "list" | "get" => println!(
            "{}",
            json!({"provider": provider.name(), "zone": zone, "records": selected})
        ),
        "delete" => {
            if let Some(value) = options.value.as_ref() {
                selected.retain(|record| record.value.contains(value));
            }
            if selected.len() != 1 {
                bail!(
                    "refusing deletion: expected exactly one matching record, found {}",
                    selected.len()
                );
            }
            let deleted = selected.remove(0);
            provider.delete(&zone, &deleted.id)?;
            let remaining = provider
                .records(&zone)?
                .into_iter()
                .any(|record| record.fqdn == deleted.fqdn && record.rtype == deleted.rtype);
            if remaining {
                bail!("matching record still present after deletion")
            }
            println!("{}", json!({"deleted": deleted, "ok": true}));
        }
        "upsert" => {
            if provider.is_ec2() {
                selected.reverse();
            }
            let replaced = selected.len();
            for record in selected {
                provider.delete(&zone, &record.id)?;
            }
            let kind = rtype.context("upsert requires type")?;
            let value = options.value.as_deref().context("upsert requires value")?;
            provider.add(&zone, fqdn, &kind, value, options.ttl)?;
            println!(
                "{}",
                json!({"name": fqdn, "type": kind, "value": value,
                "ttl": options.ttl, "replaced": replaced, "ok": true})
            );
        }
        _ => bail!("unsupported DNS action"),
    }
    Ok(())
}

fn parse_args(args: &[String]) -> Result<Options> {
    let action = args.first().context("DNS action is required")?.clone();
    if !matches!(
        action.as_str(),
        "list" | "get" | "upsert" | "delete" | "verify"
    ) {
        bail!("unsupported DNS action: {action}");
    }
    let mut positional = Vec::new();
    let mut zone = None;
    let mut rtype = None;
    let mut value_opt = None;
    let mut ttl = 900;
    let mut yes = false;
    let mut absent = false;
    let mut provider = match env::var("AKURAI_DNS_PROVIDER")
        .unwrap_or_else(|_| "ec2".into())
        .as_str()
    {
        "ec2" => Provider::Ec2,
        "1984" => Provider::Legacy,
        other => bail!("invalid provider: {other}"),
    };
    let mut index = 1;
    while index < args.len() {
        match args[index].as_str() {
            "--zone" | "--type" | "--value" | "--ttl" | "--provider" => {
                let flag = args[index].as_str();
                index += 1;
                let value = args
                    .get(index)
                    .with_context(|| format!("{flag} requires a value"))?
                    .clone();
                match flag {
                    "--zone" => zone = Some(value),
                    "--type" => rtype = Some(value),
                    "--value" => value_opt = Some(value),
                    "--ttl" => ttl = value.parse().context("--ttl must be an integer")?,
                    "--provider" => {
                        provider = match value.as_str() {
                            "ec2" => Provider::Ec2,
                            "1984" => Provider::Legacy,
                            _ => bail!("invalid provider: {value}"),
                        }
                    }
                    _ => unreachable!(),
                }
            }
            "--yes" => yes = true,
            "--absent" => absent = true,
            value if value.starts_with('-') => bail!("unrecognized argument: {value}"),
            value => positional.push(value.to_string()),
        }
        index += 1;
    }
    let expected = if action == "upsert" { 3 } else { 1 };
    if positional.len() != expected {
        bail!("{action} expects {expected} positional argument(s)")
    }
    let name = positional[0].clone();
    if action == "upsert" {
        rtype = Some(positional[1].clone());
        value_opt = Some(positional[2].clone());
    }
    if action == "delete" && rtype.is_none() {
        bail!("delete requires --type")
    }
    if action == "verify" && rtype.is_none() {
        rtype = Some("A".into());
    }
    Ok(Options {
        action,
        name,
        zone,
        rtype,
        value: value_opt,
        ttl,
        yes,
        absent,
        provider,
    })
}

fn infer_zone(fqdn: &str, explicit: Option<&str>) -> Result<String> {
    if let Some(zone) = explicit {
        return Ok(zone.trim_end_matches('.').to_string());
    }
    let labels: Vec<_> = fqdn.trim_end_matches('.').split('.').collect();
    if labels.len() < 2 {
        bail!("a fully qualified domain name is required")
    }
    Ok(labels[labels.len() - 2..].join("."))
}

fn public_values(fqdn: &str, rtype: &str) -> Result<Vec<String>> {
    let output = Command::new("dig")
        .args(["+short", fqdn, rtype])
        .output()
        .context("public DNS verification failed: dig is unavailable")?;
    if !output.status.success() {
        bail!(
            "public DNS verification failed: {}",
            String::from_utf8_lossy(&output.stderr).trim()
        );
    }
    Ok(String::from_utf8_lossy(&output.stdout)
        .lines()
        .map(|s| s.trim_matches('"').to_string())
        .collect())
}

enum DnsProvider<'a> {
    Ec2(Ec2Client<'a>),
    Legacy(LegacyClient),
}
impl<'a> DnsProvider<'a> {
    fn new(ec2: &'a Ec2, provider: Provider) -> Result<Self> {
        Ok(match provider {
            Provider::Ec2 => Self::Ec2(Ec2Client(ec2)),
            Provider::Legacy => {
                let mut client = LegacyClient::default();
                client.login()?;
                Self::Legacy(client)
            }
        })
    }
    fn name(&self) -> &'static str {
        if self.is_ec2() { "ec2" } else { "1984" }
    }
    fn is_ec2(&self) -> bool {
        matches!(self, Self::Ec2(_))
    }
    fn records(&mut self, zone: &str) -> Result<Vec<Record>> {
        match self {
            Self::Ec2(c) => c.records(zone),
            Self::Legacy(c) => c.records(zone),
        }
    }
    fn delete(&mut self, zone: &str, id: &str) -> Result<()> {
        match self {
            Self::Ec2(c) => c.delete(zone, id),
            Self::Legacy(c) => c.delete(zone, id),
        }
    }
    fn add(&mut self, zone: &str, fqdn: &str, kind: &str, value: &str, ttl: u32) -> Result<()> {
        match self {
            Self::Ec2(c) => c.add(zone, fqdn, kind, value, ttl),
            Self::Legacy(c) => c.add(zone, fqdn, kind, value, ttl),
        }
    }
}

struct Ec2Client<'a>(&'a Ec2);
impl Ec2Client<'_> {
    fn path(zone: &str) -> Result<String> {
        if zone.is_empty()
            || !zone
                .bytes()
                .all(|c| c.is_ascii_alphanumeric() || matches!(c, b'.' | b'-'))
        {
            bail!("invalid zone: {zone}")
        }
        Ok(format!("/etc/akurai-dns/zones/{zone}.toml"))
    }
    fn load(&self, zone: &str) -> Result<String> {
        self.0.ssh(&format!("sudo cat {}", Self::path(zone)?))
    }
    fn records(&self, zone: &str) -> Result<Vec<Record>> {
        parse_zone(zone, &self.load(zone)?)
    }
    fn save(&self, zone: &str, text: &str) -> Result<()> {
        parse_zone(zone, text)
            .with_context(|| format!("refusing to save invalid EC2 zone file for {zone}"))?;
        let path = Self::path(zone)?;
        let remote = format!(
            "set -euo pipefail; tmp=$(mktemp); backup={path}.bak-$(date -u +%Y%m%d-%H%M%S); cat >$tmp; sudo cp {path} $backup; sudo install -m 0644 $tmp {path}; rm -f $tmp; if ! sudo systemctl reload akurai-dns.service || ! systemctl is-active --quiet akurai-dns.service; then sudo cp $backup {path}; sudo systemctl restart akurai-dns.service; exit 1; fi"
        );
        self.0.ssh_stdin(&remote, text.as_bytes())?;
        Ok(())
    }
    fn delete(&self, zone: &str, id: &str) -> Result<()> {
        let text = self.load(zone)?;
        let blocks = record_blocks(&text);
        let index: usize = id.parse().context("invalid EC2 DNS record index")?;
        let (start, end) = *blocks
            .get(index)
            .with_context(|| format!("EC2 DNS record index no longer exists: {id}"))?;
        let updated = format!("{}{}", &text[..start], &text[end..]);
        self.save(
            zone,
            &(bump_serial(&updated)?.trim_end().to_string() + "\n"),
        )
    }
    fn add(&self, zone: &str, fqdn: &str, kind: &str, value: &str, ttl: u32) -> Result<()> {
        let text = self.load(zone)?;
        let zone_ttl = zone_ttl(&text)?;
        let host = if fqdn.trim_end_matches('.') == zone {
            "@"
        } else {
            fqdn.strip_suffix(&format!(".{zone}")).unwrap_or(fqdn)
        };
        let mut lines = vec![
            "[[record]]".to_string(),
            format!("name = {}", json!(host)),
            format!("type = {}", json!(kind)),
        ];
        if ttl != zone_ttl {
            lines.push(format!("ttl = {ttl}"));
        }
        lines.push(format!("value = {}", json!(value)));
        let updated = format!(
            "{}\n\n{}\n",
            bump_serial(&text)?.trim_end(),
            lines.join("\n")
        );
        self.save(zone, &updated)
    }
}

fn record_blocks(text: &str) -> Vec<(usize, usize)> {
    let starts: Vec<_> = text
        .match_indices("[[record]]\n")
        .filter(|(i, _)| *i == 0 || text.as_bytes()[i - 1] == b'\n')
        .map(|(i, _)| i)
        .collect();
    starts
        .iter()
        .enumerate()
        .map(|(i, start)| (*start, starts.get(i + 1).copied().unwrap_or(text.len())))
        .collect()
}
fn zone_ttl(text: &str) -> Result<u32> {
    let section = section(text, "[zone]").unwrap_or("");
    Ok(field(section, "ttl")
        .map(|v| v.parse())
        .transpose()
        .context("invalid zone ttl")?
        .unwrap_or(900))
}
fn parse_zone(zone: &str, text: &str) -> Result<Vec<Record>> {
    if section(text, "[zone]").is_none() {
        bail!("invalid EC2 zone file for {zone}: missing [zone]")
    }
    let default_ttl = zone_ttl(text)?;
    record_blocks(text)
        .into_iter()
        .enumerate()
        .map(|(index, (start, end))| {
            let block = &text[start..end];
            let host = unquote(field(block, "name").unwrap_or("\"@\""))?;
            let kind = unquote(field(block, "type").unwrap_or("\"\""))?.to_ascii_uppercase();
            let value = unquote(field(block, "value").unwrap_or("\"\""))?;
            let ttl = field(block, "ttl")
                .map(|v| v.parse())
                .transpose()
                .context("invalid record ttl")?
                .unwrap_or(default_ttl);
            let fqdn = if host.trim_end_matches('.') == "@" {
                zone.into()
            } else if host.trim_end_matches('.').ends_with(zone) {
                host.trim_end_matches('.').into()
            } else {
                format!("{}.{zone}", host.trim_end_matches('.'))
            };
            Ok(Record {
                id: index.to_string(),
                fqdn,
                rtype: kind,
                ttl: Some(ttl),
                value,
            })
        })
        .collect()
}
fn section<'a>(text: &'a str, heading: &str) -> Option<&'a str> {
    let start = text.find(heading)? + heading.len();
    let rest = &text[start..];
    let end = rest.find("\n[").unwrap_or(rest.len());
    Some(&rest[..end])
}
fn field<'a>(text: &'a str, name: &str) -> Option<&'a str> {
    text.lines().find_map(|line| {
        let line = line.trim();
        let (key, value) = line.split_once('=')?;
        (key.trim() == name).then(|| value.trim())
    })
}
fn unquote(value: &str) -> Result<String> {
    serde_json::from_str(value).with_context(|| format!("invalid TOML string: {value}"))
}
fn bump_serial(text: &str) -> Result<String> {
    let marker = "serial";
    let line_start = text
        .lines()
        .scan(0, |offset, line| {
            let start = *offset;
            *offset += line.len() + 1;
            Some((start, line))
        })
        .find(|(_, line)| line.trim_start().starts_with(marker))
        .context("zone SOA serial is missing")?;
    let eq = line_start
        .1
        .find('=')
        .context("zone SOA serial is missing")?;
    let value = line_start.1[eq + 1..].trim();
    let old: u64 = value.parse().context("invalid zone SOA serial")?;
    let date = Command::new("date")
        .args(["-u", "+%Y%m%d01"])
        .output()
        .context("date command unavailable")?;
    if !date.status.success() {
        bail!("date command failed")
    }
    let today: u64 = String::from_utf8_lossy(&date.stdout)
        .trim()
        .parse()
        .context("invalid date output")?;
    let new = old.saturating_add(1).max(today);
    let value_start = line_start.0 + eq + 1 + line_start.1[eq + 1..].len()
        - line_start.1[eq + 1..].trim_start().len();
    Ok(format!(
        "{}{}{}",
        &text[..value_start],
        new,
        &text[value_start + value.len()..]
    ))
}

#[derive(Default)]
struct LegacyClient {
    cookies: String,
    csrf: String,
}
impl LegacyClient {
    fn request(
        &self,
        path: &str,
        data: Option<&[(&str, &str)]>,
        referer: Option<&str>,
    ) -> Result<String> {
        let mut command = Command::new("curl");
        command.args([
            "--fail",
            "--silent",
            "--show-error",
            "--max-time",
            "20",
            "-A",
            "akurai-ec2-dns/1",
        ]);
        if !self.cookies.is_empty() {
            command.args(["-b", &self.cookies]);
        }
        if !self.csrf.is_empty() {
            command.args(["-H", &format!("X-CSRFToken: {}", self.csrf)]);
        }
        if let Some(referer) = referer {
            command.args(["-e", referer]);
        }
        if let Some(data) = data {
            for (key, value) in data {
                command.args(["--data-urlencode", &format!("{key}={value}")]);
            }
        }
        command.arg(format!("{BASE}{path}"));
        let output = command
            .output()
            .context("1984 Hosting request failed: curl unavailable")?;
        if !output.status.success() {
            bail!(
                "1984 Hosting request failed: {}",
                String::from_utf8_lossy(&output.stderr).trim()
            )
        }
        Ok(String::from_utf8_lossy(&output.stdout).into_owned())
    }
    fn login(&mut self) -> Result<()> {
        let session = env::var("One984HOSTING_SESSIONID_COOKIE").unwrap_or_default();
        let csrf = env::var("One984HOSTING_CSRFTOKEN_COOKIE").unwrap_or_default();
        if !session.is_empty() && !csrf.is_empty() {
            self.cookies = format!("{session}; {csrf}");
            self.csrf = csrf.split_once('=').map(|x| x.1).unwrap_or("").into();
            let auth: Value = serde_json::from_str(&self.request("/api/auth/", None, None)?)?;
            if auth["ok"].as_bool() == Some(true) {
                return Ok(());
            }
            bail!("cached 1984 Hosting session is invalid");
        }
        let username = env::var("One984HOSTING_Username").unwrap_or_default();
        let password = env::var("One984HOSTING_Password").unwrap_or_default();
        if username.is_empty() || password.is_empty() {
            bail!(
                "1984 Hosting credentials unavailable; inject PassVault values as One984HOSTING_Username and One984HOSTING_Password"
            )
        }
        let secret = env::var("One984HOSTING_TOTP_Secret").unwrap_or_default();
        let otp = if secret.is_empty() {
            String::new()
        } else {
            let out = Command::new("oathtool")
                .args(["--base32", "--totp", &secret])
                .output()
                .context("oathtool is required for the configured 1984 Hosting TOTP")?;
            if !out.status.success() {
                bail!("oathtool is required for the configured 1984 Hosting TOTP")
            }
            String::from_utf8_lossy(&out.stdout).trim().into()
        };
        bail!(
            "1984 Hosting password login requires session cookies; set One984HOSTING_SESSIONID_COOKIE and One984HOSTING_CSRFTOKEN_COOKIE (username {username}, otp length {})",
            otp.len()
        )
    }
    fn zone_id(&self, zone: &str) -> Result<String> {
        let page = self.request("/domains", None, None)?;
        let needle = zone.to_ascii_lowercase();
        for (i, _) in page.match_indices("zone/") {
            let digits: String = page[i + 5..]
                .chars()
                .take_while(|c| c.is_ascii_digit())
                .collect();
            if !digits.is_empty()
                && page[i..page.len().min(i + 805)]
                    .to_ascii_lowercase()
                    .contains(&needle)
            {
                return Ok(format!("zone/{digits}"));
            }
        }
        bail!("managed DNS zone not found: {zone}")
    }
    fn records(&self, zone: &str) -> Result<Vec<Record>> {
        let zid = self.zone_id(zone)?;
        let page = self.request(&format!("/domains/{zid}"), None, None)?;
        let mut out = Vec::new();
        for row in page
            .split("<tr")
            .skip(1)
            .filter_map(|s| s.split_once("</tr>").map(|x| x.0))
        {
            let Some(pos) = row.find("entry_") else {
                continue;
            };
            let id: String = row[pos + 6..]
                .chars()
                .take_while(|c| c.is_ascii_digit())
                .collect();
            if id.is_empty() {
                continue;
            }
            let cells: Vec<String> = row
                .split("<td")
                .skip(1)
                .filter_map(|s| {
                    s.split_once('>')
                        .and_then(|x| x.1.split_once("</td>"))
                        .map(|x| normalize_html(x.0))
                })
                .collect();
            let joined = cells.join(" | ");
            let kind = ["AAAA", "CAA", "CNAME", "MX", "NS", "SRV", "TXT", "A"]
                .into_iter()
                .find(|k| {
                    joined
                        .split(|c: char| !c.is_ascii_alphanumeric())
                        .any(|w| w.eq_ignore_ascii_case(k))
                });
            let Some(kind) = kind else { continue };
            let ttl = joined
                .split(|c: char| !c.is_ascii_digit())
                .find(|s| (2..=6).contains(&s.len()))
                .and_then(|s| s.parse().ok());
            let host = cells
                .iter()
                .find(|cell| {
                    cell.as_str() == "@"
                        || cell.ends_with(zone)
                        || (cell
                            .chars()
                            .all(|c| c.is_ascii_alphanumeric() || "_*.-".contains(c))
                            && !cell.eq_ignore_ascii_case(kind))
                })
                .map(String::as_str)
                .unwrap_or("@");
            let fqdn = if host == "@" {
                zone.into()
            } else if host.ends_with(zone) {
                host.trim_end_matches('.').into()
            } else {
                format!("{host}.{zone}")
            };
            out.push(Record {
                id,
                fqdn,
                rtype: kind.into(),
                ttl,
                value: cells.last().cloned().unwrap_or(joined),
            });
        }
        Ok(out)
    }
    fn add(&self, zone: &str, fqdn: &str, kind: &str, value: &str, ttl: u32) -> Result<()> {
        self.zone_id(zone)?;
        let host = if fqdn.trim_end_matches('.') == zone {
            "@"
        } else {
            fqdn.strip_suffix(&format!(".{zone}")).unwrap_or(fqdn)
        };
        let rdata = if kind == "TXT" {
            format!("\"{value}\"")
        } else {
            value.into()
        };
        let result: Value = serde_json::from_str(&self.request(
            "/domains/entry/",
            Some(&[
                ("entry", "new"),
                ("type", kind),
                ("ttl", &ttl.to_string()),
                ("zone", zone),
                ("host", host),
                ("rdata", &rdata),
            ]),
            Some(&format!("{BASE}/domains")),
        )?)?;
        if result["haserrors"].as_bool() == Some(true) || result["auth"].as_bool() == Some(false) {
            bail!("1984 Hosting rejected {kind} record for {fqdn}")
        }
        Ok(())
    }
    fn delete(&self, zone: &str, id: &str) -> Result<()> {
        let zid = self.zone_id(zone)?;
        let result: Value = serde_json::from_str(&self.request(
            "/domains/delentry/",
            Some(&[("entry", id)]),
            Some(&format!("{BASE}/domains/{zid}")),
        )?)?;
        if result["ok"].as_bool() != Some(true) {
            bail!("1984 Hosting failed to delete entry {id}")
        }
        Ok(())
    }
}
fn normalize_html(value: &str) -> String {
    let mut out = String::new();
    let mut tag = false;
    for c in value.chars() {
        match c {
            '<' => tag = true,
            '>' => {
                tag = false;
                out.push(' ')
            }
            _ if !tag => out.push(c),
            _ => {}
        }
    }
    out.replace("&quot;", "\"")
        .replace("&amp;", "&")
        .replace("&lt;", "<")
        .replace("&gt;", ">")
        .replace("&#39;", "'")
        .split_whitespace()
        .collect::<Vec<_>>()
        .join(" ")
}