code wiki / _hdl_build / nx_proxy_route.nx

nx_proxy_route.nx source

↩ module page · 363 lines · 18069 B

1// nx_proxy_route.nx -- SOVEREIGN data-driven PROXY route table (pure Nishi, no TLS, no crypto). 2// The DYNAMIC-routing analog of nx_host_router's static docroot table: a config file maps 3// (host, path-prefix) -> (backend_port, mode) so an app/site route is a CONFIG ROW, not a 4// recompiled `sd2_contains(" /gallery")` branch in the TLS daemon. This closes the nx_host_router 5// gap for DYNAMIC routes -> "migrate a bunch of sites" becomes add-a-row, zero recompile. 6// SOTA basis: reverse-proxy / API-gateway route tables reloadable without rebuild 7// (knowledge/fetched/plat_reverse_proxy.raw, plat_api_mgmt.raw); see 8// knowledge/research/2026-06-29-platform-sclass-exceed-standard.md. license_tier: ORIGINAL 9// 10// Config line: "<host|*> <path-prefix> <backend_port> <mode>" (hash = comment, blank lines ok) 11// host exact (case-insensitive) or '*' wildcard; exact host wins over '*' 12// path-prefix request-line path prefix, boundary-safe (/gen matches /gen,/gen/,/gen?x NOT /generated) 13// backend_port loopback 127.0.0.1:<port> to reverse-proxy to 14// mode buffered | stream | gated -- THE WORDS ARE THE VOCABULARY. The integers below are 15// the internal encoding handed to the daemon and are NOT accepted conf spellings. 16// Match rule: among all rows whose host+prefix match, LONGEST prefix wins; tie -> exact host beats '*'. 17// 18// FAIL-CLOSED MODE PARSE -- 2026-08-21, /compare/trafficsafety ITEM 1. 19// pr_mode used to `return PR_MODE_BUFFERED` for ANY token it did not recognise, so a mistyped 20// `gated` silently became the PERMISSIVE mode with no diagnostic, in the table that fronts every 21// request. `gated` is the FAIL-CLOSED variant (302 /login when the backend is down); `buffered` 22// is the permissive one -- so the old default was wrong in the dangerous direction by construction. 23// NOW: the three documented words resolve to themselves, an OMITTED token keeps the documented 24// default (buffered), and ANY other token resolves to PR_MODE_GATED -- fail closed. 25// 26// WHY NOT A HARD REFUSAL AT REQUEST TIME, decided from the call sites and not from taste. 27// pr_lookup has exactly one production caller (nx_sites_daemon_v2) and its 0 return does not mean 28// "refuse", it means "no route" -- the daemon then falls through to the legacy static cascade, 29// which is precisely the path that answered a machine client with the HOMEPAGE as a 200 and 30// triggered a retry storm (seq1294, both edge outages). Refusing a row at request time would 31// therefore make the failure LESS safe, not more, and would brick every route in a conf holding 32// one typo. The refusal belongs at LOAD time: pr_conf_scan enumerates every offending row with 33// its line number so the loader ANNOUNCES a worklist once per (re)load, instead of emitting one 34// wrong answer per request forever. 35import "nx_syscalls.nx" 36 37const PR_MODE_BUFFERED: i64 = 0 38const PR_MODE_STREAM: i64 = 1 39const PR_MODE_GATED: i64 = 2 40 41// ASCII bytes the row tokenizer compares against. Named because a bare 35 in a parser is a magic 42// number. PR_ASCII_SP and PR_CASE_DELTA are both 32 for UNRELATED reasons and therefore stay two 43// names -- one constant serving two purposes can never be tuned for either. 44const PR_ASCII_TAB: i64 = 9 45const PR_ASCII_LF: i64 = 10 46const PR_ASCII_CR: i64 = 13 47const PR_ASCII_SP: i64 = 32 48const PR_ASCII_HASH: i64 = 35 49const PR_ASCII_STAR: i64 = 42 50// decimal vocabulary, declared here ABOVE every reader (the parser resolves module consts in file order) 51const PR_ASCII_ZERO: i64 = 48 52const PR_DEC_BASE: i64 = 10 53 54// A row is exactly these four tokens; the fourth (mode) is optional. 55const PR_ROW_TOKS: i64 = 5 // host prefix port mode [timeout=<s>] -- the 5th is OPTIONAL (2026-09-02, search L2a) 56const PR_TOK_HOST: i64 = 0 57const PR_TOK_PREFIX: i64 = 1 58const PR_TOK_PORT: i64 = 2 59const PR_TOK_MODE: i64 = 3 60const PR_TOK_TMO: i64 = 4 61// Scratch layout handed to pr_row_toks: PR_ROW_TOKS offsets, then PR_ROW_TOKS lengths. 62const PR_TOK_OFF_BASE: i64 = 0 63const PR_TOK_LEN_BASE: i64 = 5 // == PR_ROW_TOKS: the len array sits past the off array 64const PR_I64_BYTES: i64 = 8 65const PR_LINE_ORIGIN: i64 = 1 66 67func pr_lower(c: u8) -> u8 { 68 if c >= (65 as u8) { if c <= (90 as u8) { return (c + (32 as u8)) } } 69 return c 70} 71 72// case-insensitive byte-compare of exactly n bytes 73func pr_ieq(a: *u8, b: *u8, n: i64) -> i64 { 74 var i: i64 = 0 75 while i < n { if pr_lower(a[i]) != pr_lower(b[i]) { return 0 } i = i + 1 } 76 return 1 77} 78 79func pr_strlen(s: *u8) -> i64 { var n: i64 = 0; while s[n] != (0 as u8) { n = n + 1 } return n } 80 81// does literal `lit` equal the token at s of length n? 82func pr_word(s: *u8, n: i64, lit: *u8) -> i64 { 83 if pr_strlen(lit) != n { return 0 } 84 var i: i64 = 0 85 while i < n { if s[i] != lit[i] { return 0 } i = i + 1 } 86 return 1 87} 88 89// parse an unsigned decimal token (len n) -> i64 (non-digits ignored) 90func pr_parse_u(s: *u8, n: i64) -> i64 { 91 var v: i64 = 0 92 var i: i64 = 0 93 while i < n { 94 let c: u8 = s[i] 95 if c >= (48 as u8) { if c <= (57 as u8) { v = v * 10 + ((c as i64) - 48) } } 96 i = i + 1 97 } 98 return v 99} 100 101// Is this mode token INSIDE the documented vocabulary? An omitted token (n==0) is documented and 102// legal -- it means "take the default". Anything else is not, and the caller decides what to do 103// about it. Split out from pr_mode deliberately: a validator must be able to tell "this token was 104// RECOGNISED as buffered" from "this token FELL BACK", and one function returning only the mode 105// cannot express that difference. 106func pr_mode_known(s: *u8, n: i64) -> i64 { 107 if n == 0 { return 1 } 108 if pr_word(s, n, "stream" as *u8) == 1 { return 1 } 109 if pr_word(s, n, "gated" as *u8) == 1 { return 1 } 110 if pr_word(s, n, "buffered" as *u8) == 1 { return 1 } 111 return 0 112} 113 114// FAIL CLOSED. An unrecognised token resolves to the fail-closed mode, never the permissive one. 115// PER-ROUTE BACKEND TIMEOUT (2026-09-02, search plan L2a): an optional 5th token `timeout=<seconds>`. Absent = 0 = 116// the caller's default. Any other 5th token is UNKNOWN: pr_tmo_known says so (the validator counts the row bad, 117// fail-closed like an unknown mode) and pr_parse_tmo answers 0 so the route keeps the default rather than a guess. 118const PR_TMO_KEY: *u8 = "timeout=" as *u8 119const PR_TMO_KEYLEN: i64 = 8 120func pr_tmo_prefixed(s: *u8, n: i64) -> i64 { 121 if n <= PR_TMO_KEYLEN { return 0 } 122 var i: i64 = 0 123 while i < PR_TMO_KEYLEN { if pr_lower(s[i]) != PR_TMO_KEY[i] { return 0 } i = i + 1 } 124 return 1 125} 126func pr_tmo_known(s: *u8, n: i64) -> i64 { 127 if n == 0 { return 1 } 128 if pr_tmo_prefixed(s, n) == 0 { return 0 } 129 var i: i64 = PR_TMO_KEYLEN 130 while i < n { let c: i64 = s[i] as i64; if c < PR_ASCII_ZERO { return 0 } if c > PR_ASCII_ZERO + PR_DEC_BASE - 1 { return 0 } i = i + 1 } 131 return 1 132} 133func pr_parse_tmo(s: *u8, n: i64) -> i64 { 134 if pr_tmo_known(s, n) == 0 { return 0 } 135 if n == 0 { return 0 } 136 return pr_parse_u(((s as i64) + PR_TMO_KEYLEN) as *u8, n - PR_TMO_KEYLEN) 137} 138func pr_mode(s: *u8, n: i64) -> i64 { 139 if pr_word(s, n, "stream" as *u8) == 1 { return PR_MODE_STREAM } 140 if pr_word(s, n, "gated" as *u8) == 1 { return PR_MODE_GATED } 141 if pr_word(s, n, "buffered" as *u8) == 1 { return PR_MODE_BUFFERED } 142 if n == 0 { return PR_MODE_BUFFERED } 143 return PR_MODE_GATED 144} 145 146// boundary-safe path-prefix test: does `path`(len pn) begin with `pref`(len prefn) at a SEGMENT 147// boundary? prefn>0 required. /gen matches /gen, /gen/, /gen?x; NOT /generated. 148func pr_path_pref(path: *u8, pn: i64, pref: *u8, prefn: i64) -> i64 { 149 if prefn == 0 { return 0 } 150 if prefn > pn { return 0 } 151 var i: i64 = 0 152 while i < prefn { if path[i] != pref[i] { return 0 } i = i + 1 } 153 if pn == prefn { return 1 } 154 let nc: u8 = path[prefn] 155 if nc == (47 as u8) { return 1 } // '/' 156 if nc == (63 as u8) { return 1 } // '?' 157 if pref[prefn - 1] == (47 as u8) { return 1 } // prefix already ended in '/' 158 return 0 159} 160 161// THE ONE ROW TOKENIZER. pr_lookup and pr_conf_scan both call it, so the matcher and the validator 162// can never disagree about what a row is -- two walkers over one grammar is the duplicate-ruler 163// defect, and the half that drifts is always the one nobody runs. 164// Returns the index at which scanning should resume. tok[] is caller-supplied scratch of 165// PR_ROW_TOKS offsets followed by PR_ROW_TOKS lengths. A comment line, a blank line or end-of-input 166// yields ALL lengths 0, so the caller's host/prefix/port presence test is unchanged from the 167// original inline walk. 168func pr_row_toks(cfg: *u8, cfgn: i64, start: i64, tok: *i64) -> i64 { 169 var t: i64 = 0 170 while t < PR_ROW_TOKS { tok[PR_TOK_OFF_BASE + t] = 0; tok[PR_TOK_LEN_BASE + t] = 0; t = t + 1 } 171 var i: i64 = start 172 // skip leading spaces/tabs 173 while i < cfgn { 174 let c: i64 = cfg[i] as i64 175 if c == PR_ASCII_SP { i = i + 1 } else { if c == PR_ASCII_TAB { i = i + 1 } else { break } } 176 } 177 // comment -> skip to EOL 178 if i < cfgn { if (cfg[i] as i64) == PR_ASCII_HASH { 179 while i < cfgn { if (cfg[i] as i64) == PR_ASCII_LF { break } i = i + 1 } 180 return i + 1 181 } } 182 // blank line 183 if i < cfgn { if (cfg[i] as i64) == PR_ASCII_LF { return i + 1 } } 184 if i < cfgn { if (cfg[i] as i64) == PR_ASCII_CR { return i + 1 } } 185 if i >= cfgn { return i } 186 t = 0 187 while t < PR_ROW_TOKS { 188 let t0: i64 = i 189 while i < cfgn { 190 let c: i64 = cfg[i] as i64 191 if c == PR_ASCII_SP { break } 192 if c == PR_ASCII_TAB { break } 193 if c == PR_ASCII_LF { break } 194 if c == PR_ASCII_CR { break } 195 i = i + 1 196 } 197 tok[PR_TOK_OFF_BASE + t] = t0 198 tok[PR_TOK_LEN_BASE + t] = i - t0 199 while i < cfgn { 200 let c2: i64 = cfg[i] as i64 201 if c2 == PR_ASCII_SP { i = i + 1 } else { if c2 == PR_ASCII_TAB { i = i + 1 } else { break } } 202 } 203 t = t + 1 204 } 205 // advance to EOL 206 while i < cfgn { if (cfg[i] as i64) == PR_ASCII_LF { break } i = i + 1 } 207 return i + 1 208} 209 210// Look up (host, path) in the config buffer. On match: sets *out_port,*out_mode and returns 1. 211// No match -> returns 0 (caller falls through to static serving). Longest-prefix wins; exact host 212// beats '*' at equal prefix length. Reads the buffer ONLY (daemon supplies it hot, per request). 213// RESOURCE ENVELOPE, stated because this runs on the front door: exactly ONE sys_mmap of 214// PR_ROW_TOKS*PR_I64_BYTES*2 bytes per call for the tokenizer scratch, freed with the request 215// child. The single production caller already performs four mmaps in the same block before 216// reaching here, so this is a bounded, named addition and not an unmeasured one. 217func pr_lookup(cfg: *u8, cfgn: i64, host: *u8, hn: i64, path: *u8, pn: i64, out_port: *i64, out_mode: *i64) -> i64 { 218 let t: *i64 = (sys_mmap(PR_I64_BYTES)) as *i64 219 return pr_lookup_tmo(cfg, cfgn, host, hn, path, pn, out_port, out_mode, t) 220} 221// the same match, plus the route's timeout (0 = none declared). pr_lookup is this with the timeout discarded. 222func pr_lookup_tmo(cfg: *u8, cfgn: i64, host: *u8, hn: i64, path: *u8, pn: i64, out_port: *i64, out_mode: *i64, out_tmo: *i64) -> i64 { 223 var i: i64 = 0 224 var best_plen: i64 = 0 - 1 225 var best_exact: i64 = 0 226 var best_port: i64 = 0 227 var best_mode: i64 = 0 228 var best_tmo: i64 = 0 229 let tok: *i64 = (sys_mmap(PR_ROW_TOKS * PR_I64_BYTES * 2)) as *i64 230 while i < cfgn { 231 i = pr_row_toks(cfg, cfgn, i, tok) 232 let h0: i64 = tok[PR_TOK_OFF_BASE + PR_TOK_HOST] 233 let h0len: i64 = tok[PR_TOK_LEN_BASE + PR_TOK_HOST] 234 let p0: i64 = tok[PR_TOK_OFF_BASE + PR_TOK_PREFIX] 235 let p0len: i64 = tok[PR_TOK_LEN_BASE + PR_TOK_PREFIX] 236 let n0: i64 = tok[PR_TOK_OFF_BASE + PR_TOK_PORT] 237 let n0len: i64 = tok[PR_TOK_LEN_BASE + PR_TOK_PORT] 238 let m0: i64 = tok[PR_TOK_OFF_BASE + PR_TOK_MODE] 239 let m0len: i64 = tok[PR_TOK_LEN_BASE + PR_TOK_MODE] 240 let t0: i64 = tok[PR_TOK_OFF_BASE + PR_TOK_TMO] 241 let t0len: i64 = tok[PR_TOK_LEN_BASE + PR_TOK_TMO] 242 // require host + prefix + port present 243 if h0len > 0 { if p0len > 0 { if n0len > 0 { 244 var hostok: i64 = 0 245 var exact: i64 = 0 246 if h0len == 1 { if (cfg[h0] as i64) == PR_ASCII_STAR { hostok = 1 } } 247 if hostok == 0 { if h0len == hn { if pr_ieq(((cfg as i64 + h0) as *u8), host, hn) == 1 { hostok = 1; exact = 1 } } } 248 if hostok == 1 { 249 if pr_path_pref(path, pn, ((cfg as i64 + p0) as *u8), p0len) == 1 { 250 var better: i64 = 0 251 if p0len > best_plen { better = 1 } 252 else { if p0len == best_plen { if exact == 1 { if best_exact == 0 { better = 1 } } } } 253 if better == 1 { 254 best_plen = p0len 255 best_exact = exact 256 best_port = pr_parse_u(((cfg as i64 + n0) as *u8), n0len) 257 best_mode = pr_mode(((cfg as i64 + m0) as *u8), m0len) 258 best_tmo = pr_parse_tmo(((cfg as i64 + t0) as *u8), t0len) 259 } 260 } 261 } 262 } } } 263 } 264 if best_plen >= 0 { 265 out_port[0] = best_port 266 out_mode[0] = best_mode 267 out_tmo[0] = best_tmo 268 return 1 269 } 270 return 0 271} 272 273// LOAD-TIME VALIDATOR -- the ANNOUNCE half of the fail-closed fix, and the only place a refusal is 274// safe. Walks every row with pr_row_toks (the same tokenizer pr_lookup uses) and reports each row 275// whose mode token is outside the documented vocabulary. 276// Returns the TOTAL number of offending rows. 277// out_lines[0..cap-1] 1-based LINE NUMBERS of the offenders -- a count without a worklist is not 278// actionable, so the reason travels with the count. 279// out_listed[0] how many line numbers were actually written. listed < returned means the 280// printed list is a PREFIX OF ITS OWN COUNT; no cap is ever reached silently. 281// out_rows[0] how many ROWS were examined, so a caller can tell "zero offenders" from 282// "nothing was examined". A verdict over the empty set is not a verdict. 283func pr_conf_scan(cfg: *u8, cfgn: i64, out_lines: *i64, cap: i64, out_listed: *i64, out_rows: *i64) -> i64 { 284 var i: i64 = 0 285 var line: i64 = PR_LINE_ORIGIN 286 var bad: i64 = 0 287 var rows: i64 = 0 288 out_listed[0] = 0 289 out_rows[0] = 0 290 let tok: *i64 = (sys_mmap(PR_ROW_TOKS * PR_I64_BYTES * 2)) as *i64 291 while i < cfgn { 292 let start: i64 = i 293 i = pr_row_toks(cfg, cfgn, i, tok) 294 let h0len: i64 = tok[PR_TOK_LEN_BASE + PR_TOK_HOST] 295 let p0len: i64 = tok[PR_TOK_LEN_BASE + PR_TOK_PREFIX] 296 let n0len: i64 = tok[PR_TOK_LEN_BASE + PR_TOK_PORT] 297 if h0len > 0 { if p0len > 0 { if n0len > 0 { 298 rows = rows + 1 299 let m0: i64 = tok[PR_TOK_OFF_BASE + PR_TOK_MODE] 300 let m0len: i64 = tok[PR_TOK_LEN_BASE + PR_TOK_MODE] 301 var rowbad: i64 = 0 302 if pr_mode_known(((cfg as i64 + m0) as *u8), m0len) == 0 { rowbad = 1 } 303 let t4: i64 = tok[PR_TOK_OFF_BASE + PR_TOK_TMO] 304 let t4len: i64 = tok[PR_TOK_LEN_BASE + PR_TOK_TMO] 305 if pr_tmo_known(((cfg as i64 + t4) as *u8), t4len) == 0 { rowbad = 1 } 306 if rowbad == 1 { 307 bad = bad + 1 308 if out_listed[0] < cap { 309 out_lines[out_listed[0]] = line 310 out_listed[0] = out_listed[0] + 1 311 } 312 } 313 } } } 314 // line number of the NEXT row = lines consumed by the row just walked 315 var k: i64 = start 316 while k < i { if k < cfgn { if (cfg[k] as i64) == PR_ASCII_LF { line = line + 1 } } k = k + 1 } 317 } 318 out_rows[0] = rows 319 return bad 320} 321 322// THE ANNOUNCE. One call a loader makes right after reading the table, so an unrecognised mode 323// token is reported ONCE PER LOAD with its line number instead of producing one wrong answer per 324// request forever. Returns the offender count so a caller can also branch on it. 325// Deliberately here and not in the daemon: the daemon then holds a single line at each of its two 326// load sites, which is the smallest possible change to the process every request crosses. 327const PR_ANN_LINES_CAP: i64 = 16 328const PR_ANN_LINES_BYTES: i64 = 128 329const PR_ANN_BOX_BYTES: i64 = 8 330const PR_ANN_NUM_BYTES: i64 = 24 331 332func pr_ann_puts(s: *u8) -> i64 { var n: i64 = 0; while s[n] != (0 as u8) { n = n + 1 } sys_write(1, s, n); return 0 } 333func pr_ann_num(v: i64) -> i64 { 334 if v == 0 { sys_write(1, "0" as *u8, 1); return 0 } 335 var m: i64 = v 336 let d: *u8 = sys_mmap(PR_ANN_NUM_BYTES) 337 var k: i64 = 0 338 while m > 0 { d[k] = ((PR_ASCII_ZERO + (m - (m / PR_DEC_BASE) * PR_DEC_BASE)) as u8); m = m / PR_DEC_BASE; k = k + 1 } 339 var j: i64 = k - 1 340 while j >= 0 { sys_write(1, ((d as i64)+j) as *u8, 1); j = j - 1 } 341 return 0 342} 343 344func pr_conf_announce(cfg: *u8, cfgn: i64) -> i64 { 345 let lines: *i64 = (sys_mmap(PR_ANN_LINES_BYTES)) as *i64 346 let listed: *i64 = (sys_mmap(PR_ANN_BOX_BYTES)) as *i64 347 let rows: *i64 = (sys_mmap(PR_ANN_BOX_BYTES)) as *i64 348 let bad: i64 = pr_conf_scan(cfg, cfgn, lines, PR_ANN_LINES_CAP, listed, rows) 349 pr_ann_puts("proxy_routes.conf: rows=" as *u8) 350 pr_ann_num(rows[0]) 351 pr_ann_puts(" unrecognised_mode_tokens=" as *u8) 352 pr_ann_num(bad) 353 if bad > 0 { 354 pr_ann_puts(" FAILING CLOSED (gated) on lines:" as *u8) 355 var i: i64 = 0 356 while i < listed[0] { pr_ann_puts(" " as *u8); pr_ann_num(lines[i]); i = i + 1 } 357 // A list shorter than its own count must say so: a cap reached in silence becomes a 358 // measurement nobody knows is partial. 359 if listed[0] < bad { pr_ann_puts(" <== THIS LIST IS A PREFIX OF ITS OWN COUNT" as *u8) } 360 } 361 pr_ann_puts("\n" as *u8) 362 return bad 363}