nx_external_path_audit.nx source
↩ module page · 188 lines · 6792 B
1// nx_external_path_audit.nx -- NR-1.5 of NISHI_ROUTER_CONTROL_ROADMAP.
2//
3// Bits-up health check for the public WAN path. Runs on the NAS (or
4// any host with internet egress); periodically tries to TCP-connect
5// from this host's OUTBOUND interface to its OWN public IP on :443.
6// Logs OK / TIMEOUT / REFUSED per check + writes a verdict file the
7// operator (or other Nishi substrate) can grep.
8//
9// Today's outage (2026-05-21) revealed the operator had ZERO bits-up
10// signal that the router was dropping inbound :443. Friend reports
11// via Waterfox console blowups are NOT a substrate signal. This is.
12//
13// V1 simplicity:
14// - Hardcoded public IP (75.28.18.94) + port (443). NR-1.5b adds DNS-
15// resolved lookup so it auto-tracks WAN IP changes.
16// - Loops forever (5 sec interval). No daemon-level scheduling -- just
17// run under nx_supervisor_run.
18// - Writes one line per probe to /tmp/nx_external_path.log (path
19// configurable via env in V2).
20//
21// V2 sub-stones:
22// - NR-1.5b: resolve target via our sovereign DNS, not hardcoded IP
23// - NR-1.5c: emit signals through D5 nx_metrics (instead of /tmp log)
24// - NR-1.5d: per-port audit (probe :443 AND :80 AND :8443 etc)
25// - NR-1.5e: tail-and-alert mode (push alert when verdict changes)
26
27import "nx_syscalls.nx"
28
29// IP packed as 32-bit network-order (big-endian) for sockaddr_in.
30// Constants for 75.28.18.94 = 0x4B121E5E -> network-order = 0x5E1E124B
31// We'll compute via direct byte assembly to avoid endian errors.
32
33const NX_EPA_TARGET_IP_A: i64 = 75
34const NX_EPA_TARGET_IP_B: i64 = 28
35const NX_EPA_TARGET_IP_C: i64 = 18
36const NX_EPA_TARGET_IP_D: i64 = 94
37const NX_EPA_TARGET_PORT: i64 = 443
38
39const NX_EPA_INTERVAL_SEC: i64 = 5
40const NX_EPA_CONNECT_TIMEOUT_SEC: i64 = 3
41
42const NX_EPA_VERDICT_OK: i64 = 0
43const NX_EPA_VERDICT_TIMEOUT: i64 = 1
44const NX_EPA_VERDICT_REFUSED: i64 = 2
45const NX_EPA_VERDICT_ERROR: i64 = 3
46
47const AF_INET_CONST: i64 = 2
48const SOCK_STREAM_CONST: i64 = 1
49
50// ===== Helpers ======================================================
51
52func _epa_strlen(s: *u8) -> i64 {
53 var n: i64 = 0
54 while s[n] != 0 { n = n + 1 }
55 return n
56}
57
58func _epa_w(fd: i64, s: *u8) -> i64 {
59 return sys_write(fd, s, _epa_strlen(s))
60}
61
62// Write decimal i64.
63func _epa_wi(fd: i64, n: i64) -> i64 {
64 let buf: *u8 = sys_mmap(24)
65 var x: i64 = n
66 var neg: i64 = 0
67 if x < 0 { neg = 1; x = 0 - x }
68 let digits: *u8 = sys_mmap(24)
69 var nd: i64 = 0
70 if x == 0 { digits[0] = 48 as u8; nd = 1 }
71 while x > 0 {
72 digits[nd] = ((x - (x / 10) * 10) + 48) as u8
73 nd = nd + 1
74 x = x / 10
75 }
76 var off: i64 = 0
77 if neg == 1 { buf[off] = 45 as u8; off = off + 1 }
78 var i: i64 = nd
79 while i > 0 { i = i - 1; buf[off] = digits[i]; off = off + 1 }
80 return sys_write(fd, buf, off)
81}
82
83// Build a sockaddr_in for IPv4 connect. 16 bytes total:
84// bytes 0-1: AF_INET (2) little-endian = {0x02, 0x00}
85// bytes 2-3: port big-endian
86// bytes 4-7: IP big-endian
87// bytes 8-15: padding zeros
88func _epa_build_sockaddr(out: *u8, ip_a: i64, ip_b: i64, ip_c: i64, ip_d: i64,
89 port: i64) -> i64 {
90 out[0] = 2 as u8 // AF_INET low byte
91 out[1] = 0 as u8 // AF_INET high byte
92 out[2] = ((port / 256) % 256) as u8 // port high
93 out[3] = (port % 256) as u8 // port low
94 out[4] = ip_a as u8
95 out[5] = ip_b as u8
96 out[6] = ip_c as u8
97 out[7] = ip_d as u8
98 var i: i64 = 8
99 while i < 16 { out[i] = 0 as u8; i = i + 1 }
100 return 0
101}
102
103// Attempt one TCP connect probe. Returns NX_EPA_VERDICT_*.
104// We do a SYNCHRONOUS connect (no SO_TIMEOUT for V1 -- relies on the
105// kernel's TCP SYN backoff, which is ~3s on first SYN retry). For V2
106// we'd add fcntl O_NONBLOCK + poll for sub-second timeouts.
107func nx_epa_probe_once() -> i64 {
108 let fd: i64 = sys_socket(AF_INET_CONST, SOCK_STREAM_CONST, 0 as i64)
109 if fd < 0 { return NX_EPA_VERDICT_ERROR }
110
111 let addr: *u8 = sys_mmap(16)
112 _epa_build_sockaddr(addr, NX_EPA_TARGET_IP_A, NX_EPA_TARGET_IP_B,
113 NX_EPA_TARGET_IP_C, NX_EPA_TARGET_IP_D,
114 NX_EPA_TARGET_PORT)
115 let rc: i64 = sys_connect(fd, addr, 16 as i64)
116 sys_close(fd)
117 if rc == 0 { return NX_EPA_VERDICT_OK }
118 // Linux connect() failure codes:
119 // ECONNREFUSED = -111 -> REFUSED (router forwarded but no listener)
120 // ETIMEDOUT = -110 -> TIMEOUT (packet dropped)
121 // EHOSTUNREACH = -113
122 // ENETUNREACH = -101
123 // V1 maps anything non-zero to TIMEOUT-or-REFUSED bucket.
124 if rc == 0 - 111 { return NX_EPA_VERDICT_REFUSED }
125 if rc == 0 - 110 { return NX_EPA_VERDICT_TIMEOUT }
126 return NX_EPA_VERDICT_ERROR
127}
128
129func _epa_verdict_name(v: i64) -> *u8 {
130 if v == NX_EPA_VERDICT_OK { return "OK" as *u8 }
131 if v == NX_EPA_VERDICT_TIMEOUT { return "TIMEOUT" as *u8 }
132 if v == NX_EPA_VERDICT_REFUSED { return "REFUSED" as *u8 }
133 return "ERROR" as *u8
134}
135
136// Sleep `secs` via clock_nanosleep.
137func _epa_sleep(secs: i64) -> i64 {
138 let ts: *i64 = sys_mmap(16) as *i64
139 ts[0] = secs
140 ts[1] = 0
141 return __syscall(SYS_CLOCK_NANOSLEEP, 0, 0, ts, 0, 0, 0)
142}
143
144// ===== Daemon entry ==================================================
145
146func nx_external_path_audit_run() -> i64 {
147 let log_path: *u8 = "/tmp/nx_external_path.log" as *u8
148 let log_fd: i64 = sys_openat_append(log_path, 0o644 as i64)
149 if log_fd < 0 { return 1 }
150
151 _epa_w(log_fd, "nx_external_path_audit: target " as *u8)
152 _epa_wi(log_fd, NX_EPA_TARGET_IP_A); _epa_w(log_fd, "." as *u8)
153 _epa_wi(log_fd, NX_EPA_TARGET_IP_B); _epa_w(log_fd, "." as *u8)
154 _epa_wi(log_fd, NX_EPA_TARGET_IP_C); _epa_w(log_fd, "." as *u8)
155 _epa_wi(log_fd, NX_EPA_TARGET_IP_D); _epa_w(log_fd, ":" as *u8)
156 _epa_wi(log_fd, NX_EPA_TARGET_PORT)
157 _epa_w(log_fd, " interval=" as *u8)
158 _epa_wi(log_fd, NX_EPA_INTERVAL_SEC)
159 _epa_w(log_fd, "s starting\n" as *u8)
160
161 // Also banner to stdout for nx_supervisor_run visibility.
162 let banner: *u8 = "nx_external_path_audit: starting (log -> /tmp/nx_external_path.log)\n" as *u8
163 sys_write(1 as i64, banner, _epa_strlen(banner))
164
165 var seq: i64 = 0
166 var running: i64 = 1
167 while running == 1 {
168 let v: i64 = nx_epa_probe_once()
169 let now: i64 = sys_now_ms()
170
171 _epa_w(log_fd, "seq=" as *u8)
172 _epa_wi(log_fd, seq)
173 _epa_w(log_fd, " ms=" as *u8)
174 _epa_wi(log_fd, now)
175 _epa_w(log_fd, " verdict=" as *u8)
176 _epa_w(log_fd, _epa_verdict_name(v))
177 _epa_w(log_fd, "\n" as *u8)
178
179 seq = seq + 1
180 _epa_sleep(NX_EPA_INTERVAL_SEC)
181 }
182 sys_close(log_fd)
183 return 0
184}
185
186func main() -> i64 {
187 return nx_external_path_audit_run()
188}