code wiki / _hdl_build / nx_lan_scan.nx

nx_lan_scan.nx source

↩ module page · 284 lines · 11580 B

1// nx_lan_scan.nx -- shared sovereign LAN sweep primitive (no main). 2// 3// WHY THIS EXISTS (rule 15, DRY through shared libraries): two organs now ask 4// the SAME question -- "is host H open on port P?" -- nx_printer_ctl (printer 5// discovery/survey) and nx_iot_ctl (home-electronics inventory). If each keeps 6// its own copy of the sweep, one gets fixed and the other silently does not, 7// and the two tools then DISAGREE about the same LAN while both look healthy. 8// The attribution rule they must agree on is the reason to share, not the 9// line count. 10// 11// The implementation is lifted VERBATIM from the proven, gate-green 12// pctl_discover_scan / pctl_probe_host (nx_printer_ctl_lib), so behaviour is 13// identical BY CONSTRUCTION rather than by re-derivation. 14// 15// SCALE LAW (learned the hard way, banked in the iot-printer-fabric memory): 16// any organ that fans out network I/O over MCP must bound total latency to the 17// tools-daemon fork-capture window, or the call returns status=0 with no output 18// while working fine on the CLI. Three rules, all encoded below: 19// 1. BATCHED -- fire all 254 non-blocking connects first, then poll. 20// 2. POLL-UNTIL-RESOLVED -- a single poll() returns as soon as ANY fd is 21// ready (the ~250 instantly-refused hosts) BEFORE a real host finishes its 22// handshake, so it finds NOTHING. Mark each fd done as its revents fire 23// (write 0xff over the pollfd fd bytes so poll ignores it next round) and 24// keep polling the still-connecting ones. 25// 3. TIGHT BUDGET -- responsive devices answer in <50ms; unreachable hosts 26// must not be waited on. 27// 28// Open iff POLLOUT && !POLLERR && !POLLHUP. 29// 30// THE BATCH IS THE BOUND (2026-08-18, lane F box health -- MEASURED, not read). 31// A later edit replaced the sweep's raw non-blocking connect with nx_connect_bounded, 32// whose contract is to POLL THAT ONE FD for up to NX_CONN_DEFAULT_MS (6,000 ms) before 33// returning. Inside the fire-all-254 loop that turned rule 1 into its opposite: 254 34// serial 6 s waits (~25 min per port, ~100 min per `monitor` run) with every socket 35// held open until the final close loop. Live evidence: ~20 `nx_printer_ctl monitor` 36// beat instances piled up at RSS 4 kB with FDSize 512 and fd counts rising 14-26 37// per minute (nx_leak_check fleet, full population), each one a sweep still stuck in 38// its OPEN loop. Rules 1-3 above were correct; the primitive underneath them had been 39// swapped for one whose OWN bound was the thing being batched away. 40// FIX, by construction: the sweep issues the raw non-blocking connect (EINPROGRESS is 41// the expected answer on a fresh SOCK_NONBLOCK socket) and lets ITS OWN poll be the 42// only bound; the single-host probe uses nx_connect_bounded with the CALLER's 43// timeout_ms once, instead of the 6 s default followed by a second poll on the same 44// fd. Neither path can now outlive its caller's budget, and no fd outlives its call. 45// 46// license_tier: ORIGINAL No hw writes (Rule 26). 47import "nx_syscalls.nx" 48import "nx_connect.nx" // bounded connect for the SINGLE-host probe; the sweep bounds itself 49const LSCAN_MAGIC_65535: i64 = 65535 50 51const LSCAN_SOCK_NONBLOCK: i64 = 2048 // SOCK_NONBLOCK 52const LSCAN_POLLOUT: i64 = 4 53const LSCAN_POLLERR: i64 = 8 54const LSCAN_POLLHUP: i64 = 16 55const LSCAN_HOSTS: i64 = 254 // .1 .. .254 of a /24 56const LSCAN_ROUNDS: i64 = 12 // poll-until-resolved rounds 57const LSCAN_MIN_PER_MS: i64 = 5 // floor on the per-round poll slice 58 59// THE SHARED SWEEP BUDGET (rule 11: this is exactly the kind of number that 60// must not be re-invented per call site). 61// 62// Sharing lscan_sweep alone did NOT make the organs agree -- MEASURED 2026-07-25: 63// nx_printer_ctl swept at 80ms/port and reported 6 hosts on 192.168.8, while 64// nx_iot_ctl swept at 60ms/port and reported 4 of the same 6. Hosts near the 65// budget (192.168.8.158, .205) flip in and out. The duplicated CODE was only 66// half the problem; the duplicated NUMBER is the other half, because a device's 67// visibility is a function of it. One constant, both organs, same answer. 68// 69// Bounded by the scale law: total = budget x TCP-port-count must stay inside 70// the tools-daemon fork-capture window (12 rows x 120ms ~= 1.4s worst case). 71const LSCAN_BUDGET_MS: i64 = 120 72 73// ---- dotted /24 prefix parse: "192.168.8" -> (192<<16)|(168<<8)|8 ---- 74// Returns -1 on anything that is not exactly three 0..255 octets. 75func lscan_parse_prefix(s: *u8) -> i64 { 76 var val: i64 = 0 77 var ndig: i64 = 0 78 var oct: i64 = 0 79 var base: i64 = 0 80 var i: i64 = 0 81 var done: i64 = 0 82 while done == 0 { 83 let c: i64 = s[i] & 0xff 84 if c == 0 { 85 done = 1 86 } else { 87 if c == 46 { 88 if ndig == 0 { return 0 - 1 } 89 if val > 255 { return 0 - 1 } 90 base = (base << 8) | val 91 oct = oct + 1 92 val = 0 93 ndig = 0 94 i = i + 1 95 } else { 96 if c < 48 { return 0 - 1 } 97 if c > 57 { return 0 - 1 } 98 val = val * 10 + (c - 48) 99 ndig = ndig + 1 100 i = i + 1 101 } 102 } 103 } 104 if ndig == 0 { return 0 - 1 } 105 if val > 255 { return 0 - 1 } 106 base = (base << 8) | val 107 oct = oct + 1 108 if oct != 3 { return 0 - 1 } 109 return base 110} 111 112// ---- dotted IPv4 parse: "192.168.1.42" -> packed --------------------- 113// Returns -1 on anything that is not exactly four 0..255 octets. 114func lscan_parse_ip(s: *u8) -> i64 { 115 var val: i64 = 0 116 var ndig: i64 = 0 117 var oct: i64 = 0 118 var packed: i64 = 0 119 var i: i64 = 0 120 var done: i64 = 0 121 while done == 0 { 122 let c: i64 = s[i] & 0xff 123 if c == 0 { 124 done = 1 125 } else { 126 if c == 46 { 127 if ndig == 0 { return 0 - 1 } 128 if val > 255 { return 0 - 1 } 129 packed = (packed << 8) | val 130 oct = oct + 1 131 val = 0 132 ndig = 0 133 i = i + 1 134 } else { 135 if c < 48 { return 0 - 1 } 136 if c > 57 { return 0 - 1 } 137 val = val * 10 + (c - 48) 138 ndig = ndig + 1 139 i = i + 1 140 } 141 } 142 } 143 if ndig == 0 { return 0 - 1 } 144 if val > 255 { return 0 - 1 } 145 packed = (packed << 8) | val 146 oct = oct + 1 147 if oct != 4 { return 0 - 1 } 148 return packed 149} 150 151// ---- port parse: decimal 1..65535, else -1 --------------------------- 152func lscan_parse_port(s: *u8) -> i64 { 153 var v: i64 = 0 154 var nd: i64 = 0 155 var i: i64 = 0 156 var done: i64 = 0 157 while done == 0 { 158 let c: i64 = s[i] & 0xff 159 if c == 0 { done = 1 } 160 else { 161 if c < 48 { return 0 - 1 } 162 if c > 57 { return 0 - 1 } 163 v = v * 10 + (c - 48) 164 nd = nd + 1 165 if v > LSCAN_MAGIC_65535 { return 0 - 1 } 166 i = i + 1 167 } 168 } 169 if nd == 0 { return 0 - 1 } 170 if v < 1 { return 0 - 1 } 171 return v 172} 173 174// ---- fill a sockaddr_in (16 bytes) ---------------------------------- 175func lscan_fill_sockaddr(addr: *u8, ipv4: i64, port: i64) -> i64 { 176 addr[0] = 2 as u8 177 addr[1] = 0 as u8 178 addr[2] = ((port >> 8) & 0xff) as u8 179 addr[3] = (port & 0xff) as u8 180 addr[4] = ((ipv4 >> 24) & 0xff) as u8 181 addr[5] = ((ipv4 >> 16) & 0xff) as u8 182 addr[6] = ((ipv4 >> 8) & 0xff) as u8 183 addr[7] = (ipv4 & 0xff) as u8 184 var z: i64 = 8 185 while z < 16 { addr[z] = 0 as u8; z = z + 1 } 186 return 16 187} 188 189// ---- single-host non-blocking TCP connect probe --------------------- 190// 1 if <ipv4>:<port> accepts within timeout_ms, else 0. Never blocks longer 191// than timeout_ms; closes the fd on every path. ONE bounded wait, at the 192// caller's budget: nx_connect_bounded already polls the fd for writability and 193// re-asks connect() for the real verdict, so a second poll here would only 194// double the wait and add nothing to the answer. 195func lscan_probe_host(ipv4: i64, port: i64, timeout_ms: i64) -> i64 { 196 let fd: i64 = sys_socket(2, 1 | LSCAN_SOCK_NONBLOCK, 0) 197 if fd < 0 { return 0 } 198 let addr: *u8 = sys_mmap(16) 199 lscan_fill_sockaddr(addr, ipv4, port) 200 var open: i64 = 0 201 if nx_connect_bounded(fd, addr, 16, timeout_ms) == 0 { open = 1 } 202 sys_close(fd) 203 return open 204} 205 206// ---- /24 sweep ------------------------------------------------------- 207// Scan base24.1 .. base24.254 on <port>; fill out_ips with packed IPv4 of every 208// host that accepted; return the count (capped at max). 209// The connect here is the RAW non-blocking sys_connect ON PURPOSE (see the header): 210// on a SOCK_NONBLOCK socket it returns at once (0, or -EINPROGRESS while the SYN is in 211// flight); the poll-until-resolved loop below is the single bound over all 254 fds. 212func lscan_sweep(base24: i64, port: i64, timeout_ms: i64, out_ips: *i64, max: i64) -> i64 { 213 let pfds: *u8 = sys_mmap(LSCAN_HOSTS * 8) 214 let hmap: *i64 = sys_mmap(LSCAN_HOSTS * 8) as *i64 215 let fds: *i64 = sys_mmap(LSCAN_HOSTS * 8) as *i64 216 let addr: *u8 = sys_mmap(16) 217 var nact: i64 = 0 218 var h: i64 = 1 219 while h <= LSCAN_HOSTS { 220 let fd: i64 = sys_socket(2, 1 | LSCAN_SOCK_NONBLOCK, 0) 221 if fd >= 0 { 222 let ipv4: i64 = (base24 << 8) | h 223 lscan_fill_sockaddr(addr, ipv4, port) 224 sys_connect(fd, addr, 16) 225 let po: i64 = nact * 8 226 pfds[po + 0] = (fd & 0xff) as u8 227 pfds[po + 1] = ((fd >> 8) & 0xff) as u8 228 pfds[po + 2] = ((fd >> 16) & 0xff) as u8 229 pfds[po + 3] = ((fd >> 24) & 0xff) as u8 230 pfds[po + 4] = LSCAN_POLLOUT as u8 231 pfds[po + 5] = 0 as u8 232 pfds[po + 6] = 0 as u8 233 pfds[po + 7] = 0 as u8 234 hmap[nact] = h 235 fds[nact] = fd 236 nact = nact + 1 237 } 238 h = h + 1 239 } 240 let done: *i64 = sys_mmap(LSCAN_HOSTS * 8) as *i64 241 var d0: i64 = 0 242 while d0 < nact { done[d0] = 0; d0 = d0 + 1 } 243 var cnt: i64 = 0 244 var remaining: i64 = nact 245 var per: i64 = timeout_ms / LSCAN_ROUNDS 246 if per < LSCAN_MIN_PER_MS { per = LSCAN_MIN_PER_MS } 247 var round: i64 = 0 248 while round < LSCAN_ROUNDS { 249 if remaining <= 0 { 250 round = LSCAN_ROUNDS 251 } else { 252 sys_poll(pfds, nact, per) 253 var i: i64 = 0 254 while i < nact { 255 if done[i] == 0 { 256 let rev: i64 = (pfds[i * 8 + 6] as i64) & 0xff 257 if rev != 0 { 258 done[i] = 1 259 remaining = remaining - 1 260 pfds[i * 8 + 0] = 0xff as u8 261 pfds[i * 8 + 1] = 0xff as u8 262 pfds[i * 8 + 2] = 0xff as u8 263 pfds[i * 8 + 3] = 0xff as u8 264 if (rev & LSCAN_POLLOUT) != 0 { 265 if (rev & LSCAN_POLLERR) == 0 { 266 if (rev & LSCAN_POLLHUP) == 0 { 267 if cnt < max { 268 out_ips[cnt] = (base24 << 8) | hmap[i] 269 cnt = cnt + 1 270 } 271 } 272 } 273 } 274 } 275 } 276 i = i + 1 277 } 278 round = round + 1 279 } 280 } 281 var c: i64 = 0 282 while c < nact { sys_close(fds[c]); c = c + 1 } 283 return cnt 284}