code wiki / (root) / nx_socket_list.nx

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}