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

nordisk/monetize

crates/monetize-dns-guest/src/lib.rs

//! **The guest side of a monetize DNS writer** — what a DNS plugin compiles against, and the
//! contract the server's DNS host (`monetize-cloud-wasm::dns`) speaks.
//!
//! The owner, 2026-10-11: the ONE box links NO provider. A DNS writer (Cloudflare) is a signed
//! `.wasm` the running server takes in — exactly as a cloud (UpCloud) and a payment vendor
//! (Stripe) are — built in monetize-impl, signed under the plugin root, pinned by the kit.
//!
//! | piece | what it is |
//! |---|---|
//! | [`DnsGuest`] | the trait a DNS plugin implements: [`monetize_cloud::dns::DnsWriter`]'s four doors over the host's |
//! | [`ops`] | the op table, 400.. (products below 100, vendors 100.., clouds 200.., signups 300..) |
//! | [`wire`] | the call envelope (the block's settings, then the op's arguments) and the answer |
//! | [`dispatch`] | the op switch — the wasm export's AND a native test's |
//! | [`export_dns!`] | the product guest's exports and host bridge with this dispatch |
//!
//! # What never crosses into the guest
//!
//! **The provider's token.** The host's HTTP door presents it (`http_auth`, the fed secret by
//! name); a guest builds requests with no `Authorization` header and never reads a secret.

pub use monetize_cloud::dns::{DnsRecord, Replaced, Written, TAKE_OVER_KINDS};
pub use monetize_product_guest as product_guest;
pub use monetize_product_guest::{abi, grants, kinds, Host, Manifest, NoHost};

/// **The op table** — one number per [`monetize_cloud::dns::DnsWriter`] door.
pub mod ops {
    pub const ID: u32 = monetize_product_guest::ops::ID;
    /// The block's settings judged at load (a zone named, …): refused by name before any write.
    pub const CHECK: u32 = 400;
    pub const WRITE: u32 = 410;
    pub const REMOVE: u32 = 411;
    pub const LISTED: u32 = 412;
    pub const SETTLED: u32 = 413;
    /// A write that may take over the untagged address records at the names it is given
    /// (an operator site's own names, 2026-10-22). A guest older than it answers "not in
    /// this DNS guest's op table" and the host falls back to [`WRITE`].
    pub const WRITE_TAKING_OVER: u32 = 414;
    /// The zone the guest writes in, as its provider names it (A32-3): the host refuses any
    /// name outside it before it asks a write. A guest older than it answers "not in this DNS
    /// guest's op table" and the host can refuse nothing for it.
    pub const ZONE: u32 = 415;

    pub const ALL: &[(u32, &str)] = &[
        (ID, "id"),
        (CHECK, "check"),
        (WRITE, "write"),
        (REMOVE, "remove"),
        (LISTED, "listed"),
        (SETTLED, "settled"),
        (WRITE_TAKING_OVER, "write_taking_over"),
        (ZONE, "zone"),
    ];

    pub fn name(op: u32) -> Option<&'static str> {
        ALL.iter().find(|(n, _)| *n == op).map(|(_, s)| *s)
    }
}

/// The call envelope and the answer.
pub mod wire {
    pub use monetize_product_guest::wire::{decode, encode};

    /// One call: the block's settings (the host's keys removed), then the op's argument.
    #[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
    pub struct Call<T> {
        pub settings: serde_json::Value,
        pub req: T,
    }

    /// [`super::ops::WRITE_TAKING_OVER`]'s argument: the records, and the names whose
    /// untagged A/AAAA/CNAME records may be replaced.
    #[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
    pub struct TakeOver {
        pub records: Vec<super::DnsRecord>,
        pub take_over: Vec<String>,
    }

    /// Every answer: the door's own `Result<_, String>`.
    pub type Answer<T> = Result<T, String>;
}

use wire::Call;

/// **A DNS plugin**: [`monetize_cloud::dns::DnsWriter`]'s doors, each over the host's (its
/// `http` door to the provider's API, the token presented by the host) and the block's
/// settings (the zone, …). A fresh instance per call: it remembers nothing.
pub trait DnsGuest {
    fn manifest(&self) -> Manifest;
    /// The block's settings, judged at load.
    fn check(&self, host: &dyn Host, settings: &serde_json::Value) -> Result<(), String> {
        let _ = (host, settings);
        Ok(())
    }
    fn write(
        &self,
        host: &dyn Host,
        settings: &serde_json::Value,
        records: &[DnsRecord],
    ) -> Result<String, String>;
    /// [`monetize_cloud::dns::DnsWriter::write_taking_over`]. The default takes nothing over:
    /// [`Self::write`], nothing replaced.
    fn write_taking_over(
        &self,
        host: &dyn Host,
        settings: &serde_json::Value,
        records: &[DnsRecord],
        take_over: &[String],
    ) -> Result<Written, String> {
        let _ = take_over;
        self.write(host, settings, records).map(|line| Written {
            line,
            replaced: Vec::new(),
        })
    }
    fn remove(
        &self,
        host: &dyn Host,
        settings: &serde_json::Value,
        names: &[String],
    ) -> Result<String, String>;
    fn listed(
        &self,
        host: &dyn Host,
        settings: &serde_json::Value,
    ) -> Result<Vec<DnsRecord>, String>;
    /// Written through the provider's API: settled when the write answered.
    fn settled(
        &self,
        host: &dyn Host,
        settings: &serde_json::Value,
        records: &[DnsRecord],
    ) -> Result<bool, String> {
        let _ = (host, settings, records);
        Ok(true)
    }
    /// [`monetize_cloud::dns::DnsWriter::zone`]: the apex it writes in. The default: it
    /// cannot say.
    fn zone(
        &self,
        host: &dyn Host,
        settings: &serde_json::Value,
    ) -> Result<Option<String>, String> {
        let _ = (host, settings);
        Ok(None)
    }
}

/// **The op switch** — the wasm export's and a native test's.
pub fn dispatch(guest: &dyn DnsGuest, host: &dyn Host, op: u32, payload: &[u8]) -> Vec<u8> {
    fn call<T: serde::de::DeserializeOwned>(op: u32, payload: &[u8]) -> Result<Call<T>, String> {
        wire::decode(payload).map_err(|e| {
            format!(
                "op {op} ({}): undecodable request: {e}",
                ops::name(op).unwrap_or("?")
            )
        })
    }
    fn ans<T: serde::Serialize>(r: Result<T, String>) -> Vec<u8> {
        wire::encode(&r)
    }
    match op {
        ops::ID => ans::<String>(Ok(guest.manifest().product)),
        ops::CHECK => ans(call::<()>(op, payload).and_then(|c| guest.check(host, &c.settings))),
        ops::WRITE => ans(call::<Vec<DnsRecord>>(op, payload)
            .and_then(|c| guest.write(host, &c.settings, &c.req))),
        ops::WRITE_TAKING_OVER => ans(call::<wire::TakeOver>(op, payload).and_then(|c| {
            guest.write_taking_over(host, &c.settings, &c.req.records, &c.req.take_over)
        })),
        ops::REMOVE => {
            ans(call::<Vec<String>>(op, payload)
                .and_then(|c| guest.remove(host, &c.settings, &c.req)))
        }
        ops::LISTED => ans(call::<()>(op, payload).and_then(|c| guest.listed(host, &c.settings))),
        ops::SETTLED => ans(call::<Vec<DnsRecord>>(op, payload)
            .and_then(|c| guest.settled(host, &c.settings, &c.req))),
        ops::ZONE => ans(call::<()>(op, payload).and_then(|c| guest.zone(host, &c.settings))),
        other => ans::<()>(Err(format!(
            "op {other} is not in this DNS guest's op table"
        ))),
    }
}

#[macro_export]
macro_rules! export_dns {
    ($guest:ty, grants = [$($grant:ident),* $(,)?] $(,)?) => {
        $crate::product_guest::__export_guest!($guest, $crate::DnsGuest, $crate::dispatch, grants = [$($grant),*]);
    };
}

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

    #[derive(Default)]
    struct Canned;
    impl DnsGuest for Canned {
        fn manifest(&self) -> Manifest {
            Manifest::of_kind(kinds::DNS, "canned", &[])
        }
        fn check(&self, _: &dyn Host, settings: &serde_json::Value) -> Result<(), String> {
            settings
                .get("zone_id")
                .map(|_| ())
                .ok_or_else(|| "names no zone_id".to_string())
        }
        fn write(
            &self,
            _: &dyn Host,
            _: &serde_json::Value,
            records: &[DnsRecord],
        ) -> Result<String, String> {
            Ok(format!("{} written", records.len()))
        }
        fn remove(
            &self,
            _: &dyn Host,
            _: &serde_json::Value,
            names: &[String],
        ) -> Result<String, String> {
            if names.iter().any(|n| n == "down") {
                return Err("the provider is down".into());
            }
            Ok(format!("{} removed", names.len()))
        }
        fn listed(&self, _: &dyn Host, _: &serde_json::Value) -> Result<Vec<DnsRecord>, String> {
            Ok(vec![DnsRecord {
                name: "acme.gunnar.rs".into(),
                kind: "A".into(),
                value: "198.51.100.1".into(),
                proxied: false,
            }])
        }
    }

    fn call<T: serde::Serialize>(op: u32, settings: serde_json::Value, req: T) -> Vec<u8> {
        dispatch(&Canned, &NoHost, op, &wire::encode(&Call { settings, req }))
    }

    /// ★ **Every door round-trips and an error keeps its words** — a DNS guest answers the
    /// four doors the shop's writer has; an op it does not know is never a success.
    #[test]
    fn every_door_round_trips_and_an_error_keeps_its_words() {
        let s = serde_json::json!({"zone_id": "z"});
        let rec = DnsRecord {
            name: "acme.gunnar.rs".into(),
            kind: "A".into(),
            value: "198.51.100.1".into(),
            proxied: false,
        };
        let w: wire::Answer<String> =
            wire::decode(&call(ops::WRITE, s.clone(), vec![rec.clone(), rec.clone()])).unwrap();
        assert_eq!(w, Ok("2 written".into()));
        let t: wire::Answer<Written> = wire::decode(&call(
            ops::WRITE_TAKING_OVER,
            s.clone(),
            wire::TakeOver {
                records: vec![rec.clone()],
                take_over: vec!["acme.gunnar.rs".into()],
            },
        ))
        .unwrap();
        assert_eq!(
            t,
            Ok(Written {
                line: "1 written".into(),
                replaced: vec![]
            }),
            "the default takes nothing over"
        );
        let r: wire::Answer<String> =
            wire::decode(&call(ops::REMOVE, s.clone(), vec!["down".to_string()])).unwrap();
        assert_eq!(r, Err("the provider is down".into()));
        let l: wire::Answer<Vec<DnsRecord>> =
            wire::decode(&call(ops::LISTED, s.clone(), ())).unwrap();
        assert_eq!(l.unwrap()[0].name, "acme.gunnar.rs");
        let st: wire::Answer<bool> =
            wire::decode(&call(ops::SETTLED, s.clone(), vec![rec])).unwrap();
        assert_eq!(st, Ok(true));
        let c: wire::Answer<()> =
            wire::decode(&call(ops::CHECK, serde_json::json!({}), ())).unwrap();
        assert_eq!(c, Err("names no zone_id".into()));
        let u: wire::Answer<()> = wire::decode(&dispatch(&Canned, &NoHost, 999, b"{}")).unwrap();
        assert!(matches!(u, Err(m) if m.contains("999")));
        let bad: wire::Answer<String> =
            wire::decode(&dispatch(&Canned, &NoHost, ops::WRITE, b"not json")).unwrap();
        assert!(matches!(bad, Err(m) if m.contains("undecodable")));
        assert_eq!(Canned.manifest().kind, kinds::DNS);
    }
}