code wiki / (root) / nx_dns_resolve_a_record.nx

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}