~ knotless Search/
↑↓ move↵ open⇧↵ all results

johnandersen777.bsky.social/atlibs-marque

0
0
main
386 lines · 18.0 KB Raw History
// marque: the DNS zones of the domains an account registered through Marque (marque.at).
//
// A domain is an at.marque.domain record in the account's own repo, keyed by the FQDN. Its zone is ONE
// at.marque.dns record with the same key: { domain, subject (strongRef to the domain record), records,
// createdAt }. Marque's nameserver serves what that public record says, so an edit is a repo write:
// read both records, change the zone's records list, put it back with swapRecord (a concurrent edit
// fails instead of being lost).
//
// Entries (step protocol):
//   list_domains  -> listRecords at.marque.domain (paged)      -> the account's domains
//   list_records  -> getRecord domain + zone                   -> the zone's records
//   create_record -> getRecord domain + zone -> put/createRecord -> the zone with one record added
//   update_record -> getRecord domain + zone -> putRecord        -> one record changed
//   delete_record -> getRecord domain + zone -> putRecord        -> one record removed

use std::net::{Ipv4Addr, Ipv6Addr};

use abi::repo::{self, CREATE_RECORD, PUT_RECORD, get_record};
use abi::{AtUri, Call, Datetime, Did, Event, Handle, Run, entries};
use serde::Deserialize;
use serde_json::{Value, json};

const DOMAIN: &str = "at.marque.domain";
const DNS: &str = "at.marque.dns";
const PAGE_LIMIT: usize = 100;
const TYPES: [&str; 8] = ["A", "AAAA", "CNAME", "MX", "TXT", "SRV", "NS", "CAA"];
const DEFAULT_TTL: i64 = 3600;

#[derive(Deserialize)]
struct Args {
    did: Did,
    now: String,
    #[serde(default)]
    domain: Option<String>,
    #[serde(default)]
    name: Option<String>,
    #[serde(default, rename = "type")]
    kind: Option<String>,
    #[serde(default)]
    value: Option<String>,
    #[serde(default)]
    ttl: Option<Value>,
    #[serde(default)]
    priority: Option<Value>,
    #[serde(default, rename = "newName")]
    new_name: Option<String>,
    #[serde(default, rename = "newValue")]
    new_value: Option<String>,
}

#[derive(Clone, Copy, PartialEq)]
enum Action {
    List,
    Create,
    Update,
    Delete,
}

// --- validation -------------------------------------------------------------------------------

fn label_ok(label: &str) -> bool {
    (1..=63).contains(&label.len())
        && label.chars().all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-' || c == '_')
        && !label.starts_with('-')
        && !label.ends_with('-')
}

// A hostname: lowercase, one trailing dot dropped (Marque stores names without it).
fn host(value: &str, what: &str) -> Result<String, String> {
    let host = value.trim().to_ascii_lowercase();
    let host = host.strip_suffix('.').unwrap_or(&host).to_string();
    if host.is_empty() || host.len() > 253 || !host.split('.').all(label_ok) {
        return Err(format!("{what} must be a hostname like mail.example.com, got {value:?}"));
    }
    Ok(host)
}

fn target(value: &str, what: &str) -> Result<String, String> {
    if value.trim() == "." { Ok(".".into()) } else { host(value, what) }
}

fn domain_arg(domain: Option<&str>) -> Result<String, String> {
    let domain = host(domain.unwrap_or_default(), "domain")?;
    if !domain.contains('.') {
        return Err("domain must be a registered domain like example.com".into());
    }
    Ok(domain)
}

// A name relative to the zone: "@" for the apex, else labels ("www", "_atproto", "*.app").
fn record_name(name: &str, domain: &str, kind: &str) -> Result<String, String> {
    let name = name.trim().to_ascii_lowercase();
    if name == "@" {
        if kind == "NS" {
            return Err("NS records at @ are the domain's nameservers: change those in Marque's domain settings".into());
        }
        return Ok(name);
    }
    let labels: Vec<&str> = name.split('.').collect();
    let wildcard = labels[0] == "*";
    if name.is_empty() || name.ends_with('.') || !labels.iter().skip(usize::from(wildcard)).all(|l| label_ok(l)) {
        return Err(format!("name must be @ or a name relative to {domain}, like www, _atproto or *.app (no trailing dot), got {name:?}"));
    }
    if name.len() + 1 + domain.len() > 253 {
        return Err(format!("{name}.{domain} is longer than 253 characters"));
    }
    if kind == "SRV" && !(labels.len() >= 2 && labels[0].starts_with('_') && labels[1].starts_with('_')) {
        return Err("SRV name must start with _service._proto, like _sip._tcp".into());
    }
    Ok(name)
}

fn number(value: &str, what: &str, max: u32) -> Result<u32, String> {
    value.parse::<u32>().ok().filter(|n| *n <= max).ok_or_else(|| format!("{what} must be a number from 0 to {max}, got {value:?}"))
}

fn printable(value: &str, what: &str) -> Result<(), String> {
    if value.chars().any(char::is_control) { Err(format!("{what} must not contain control characters")) } else { Ok(()) }
}

// The value as Marque stores it, per type.
fn record_value(kind: &str, value: &str) -> Result<String, String> {
    let trimmed = value.trim();
    match kind {
        "A" => trimmed.parse::<Ipv4Addr>().map(|ip| ip.to_string()).map_err(|_| format!("A value must be an IPv4 address, got {value:?}")),
        "AAAA" => trimmed.parse::<Ipv6Addr>().map(|ip| ip.to_string()).map_err(|_| format!("AAAA value must be an IPv6 address, got {value:?}")),
        "CNAME" | "NS" => host(value, &format!("{kind} value")),
        "MX" => target(value, "MX value"),
        "TXT" => {
            printable(value, "TXT value")?;
            if value.is_empty() || value.len() > 4096 {
                return Err("TXT value must be 1 to 4096 bytes".into());
            }
            Ok(value.to_string())
        }
        // weight port target; priority is its own field, as for MX.
        "SRV" => match trimmed.split_whitespace().collect::<Vec<_>>()[..] {
            [weight, port, to] => Ok(format!("{} {} {}", number(weight, "SRV weight", 65535)?, number(port, "SRV port", 65535)?, target(to, "SRV target")?)),
            _ => Err(format!("SRV value must be \"weight port target\", like 5 5060 sip.example.com, got {value:?}")),
        },
        // flags tag value: 0 issue "letsencrypt.org"
        "CAA" => match trimmed.splitn(3, ' ').collect::<Vec<_>>()[..] {
            [flags, tag, v] if !v.trim().is_empty() && (1..=15).contains(&tag.len()) && tag.chars().all(|c| c.is_ascii_alphanumeric()) => {
                printable(v, "CAA value")?;
                Ok(format!("{} {} {}", number(flags, "CAA flags", 255)?, tag.to_ascii_lowercase(), v.trim()))
            }
            _ => Err(format!("CAA value must be \"flags tag value\", like 0 issue \"letsencrypt.org\", got {value:?}")),
        },
        _ => Err(format!("type must be one of {}", TYPES.join(", "))),
    }
}

fn kind_arg(kind: Option<&str>) -> Result<String, String> {
    let kind = kind.unwrap_or_default().trim().to_ascii_uppercase();
    if TYPES.contains(&kind.as_str()) { Ok(kind) } else { Err(format!("type must be one of {}", TYPES.join(", "))) }
}

// Form fields arrive as strings, JSON callers send numbers: take either. Blank is absent.
fn int(value: Option<&Value>, what: &str) -> Result<Option<i64>, String> {
    match value {
        None | Some(Value::Null) => Ok(None),
        Some(Value::Number(n)) => n.as_i64().map(Some).ok_or_else(|| format!("{what} must be a whole number")),
        Some(Value::String(s)) if s.trim().is_empty() => Ok(None),
        Some(Value::String(s)) => s.trim().parse().map(Some).map_err(|_| format!("{what} must be a whole number, got {s:?}")),
        Some(other) => Err(format!("{what} must be a whole number, got {other}")),
    }
}

// One at.marque.dns#entry, checked.
fn entry(domain: &str, name: &str, kind: &str, value: &str, ttl: Option<i64>, priority: Option<i64>) -> Result<Value, String> {
    let name = record_name(name, domain, kind)?;
    let value = record_value(kind, value)?;
    if value.len() > 4096 {
        return Err("value must be at most 4096 bytes".into());
    }
    let ttl = ttl.unwrap_or(DEFAULT_TTL);
    if !(1..=i64::from(i32::MAX)).contains(&ttl) {
        return Err(format!("ttl must be from 1 to {} seconds", i32::MAX));
    }
    let mut out = json!({ "name": name, "recordType": kind, "value": value, "ttl": ttl });
    match (kind, priority) {
        ("MX" | "SRV", Some(p)) if (0..=65535).contains(&p) => out["priority"] = json!(p),
        ("MX" | "SRV", _) => return Err(format!("{kind} needs a priority from 0 to 65535")),
        (_, Some(_)) => return Err(format!("{kind} records take no priority")),
        _ => {}
    }
    Ok(out)
}

// --- the zone ---------------------------------------------------------------------------------

fn text(v: &Value, key: &str) -> String {
    v[key].as_str().unwrap_or_default().to_string()
}

// Compare values as they would be stored; an existing value Marque wrote is taken as is if it does not parse.
fn same_record(r: &Value, name: &str, kind: &str, value: &str) -> bool {
    let kind_r = text(r, "recordType").to_ascii_uppercase();
    let value_r = text(r, "value");
    text(r, "name").eq_ignore_ascii_case(name) && kind_r == kind && record_value(&kind_r, &value_r).unwrap_or(value_r) == value
}

// No exact duplicates, and a CNAME is the only record at its name (RFC 1034 3.6.2).
fn check_conflicts(records: &[Value], new: &Value) -> Result<(), String> {
    let (name, kind, value) = (text(new, "name"), text(new, "recordType"), text(new, "value"));
    for r in records.iter().filter(|r| text(r, "name").eq_ignore_ascii_case(&name)) {
        if same_record(r, &name, &kind, &value) {
            return Err(format!("{kind} {name} {value} already exists"));
        }
        if kind == "CNAME" || text(r, "recordType").eq_ignore_ascii_case("CNAME") {
            return Err(format!("a CNAME cannot share the name {name} with any other record"));
        }
    }
    Ok(())
}

// The record named by name, type and value (update and delete), as its index.
fn find(records: &[Value], args: &Args, domain: &str) -> Result<usize, String> {
    let kind = kind_arg(args.kind.as_deref())?;
    let name = record_name(args.name.as_deref().unwrap_or_default(), domain, "")?;
    let raw = args.value.as_deref().unwrap_or_default();
    let value = record_value(&kind, raw).unwrap_or_else(|_| raw.trim().to_string());
    records
        .iter()
        .position(|r| same_record(r, &name, &kind, &value))
        .ok_or_else(|| format!("no {kind} record {name} {value} in {domain}"))
}

// The checks that need no zone, so bad input fails before any call.
fn check_input(action: Action, args: &Args, domain: &str) -> Result<(), String> {
    let kind = || kind_arg(args.kind.as_deref());
    match action {
        Action::Create => entry(domain, args.name.as_deref().unwrap_or_default(), &kind()?, args.value.as_deref().unwrap_or_default(), int(args.ttl.as_ref(), "ttl")?, int(args.priority.as_ref(), "priority")?).map(drop),
        Action::Update | Action::Delete => {
            kind()?;
            record_name(args.name.as_deref().unwrap_or_default(), domain, "")?;
            if args.value.as_deref().unwrap_or_default().trim().is_empty() {
                return Err("value is required to say which record".into());
            }
            int(args.ttl.as_ref(), "ttl")?;
            int(args.priority.as_ref(), "priority").map(drop)
        }
        Action::List => Ok(()),
    }
}

// The zone's records after the action, and the record added, changed or removed.
fn apply(action: Action, mut records: Vec<Value>, args: &Args, domain: &str) -> Result<(Vec<Value>, Value), String> {
    let ttl = int(args.ttl.as_ref(), "ttl")?;
    let priority = int(args.priority.as_ref(), "priority")?;
    match action {
        Action::Create => {
            let kind = kind_arg(args.kind.as_deref())?;
            let new = entry(domain, args.name.as_deref().unwrap_or_default(), &kind, args.value.as_deref().unwrap_or_default(), ttl, priority)?;
            check_conflicts(&records, &new)?;
            records.push(new.clone());
            Ok((records, new))
        }
        Action::Update => {
            let at = find(&records, args, domain)?;
            let old = records.remove(at);
            let pick = |new: &Option<String>, key| new.as_deref().filter(|s| !s.trim().is_empty()).map(String::from).unwrap_or_else(|| text(&old, key));
            let kind = text(&old, "recordType").to_ascii_uppercase();
            let new = entry(domain, &pick(&args.new_name, "name"), &kind, &pick(&args.new_value, "value"), ttl.or(old["ttl"].as_i64()), priority.or(old["priority"].as_i64()))?;
            check_conflicts(&records, &new)?;
            records.insert(at, new.clone());
            Ok((records, new))
        }
        Action::Delete => {
            let at = find(&records, args, domain)?;
            let old = records.remove(at);
            Ok((records, old))
        }
        Action::List => Ok((records, Value::Null)),
    }
}

// --- handlers ---------------------------------------------------------------------------------

fn list_page(did: &Did, cursor: Option<&str>) -> Call {
    repo::list_records(did.as_str(), DOMAIN, PAGE_LIMIT, cursor).id("page").service(did.as_str())
}

fn list_domains_handler(run: Run<Args>) -> Result<Handle, String> {
    let Run { event, state, args } = run;
    Ok(match event {
        Event::Start => Handle::new().call(list_page(&args.did, None)).state(json!({ "domains": [] })),
        Event::Result { result, .. } => {
            let output = result.output()?;
            let mut domains = state["domains"].as_array().cloned().unwrap_or_default();
            for record in output["records"].as_array().into_iter().flatten() {
                let mut domain = record["value"].clone();
                let Some(fields) = domain.as_object_mut() else { continue };
                fields.remove("$type");
                fields.insert("uri".into(), record["uri"].clone());
                domains.push(domain);
            }
            match output["cursor"].as_str().filter(|c| !c.is_empty()) {
                Some(cursor) => Handle::new().call(list_page(&args.did, Some(cursor))).state(json!({ "domains": domains })),
                None => Handle::new().result(json!({ "domains": domains })).done(),
            }
        }
        _ => Handle::new(),
    })
}

// A getRecord's { uri, cid, value }, or null when the record does not exist.
fn got(result: &abi::CallResult) -> Result<Value, String> {
    match result.output() {
        Ok(o) => Ok(json!({ "uri": o["uri"], "cid": o["cid"], "value": o["value"] })),
        Err(e) if e.contains("RecordNotFound") => Ok(Value::Null),
        Err(e) => Err(e),
    }
}

fn zone_handler(run: Run<Args>, action: Action) -> Result<Handle, String> {
    let Run { event, mut state, args } = run;
    let domain = domain_arg(args.domain.as_deref())?;
    let did = args.did.as_str();
    match event {
        // Reads go to the account's own PDS (a public read), so listing works signed out too.
        Event::Start => {
            check_input(action, &args, &domain)?;
            Ok(Handle::new()
                .call(get_record(&AtUri::new(did, DOMAIN, &domain)).id("domain").service(did))
                .call(get_record(&AtUri::new(did, DNS, &domain)).id("zone").service(did))
                .state(json!({})))
        }
        Event::Result { id, result, .. } if id == "domain" || id == "zone" => {
            state[id.as_str()] = got(&result)?;
            let (Some(owned), Some(zone)) = (state.get("domain"), state.get("zone")) else { return Ok(Handle::new().state(state)) };
            if owned.is_null() {
                return Err(format!("{domain} is not a Marque domain of {did}: there is no at.marque.domain/{domain} record in its repo"));
            }
            let records = zone["value"]["records"].as_array().cloned().unwrap_or_default();
            if action == Action::List {
                return Ok(Handle::new().result(json!({ "domain": domain, "status": owned["value"]["status"], "uri": zone["uri"], "cid": zone["cid"], "records": records })).done());
            }
            let (records, changed) = apply(action, records, &args, &domain)?;
            let now = Datetime::new(args.now).map_err(|e| format!("invalid now: {e}"))?;
            let created_at = Datetime::try_from(now.to_utc()).map_err(|e| format!("invalid now: {e}"))?;
            // Keep any fields Marque added to the zone; point subject at the domain record as it is now.
            let mut record = if zone.is_null() { json!({ "$type": DNS }) } else { zone["value"].clone() };
            record["domain"] = json!(domain);
            record["subject"] = json!({ "uri": owned["uri"], "cid": owned["cid"] });
            record["records"] = json!(records);
            record["createdAt"] = json!(created_at);
            let input = json!({ "repo": did, "collection": DNS, "rkey": domain, "record": record });
            let call = if zone.is_null() {
                Call::new("write", CREATE_RECORD).input(input)
            } else {
                let mut input = input;
                input["swapRecord"] = zone["cid"].clone();
                Call::new("write", PUT_RECORD).input(input)
            };
            let summary = json!({ "record": changed, "records": records.len() });
            Ok(Handle::new().call(call).state(summary))
        }
        Event::Result { id, result, .. } if id == "write" => {
            let written = result.output().map_err(|e| if e.contains("InvalidSwap") { format!("{domain}'s zone changed while editing it, try again ({e})") } else { e })?;
            Ok(Handle::new().result(json!({ "domain": domain, "uri": written["uri"], "cid": written["cid"], "record": state["record"], "records": state["records"] })).done())
        }
        _ => Ok(Handle::new()),
    }
}

fn list_records_handler(run: Run<Args>) -> Result<Handle, String> {
    zone_handler(run, Action::List)
}
fn create_record_handler(run: Run<Args>) -> Result<Handle, String> {
    zone_handler(run, Action::Create)
}
fn update_record_handler(run: Run<Args>) -> Result<Handle, String> {
    zone_handler(run, Action::Update)
}
fn delete_record_handler(run: Run<Args>) -> Result<Handle, String> {
    zone_handler(run, Action::Delete)
}

entries!(
    list_domains => list_domains_handler,
    list_records => list_records_handler,
    create_record => create_record_handler,
    update_record => update_record_handler,
    delete_record => delete_record_handler
);