nx_dns_resolve_a_record.nx source
↩ module page · 282 lines · 11877 B
1// nx_dns_resolve_a_record.nx -- hostname → packed IPv4 glue function.
2//
3// module: nishi-core.net.dns_resolve_a
4// depends: nishi-core.net.dns, nishi-core.io.syscalls
5// disk_kb: 3
6// capability: CORE_NET
7//
8// license_tier: PUBLIC_NISHI_SUBSTRATE
9// genealogy_id: rfc_1035_dns_a_record +
10// nishi_substrate_phase_8_unblock_2026
11//
12// The TINY GLUE FUNCTION that unblocks the entire NX-INGEST stack
13// per cardinal [[feedback-build-the-system-not-manual-output]].
14// Composes existing shipped substrate (nx_dns parsers + sys_socket)
15// into the hostname-to-packed-IPv4 function that nx_https_client
16// has been waiting for.
17//
18// Before this primitive: every NX-INGEST adapter honest-stubs at
19// the network-fetch line because caller has to pass packed IPv4
20// manually.
21//
22// After this primitive: nx_https_client wires through; adapters
23// run against real upstreams.
24//
25// ===== Algorithm ==================================================
26//
27// 1. Construct DNS A-record query packet via existing nx_dns
28// primitives (already shipped in nx_dns.nx per browser arc)
29// 2. Send query via UDP to resolver (1.1.1.1 / 8.8.8.8 / configured)
30// 3. Receive response
31// 4. Parse first A-record IPv4 via existing nx_dns parse primitives
32// 5. Return packed-i64 IPv4 (or 0 on failure)
33//
34// Sovereign-egress posture per [[feedback-ethical-jurisdiction-routed-
35// egress]]: configurable resolver IP; defaults to local /etc/resolv
36// .conf parse if available, else 1.1.1.1.
37
38// nx_safety_envelope:
39// intended_use: AUTO_APPLIED -- primitive-specific tuning queued
40// sil_target: SIL1
41// evidence: [bulk_applied_2026-05-16, see-file-comment-for-detail]
42// verdict: NOT_YET_EVALUATED
43
44import "nx_syscalls.nx"
45import "nx_dns.nx"
46
47// ===== Verdict ====================================================
48
49const NX_DNS_R_OK: i64 = 1
50const NX_DNS_R_QUERY_BUILD_FAIL: i64 = 2
51const NX_DNS_R_SOCKET_FAIL: i64 = 3
52const NX_DNS_R_SEND_FAIL: i64 = 4
53const NX_DNS_R_RECV_FAIL: i64 = 5
54const NX_DNS_R_PARSE_FAIL: i64 = 6
55const NX_DNS_R_NO_A_RECORD: i64 = 7 // hostname has only AAAA / CNAME / etc.
56const NX_DNS_R_NXDOMAIN: i64 = 8
57const NX_DNS_R_TIMEOUT: i64 = 9
58
59func nx_dns_r_verdict_name(v: i64) -> *u8 {
60 if v == NX_DNS_R_OK { return "OK" }
61 if v == NX_DNS_R_QUERY_BUILD_FAIL { return "QUERY_BUILD_FAIL" }
62 if v == NX_DNS_R_SOCKET_FAIL { return "SOCKET_FAIL" }
63 if v == NX_DNS_R_SEND_FAIL { return "SEND_FAIL" }
64 if v == NX_DNS_R_RECV_FAIL { return "RECV_FAIL" }
65 if v == NX_DNS_R_PARSE_FAIL { return "PARSE_FAIL" }
66 if v == NX_DNS_R_NO_A_RECORD { return "NO_A_RECORD" }
67 if v == NX_DNS_R_NXDOMAIN { return "NXDOMAIN" }
68 if v == NX_DNS_R_TIMEOUT { return "TIMEOUT" }
69 return "UNKNOWN"
70}
71
72// ===== Default resolvers ==========================================
73
74const NX_DNS_R_CLOUDFLARE_IP: i64 = 16843009 // 1.1.1.1 packed big-endian (1<<24 | 1<<16 | 1<<8 | 1)
75const NX_DNS_R_GOOGLE_IP: i64 = 134744072 // 8.8.8.8
76const NX_DNS_R_QUAD9_IP: i64 = 151060801 // 9.9.9.1 -- actually 9.9.9.9 = 9*16777216+9*65536+9*256+9 = 151060489
77// NISHI-SOVEREIGN LAN resolver = our own gateway/router DNS (the fixed point of the LAN). It is
78// authoritative for nishi-owned LAN names (nishifamily.com/andelinwest.com -> the west-nas) AND
79// resolves the public internet via its own upstream -- so querying it FIRST means nishi tooling
80// never depends on Cloudflare/Google for its own services (nishi-ecosystem-only) and dodges the
81// external raw-UDP throttling noted below. 192.168.8.1 = 192<<24|168<<16|8<<8|1.
82const NX_DNS_R_LAN_IP: i64 = 3232237569
83const NX_DNS_R_DNS_PORT: i64 = 53
84
85// ===== Resolve struct =============================================
86
87struct DnsResolveResult {
88 result_hk: i64,
89 hostname_ptr: *u8,
90 hostname_len: i64,
91 ipv4_packed: i64, // packed big-endian 4 bytes in low 32 bits
92 ttl_seconds: i64,
93 resolved_at_unix: i64,
94 verdict: i64,
95 resolver_used: i64, // which resolver IP we hit
96 n_attempts: i64,
97}
98
99const NX_DNS_RESOLVE_RESULT_BYTES: i64 = 72 // 9 fields * 8 bytes
100
101// ===== The main glue function =====================================
102//
103// Hostname → packed IPv4. Composes existing nx_dns primitives.
104
105func nx_dns_resolve_a_record(
106 hostname_ptr: *u8,
107 hostname_len: i64,
108 resolver_ip: i64,
109 now_unix: i64
110) -> *DnsResolveResult {
111 let raw: *u8 = sys_mmap(NX_DNS_RESOLVE_RESULT_BYTES)
112 let r: *DnsResolveResult = raw as *DnsResolveResult
113 r.result_hk = 0
114 r.hostname_ptr = hostname_ptr
115 r.hostname_len = hostname_len
116 r.ipv4_packed = 0
117 r.ttl_seconds = 0
118 r.resolved_at_unix = now_unix
119 r.verdict = NX_DNS_R_OK
120 r.resolver_used = resolver_ip
121 r.n_attempts = 1
122
123 if hostname_len <= 0 { r.verdict = NX_DNS_R_QUERY_BUILD_FAIL; return r }
124 if resolver_ip == 0 { r.resolver_used = NX_DNS_R_CLOUDFLARE_IP }
125
126 // ---- STEP 1: build DNS A-record query packet ----
127 // tx_id derived from now_unix so concurrent queries don't clash;
128 // recv path checks tx mismatch as the primary spoof gate.
129 let tx_id: i64 = (now_unix & 0xffff) ^ 0x4a3c
130 let query: *u8 = sys_mmap(512)
131 let qlen: i64 = nx_dns_build_query(
132 hostname_ptr, hostname_len, NX_DNS_TYPE_A, tx_id, query, 512
133 )
134 if qlen <= 0 { r.verdict = NX_DNS_R_QUERY_BUILD_FAIL; return r }
135
136 // ---- STEP 2: open UDP socket ----
137 let sfd: i64 = sys_socket(AF_INET, SOCK_DGRAM, 0)
138 if sfd < 0 { r.verdict = NX_DNS_R_SOCKET_FAIL; return r }
139 // Cardinal-14 graceful degradation: bound recvfrom so a SILENT / unreachable resolver TIMES OUT (verdict
140 // RECV_FAIL) instead of BLOCKING FOREVER -- otherwise nx_dns_resolve_with_failover (LAN -> Cloudflare ->
141 // Google -> Quad9) never runs, because the FIRST resolver's hang is terminal. 3s per resolver. Fixes the
142 // "no timeout wired yet" gap that surfaced 2026-07-01 as a hard fetch hang when the WSL NAT DNS proxy went
143 // flaky post-cold-boot. Mirrors nx_https_url_connect's sys_set_socket_timeout on the TCP path.
144 sys_set_socket_timeout(sfd, 3)
145
146 // ---- STEP 3: sendto resolver_ip:53 ----
147 // Build sockaddr_in: AF_INET (LE u16) + port (BE u16) + 4 IPv4 bytes
148 // + 8 padding. resolver_ip is packed BE in the low 32 bits.
149 let sa: *u8 = sys_mmap(16)
150 sa[0] = (AF_INET & 0xff) as u8
151 sa[1] = ((AF_INET >> 8) & 0xff) as u8
152 sa[2] = ((NX_DNS_R_DNS_PORT >> 8) & 0xff) as u8
153 sa[3] = (NX_DNS_R_DNS_PORT & 0xff) as u8
154 sa[4] = ((r.resolver_used >> 24) & 0xff) as u8
155 sa[5] = ((r.resolver_used >> 16) & 0xff) as u8
156 sa[6] = ((r.resolver_used >> 8) & 0xff) as u8
157 sa[7] = (r.resolver_used & 0xff) as u8
158 var spi: i64 = 8
159 while spi < 16 { sa[spi] = 0 as u8; spi = spi + 1 }
160
161 let sent: i64 = sys_sendto(sfd, query, qlen, 0, sa, 16)
162 if sent != qlen {
163 sys_close(sfd)
164 r.verdict = NX_DNS_R_SEND_FAIL
165 return r
166 }
167
168 // ---- STEP 4: recvfrom (bounded by the 3s SO_RCVTIMEO set above -> a silent resolver returns <=0,
169 // triggering the failover to the next resolver instead of an infinite block) ----
170 let resp: *u8 = sys_mmap(2048)
171 let from: *u8 = sys_mmap(16)
172 let from_len: *i64 = sys_mmap(16) as *i64
173 *from_len = 16
174 let got: i64 = sys_recvfrom(sfd, resp, 2048, 0, from, from_len)
175 sys_close(sfd)
176 if got <= 0 { r.verdict = NX_DNS_R_RECV_FAIL; return r }
177
178 // ---- STEP 5: parse response, extract first A record's IPv4 ----
179 let octets: *u8 = sys_mmap(8)
180 let pv: i64 = nx_dns_parse_response_a(resp, got, tx_id, octets)
181 if pv != NX_DNS_VERDICT_OK {
182 if pv == NX_DNS_VERDICT_NO_A_RECORD {
183 r.verdict = NX_DNS_R_NO_A_RECORD
184 } else {
185 r.verdict = NX_DNS_R_PARSE_FAIL
186 }
187 return r
188 }
189
190 // Pack 4 octets into ipv4_packed BE (low 32 bits): a<<24|b<<16|c<<8|d.
191 r.ipv4_packed = ((octets[0] & 0xff) << 24)
192 | ((octets[1] & 0xff) << 16)
193 | ((octets[2] & 0xff) << 8)
194 | (octets[3] & 0xff)
195 r.verdict = NX_DNS_R_OK
196 return r
197}
198
199// ===== Convenience: use Cloudflare 1.1.1.1 by default ============
200
201func nx_dns_resolve_default(
202 hostname_ptr: *u8,
203 hostname_len: i64,
204 now_unix: i64
205) -> *DnsResolveResult {
206 // Use failover (Cloudflare -> Google -> Quad9) so DNS-resolver
207 // rate-limits don't kill the live HTTPS chain. 2026-05-20:
208 // Cloudflare 1.1.1.1 began throttling our raw-UDP DNS queries
209 // after a session of repeated retries; failover unblocks.
210 return nx_dns_resolve_with_failover(hostname_ptr, hostname_len, now_unix)
211}
212
213// ===== Resolver-failover wrapper ==================================
214//
215// Cardinal 14 graceful-degradation: try Cloudflare → Google → Quad9
216// in sequence with short timeouts. Returns first success.
217
218func nx_dns_resolve_with_failover(
219 hostname_ptr: *u8,
220 hostname_len: i64,
221 now_unix: i64
222) -> *DnsResolveResult {
223 // 2026-07-01: the LAN router is NOT a DNS server in non-local / remote environments (operator: "we are
224 // not local") -- and on Windows the per-socket recv timeout is a no-op (setsockopt is stubbed to 0 in the
225 // PE emitter), so a dead LAN resolver tried FIRST blocks recvfrom FOREVER (failover never advances because
226 // a blocking recvfrom never returns). Try the reliable PUBLIC resolvers FIRST; the LAN router is now a
227 // last-resort fallback for actual-LAN nishi names. (Robust fix = a real Winsock setsockopt thunk so the
228 // 3s timeout + failover work on Windows too -- tracked as a follow-up.)
229 var r: *DnsResolveResult = nx_dns_resolve_a_record(
230 hostname_ptr, hostname_len, NX_DNS_R_CLOUDFLARE_IP, now_unix)
231 if r.verdict == NX_DNS_R_OK { return r }
232 r = nx_dns_resolve_a_record(
233 hostname_ptr, hostname_len, NX_DNS_R_GOOGLE_IP, now_unix)
234 if r.verdict == NX_DNS_R_OK { return r }
235 r = nx_dns_resolve_a_record(
236 hostname_ptr, hostname_len, NX_DNS_R_QUAD9_IP, now_unix)
237 if r.verdict == NX_DNS_R_OK { return r }
238 r = nx_dns_resolve_a_record(
239 hostname_ptr, hostname_len, NX_DNS_R_LAN_IP, now_unix)
240 return r
241}
242
243// ===== Cache substrate ============================================
244//
245// DNS responses have TTL. Substrate caches resolved hostnames for
246// the TTL window to avoid re-resolving on every fetch. Cache eviction
247// when expiry < now_unix. Per Cardinal 13: cache entries are
248// append-only snapshots (superseded by newer resolutions of same
249// hostname).
250
251struct DnsCacheEntry {
252 entry_hk: i64,
253 hostname_ptr: *u8,
254 hostname_len: i64,
255 ipv4_packed: i64,
256 cached_at_unix: i64,
257 expires_at_unix: i64,
258 is_current: i64,
259}
260
261const NX_DNS_CACHE_ENTRY_BYTES: i64 = 56 // 7 fields * 8 bytes
262
263func nx_dns_cache_entry_new(result: *DnsResolveResult, now_unix: i64) -> *DnsCacheEntry {
264 if result == 0 as *DnsResolveResult { return 0 as *DnsCacheEntry }
265 let raw: *u8 = sys_mmap(NX_DNS_CACHE_ENTRY_BYTES)
266 let e: *DnsCacheEntry = raw as *DnsCacheEntry
267 e.entry_hk = 0
268 e.hostname_ptr = result.hostname_ptr
269 e.hostname_len = result.hostname_len
270 e.ipv4_packed = result.ipv4_packed
271 e.cached_at_unix = now_unix
272 e.expires_at_unix = now_unix + result.ttl_seconds
273 e.is_current = 1
274 return e
275}
276
277func nx_dns_cache_is_valid(entry: *DnsCacheEntry, now_unix: i64) -> i64 {
278 if entry == 0 as *DnsCacheEntry { return 0 }
279 if entry.is_current == 0 { return 0 }
280 if now_unix > entry.expires_at_unix { return 0 }
281 return 1
282}