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}