// 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, #[serde(default)] name: Option, #[serde(default, rename = "type")] kind: Option, #[serde(default)] value: Option, #[serde(default)] ttl: Option, #[serde(default)] priority: Option, #[serde(default, rename = "newName")] new_name: Option, #[serde(default, rename = "newValue")] new_value: Option, } #[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 { 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 { if value.trim() == "." { Ok(".".into()) } else { host(value, what) } } fn domain_arg(domain: Option<&str>) -> Result { 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 { 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 { value.parse::().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 { let trimmed = value.trim(); match kind { "A" => trimmed.parse::().map(|ip| ip.to_string()).map_err(|_| format!("A value must be an IPv4 address, got {value:?}")), "AAAA" => trimmed.parse::().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::>()[..] { [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::>()[..] { [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 { 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, 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, priority: Option) -> Result { 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 { 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, args: &Args, domain: &str) -> Result<(Vec, 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, 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) -> Result { 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 { 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, action: Action) -> Result { 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) -> Result { zone_handler(run, Action::List) } fn create_record_handler(run: Run) -> Result { zone_handler(run, Action::Create) } fn update_record_handler(run: Run) -> Result { zone_handler(run, Action::Update) } fn delete_record_handler(run: Run) -> Result { 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 );