code wiki / (root) / nx_http_server.nx

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}