nx_http_server.nx source
↩ module page · 393 lines · 19036 B
1// nx_http_server.nx -- bind/listen/accept-loop HTTP/1.1 server.
2//
3// Closes the F1 rank-4 ship-order (CWE-352 CSRF). Composes existing
4// substrate primitives -- nothing new at the protocol layer:
5//
6// nx_http_io.nx -- nx_http_parse_request / nx_http_write_response
7// nx_http_resp.nx -- response builder (status, headers, body)
8// nx_http_router.nx -- route dispatch (method + path -> handler_id)
9// nx_syscalls_x86_64 -- socket / bind / listen / accept / setsockopt
10//
11// This file is the COMPOSITION FILE: it ties the existing pieces
12// into an accept-loop with sealed-enum verdicts at every step.
13//
14// Per audit-dashboard roadmap phase 2: this is the load-bearing
15// surface that lets nishifamily.com/audit be served live. The
16// dispatch handler for /audit reads docs/audit/snapshot.json +
17// nx_html_render's output buffer; this server is the wrapper.
18//
19// Sealed-enum verdict (server-state, distinct from HTTP status codes):
20// NXS_OK accept-loop iteration succeeded
21// NXS_SOCKET_ERR socket() failed -- usually EACCES (port<1024) or
22// EMFILE (too many open files)
23// NXS_BIND_ERR bind() failed -- usually EADDRINUSE (port in use)
24// NXS_LISTEN_ERR listen() failed
25// NXS_ACCEPT_ERR accept() returned negative (usually EINTR;
26// caller retries)
27// NXS_PARSE_ERR request didn't parse as HTTP/1.1
28// NXS_WRITE_ERR write to client socket failed (client gone)
29// NXS_BAD_ARG null pointer / negative size / unknown method
30//
31// Per cardinal user-owns-every-bit: caller picks the bind address +
32// port; substrate doesn't auto-bind to 0.0.0.0:80. Default in the
33// helper is 127.0.0.1 (loopback) -- user must explicitly opt in to
34// public binding.
35//
36// Per cardinal feedback-no-third-party-trust-native-or-nothing: no
37// external HTTP framework. No nginx, no caddy, no fastcgi.
38//
39// CSRF prevention (rank-4 CWE-352): the route-registration helper
40// nx_http_server_register_state_change refuses any path that
41// doesn't include `same_origin_check=1` in the route flags. This
42// is the structural prevention -- you cannot register a state-
43// mutating route without declaring same-origin or CSRF-token check
44// at registration time.
45//
46// nx_capability_claims:
47// needs: [sealed_enum, sockets, bounded_buffer]
48// provides: [http_server_accept_loop, http_route_registration,
49// csrf_structural_prevention_via_route_flags]
50// safety: [no_unchecked_deref, no_floating_point,
51// caller_chosen_bind_addr, bounded_request_buffer]
52// verdict: [sealed_enum_8_state]
53// license: ORIGINAL
54// kind: racing_crew_specialist
55// layer: L4 (composite over L3 nx_http + nx_http_io + router)
56// cwe: [CWE-352 CSRF structural prevention via route flags]
57// sss: [S6 sealed verdicts; S11 timing-mode declarable;
58// S13 zero external resources]
59
60// nx_safety_envelope:
61// intended_use: AUTO_APPLIED -- primitive-specific tuning queued
62// sil_target: SIL1
63// evidence: [bulk_applied_2026-05-16, see-file-comment-for-detail]
64// verdict: NOT_YET_EVALUATED
65
66import "nx_syscalls.nx"
67import "nx_http_io.nx"
68const NXS_MAGIC_65535: i64 = 65535
69
70// ---- Sealed enum: server verdict ---------------------------------
71
72const NXS_OK: i64 = 0
73const NXS_SOCKET_ERR: i64 = 1
74const NXS_BIND_ERR: i64 = 2
75const NXS_LISTEN_ERR: i64 = 3
76const NXS_ACCEPT_ERR: i64 = 4
77const NXS_PARSE_ERR: i64 = 5
78const NXS_WRITE_ERR: i64 = 6
79const NXS_BAD_ARG: i64 = 7
80const NXS_VERDICT_N: i64 = 8
81
82func nxs_verdict_is_valid(v: i64) -> i64 {
83 if v < 0 { return 0 }
84 if v >= NXS_VERDICT_N { return 0 }
85 return 1
86}
87
88func nxs_verdict_name(v: i64) -> *u8 {
89 if v == NXS_OK { return "OK" as *u8 }
90 if v == NXS_SOCKET_ERR { return "SOCKET_ERR" as *u8 }
91 if v == NXS_BIND_ERR { return "BIND_ERR" as *u8 }
92 if v == NXS_LISTEN_ERR { return "LISTEN_ERR" as *u8 }
93 if v == NXS_ACCEPT_ERR { return "ACCEPT_ERR" as *u8 }
94 if v == NXS_PARSE_ERR { return "PARSE_ERR" as *u8 }
95 if v == NXS_WRITE_ERR { return "WRITE_ERR" as *u8 }
96 if v == NXS_BAD_ARG { return "BAD_ARG" as *u8 }
97 return "INVALID" as *u8
98}
99
100// ---- AF_INET sockaddr builder ------------------------------------
101//
102// struct sockaddr_in {
103// uint16_t sin_family; // AF_INET = 2 (LE on x86)
104// uint16_t sin_port; // network-order (BE)
105// uint32_t sin_addr; // network-order (BE)
106// uint8_t sin_zero[8]; // padding
107// }
108// total 16 bytes.
109
110// Build a sockaddr_in in caller-provided buffer (must be >= 16 bytes).
111// addr_be_bytes is the 4-byte IPv4 address in NETWORK byte order
112// (caller responsibility; use nx_http_server_addr_loopback for 127.0.0.1).
113// Returns 16 on success, NXS_BAD_ARG on failure.
114func nx_http_server_make_sockaddr(out: *u8, port_host_order: i64,
115 addr_be_a: i64, addr_be_b: i64,
116 addr_be_c: i64, addr_be_d: i64) -> i64 {
117 if out == (0 as *u8) { return NXS_BAD_ARG }
118 if port_host_order < 0 { return NXS_BAD_ARG }
119 if port_host_order > NXS_MAGIC_65535 { return NXS_BAD_ARG }
120 // AF_INET = 2, little-endian on x86_64
121 out[0] = 2 as u8
122 out[1] = 0 as u8
123 // port: big-endian (network)
124 out[2] = ((port_host_order >> 8) & 0xff) as u8
125 out[3] = (port_host_order & 0xff) as u8
126 // address (already in network order via individual bytes)
127 out[4] = addr_be_a as u8
128 out[5] = addr_be_b as u8
129 out[6] = addr_be_c as u8
130 out[7] = addr_be_d as u8
131 // padding
132 out[8] = 0 as u8; out[9] = 0 as u8
133 out[10] = 0 as u8; out[11] = 0 as u8
134 out[12] = 0 as u8; out[13] = 0 as u8
135 out[14] = 0 as u8; out[15] = 0 as u8
136 return 16
137}
138
139// Convenience: 127.0.0.1
140func nx_http_server_addr_loopback(out: *u8, port: i64) -> i64 {
141 return nx_http_server_make_sockaddr(out, port, 127, 0, 0, 1)
142}
143
144// Convenience: 0.0.0.0 (any). Caller must explicitly invoke -- per
145// cardinal user-owns-every-bit, substrate doesn't default to public.
146func nx_http_server_addr_any(out: *u8, port: i64) -> i64 {
147 return nx_http_server_make_sockaddr(out, port, 0, 0, 0, 0)
148}
149
150// ---- Listen socket helper ----------------------------------------
151//
152// Creates a TCP socket, sets SO_REUSEADDR, binds, listens. Returns
153// the listen-fd on success or a negative sealed verdict (NXS_*_ERR)
154// negated to fit in the i64 return.
155//
156// Convention: positive return = fd; negative return = -(verdict+100)
157// so caller can distinguish from raw errno.
158
159const AF_INET_CONST: i64 = 2
160
161func nx_http_server_listen(addr_bytes: *u8, backlog: i64,
162 out_verdict: *i64) -> i64 {
163 if addr_bytes == (0 as *u8) {
164 *out_verdict = NXS_BAD_ARG
165 return -1
166 }
167 // SIGPIPE-safety BY CONSTRUCTION for every server built on this primitive.
168 //
169 // A process that writes to a peer which has closed its end takes SIGPIPE's
170 // default action -- TERMINATE. For an accept-loop daemon that is an outage
171 // with no diagnosis: it dies holding a healthy listening socket, so a
172 // liveness guard reports a crash-loop with no cause while the request that
173 // triggered it looks failed even though the work completed. Any client that
174 // gives up mid-response -- or a proxy hitting its own read timeout -- fires
175 // it (the seq1261/seq1126 outage class).
176 //
177 // A tree-wide search found ZERO SIGPIPE handling before 2026-07-30, i.e.
178 // EVERY server here had the flaw. Installing it per-daemon would be 52
179 // separate remembering-to-do-it sites; binding it to the act of creating a
180 // listening socket means a daemon CANNOT forget, and a new one inherits the
181 // fix on the day it is written. Writers see -EPIPE (-32) and handle it like
182 // any other failed write; unrelated write errors are unaffected. Idempotent,
183 // so a caller that also installs it directly stays correct.
184 // Proven both ways by nx_sigpipe_gate (T1 disease, T2 cure, T4 no masking).
185 sys_ignore_sigpipe()
186 if backlog <= 0 {
187 *out_verdict = NXS_BAD_ARG
188 return -1
189 }
190 let fd: i64 = sys_socket(AF_INET_CONST, SOCK_STREAM, 0)
191 if fd < 0 {
192 *out_verdict = NXS_SOCKET_ERR
193 return -1
194 }
195 // FD_CLOEXEC BY CONSTRUCTION -- the port-hostage class, measured live 2026-07-30.
196 //
197 // A listening socket without FD_CLOEXEC is INHERITED by every fork+execve the owner performs. The child
198 // then holds the port open for as long as it lives, and the OWNER CAN NEVER REBIND IT. Restarting the
199 // victim cannot possibly help, so a liveness guard restarts it forever against a port it will never get:
200 // an outage that SURVIVES EVERY RESTART and looks like a crash-loop with no cause.
201 //
202 // MEASURED: `nx_opaque_login.elf 9091 ...` -- argv naming port 9091 ONLY -- was found LISTENING on
203 // 127.0.0.1:18098, the mgmt API's port, while correctly serving 9091 at the same time. It never asked
204 // for 18098; it was forked from a parent holding that socket. nx_mgmt_api was DOWN with the restart
205 // counter climbing 6 -> 7, every mcp tool 503'd at the edge, and the whole control plane was unusable
206 // for every seat until the squatter happened to exit and released the fd.
207 //
208 // ⚠SO_REUSEPORT DOES NOT RESCUE THIS: the kernel only allows co-binding when EVERY socket on the port
209 // set SO_REUSEPORT, so one inherited legacy socket locks out even a REUSEPORT binder (and where it DOES
210 // co-bind it silently SPLITS traffic instead of failing loudly -- see nx_hotlisten_gate T1/T2).
211 //
212 // nx_torrent_daemon.nx:1257 already carried this exact line, learned from its own outage -- ONE site
213 // remembering for a tree of 52. Binding it to the act of creating a listening socket means a daemon
214 // CANNOT forget and a new one inherits the fix the day it is written. Same law as the SIGPIPE block
215 // above. Idempotent; a caller that also sets it stays correct. fcntl(fd, F_SETFD=2, FD_CLOEXEC=1).
216 __syscall(72, fd, 2, 1, 0, 0, 0)
217 // SO_REUSEADDR = 1 (i32 LE).
218 let optval: *u8 = sys_mmap(4)
219 optval[0] = 1 as u8
220 optval[1] = 0 as u8
221 optval[2] = 0 as u8
222 optval[3] = 0 as u8
223 sys_setsockopt(fd, SOL_SOCKET, SO_REUSEADDR, optval, 4)
224 let brc: i64 = sys_bind(fd, addr_bytes, 16)
225 if brc < 0 {
226 sys_close(fd)
227 *out_verdict = NXS_BIND_ERR
228 return -1
229 }
230 let lrc: i64 = sys_listen(fd, backlog)
231 if lrc < 0 {
232 sys_close(fd)
233 *out_verdict = NXS_LISTEN_ERR
234 return -1
235 }
236 *out_verdict = NXS_OK
237 return fd
238}
239
240// ---- R5: HOT-RESTART LISTENER (SO_REUSEPORT) --------------------------------------------------------
241// THE CLASS THIS DELETES: every /api/deploy and /api/restart today drops the in-flight request (FETCH-FAIL,
242// ~10x in one session) because the daemon is KILLED while answering. We wrote DOCTRINE around that ("503 =
243// EXPECTED, do NOT retry-hammer") -- a doctrine patch over a missing mechanism. With SO_REUSEPORT the NEW
244// process can bind the SAME port while the OLD one is still serving, so the old finishes its in-flight work
245// and exits with no connection ever refused. Then there is no 503 to announce, because there is no outage.
246// It also removes the seq1563 self-restart lease strand: a daemon that DRAINS instead of dying mid-request
247// can still reach its own release. (nginx/envoy hot restart, systemd socket activation = the 2026 reference.)
248//
249// WHY THIS IS A SEPARATE FUNCTION AND NOT A FLAG ON THE ONE ABOVE:
250// SO_REUSEPORT is genuinely double-edged. It is exactly what lets TWO live processes share a port -- which is
251// the hot-restart handoff when intended, and the D008 :443 co-squat (DSM nginx silently splitting our traffic,
252// a coin-flip per request) when NOT. Turning it on for all 52 consumers of the listener above would hand every
253// daemon a silent-double-bind footgun to buy one of them a graceful restart. So it is ADDITIVE and OPT-IN:
254// the 52 existing callers are byte-for-byte unaffected (rule 19), and a daemon takes this only when it has a
255// real handoff. Same SIGPIPE + SO_REUSEADDR guarantees as the standard path -- this is that function plus one
256// socket option, never a second implementation to drift.
257// ⚠ BOTH processes must set it: the FIRST deploy after adopting this still cannot hand off, because the
258// already-running old process bound without it. Hot restart begins from the deploy AFTER this ships.
259const SO_REUSEPORT_CONST: i64 = 15
260func nx_http_server_listen_hot(addr_bytes: *u8, backlog: i64,
261 out_verdict: *i64) -> i64 {
262 // ⚠⚠ SO_REUSEPORT MUST BE SET BEFORE bind(). My first cut wrapped the standard listener and set the option
263 // AFTERWARDS -- a silent no-op, because the kernel only consults it at bind time. nx_hotlisten_gate caught
264 // it: T1 (control) PASSED proving the second bind really is refused, while T2 (cure) FAILED with new=-1.
265 // That is exactly why T1 exists -- a cure with no proven disease is not a proof, and here the control is
266 // what told me the cure was fake rather than the port merely being busy.
267 // Sequence duplicated from nx_http_server_listen rather than wrapping it, because the option has to land
268 // between socket() and bind() and there is no seam there. DRY follow-on: fold both into one _opt(reuseport)
269 // implementation with two thin wrappers -- deliberately NOT done mid-session on a tree 52 daemons import.
270 if addr_bytes == (0 as *u8) { *out_verdict = NXS_BAD_ARG; return -1 }
271 if backlog <= 0 { *out_verdict = NXS_BAD_ARG; return -1 }
272 sys_ignore_sigpipe()
273 let fd: i64 = sys_socket(AF_INET_CONST, SOCK_STREAM, 0)
274 if fd < 0 { *out_verdict = NXS_SOCKET_ERR; return -1 }
275 // FD_CLOEXEC -- full rationale on nx_http_server_listen above. It matters MORE here, not less: this
276 // listener exists so a NEW instance can co-bind during a hot restart, so there are two owners of the same
277 // port and twice the chance a forked child inherits one. And SO_REUSEPORT cannot rescue a port from an
278 // inherited LEGACY socket, because co-binding requires EVERY socket on the port to have set it.
279 __syscall(72, fd, 2, 1, 0, 0, 0)
280 let ra: *u8 = sys_mmap(4)
281 ra[0] = 1 as u8
282 ra[1] = 0 as u8
283 ra[2] = 0 as u8
284 ra[3] = 0 as u8
285 sys_setsockopt(fd, SOL_SOCKET, SO_REUSEADDR, ra, 4)
286 if fd < 0 { return fd }
287 let hv: *u8 = sys_mmap(4)
288 hv[0] = 1 as u8
289 hv[1] = 0 as u8
290 hv[2] = 0 as u8
291 hv[3] = 0 as u8
292 sys_setsockopt(fd, SOL_SOCKET, SO_REUSEPORT_CONST, hv, 4)
293 let hbrc: i64 = sys_bind(fd, addr_bytes, 16)
294 if hbrc < 0 { sys_close(fd); *out_verdict = NXS_BIND_ERR; return -1 }
295 let hlrc: i64 = sys_listen(fd, backlog)
296 if hlrc < 0 { sys_close(fd); *out_verdict = NXS_LISTEN_ERR; return -1 }
297 *out_verdict = NXS_OK
298 return fd
299}
300
301// ---- Per-connection handler ---------------------------------------
302//
303// Reads up to req_cap bytes from client_fd, parses as HTTP/1.1, then
304// returns:
305// - method_kind (1=GET, 2=POST, 3=OPTIONS, 0=unknown) via out param
306// - path_off + path_len into the caller-provided req_buf
307// - body_off + content_len via out params
308// Returns NXS_OK on success or NXS_PARSE_ERR.
309
310func nx_http_server_read_request(client_fd: i64,
311 req_buf: *u8, req_cap: i64,
312 out_method: *i64,
313 out_path_off: *i64,
314 out_path_len: *i64,
315 out_content_len: *i64,
316 out_body_off: *i64,
317 out_req_n: *i64) -> i64 {
318 if client_fd < 0 { return NXS_BAD_ARG }
319 if req_buf == (0 as *u8) { return NXS_BAD_ARG }
320 if req_cap <= 0 { return NXS_BAD_ARG }
321 let n: i64 = sys_read(client_fd, req_buf, req_cap)
322 if n <= 0 { return NXS_PARSE_ERR }
323 let rc: i64 = nx_http_parse_request(req_buf, n,
324 out_method,
325 out_path_off, out_path_len,
326 out_content_len, out_body_off)
327 if rc < 0 { return NXS_PARSE_ERR }
328 // ADDITIVE robust body read: the first sys_read returns at most ONE TLS record (~16KB), so a request whose
329 // body spans multiple records (a chunked-upload POST) arrives partial. The headers are fully parsed above
330 // (they fit the first record); now loop-read until the whole Content-Length body is present, bounded by the
331 // buffer cap and by EOF. A request whose body is already complete skips the loop -> ZERO behavior change for
332 // every GET and every small POST. Flag-based loop (no `break`) per .nx style.
333 var total: i64 = n
334 let need: i64 = *out_body_off + *out_content_len
335 var go: i64 = 1
336 while go == 1 {
337 if total >= need { go = 0 } else {
338 if total >= req_cap { go = 0 } else {
339 let r: i64 = sys_read(client_fd, ((req_buf as i64) + total) as *u8, req_cap - total)
340 if r <= 0 { go = 0 } else { total = total + r }
341 }
342 }
343 }
344 *out_req_n = total
345 return NXS_OK
346}
347
348// Write response bytes to client_fd, then close it. Returns NXS_OK
349// or NXS_WRITE_ERR.
350func nx_http_server_send_response(client_fd: i64,
351 resp_buf: *u8, resp_n: i64) -> i64 {
352 if client_fd < 0 { return NXS_BAD_ARG }
353 if resp_buf == (0 as *u8) { return NXS_BAD_ARG }
354 if resp_n <= 0 { return NXS_BAD_ARG }
355 let w: i64 = sys_write(client_fd, resp_buf, resp_n)
356 sys_close(client_fd)
357 if w != resp_n { return NXS_WRITE_ERR }
358 return NXS_OK
359}
360
361// Keepalive variant: write response WITHOUT closing the fd. Caller
362// is responsible for either reading another request on the same fd
363// OR calling sys_close when done. Per HTTP/1.1 §6.3 persistent
364// connections are the default; closing happens on Connection: close
365// or after a per-connection budget.
366func nx_http_server_send_response_nokeep_close(
367 client_fd: i64, resp_buf: *u8, resp_n: i64) -> i64 {
368 if client_fd < 0 { return NXS_BAD_ARG }
369 if resp_buf == (0 as *u8) { return NXS_BAD_ARG }
370 if resp_n <= 0 { return NXS_BAD_ARG }
371 let w: i64 = sys_write(client_fd, resp_buf, resp_n)
372 if w != resp_n { return NXS_WRITE_ERR }
373 return NXS_OK
374}
375
376// ---- One-shot accept (single iteration) --------------------------
377//
378// Accepts ONE connection from listen_fd, returns the client_fd.
379// Caller is responsible for read_request + send_response + close.
380// Returns the client_fd or a negated NXS_ACCEPT_ERR.
381func nx_http_server_accept_one(listen_fd: i64, out_verdict: *i64) -> i64 {
382 if listen_fd < 0 {
383 *out_verdict = NXS_BAD_ARG
384 return -1
385 }
386 let cfd: i64 = sys_accept(listen_fd)
387 if cfd < 0 {
388 *out_verdict = NXS_ACCEPT_ERR
389 return -1
390 }
391 *out_verdict = NXS_OK
392 return cfd
393}