code wiki / _hdl_build / nx_lan_scan.nx

nx_lan_scan.nx source

↩ module page · 284 lines · 10531 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// license_tier: ORIGINAL No hw writes (Rule 26). 31import "nx_syscalls.nx" 32import "nx_connect.nx" // bounded connect: a raw sys_connect hangs ~127s on a black-holed host 33const LSCAN_MAGIC_65535: i64 = 65535 34 35const LSCAN_SOCK_NONBLOCK: i64 = 2048 // SOCK_NONBLOCK 36const LSCAN_POLLOUT: i64 = 4 37const LSCAN_POLLERR: i64 = 8 38const LSCAN_POLLHUP: i64 = 16 39const LSCAN_HOSTS: i64 = 254 // .1 .. .254 of a /24 40const LSCAN_ROUNDS: i64 = 12 // poll-until-resolved rounds 41const LSCAN_MIN_PER_MS: i64 = 5 // floor on the per-round poll slice 42 43// THE SHARED SWEEP BUDGET (rule 11: this is exactly the kind of number that 44// must not be re-invented per call site). 45// 46// Sharing lscan_sweep alone did NOT make the organs agree -- MEASURED 2026-07-25: 47// nx_printer_ctl swept at 80ms/port and reported 6 hosts on 192.168.8, while 48// nx_iot_ctl swept at 60ms/port and reported 4 of the same 6. Hosts near the 49// budget (192.168.8.158, .205) flip in and out. The duplicated CODE was only 50// half the problem; the duplicated NUMBER is the other half, because a device's 51// visibility is a function of it. One constant, both organs, same answer. 52// 53// Bounded by the scale law: total = budget x TCP-port-count must stay inside 54// the tools-daemon fork-capture window (12 rows x 120ms ~= 1.4s worst case). 55const LSCAN_BUDGET_MS: i64 = 120 56 57// ---- dotted /24 prefix parse: "192.168.8" -> (192<<16)|(168<<8)|8 ---- 58// Returns -1 on anything that is not exactly three 0..255 octets. 59func lscan_parse_prefix(s: *u8) -> i64 { 60 var val: i64 = 0 61 var ndig: i64 = 0 62 var oct: i64 = 0 63 var base: i64 = 0 64 var i: i64 = 0 65 var done: i64 = 0 66 while done == 0 { 67 let c: i64 = s[i] & 0xff 68 if c == 0 { 69 done = 1 70 } else { 71 if c == 46 { 72 if ndig == 0 { return 0 - 1 } 73 if val > 255 { return 0 - 1 } 74 base = (base << 8) | val 75 oct = oct + 1 76 val = 0 77 ndig = 0 78 i = i + 1 79 } else { 80 if c < 48 { return 0 - 1 } 81 if c > 57 { return 0 - 1 } 82 val = val * 10 + (c - 48) 83 ndig = ndig + 1 84 i = i + 1 85 } 86 } 87 } 88 if ndig == 0 { return 0 - 1 } 89 if val > 255 { return 0 - 1 } 90 base = (base << 8) | val 91 oct = oct + 1 92 if oct != 3 { return 0 - 1 } 93 return base 94} 95 96// ---- dotted IPv4 parse: "192.168.1.42" -> packed --------------------- 97// Returns -1 on anything that is not exactly four 0..255 octets. 98func lscan_parse_ip(s: *u8) -> i64 { 99 var val: i64 = 0 100 var ndig: i64 = 0 101 var oct: i64 = 0 102 var packed: i64 = 0 103 var i: i64 = 0 104 var done: i64 = 0 105 while done == 0 { 106 let c: i64 = s[i] & 0xff 107 if c == 0 { 108 done = 1 109 } else { 110 if c == 46 { 111 if ndig == 0 { return 0 - 1 } 112 if val > 255 { return 0 - 1 } 113 packed = (packed << 8) | val 114 oct = oct + 1 115 val = 0 116 ndig = 0 117 i = i + 1 118 } else { 119 if c < 48 { return 0 - 1 } 120 if c > 57 { return 0 - 1 } 121 val = val * 10 + (c - 48) 122 ndig = ndig + 1 123 i = i + 1 124 } 125 } 126 } 127 if ndig == 0 { return 0 - 1 } 128 if val > 255 { return 0 - 1 } 129 packed = (packed << 8) | val 130 oct = oct + 1 131 if oct != 4 { return 0 - 1 } 132 return packed 133} 134 135// ---- port parse: decimal 1..65535, else -1 --------------------------- 136func lscan_parse_port(s: *u8) -> i64 { 137 var v: i64 = 0 138 var nd: i64 = 0 139 var i: i64 = 0 140 var done: i64 = 0 141 while done == 0 { 142 let c: i64 = s[i] & 0xff 143 if c == 0 { done = 1 } 144 else { 145 if c < 48 { return 0 - 1 } 146 if c > 57 { return 0 - 1 } 147 v = v * 10 + (c - 48) 148 nd = nd + 1 149 if v > LSCAN_MAGIC_65535 { return 0 - 1 } 150 i = i + 1 151 } 152 } 153 if nd == 0 { return 0 - 1 } 154 if v < 1 { return 0 - 1 } 155 return v 156} 157 158// ---- fill a sockaddr_in (16 bytes) ---------------------------------- 159func lscan_fill_sockaddr(addr: *u8, ipv4: i64, port: i64) -> i64 { 160 addr[0] = 2 as u8 161 addr[1] = 0 as u8 162 addr[2] = ((port >> 8) & 0xff) as u8 163 addr[3] = (port & 0xff) as u8 164 addr[4] = ((ipv4 >> 24) & 0xff) as u8 165 addr[5] = ((ipv4 >> 16) & 0xff) as u8 166 addr[6] = ((ipv4 >> 8) & 0xff) as u8 167 addr[7] = (ipv4 & 0xff) as u8 168 var z: i64 = 8 169 while z < 16 { addr[z] = 0 as u8; z = z + 1 } 170 return 16 171} 172 173// ---- single-host non-blocking TCP connect probe --------------------- 174// 1 if <ipv4>:<port> accepts within timeout_ms, else 0. Never blocks longer 175// than timeout_ms; closes the fd on every path. 176func lscan_probe_host(ipv4: i64, port: i64, timeout_ms: i64) -> i64 { 177 let fd: i64 = sys_socket(2, 1 | LSCAN_SOCK_NONBLOCK, 0) 178 if fd < 0 { return 0 } 179 let addr: *u8 = sys_mmap(16) 180 lscan_fill_sockaddr(addr, ipv4, port) 181 let rc: i64 = nx_connect_bounded(fd, addr, 16, NX_CONN_DEFAULT_MS) 182 var open: i64 = 0 183 if rc == 0 { 184 open = 1 185 } else { 186 let pf: *u8 = sys_mmap(8) 187 pf[0] = (fd & 0xff) as u8 188 pf[1] = ((fd >> 8) & 0xff) as u8 189 pf[2] = ((fd >> 16) & 0xff) as u8 190 pf[3] = ((fd >> 24) & 0xff) as u8 191 pf[4] = LSCAN_POLLOUT as u8 192 pf[5] = 0 as u8 193 pf[6] = 0 as u8 194 pf[7] = 0 as u8 195 let pr: i64 = sys_poll(pf, 1, timeout_ms) 196 if pr > 0 { 197 let rev: i64 = (pf[6] as i64) & 0xff 198 if (rev & LSCAN_POLLOUT) != 0 { 199 if (rev & LSCAN_POLLERR) == 0 { 200 if (rev & LSCAN_POLLHUP) == 0 { open = 1 } 201 } 202 } 203 } 204 } 205 sys_close(fd) 206 return open 207} 208 209// ---- /24 sweep ------------------------------------------------------- 210// Scan base24.1 .. base24.254 on <port>; fill out_ips with packed IPv4 of every 211// host that accepted; return the count (capped at max). 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 nx_connect_bounded(fd, addr, 16, NX_CONN_DEFAULT_MS) 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}