Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/monetize

crates/monetize-cloud/src/dns.rs

//! **An estate's names, written by a linked DNS writer** (PRIVATE-GUNNAR.md
//! §6): a private gunnar ordered as `<company>.<apex>` gets four A records —
//! `<company>.<apex>`, `www.`, `git.` and `monetize.` — pointing at its front,
//! DNS-only (never proxied: a proxied `git` record breaks every clone), and
//! loses them when the order fails or the estate is deleted.
//!
//! The seam is the clouds' (`factory`): the open server names no DNS
//! provider. A writer that needs an account (Cloudflare) lives in the private
//! `monetize-cloud-impl` and registers a [`DnsFactory`]; the open server keeps
//! only the mock's recording file and "no writer". So the mock stays fake by
//! construction: a bench links no account's writer.
//!
//! # The credential is the custodian's, never the config's
//!
//! A writer's zone and token are sealed in monetize's custody
//! (`monetize-server --custody-credential dns.<writer>`), never in
//! `monetize.toml` and never in the environment. [`DnsBuild::credential`]
//! unseals them for ONE call and the writer drops them after it; a
//! [`DnsCredential`] prints as names only and is overwritten when dropped,
//! and no writer puts it in an error, a log line or an answer.

use std::sync::Arc;

pub use monetize_pay::factory::Settings;

/// One record of a private estate.
#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct DnsRecord {
    pub name: String,
    pub kind: String,
    pub value: String,
    pub proxied: bool,
}

/// **The record kinds a takeover may replace** (owner 2026-10-22): the site's own ADDRESS
/// records only. MX, TXT, SRV, CAA, NS and the rest at a name are never touched.
pub const TAKE_OVER_KINDS: &[&str] = &["A", "AAAA", "CNAME"];

/// One record a takeover replaced: what stood at the name before this writer took it.
#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct Replaced {
    pub name: String,
    pub kind: String,
    /// The old target (an address, or a CNAME's name).
    pub value: String,
}

/// **What a write answered**: the run's line and every record a takeover replaced.
#[derive(Clone, Debug, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct Written {
    pub line: String,
    #[serde(default)]
    pub replaced: Vec<Replaced>,
}

impl Written {
    /// The operator's words for the records replaced (empty when none were).
    pub fn replaced_words(&self) -> String {
        if self.replaced.is_empty() {
            return String::new();
        }
        let rows: Vec<String> = self.replaced.iter().map(|r| format!("{} {} -> {}", r.kind, r.name, r.value)).collect();
        format!("took over {} address record(s) this writer did not make (old targets): {}", rows.len(), rows.join(", "))
    }
}

/// **Where an estate's names are written** — the ONE DNS seam of a sale (the owner,
/// 2026-10-08): `write` sets the records, `remove` takes them away at a give-back or a
/// cancellation, `settled` says whether they answer yet. The FREE impl is the open server's
/// `ManualDns` (the operator sets the records by hand; the shop waits until they answer); the
/// COMMERCIAL one (`cloudflare`, monetize-impl) sets and removes them itself.
pub trait DnsWriter: Send + Sync {
    /// Set these records: a name that has a record of the kind THIS writer made is updated to
    /// the value, one that has none is created. A writer that marks its own records
    /// (Cloudflare's `monetize-estate` comment) REFUSES a name whose record of the kind it did
    /// not make, and writes nothing: only [`Self::write_taking_over`], with the name in its
    /// permission, replaces one. Answers one line for the run (no credential in it).
    fn write(&self, records: &[DnsRecord]) -> Result<String, String>;
    /// **Write, TAKING OVER the address records at `take_over`** (owner 2026-10-22): an
    /// operator site's own names (apex, `www.`, `git.`) may stand as A, AAAA or CNAME records
    /// this writer did not make (an old box's, set by hand); for exactly those names, those
    /// kinds ([`TAKE_OVER_KINDS`]) are replaced and marked as this writer's. Every other kind
    /// and every other name is left as it is. Only the operator's own site order asks it; a
    /// sold estate's write is [`Self::write`], which refuses a record it did not make.
    /// The default (a writer that refuses nothing): [`Self::write`], nothing replaced.
    fn write_taking_over(&self, records: &[DnsRecord], take_over: &[String]) -> Result<Written, String> {
        let _ = take_over;
        self.write(records).map(|line| Written { line, replaced: Vec::new() })
    }
    /// Remove every record with one of these names. Absent is removed.
    fn remove(&self, names: &[String]) -> Result<String, String>;
    /// What is written now, under the names this writer manages.
    fn listed(&self) -> Result<Vec<DnsRecord>, String>;
    /// **Does [`Self::listed`] say what this writer holds?** (DNS-WRITER, 2026-10-26): a writer
    /// that reads its zone back (the Cloudflare guest, the recording one) answers `true`, and a
    /// give-back asks it first which of a run's names it holds — one it does not is said, loud.
    /// The default (a writer that lists nothing: by hand, none): `false`.
    fn lists(&self) -> bool {
        false
    }
    /// **Do these records answer yet?** The installer waits for them (a front's certificate
    /// is asked for its names). The default: a writer that wrote them through the provider's
    /// API is settled when the write answered.
    fn settled(&self, records: &[DnsRecord]) -> Result<bool, String> {
        let _ = records;
        Ok(true)
    }
    /// **Does this writer set records at all?** (S2, 2026-10-09): `false` only for the shop's
    /// "no writer" — a shop whose sold hosts nobody makes resolve SELLS nothing.
    fn writes(&self) -> bool {
        true
    }
    /// **The zone this writer writes in** (A32-3, 2026-10-26): its apex as its provider names
    /// it, lower case (`gunnar.rs`). Every name it is asked to write, take over or remove must
    /// be at or under it, and [`Zoned`] refuses any other by name before the writer is asked
    /// (Cloudflare reads a name outside its zone as RELATIVE to it: `x.holger.rs` in gunnar.rs
    /// became `x.holger.rs.gunnar.rs`). `None`: the writer cannot say; [`Zoned`] then REFUSES
    /// every change by name (RA1-5, fail closed: an older guest, a provider without a zone),
    /// unless the writer writes into no zone at all ([`Self::zoneless`]).
    fn zone(&self) -> Result<Option<String>, String> {
        Ok(None)
    }
    /// **Does this writer write into no provider's zone at all?** (RA1-5, 2026-10-26): `true`
    /// only for a writer that writes nothing anywhere a name could land outside a zone — by
    /// hand (the operator sets the records), a recording file, the bench's printer, no writer.
    /// For such a writer "no zone" is the truth and [`Zoned`] passes it. Every other writer
    /// (a provider, a guest) that names no zone is REFUSED by name: fail closed. The default:
    /// `false`.
    fn zoneless(&self) -> bool {
        false
    }
}

/// Pure: **is `name` the zone `apex` or a name under it?** (any case, a trailing dot).
pub fn inside_zone(name: &str, apex: &str) -> bool {
    let n = name.trim().trim_end_matches('.').to_ascii_lowercase();
    let z = apex.trim().trim_end_matches('.').to_ascii_lowercase();
    !z.is_empty() && (n == z || n.strip_suffix(&z).is_some_and(|head| head.len() > 1 && head.ends_with('.')))
}

/// **Every writer the shop holds, guarded by its zone** ([`DnsWriter::zone`]): a write, a
/// takeover or a removal naming anything outside the zone is refused by name
/// (`[dns-outside-zone]`) and the writer is never asked; a writer that cannot say its zone
/// right now refuses the change (nothing is written into a zone nobody named); one that names
/// none is refused by name too (`[dns-zone-none]`, RA1-5: fail closed), unless it writes into
/// no zone at all ([`DnsWriter::zoneless`]: by hand, a file).
pub struct Zoned(pub std::sync::Arc<dyn DnsWriter>);

/// The refusal for a writer that names no zone (RA1-5).
pub const DNS_ZONE_NONE: &str = "REFUSED [dns-zone-none] this DNS writer names no zone it writes in, so nothing is written (fail closed): a DNS guest older than op 415 (`zone`) must be rebuilt and re-signed; a linked provider must say its zone; nothing was written";

impl Zoned {
    fn guard<'a>(&self, names: impl IntoIterator<Item = &'a str>) -> Result<(), String> {
        let Some(zone) = self.0.zone().map_err(|e| format!("REFUSED [dns-zone-unknown] the DNS writer could not say its zone ({e}); nothing was written"))? else {
            if self.0.zoneless() {
                return Ok(());
            }
            return Err(DNS_ZONE_NONE.to_string());
        };
        let out: Vec<&str> = names.into_iter().filter(|n| !inside_zone(n, &zone)).collect();
        if out.is_empty() {
            return Ok(());
        }
        Err(format!("REFUSED [dns-outside-zone] {} {} not in the zone {zone} this writer writes in; nothing was written", out.join(", "), if out.len() == 1 { "is" } else { "are" }))
    }
}

impl DnsWriter for Zoned {
    fn write(&self, records: &[DnsRecord]) -> Result<String, String> {
        self.guard(records.iter().map(|r| r.name.as_str()))?;
        self.0.write(records)
    }
    fn write_taking_over(&self, records: &[DnsRecord], take_over: &[String]) -> Result<Written, String> {
        self.guard(records.iter().map(|r| r.name.as_str()).chain(take_over.iter().map(String::as_str)))?;
        self.0.write_taking_over(records, take_over)
    }
    fn remove(&self, names: &[String]) -> Result<String, String> {
        self.guard(names.iter().map(String::as_str))?;
        self.0.remove(names)
    }
    fn listed(&self) -> Result<Vec<DnsRecord>, String> {
        self.0.listed()
    }
    fn lists(&self) -> bool {
        self.0.lists()
    }
    fn settled(&self, records: &[DnsRecord]) -> Result<bool, String> {
        self.0.settled(records)
    }
    fn writes(&self) -> bool {
        self.0.writes()
    }
    fn zone(&self) -> Result<Option<String>, String> {
        self.0.zone()
    }
    fn zoneless(&self) -> bool {
        self.0.zoneless()
    }
}

/// **A writer's account**: the zone its records live in and the token that
/// may write them. Never printed, never kept past the call it was unsealed for.
pub struct DnsCredential {
    pub zone_id: String,
    pub api_token: String,
}

impl DnsCredential {
    /// From the sealed JSON `{"zone_id": …, "api_token": …}`.
    pub fn from_json(bytes: &[u8]) -> Result<DnsCredential, String> {
        #[derive(serde::Deserialize)]
        struct Plain {
            zone_id: String,
            api_token: String,
        }
        let p: Plain = serde_json::from_slice(bytes).map_err(|_| "the DNS credential is not {\"zone_id\": …, \"api_token\": …}".to_string())?;
        if p.zone_id.trim().is_empty() || p.api_token.trim().is_empty() {
            return Err("the DNS credential names no zone or no token".into());
        }
        Ok(DnsCredential { zone_id: p.zone_id.trim().to_string(), api_token: p.api_token.trim().to_string() })
    }
}

impl std::fmt::Debug for DnsCredential {
    /// Names, never values.
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        f.write_str("DnsCredential { zone_id, api_token }")
    }
}

impl Drop for DnsCredential {
    fn drop(&mut self) {
        // SAFETY-free overwrite: the bytes are replaced in place, then cleared.
        for s in [&mut self.zone_id, &mut self.api_token] {
            let n = s.len();
            s.clear();
            s.extend(std::iter::repeat_n('\0', n));
            s.clear();
        }
    }
}

/// Unseals the writer's credential for one call.
pub type CredentialSource = Arc<dyn Fn() -> Result<DnsCredential, String> + Send + Sync>;

/// What a writer is built from.
pub struct DnsBuild<'a> {
    /// The writer's name (`[estate_orders] dns = "<name>"`).
    pub name: &'a str,
    /// `[estate_orders.dns_settings]`: what is not secret (the API's base, for
    /// a test's fake).
    pub settings: Settings<'a>,
    /// The zone and token, unsealed from the custodian per call.
    pub credential: CredentialSource,
}

pub type DnsBuilder = fn(DnsBuild<'_>) -> Result<Box<dyn DnsWriter>, String>;

/// One linkable DNS writer.
#[derive(Clone, Copy)]
pub struct DnsFactory {
    pub name: &'static str,
    pub build: DnsBuilder,
}

impl std::fmt::Debug for DnsFactory {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        write!(f, "DnsFactory({})", self.name)
    }
}

/// Register `f`, replacing one of the same name.
pub fn register(list: &mut Vec<DnsFactory>, f: DnsFactory) {
    list.retain(|x| x.name != f.name);
    list.push(f);
}

#[cfg(test)]
mod tests {
    use super::*;

    /// A writer that writes into no provider's zone (by hand, a file): it says so.
    struct NoZoneAtAll;
    impl DnsWriter for NoZoneAtAll {
        fn write(&self, r: &[DnsRecord]) -> Result<String, String> {
            Ok(format!("printed {}", r.len()))
        }
        fn remove(&self, _: &[String]) -> Result<String, String> {
            Ok("r".into())
        }
        fn listed(&self) -> Result<Vec<DnsRecord>, String> {
            Ok(Vec::new())
        }
        fn zoneless(&self) -> bool {
            true
        }
    }

    /// ★ **RA1-5: a writer that names no zone is refused, fail closed** — a provider or a guest
    /// older than op 415 that cannot say its zone writes nothing, by name, and is never asked;
    /// only a writer that writes into no zone at all (by hand, a file) is passed.
    #[test]
    fn a_writer_that_names_no_zone_is_refused_by_name() {
        let rec = |name: &str| DnsRecord { name: name.into(), kind: "A".into(), value: "192.0.2.1".into(), proxied: false };
        let inner = std::sync::Arc::new(InZone(None, Default::default()));
        let z = Zoned(inner.clone());
        let e = z.write(&[rec("acme.gunnar.rs")]).unwrap_err();
        assert!(e.contains("[dns-zone-none]") && e.contains("op 415"), "refused by name: {e}");
        assert!(z.write_taking_over(&[rec("acme.gunnar.rs")], &[]).unwrap_err().contains("[dns-zone-none]"));
        assert!(z.remove(&["acme.gunnar.rs".into()]).unwrap_err().contains("[dns-zone-none]"));
        assert!(inner.1.lock().unwrap().is_empty(), "the writer was never asked");
        assert!(!z.zoneless(), "a provider is not zoneless");
        let by_hand = Zoned(std::sync::Arc::new(NoZoneAtAll));
        assert!(by_hand.zoneless(), "said through the guard");
        assert_eq!(by_hand.write(&[rec("anything.example")]).unwrap(), "printed 1", "a writer that writes into no zone is passed");
    }

    /// A writer that says its zone and keeps every call it was asked.
    struct InZone(Option<Result<Option<String>, String>>, std::sync::Mutex<Vec<String>>);
    impl DnsWriter for InZone {
        fn write(&self, r: &[DnsRecord]) -> Result<String, String> {
            self.1.lock().unwrap().push(format!("write {}", r.len()));
            Ok("w".into())
        }
        fn remove(&self, n: &[String]) -> Result<String, String> {
            self.1.lock().unwrap().push(format!("remove {}", n.len()));
            Ok("r".into())
        }
        fn listed(&self) -> Result<Vec<DnsRecord>, String> {
            Ok(Vec::new())
        }
        fn zone(&self) -> Result<Option<String>, String> {
            self.0.clone().unwrap_or(Ok(None))
        }
    }

    /// ★ **The host's zone guard** (A32-3): outside the zone refused by name and the writer
    /// never asked; inside written; a writer that names no zone passed; one that cannot say
    /// refused (RA1-5: one that names no zone is refused too, below).
    #[test]
    fn zoned_refuses_a_name_outside_the_writers_zone_before_the_writer_is_asked() {
        let rec = |name: &str| DnsRecord { name: name.into(), kind: "A".into(), value: "192.0.2.1".into(), proxied: false };
        let inner = std::sync::Arc::new(InZone(Some(Ok(Some("gunnar.rs".into()))), Default::default()));
        let z = Zoned(inner.clone());
        let e = z.write(&[rec("acme.gunnar.rs"), rec("r34e.holger.rs")]).unwrap_err();
        assert!(e.contains("[dns-outside-zone]") && e.contains("r34e.holger.rs is not in the zone gunnar.rs"), "{e}");
        assert!(z.write_taking_over(&[rec("gunnar.rs")], &["holger.rs".into()]).unwrap_err().contains("holger.rs"));
        assert!(z.remove(&["x.holger.rs".into()]).is_err());
        assert!(inner.1.lock().unwrap().is_empty(), "the writer was never asked");
        z.write(&[rec("gunnar.rs"), rec("www.Acme.Gunnar.RS.")]).unwrap();
        z.remove(&["acme.gunnar.rs".into()]).unwrap();
        assert_eq!(*inner.1.lock().unwrap(), ["write 2", "remove 1"]);
        let blind = Zoned(std::sync::Arc::new(InZone(Some(Err("HTTP 503".into())), Default::default())));
        assert!(blind.write(&[rec("acme.gunnar.rs")]).unwrap_err().contains("[dns-zone-unknown]"));
    }

    #[test]
    fn inside_zone_takes_the_apex_and_names_under_it_only() {
        for n in ["gunnar.rs", "GUNNAR.rs.", "a.gunnar.rs", "www.a.gunnar.rs"] {
            assert!(inside_zone(n, "gunnar.rs"), "{n}");
        }
        for n in ["xgunnar.rs", ".gunnar.rs", "gunnar.rs.evil.example", "holger.rs", ""] {
            assert!(!inside_zone(n, "gunnar.rs"), "{n}");
        }
        assert!(!inside_zone("gunnar.rs", ""), "no zone: nothing is inside");
    }

    #[test]
    fn a_credential_prints_as_names_and_refuses_a_half() {
        let c = DnsCredential::from_json(br#"{"zone_id": "z0ne", "api_token": "s3cr3t-token"}"#).unwrap();
        let shown = format!("{c:?}");
        assert!(!shown.contains("z0ne") && !shown.contains("s3cr3t"), "{shown}");
        assert!(DnsCredential::from_json(br#"{"zone_id": "z", "api_token": " "}"#).is_err());
        let e = DnsCredential::from_json(br#"{"zone_id": "z", "token": "s3cr3t-token"}"#).unwrap_err();
        assert!(!e.contains("s3cr3t"), "a malformed credential is not echoed: {e}");
    }

    #[test]
    fn registering_replaces_by_name() {
        fn none(_: DnsBuild<'_>) -> Result<Box<dyn DnsWriter>, String> {
            Err("none".into())
        }
        let mut l = Vec::new();
        register(&mut l, DnsFactory { name: "a", build: none });
        register(&mut l, DnsFactory { name: "a", build: none });
        register(&mut l, DnsFactory { name: "b", build: none });
        assert_eq!(l.iter().map(|f| f.name).collect::<Vec<_>>(), ["a", "b"]);
    }
}