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