Menu
AkurAI-Build
publicLatest 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(""", "\"")
.replace("&", "&")
.replace("<", "<")
.replace(">", ">")
.replace("'", "'")
.split_whitespace()
.collect::<Vec<_>>()
.join(" ")
}