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-wasm/src/dns.rs

//! **The DNS guest host** — `[dns_writers.<name>] guest = "<name>.wasm"`: a sold estate's
//! names written at the zone's provider (Cloudflare) by a SIGNED plugin the running server
//! takes in, exactly as a cloud (UpCloud) and a payment vendor (Stripe) are — the open
//! monetize links no provider (the owner, 2026-10-11).
//!
//! ```text
//!  [dns_writers.<name>] guest = "…wasm"   ──▶ the product guest's load order, kind = "dns"
//!       guest_digest / guest_trust_roots  ──▶ signed under the plugin root
//!       grants = ["http"], http_hosts     ──▶ exactly that door, those hosts
//!       http_auth (secret by NAME)        ──▶ the token, presented by the HOST per request
//!       every other key (zone_id, …)      ──▶ the guest's own settings
//! ```
//!
//! One call at a time; a fresh instance per call; a host-side failure (a bound, a trap, a
//! shape) is an `Err` naming the block — never a write that did not happen.

use std::path::Path;
#[cfg(test)]
use std::path::PathBuf;
use std::sync::{Arc, Mutex};
use std::time::Duration;

use guest_host::module::{Instantiated, LoadedModule};
use guest_host::wasmtime::Linker;
use monetize_cloud::dns::{DnsRecord, DnsWriter, Written};
use monetize_dns_guest::ops;
use monetize_dns_guest::wire::Call;
use monetize_product::GuestSpec;
use monetize_product_wasm::doors::{self, CallState, DoorSpec, Doors};
use monetize_product_wasm::{Block, LoadedGuest};

/// **The keys of `[dns_writers.<name>]` that are the HOST's** — never sent to the guest.
pub const HOST_KEYS: &[&str] = &[
    "guest",
    "guest_digest",
    "guest_trust_roots",
    "grants",
    "http_hosts",
    "http_auth",
    "http_resolve",
    "secrets_dir",
    "guest_fuel",
    "guest_call_timeout_ms",
    "guest_max_memory_bytes",
];

/// The block as the GUEST sees it: every key but [`HOST_KEYS`].
pub fn guest_settings(settings: &serde_json::Value) -> serde_json::Value {
    let mut out = serde_json::Map::new();
    if let Some(obj) = settings.as_object() {
        for (k, v) in obj {
            if !HOST_KEYS.contains(&k.as_str()) {
                out.insert(k.clone(), v.clone());
            }
        }
    }
    serde_json::Value::Object(out)
}

/// The block's guest keys, typed — `None` when it names no guest.
pub fn guest_spec(settings: &serde_json::Value) -> Result<Option<GuestSpec>, String> {
    crate::guest_spec("dns_writers", settings)
        .map_err(|e| e.replacen("[clouds.dns_writers]", "[dns_writers]", 1))
}

/// The DNS kind's ceilings per call: a few HTTPS calls to the provider.
pub const DNS_FUEL_CEILING: u64 = 5_000_000_000;
pub const DNS_CALL_TIMEOUT: Duration = Duration::from_secs(120);
pub const DNS_DOOR_TIMEOUT: Duration = Duration::from_secs(30);

/// **Whether the block `block` may load the DNS guest whose manifest says `vendor`**: its own
/// block (`[dns_writers.cloudflare]`), or a zone of that vendor's, `<vendor>-<zone>`
/// (`[dns_writers.cloudflare-holger]`, Holger's holger.rs, lane F1). A DNS writer meters no
/// tenants and takes no money; a guest of ANOTHER vendor under the block is still refused, and
/// so is a name that only shares the vendor's letters (`cloudflareholger`).
pub fn names_vendor(block: &str, vendor: &str) -> bool {
    block == vendor
        || block
            .strip_prefix(vendor)
            .and_then(|rest| rest.strip_prefix('-'))
            .is_some_and(|zone| !zone.is_empty())
}

/// **A DNS guest, as the server holds it.**
pub struct GuestDns {
    name: String,
    module: LoadedModule,
    doors: Arc<Doors>,
    settings: serde_json::Value,
    serial: Mutex<()>,
}

impl std::fmt::Debug for GuestDns {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        f.debug_struct("GuestDns")
            .field("name", &self.name)
            .field("module", &self.module.path())
            .field("digest", &self.module.digest_hex())
            .finish()
    }
}

impl GuestDns {
    /// The load order (kind `dns`, named for the block), the doors, then `check`.
    pub fn build(
        data_dir: &Path,
        name: &str,
        settings: &serde_json::Value,
    ) -> Result<GuestDns, String> {
        let blk = format!("[dns_writers.{name}]");
        let spec = guest_spec(settings)
            .map_err(|e| format!("{blk} {e}"))?
            .ok_or_else(|| {
                format!("{blk} names no `guest = \"<path>.wasm\"`: a DNS writer is a signed plugin")
            })?;
        if !spec.module.is_file() {
            return Err(format!(
                "{blk} its module {} is not there",
                spec.module.display()
            ));
        }
        let block = Block {
            section: "dns_writers",
            id: name,
            spec: Some(&spec),
            kind: monetize_product_guest::kinds::DNS,
        };
        let loaded = LoadedGuest::load_block_accepting(
            block,
            name,
            &|vendor| names_vendor(name, vendor),
            DNS_FUEL_CEILING,
            DNS_CALL_TIMEOUT,
        )
        .map_err(|e| format!("{blk} {e}"))?;
        let secrets_dir = loaded
            .spec
            .secrets_dir
            .clone()
            .unwrap_or_else(|| data_dir.join("secrets"));
        let call_timeout = loaded.module.limits().call_timeout.min(DNS_CALL_TIMEOUT);
        let resolve: Vec<String> = settings
            .get("http_resolve")
            .and_then(|v| serde_json::from_value(v.clone()).ok())
            .unwrap_or_default();
        let doors = Doors::open_with(DoorSpec {
            product: name,
            endpoint: "",
            bearer: None,
            twin: None,
            tls_ca: None,
            tls_client: None,
            grants: &loaded.spec.grants,
            http_hosts: &loaded.spec.http_hosts,
            grpc_hosts: &[],
            call_timeout: DNS_DOOR_TIMEOUT.min(call_timeout),
            http_auth: &loaded.spec.http_auth,
            hmac_secrets: &[],
            secrets_dir: Some(&secrets_dir),
            http_resolve: &resolve,
        })
        .map_err(|e| format!("{blk} {e}"))?;
        let g = GuestDns {
            name: name.to_string(),
            module: loaded.module,
            doors: Arc::new(doors),
            settings: guest_settings(settings),
            serial: Mutex::new(()),
        };
        g.call::<(), ()>(ops::CHECK, &())
            .map_err(|e| format!("{blk} its guest refused its own settings: {e}"))?;
        Ok(g)
    }

    fn instantiate(&self) -> Result<Instantiated<CallState>, String> {
        let mut linker = Linker::new(self.module.engine());
        doors::register(&mut linker, &self.doors.granted_imports)?;
        let state = CallState::new(Arc::clone(&self.doors), self.module.limits().limiter());
        self.module
            .instantiate(state, &linker)
            .map_err(|e| e.to_string())
    }

    /// **One op**, serialised; a host-side failure names the block and the op.
    fn call<A: serde::Serialize, T: serde::de::DeserializeOwned>(
        &self,
        op: u32,
        req: &A,
    ) -> Result<T, String> {
        let _one = self.serial.lock().unwrap_or_else(|p| p.into_inner());
        let failed = |e: String| {
            format!(
                "[dns_writers.{}] guest {}: op {op} ({}): {e}",
                self.name,
                self.module.path().display(),
                ops::name(op).unwrap_or("?")
            )
        };
        let payload = monetize_dns_guest::wire::encode(&Call {
            settings: self.settings.clone(),
            req,
        });
        let raw = self.instantiate().map_err(failed).and_then(|mut inst| {
            self.module
                .call_op(&mut inst, op, &payload)
                .map_err(|e| failed(e.to_string()))
        });
        self.doors.sweep_streams();
        let raw = raw?;
        let answer: Result<T, String> = monetize_dns_guest::wire::decode(&raw)
            .map_err(|e| failed(format!("the guest answered undecodable bytes: {e}")))?;
        answer.map_err(|e| format!("[dns_writers.{}] {e}", self.name))
    }
}

/// The words a guest older than [`ops::WRITE_TAKING_OVER`] answers it with.
fn not_in_op_table(e: &str) -> bool {
    not_in_table(e, ops::WRITE_TAKING_OVER)
}

/// The words a guest older than `op` answers it with.
fn not_in_table(e: &str, op: u32) -> bool {
    e.contains(&format!("op {op} is not in this DNS guest's op table"))
}

impl DnsWriter for GuestDns {
    fn write(&self, records: &[DnsRecord]) -> Result<String, String> {
        self.call(ops::WRITE, &records.to_vec())
    }
    /// Op 414; a guest older than it is asked the plain write, and its refusal says why.
    fn write_taking_over(
        &self,
        records: &[DnsRecord],
        take_over: &[String],
    ) -> Result<Written, String> {
        let req = monetize_dns_guest::wire::TakeOver {
            records: records.to_vec(),
            take_over: take_over.to_vec(),
        };
        match self.call::<_, Written>(ops::WRITE_TAKING_OVER, &req) {
            Err(e) if not_in_op_table(&e) => self
                .write(records)
                .map(|line| Written { line, replaced: Vec::new() })
                .map_err(|e| format!("{e} (this DNS plugin {} is older than the takeover of an operator site's own records: rebuild and feed the new one)", self.module.path().display())),
            other => other,
        }
    }
    fn remove(&self, names: &[String]) -> Result<String, String> {
        self.call(ops::REMOVE, &names.to_vec())
    }
    fn listed(&self) -> Result<Vec<DnsRecord>, String> {
        self.call(ops::LISTED, &())
    }
    /// A guest reads its zone back (op LISTED): a give-back asks it what it holds first.
    fn lists(&self) -> bool {
        true
    }
    fn settled(&self, records: &[DnsRecord]) -> Result<bool, String> {
        self.call(ops::SETTLED, &records.to_vec())
    }
    /// Op 415; a guest older than it cannot say, and the host then refuses every change by name
    /// (RA1-5, `[dns-zone-none]`: rebuild and re-sign it).
    fn zone(&self) -> Result<Option<String>, String> {
        match self.call::<_, Option<String>>(ops::ZONE, &()) {
            Err(e) if not_in_table(&e, ops::ZONE) => Ok(None),
            other => other.map(|z| {
                z.map(|z| z.trim().trim_end_matches('.').to_ascii_lowercase())
                    .filter(|z| !z.is_empty())
            }),
        }
    }
}

/// **A DNS guest taken in LIVE** (2026-10-17): the writer the shop and its sites hold, rebuilt
/// in place when its block, its module, its signature or its roots change — the same gates as
/// at start ([`GuestDns::build`]); a rebuild that is refused keeps the writer that served.
/// Every one built is followed by [`follow_all`], which the server's guest watcher calls on
/// each look, so a module written over the old one (the plugin update) is taken in with no
/// restart, as a product's, a vendor's and a cloud's are.
pub struct HotDns {
    name: String,
    data_dir: std::path::PathBuf,
    held: std::sync::RwLock<(String, Arc<GuestDns>)>,
}

static LIVE: Mutex<Vec<std::sync::Weak<HotDns>>> = Mutex::new(Vec::new());

fn fingerprint(name: &str, settings: &serde_json::Value) -> Result<String, String> {
    crate::block_fingerprint(name, settings).map_err(|e| {
        e.replacen(
            &format!("[clouds.{name}]"),
            &format!("[dns_writers.{name}]"),
            1,
        )
    })
}

impl HotDns {
    /// [`GuestDns::build`], held so [`follow_all`] can replace it.
    pub fn build(
        data_dir: &Path,
        name: &str,
        settings: &serde_json::Value,
    ) -> Result<Arc<HotDns>, String> {
        let fp = fingerprint(name, settings).map_err(|e| format!("[dns_writers.{name}] {e}"))?;
        let g = GuestDns::build(data_dir, name, settings)?;
        let hot = Arc::new(HotDns {
            name: name.to_string(),
            data_dir: data_dir.to_path_buf(),
            held: std::sync::RwLock::new((fp, Arc::new(g))),
        });
        let mut live = LIVE.lock().unwrap_or_else(|p| p.into_inner());
        live.retain(|w| w.strong_count() > 0);
        live.push(Arc::downgrade(&hot));
        Ok(hot)
    }

    fn now(&self) -> Arc<GuestDns> {
        self.held
            .read()
            .unwrap_or_else(|p| p.into_inner())
            .1
            .clone()
    }

    /// The digest of the module that serves now.
    pub fn digest_hex(&self) -> String {
        self.now().module.digest_hex().to_string()
    }

    /// **Follow `settings`**: nothing when the block and its files are as held; else rebuilt and
    /// swapped in, or refused with the old writer serving on. Answers the line to log.
    pub fn follow(&self, settings: &serde_json::Value) -> Option<String> {
        let blk = format!("[dns_writers.{}]", self.name);
        let fp = match fingerprint(&self.name, settings) {
            Ok(fp) => fp,
            Err(e) => {
                return Some(format!(
                "dns guest REFUSED [guest-refused] {blk} {e} — the writer that served still serves"
            ))
            }
        };
        let old = self
            .held
            .read()
            .unwrap_or_else(|p| p.into_inner())
            .0
            .clone();
        if fp == old {
            return None;
        }
        let was = self.digest_hex();
        let out = match GuestDns::build(&self.data_dir, &self.name, settings) {
            Ok(g) => {
                let now = g.module.digest_hex().to_string();
                *self.held.write().unwrap_or_else(|p| p.into_inner()) = (fp, Arc::new(g));
                format!("dns guest REPLACED {blk} blake3 {was:.12} → {now:.12}")
            }
            Err(e) => {
                // Said once per change, not on every look.
                self.held.write().unwrap_or_else(|p| p.into_inner()).0 = fp;
                format!(
                    "dns guest REFUSED [guest-refused] {e} — the writer that served still serves"
                )
            }
        };
        Some(out)
    }
}

/// **Every live DNS guest of the server at `data_dir`, following `blocks`**
/// (`[dns_writers.<name>]` as the server loads them). Answers the lines to log.
pub fn follow_all(
    data_dir: &Path,
    blocks: &std::collections::BTreeMap<String, serde_json::Value>,
) -> Vec<String> {
    let live: Vec<Arc<HotDns>> = {
        let mut l = LIVE.lock().unwrap_or_else(|p| p.into_inner());
        l.retain(|w| w.strong_count() > 0);
        l.iter()
            .filter_map(|w| w.upgrade())
            .filter(|h| h.data_dir == data_dir)
            .collect()
    };
    let mut seen = std::collections::BTreeSet::new();
    let mut lines = Vec::new();
    for h in live {
        let Some(block) = blocks.get(&h.name) else {
            continue;
        };
        if let Some(line) = h.follow(block) {
            // One line per writer, however many sites hold one.
            if seen.insert((h.name.clone(), line.clone())) {
                lines.push(line);
            }
        }
    }
    lines
}

impl DnsWriter for HotDns {
    fn write(&self, records: &[DnsRecord]) -> Result<String, String> {
        self.now().write(records)
    }
    fn write_taking_over(
        &self,
        records: &[DnsRecord],
        take_over: &[String],
    ) -> Result<Written, String> {
        self.now().write_taking_over(records, take_over)
    }
    fn remove(&self, names: &[String]) -> Result<String, String> {
        self.now().remove(names)
    }
    fn listed(&self) -> Result<Vec<DnsRecord>, String> {
        self.now().listed()
    }
    fn lists(&self) -> bool {
        self.now().lists()
    }
    fn settled(&self, records: &[DnsRecord]) -> Result<bool, String> {
        self.now().settled(records)
    }
    fn zone(&self) -> Result<Option<String>, String> {
        self.now().zone()
    }
}

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

    /// ★ **The token is the HOST's; the guest gets its settings only** — `http_auth` (the
    /// secret by name), the grants, the hosts and the module's own keys never reach the
    /// guest; the zone does.
    #[test]
    fn the_token_and_the_doors_are_the_hosts_and_the_zone_is_the_guests() {
        let s = serde_json::json!({"guest": "/g.wasm", "grants": ["http"], "http_hosts": ["api.cloudflare.com"], "http_auth": [{"host": "api.cloudflare.com", "prefix": "Bearer ", "secret": "dns_cloudflare_token"}], "zone_id": "z1"});
        let g = guest_settings(&s);
        assert_eq!(g, serde_json::json!({"zone_id": "z1"}));
        let spec = guest_spec(&s).unwrap().expect("a guest block");
        assert_eq!(spec.module, PathBuf::from("/g.wasm"));
        assert_eq!(spec.grants, vec!["http".to_string()]);
        assert_eq!(spec.http_auth.len(), 1);
        assert!(
            guest_spec(&serde_json::json!({"zone_id": "z"}))
                .unwrap()
                .is_none(),
            "no guest, no spec"
        );
    }

    /// ★ **A zone writer names its vendor** (lane F1): `cloudflare` and `cloudflare-holger`
    /// load the `cloudflare` guest; another vendor, a bare dash and run-together letters do not.
    #[test]
    fn a_zone_writer_block_names_its_vendors_guest_and_no_other() {
        assert!(names_vendor("cloudflare", "cloudflare"));
        assert!(names_vendor("cloudflare-holger", "cloudflare"));
        for (block, vendor) in [
            ("cloudflare-holger", "toydns"),
            ("cloudflare-", "cloudflare"),
            ("cloudflareholger", "cloudflare"),
            ("holger-cloudflare", "cloudflare"),
            ("cloud", "cloudflare"),
            ("cloudflare", "cloud"),
        ] {
            assert!(
                !names_vendor(block, vendor),
                "{block} must not load {vendor}"
            );
        }
    }

    /// ★ **An old guest's "no such op" is what the host falls back on** — and nothing else.
    #[test]
    fn only_an_old_guests_unknown_op_falls_back_to_the_plain_write() {
        assert!(not_in_op_table(
            "[dns_writers.cloudflare] op 414 is not in this DNS guest's op table"
        ));
        assert!(!not_in_op_table(
            "[dns_writers.cloudflare] cloudflare: holger.rs has 2 A records"
        ));
    }

    /// ★ **A block whose module is not there is refused by name** — at load, never at the
    /// first sold host.
    #[test]
    fn a_block_whose_module_is_absent_is_refused_by_name() {
        let dir = std::env::temp_dir();
        let e = GuestDns::build(&dir, "cloudflare", &serde_json::json!({"guest": "/nowhere/dns_cloudflare.wasm", "grants": ["http"], "zone_id": "z"})).expect_err("refused");
        assert!(
            e.contains("[dns_writers.cloudflare]") && e.contains("dns_cloudflare.wasm"),
            "{e}"
        );
        let e = GuestDns::build(&dir, "cloudflare", &serde_json::json!({"zone_id": "z"}))
            .expect_err("refused");
        assert!(e.contains("names no `guest"), "{e}");
    }
}