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"]);
}
}