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}