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}