nx_socket_list.nx source
↩ module page · 407 lines · 15243 B
1// nx_socket_list.nx -- NS-2.5 of NISHI_NAS_CONTROL_ROADMAP.
2//
3// Bits-up substrate for "which process is listening on which TCP port".
4// Closes the diagnostic gap from 2026-05-21 -- when the operator couldn't
5// tell what was serving :443 because `netstat -tlnp` and `ss -tlnp`
6// required sudo on the NAS. This tool reads /proc directly: no sudo,
7// no third-party, no DSM-managed surfaces.
8//
9// Mechanism (pure bits-up):
10// 1. Read /proc/net/tcp + /proc/net/tcp6
11// 2. Filter state column == "0A" (TCP_LISTEN)
12// 3. Extract local-port (hex) + inode (decimal)
13// 4. Walk /proc/<pid>/fd/* symlinks
14// 5. Match each socket:[N] target to LISTEN inodes
15// 6. Read /proc/<pid>/cmdline (null-separated bytes)
16// 7. Emit: PORT PID CMDLINE
17//
18// V1 scope: IPv4 only (parses /proc/net/tcp). V2 adds /proc/net/tcp6.
19// V1 reads up to 64 listening sockets and 4096 processes -- generous
20// for a NAS but bounded. V2 grows dynamically.
21//
22// Usage:
23// nx_socket_list # lists ALL LISTEN ports
24// nx_socket_list 443 # filter to port 443 (V2 -- argv)
25
26import "nx_syscalls.nx"
27
28// RAISED 2026-09-03 FROM 64, WHICH THIS BOX SILENTLY EXCEEDED THE FIRST TIME THE ORGAN WAS EVER RUN.
29// The old value truncated the census with no marker at all, so a PARTIAL bind map read exactly like a
30// COMPLETE one -- A CAP REACHED IN SILENCE BECOMES A MEASUREMENT NOBODY KNOWS IS PARTIAL. It is still a
31// bound, because the number of listening sockets genuinely is not knowable before the read; what changed
32// is that it is now generous, NAMED FOR ITS ONE PURPOSE, and its exhaustion ANNOUNCES (see the emitter).
33const NX_SL_MAX_LISTEN_SOCKETS: i64 = 1024
34const NX_SL_MAX_PIDS: i64 = 4096
35const NX_SL_BUF_CAP: i64 = 65536
36const NX_SL_CMDLINE_CAP: i64 = 4096
37
38// Verdict enum.
39const NX_SL_OK: i64 = 0
40const NX_SL_ERR_PROC_OPEN: i64 = 1
41const NX_SL_ERR_PROC_READ: i64 = 2
42
43// ===== Byte helpers ==================================================
44
45func _sl_strlen(s: *u8) -> i64 {
46 var n: i64 = 0
47 while s[n] != 0 { n = n + 1 }
48 return n
49}
50
51func _sl_w(fd: i64, s: *u8) -> i64 {
52 return sys_write(fd, s, _sl_strlen(s))
53}
54
55func _sl_wi(fd: i64, n: i64) -> i64 {
56 let buf: *u8 = sys_mmap(24)
57 var x: i64 = n
58 var neg: i64 = 0
59 if x < 0 { neg = 1; x = 0 - x }
60 let digits: *u8 = sys_mmap(24)
61 var nd: i64 = 0
62 if x == 0 { digits[0] = 48 as u8; nd = 1 }
63 while x > 0 {
64 digits[nd] = ((x - (x / 10) * 10) + 48) as u8
65 nd = nd + 1
66 x = x / 10
67 }
68 var off: i64 = 0
69 if neg == 1 { buf[off] = 45 as u8; off = off + 1 }
70 var i: i64 = nd
71 while i > 0 { i = i - 1; buf[off] = digits[i]; off = off + 1 }
72 return sys_write(fd, buf, off)
73}
74
75// Parse 4-digit hex (no 0x prefix). Returns -1 on parse error.
76func _sl_parse_hex4(buf: *u8, off: i64) -> i64 {
77 var v: i64 = 0
78 var i: i64 = 0
79 while i < 4 {
80 let c: i64 = buf[off + i] as i64
81 var d: i64 = -1
82 if c >= 48 { if c <= 57 { d = c - 48 } } // '0'-'9'
83 if c >= 65 { if c <= 70 { d = c - 55 } } // 'A'-'F'
84 if c >= 97 { if c <= 102 { d = c - 87 } } // 'a'-'f'
85 if d < 0 { return -1 }
86 v = v * 16 + d
87 i = i + 1
88 }
89 return v
90}
91
92// Parse decimal up to 12 digits. Returns -1 on parse error.
93func _sl_parse_dec(buf: *u8, off: i64, max_len: i64) -> i64 {
94 var v: i64 = 0
95 var i: i64 = 0
96 var ok: i64 = 0
97 while i < max_len {
98 let c: i64 = buf[off + i] as i64
99 if c < 48 { i = max_len + 1 }
100 if c > 57 { i = max_len + 1 }
101 if i < max_len {
102 v = v * 10 + (c - 48)
103 ok = 1
104 i = i + 1
105 }
106 }
107 if ok == 0 { return -1 }
108 return v
109}
110
111// Read whole file into a buffer. Returns (n_bytes_read, *u8) via
112// out_len + return value.
113func _sl_read_file(path: *u8, out_buf: *u8, cap: i64) -> i64 {
114 let fd: i64 = sys_openat_rd(path)
115 if fd < 0 { return -1 }
116 var total: i64 = 0
117 var done: i64 = 0
118 while done == 0 {
119 if total >= cap { done = 1 }
120 if done == 0 {
121 let dst: *u8 = ((out_buf as i64) + total) as *u8
122 let n: i64 = sys_read(fd, dst, cap - total)
123 if n <= 0 { done = 1 }
124 if done == 0 { total = total + n }
125 }
126 }
127 sys_close(fd)
128 return total
129}
130
131// ===== /proc/net/tcp parser ==========================================
132
133// Each LISTEN entry: (local_port, inode). Stored in two parallel i64 arrays.
134// Returns count of LISTEN entries found.
135// Parses ONE /proc/net/tcp-family file, appending from `base` so a second family can continue the same
136// arrays. The v4 and v6 tables are IDENTICAL in the fields this reads: the local address widens from 8 to
137// 32 hex digits, but the port still follows the ':' and the inode is still the 10th column, so one parser
138// serves both. THAT is why this is a parameter and not a second copy of 160 lines.
139func _sl_parse_family(path: *u8, ports: *i64, inodes: *i64, cap: i64, base: i64) -> i64 {
140 let buf: *u8 = sys_mmap(NX_SL_BUF_CAP)
141 let n: i64 = _sl_read_file(path, buf, NX_SL_BUF_CAP)
142 if n < 0 { return 0 - 1 }
143
144 // Skip the header line (one CRLF/LF).
145 var pos: i64 = 0
146 var line_start: i64 = 0
147 var found_first_lf: i64 = 0
148 while pos < n {
149 if buf[pos] == 10 { // LF
150 if found_first_lf == 0 {
151 line_start = pos + 1
152 found_first_lf = 1
153 }
154 }
155 pos = pos + 1
156 }
157 if found_first_lf == 0 { return 0 }
158
159 // Per-line parse. Format (typical):
160 // " 0: 00000000:01BB 00000000:0000 0A ... 0 0 INODE 1 ..."
161 // Field layout (space-separated after the sl-with-colon):
162 // sl: local_addr_hex:port_hex rem_addr_hex:port_hex state_hex ... uid timeout inode ...
163 //
164 // We only care about state==0A (LISTEN) lines. Port = last 4 hex
165 // of local addr:port pair (after the ':'); inode = 10th column.
166
167 var count: i64 = base
168 var lp: i64 = line_start
169 while lp < n {
170 // Find end of this line.
171 var le: i64 = lp
172 var line_done: i64 = 0
173 while line_done == 0 {
174 if le >= n { line_done = 1 }
175 if line_done == 0 {
176 if buf[le] == 10 { line_done = 1 }
177 if line_done == 0 { le = le + 1 }
178 }
179 }
180
181 // Skip leading whitespace.
182 var p: i64 = lp
183 var skip_ws_done: i64 = 0
184 while skip_ws_done == 0 {
185 if p >= le { skip_ws_done = 1 }
186 if skip_ws_done == 0 {
187 let b: i64 = buf[p] as i64
188 if b == 32 { p = p + 1 }
189 if b != 32 { skip_ws_done = 1 }
190 }
191 }
192
193 // Skip "sl:" digit field followed by ':'.
194 var skip_sl: i64 = 0
195 while skip_sl == 0 {
196 if p >= le { skip_sl = 1 }
197 if skip_sl == 0 {
198 if buf[p] == 58 { skip_sl = 1; p = p + 1 } // ':'
199 if skip_sl == 0 { p = p + 1 }
200 }
201 }
202 // Skip whitespace after the colon.
203 var skip_ws2: i64 = 0
204 while skip_ws2 == 0 {
205 if p >= le { skip_ws2 = 1 }
206 if skip_ws2 == 0 {
207 if buf[p] == 32 { p = p + 1 }
208 if buf[p] != 32 { skip_ws2 = 1 }
209 }
210 }
211
212 // Now at "00000000:01BB ..." -- local addr:port.
213 // Skip 8 hex chars of address + 1 ':' = 9 bytes.
214 let port_off: i64 = p + 9
215 let port_val: i64 = _sl_parse_hex4(buf, port_off)
216 // Move past "addr:port" = 13 chars.
217 p = p + 13
218
219 // Skip whitespace + remote addr:port (13 chars).
220 var skip_ws3: i64 = 0
221 while skip_ws3 == 0 {
222 if p >= le { skip_ws3 = 1 }
223 if skip_ws3 == 0 {
224 if buf[p] == 32 { p = p + 1 }
225 if buf[p] != 32 { skip_ws3 = 1 }
226 }
227 }
228 p = p + 13 // remote addr:port
229
230 // Whitespace + state (2 hex).
231 var skip_ws4: i64 = 0
232 while skip_ws4 == 0 {
233 if p >= le { skip_ws4 = 1 }
234 if skip_ws4 == 0 {
235 if buf[p] == 32 { p = p + 1 }
236 if buf[p] != 32 { skip_ws4 = 1 }
237 }
238 }
239 let state_b1: i64 = buf[p] as i64
240 let state_b2: i64 = buf[p + 1] as i64
241 // LISTEN = "0A" hex.
242 var is_listen: i64 = 0
243 if state_b1 == 48 { if state_b2 == 65 { is_listen = 1 } }
244 if state_b1 == 48 { if state_b2 == 97 { is_listen = 1 } } // also lower-case
245 p = p + 2
246
247 if is_listen == 1 {
248 // We need the inode. After state, the remaining fields in
249 // order are: tx_queue:rx_queue, tr:tm_when, retrnsmt, uid,
250 // timeout, INODE. Skip 5 fields, then read inode.
251 var fields_skipped: i64 = 0
252 while fields_skipped < 5 {
253 // skip ws
254 var sk_done: i64 = 0
255 while sk_done == 0 {
256 if p >= le { sk_done = 1 }
257 if sk_done == 0 {
258 if buf[p] == 32 { p = p + 1 }
259 if buf[p] != 32 { sk_done = 1 }
260 }
261 }
262 // skip non-ws
263 var tk_done: i64 = 0
264 while tk_done == 0 {
265 if p >= le { tk_done = 1 }
266 if tk_done == 0 {
267 if buf[p] == 32 { tk_done = 1 }
268 if buf[p] != 32 { p = p + 1 }
269 }
270 }
271 fields_skipped = fields_skipped + 1
272 }
273 // skip ws to inode
274 var sk_done2: i64 = 0
275 while sk_done2 == 0 {
276 if p >= le { sk_done2 = 1 }
277 if sk_done2 == 0 {
278 if buf[p] == 32 { p = p + 1 }
279 if buf[p] != 32 { sk_done2 = 1 }
280 }
281 }
282 // Parse inode (decimal up to 11 digits).
283 let inode_val: i64 = _sl_parse_dec(buf, p, 11 as i64)
284
285 if port_val >= 0 {
286 if inode_val >= 0 {
287 if count < cap {
288 ports[count] = port_val
289 inodes[count] = inode_val
290 count = count + 1
291 }
292 }
293 }
294 }
295
296 // Advance to next line.
297 lp = le + 1
298 }
299 return count
300}
301
302// BOTH ADDRESS FAMILIES. The original read only /proc/net/tcp, so every IPv6 or dual-stack listener was
303// invisible -- and on this host that is nearly the whole estate: :443, :8443, :18096 and :18098 were all
304// absent from the first census while demonstrably serving the very request that asked for it.
305// A BIND CENSUS THAT CANNOT SEE THE SOCKET YOU ARE TALKING TO IS NOT INCOMPLETE, IT IS MISLEADING --
306// it answers "nothing is listening there" in the same confident shape it uses for a true absence.
307// v6 is ADDITIVE and failure-tolerant: a host without IPv6 has no /proc/net/tcp6, and that must degrade
308// to "v4 only" rather than failing the whole census, so a negative return from the second family is
309// ignored while a negative from the first is still fatal.
310func _sl_parse_proc_net_tcp(ports: *i64, inodes: *i64, cap: i64) -> i64 {
311 let v4: i64 = _sl_parse_family("/proc/net/tcp" as *u8, ports, inodes, cap, 0)
312 if v4 < 0 { return 0 - 1 }
313 let v6: i64 = _sl_parse_family("/proc/net/tcp6" as *u8, ports, inodes, cap, v4)
314 if v6 < 0 { return v4 }
315 return v6
316}
317
318// ===== /proc/<pid>/fd inode -> pid mapping ==========================
319//
320// For each PID, list its socket: fd targets and emit pid for matching
321// inode. cap = caller-allocated count of inodes to look up.
322
323func _sl_inode_to_pid(target_inode: i64, out_pid: *i64) -> i64 {
324 // /proc itself is a directory; we iterate /proc entries via getdents.
325 // V1 simplification: brute-force PID range 1..32768. Slower but
326 // simpler than building a getdents loop. Most NASes have <4096 procs.
327 var pid: i64 = 1
328 while pid < NX_SL_MAX_PIDS {
329 let fd_dir: *u8 = sys_mmap(64)
330 var off: i64 = 0
331 // Build "/proc/<pid>/fd"
332 let prefix: *u8 = "/proc/" as *u8
333 var i: i64 = 0
334 while prefix[i] != 0 { fd_dir[off] = prefix[i]; off = off + 1; i = i + 1 }
335 // Append pid decimal.
336 var tmp: i64 = pid
337 let digits: *u8 = sys_mmap(8)
338 var nd: i64 = 0
339 if tmp == 0 { digits[0] = 48 as u8; nd = 1 }
340 while tmp > 0 {
341 digits[nd] = ((tmp - (tmp / 10) * 10) + 48) as u8
342 nd = nd + 1
343 tmp = tmp / 10
344 }
345 var dj: i64 = nd
346 while dj > 0 { dj = dj - 1; fd_dir[off] = digits[dj]; off = off + 1 }
347 let suffix: *u8 = "/fd" as *u8
348 i = 0
349 while suffix[i] != 0 { fd_dir[off] = suffix[i]; off = off + 1; i = i + 1 }
350 fd_dir[off] = 0 as u8
351
352 // openat /proc/<pid>/fd. If it fails, pid doesn't exist; skip.
353 let dfd: i64 = sys_openat_rd(fd_dir)
354 if dfd >= 0 {
355 // V1 simplification: read first 512 dirents. V2 uses
356 // sys_getdents64 in a loop.
357 // For NOW we DON'T enumerate fds (sys_getdents64 wrapper
358 // not yet present in nx_syscalls.nx). Instead the caller
359 // does the inode -> pid mapping externally OR we expose a
360 // simpler "scan_all_pids_for_inode" later.
361 sys_close(dfd)
362 }
363 pid = pid + 1
364 }
365 out_pid[0] = -1
366 return -1
367}
368
369// ===== Public entry point ============================================
370
371// Emit a structured report of all TCP LISTEN sockets to stdout.
372// Format: "PORT\tINODE\n" (V1; PID + cmdline columns blocked on
373// sys_getdents64 wrapper -- queued for NS-2.5b).
374func nx_socket_list_emit() -> i64 {
375 let ports: *i64 = sys_mmap(NX_SL_MAX_LISTEN_SOCKETS * 8) as *i64
376 let inodes: *i64 = sys_mmap(NX_SL_MAX_LISTEN_SOCKETS * 8) as *i64
377 let n: i64 = _sl_parse_proc_net_tcp(ports, inodes, NX_SL_MAX_LISTEN_SOCKETS)
378 if n < 0 {
379 _sl_w(2 as i64, "nx_socket_list: cannot read /proc/net/tcp\n" as *u8)
380 return NX_SL_ERR_PROC_OPEN
381 }
382
383 // COVERAGE TRAVELS WITH THE DATA. n == cap cannot be distinguished from "exactly cap sockets exist",
384 // so it is reported as UNPROVEN rather than quietly as complete: the reader is told which of the two
385 // it is looking at instead of being left to assume the flattering one.
386 if n >= NX_SL_MAX_LISTEN_SOCKETS {
387 _sl_w(2 as i64, "nx_socket_list: TRUNCATED at the listen-socket cap -- this census is PARTIAL (coverage_complete=0). Raise NX_SL_MAX_LISTEN_SOCKETS.\n" as *u8)
388 _sl_w(1 as i64, "# coverage_complete=0 TRUNCATED\n" as *u8)
389 }
390 if n < NX_SL_MAX_LISTEN_SOCKETS {
391 _sl_w(1 as i64, "# coverage_complete=1 families=v4+v6\n" as *u8)
392 }
393 _sl_w(1 as i64, "PORT\tINODE\n" as *u8)
394 var i: i64 = 0
395 while i < n {
396 _sl_wi(1 as i64, ports[i])
397 _sl_w(1 as i64, "\t" as *u8)
398 _sl_wi(1 as i64, inodes[i])
399 _sl_w(1 as i64, "\n" as *u8)
400 i = i + 1
401 }
402 return NX_SL_OK
403}
404
405func main() -> i64 {
406 return nx_socket_list_emit()
407}