code wiki / (root) / nx_acme_dns01_propagation.nx

nx_acme_dns01_propagation.nx source

↩ module page · 191 lines · 8390 B

1// nx_acme_dns01_propagation.nx -- real DNS-01 TXT propagation poll. 2// 3// Replaces the fixed sys_sleep_ms(360000) "hope it propagated" wait in 4// nx_acme_dns01_issue with an ACTIVE poll: after the _acme-challenge TXT 5// records are created at Porkbun, query DNS until the expected value(s) are 6// VISIBLE, then trigger immediately -- bounded by a ~6 min ceiling so a 7// propagation stall never hangs the renewal (it falls through to trigger, 8// matching the old behaviour's worst case). 9// 10// WHY query the AUTHORITATIVE nameserver, not just 1.1.1.1: 11// The same name _acme-challenge.<domain> is reused every renewal with a 12// NEW value; a public recursive resolver can serve the PREVIOUS run's 13// cached TXT RRset (TTL 600s) and report "visible" against stale data, or 14// cache a negative answer. The authoritative Porkbun NS always serves the 15// live zone -- exactly what Let's Encrypt's own validators resolve. So we: 16// 1. look up the zone's NS set (recursive resolver), 17// 2. resolve one NS hostname -> IP, 18// 3. query the TXT DIRECTLY at that authoritative IP. 19// If NS discovery fails we fall back to a public resolver (still bounded). 20// 21// Sovereign UDP (sys_socket SOCK_DGRAM / sendto / recvfrom) + nx_dns wire 22// codec + nx_dns_txt parsers. No third-party resolver library. 23// 24// license_tier: ORIGINAL (composes nx_dns RFC 1035 + the shipped A-resolver) 25// genealogy_id: international-research-sources/ietf/rfc_1035 + rfc_8555 ยง8.4 26 27import "nx_syscalls.nx" 28import "nx_dns.nx" 29import "nx_dns_txt.nx" 30import "nx_dns_resolve_a_record.nx" 31 32// Poll cadence + ceiling (no magic numbers; tunable here). The 6-min ceiling 33// matches the old fixed-sleep worst case so behaviour never regresses; the 34// poll interval keeps the authoritative NS query rate civil. 35const NX_DNS01_POLL_INTERVAL_MS: i64 = 5000 36const NX_DNS01_POLL_TIMEOUT_MS: i64 = 360000 37const NX_DNS01_UDP_TIMEOUT_SEC: i64 = 3 38const NX_DNS01_UDP_BUF: i64 = 2048 39 40// Build a sockaddr_in (AF_INET LE, port BE, ipv4 BE, 8 pad) into out[0..16). 41func _dns01_sockaddr(out: *u8, ipv4_packed: i64, port: i64) -> i64 { 42 out[0] = (AF_INET & 0xff) as u8 43 out[1] = ((AF_INET >> 8) & 0xff) as u8 44 out[2] = ((port >> 8) & 0xff) as u8 45 out[3] = (port & 0xff) as u8 46 out[4] = ((ipv4_packed >> 24) & 0xff) as u8 47 out[5] = ((ipv4_packed >> 16) & 0xff) as u8 48 out[6] = ((ipv4_packed >> 8) & 0xff) as u8 49 out[7] = (ipv4_packed & 0xff) as u8 50 var i: i64 = 8 51 while i < 16 { out[i] = 0 as u8; i = i + 1 } 52 return 16 53} 54 55// One UDP DNS query/response round trip to resolver_ip:53. Builds a query 56// for (qname,qtype) with a fresh tx_id, sends it, reads ONE datagram into 57// resp_out. Returns bytes received (>0), the tx_id via out_txid, or a 58// NEGATIVE error. A 3s recv timeout means a dropped packet fails this round 59// (the caller's poll loop retries) instead of hanging forever. 60func nx_dns01_udp_query( 61 resolver_ip: i64, 62 qname: *u8, qname_len: i64, qtype: i64, 63 resp_out: *u8, resp_cap: i64, 64 out_txid: *i64, now: i64 65) -> i64 { 66 // Fresh tx_id per query (RFC 5452 off-path spoof defense): millisecond 67 // clock mixed with the query type, so back-to-back polls never reuse one. 68 let nowms: i64 = sys_now_realtime_ms() 69 let tx_id: i64 = ((nowms & 0xffff) ^ ((qtype << 7) & 0xffff)) ^ 0x5b2d 70 out_txid[0] = tx_id 71 let q: *u8 = sys_mmap(512) 72 let qlen: i64 = nx_dns_build_query(qname, qname_len, qtype, tx_id, q, 512) 73 if qlen < 0 { return 0 - 1 } 74 let fd: i64 = sys_socket(AF_INET, SOCK_DGRAM, 0) 75 if fd < 0 { return 0 - 2 } 76 sys_set_socket_timeout(fd, NX_DNS01_UDP_TIMEOUT_SEC) // bounded recv 77 let sa: *u8 = sys_mmap(16) 78 _dns01_sockaddr(sa, resolver_ip, NX_DNS_PORT) 79 if sys_sendto(fd, q, qlen, 0, sa, 16) != qlen { sys_close(fd); return 0 - 3 } 80 let from: *u8 = sys_mmap(16) 81 let from_len: *i64 = sys_mmap(16) as *i64 82 from_len[0] = 16 83 let got: i64 = sys_recvfrom(fd, resp_out, resp_cap, 0, from, from_len) 84 sys_close(fd) 85 if got <= 0 { return 0 - 4 } 86 return got 87} 88 89// Is `want` present as a TXT value for `fqdn` at resolver_ip? 90// 1 = visible, 0 = answered-but-not-present, <0 = query/transport error. 91func nx_dns01_txt_present_at( 92 resolver_ip: i64, 93 fqdn: *u8, fqdn_len: i64, 94 want: *u8, want_len: i64, 95 now: i64 96) -> i64 { 97 let resp: *u8 = sys_mmap(NX_DNS01_UDP_BUF) 98 let txid: *i64 = sys_mmap(8) as *i64 99 let got: i64 = nx_dns01_udp_query(resolver_ip, fqdn, fqdn_len, NX_DNS_TYPE_TXT, resp, NX_DNS01_UDP_BUF, txid, now) 100 if got < 0 { return got } 101 let found: *i64 = sys_mmap(8) as *i64 102 let v: i64 = nx_dns_txt_contains(resp, got, txid[0], want, want_len, found) 103 if v != NX_DNS_TXT_OK { return 0 - 100 - v } // distinct negative band 104 return found[0] 105} 106 107// Discover the zone's first authoritative NS IP via a recursive resolver, 108// then return it (packed BE), or 0 if discovery failed. 109func nx_dns01_authoritative_ip(domain: *u8, domain_len: i64, now: i64) -> i64 { 110 // 1. NS lookup at a recursive resolver (Cloudflare; NS RRset is stable). 111 let resp: *u8 = sys_mmap(NX_DNS01_UDP_BUF) 112 let txid: *i64 = sys_mmap(8) as *i64 113 let got: i64 = nx_dns01_udp_query(NX_DNS_R_CLOUDFLARE_IP, domain, domain_len, NX_DNS_TYPE_NS, resp, NX_DNS01_UDP_BUF, txid, now) 114 if got < 0 { return 0 } 115 let ns_name: *u8 = sys_mmap(256) 116 let ns_len: i64 = nx_dns_first_ns(resp, got, txid[0], ns_name, 255) 117 if ns_len <= 0 { return 0 } 118 ns_name[ns_len] = 0 as u8 119 // 2. Resolve that NS hostname -> IP (LAN->Cloudflare->... failover). 120 let r: *DnsResolveResult = nx_dns_resolve_default(ns_name, ns_len, now) 121 if r.verdict != NX_DNS_R_OK { return 0 } 122 if r.ipv4_packed == 0 { return 0 } 123 return r.ipv4_packed 124} 125 126// Is `want` visible for `fqdn`, preferring the AUTHORITATIVE NS (no stale 127// cache) and falling back to a public resolver? 1/0/<0 as above. 128func nx_dns01_txt_visible( 129 domain: *u8, domain_len: i64, 130 fqdn: *u8, fqdn_len: i64, 131 want: *u8, want_len: i64, 132 now: i64 133) -> i64 { 134 let auth_ip: i64 = nx_dns01_authoritative_ip(domain, domain_len, now) 135 if auth_ip != 0 { 136 let av: i64 = nx_dns01_txt_present_at(auth_ip, fqdn, fqdn_len, want, want_len, now) 137 if av >= 0 { return av } // authoritative answered (visible or not) 138 } 139 // Fallback: public resolver (may lag on cache, still bounded). 140 return nx_dns01_txt_present_at(NX_DNS_R_CLOUDFLARE_IP, fqdn, fqdn_len, want, want_len, now) 141} 142 143// POLL until BOTH expected TXT values (wants[0..n)) are visible for the 144// _acme-challenge.<domain> name, or `timeout_ms` elapses. 145// returns 1 = all visible (trigger now), 0 = timed out (fall through and 146// trigger anyway -- old behaviour's worst case, never hangs). 147// wants is an array of n value pointers (i64-cast) with lengths in want_lens. 148// timeout_ms is an explicit parameter (configurable per call, rule 17); the 149// production caller passes NX_DNS01_POLL_TIMEOUT_MS. 150func nx_dns01_wait_txt_propagated( 151 domain: *u8, domain_len: i64, 152 wants: *i64, want_lens: *i64, n: i64, 153 timeout_ms: i64, now: i64 154) -> i64 { 155 // fqdn = "_acme-challenge." + domain 156 let fqdn: *u8 = sys_mmap(domain_len + 32) 157 let pfx: *u8 = "_acme-challenge." as *u8 158 var fo: i64 = 0 159 var pi: i64 = 0 160 while pfx[pi] != (0 as u8) { fqdn[fo] = pfx[pi]; fo = fo + 1; pi = pi + 1 } 161 var di: i64 = 0 162 while di < domain_len { fqdn[fo] = domain[di]; fo = fo + 1; di = di + 1 } 163 let fqdn_len: i64 = fo 164 165 var elapsed: i64 = 0 166 var first: i64 = 1 167 while elapsed < timeout_ms { 168 // settle between rounds (but check immediately on the first round) 169 if first == 0 { 170 sys_sleep_ms(NX_DNS01_POLL_INTERVAL_MS) 171 elapsed = elapsed + NX_DNS01_POLL_INTERVAL_MS 172 } 173 first = 0 174 var all: i64 = 1 175 var i: i64 = 0 176 while i < n { 177 let w: *u8 = wants[i] as *u8 178 let wl: i64 = want_lens[i] 179 let vis: i64 = nx_dns01_txt_visible(domain, domain_len, fqdn, fqdn_len, w, wl, now) 180 if vis != 1 { all = 0; i = n } // not yet visible -> stop checking this round 181 else { i = i + 1 } 182 } 183 if all == 1 { return 1 } 184 } 185 return 0 186} 187 188// Compile-only smoke. Live proof in nx_acme_dns01_propagation_gate.nx. 189func main() -> i64 { 190 return 0 191}