code wiki / (root) / nx_mgmt_client.nx

nx_mgmt_client.nx source

↩ module page · 687 lines · 39671 B

1// nx_mgmt_client.nx -- SOVEREIGN CLIENT for the live control-plane mgmt API (nx_mgmt_api.nx, https://<host>/api). 2// Operates the ecosystem OVER THE WIRE (sovereign TLS-1.3 + canonical Modern Auth: the X-Nishi-Session header, 3// NO cookies) instead of the shell/ssh dance. Two modes (parse argv): 4// nx_mgmt_client <base_url> login <handle> <passphrase_file> <token_out_file> 5// POST <base_url>/api/login body "handle=<enc>&passphrase=<enc>" (x-www-form-urlencoded, url-ENCODED so a 6// '&'/'='/space in a passphrase can't corrupt the form the daemon's sd_form_field parses) -> parse the 7// {"token":"<b64>"} value -> write the RAW b64 token to <token_out_file> (0600). Prints LOGIN OK | LOGIN FAIL status=<n>. 8// nx_mgmt_client <base_url> call <METHOD> <path> <token_file> [body_file] 9// <METHOD> <base_url><path> with header "X-Nishi-Session: <token>" (token read from file), optional 10// x-www-form-urlencoded body from body_file. Prints "status=<n>" then the response body. 11// nx_mgmt_client <base_url> upload <target> <artifact_file> <token_file> 12// PUBLISH an artifact over the authenticated API from ANYWHERE (retires the LAN-only nx_aw_send). Reads 13// <artifact_file>, splits it into <=48KB chunks (one request must fit under the daemon's 64KB read cap), 14// and POSTs each as POST /api/upload?target=<t>&seq=<i>&final=<0|1> with "X-Nishi-Session: <token>" and 15// the RAW chunk as the body. Aborts loudly on any non-200. On the final chunk prints the {"staged":...} 16// body + "UPLOAD OK <target>.new (<bytes> bytes)". The staged <target>.new is then promoted by /api/deploy. 17// 18// REUSE (compose, don't rebuild TLS): the request BUILDERS (mcl_build_login_body / mcl_build_request) are pure 19// byte emitters; the TRANSPORT (mcl_send_drain) composes the SHIPPED TLS-1.3 record primitives -- exactly the 20// primitives nx_https_post_complete + nx_login_e2e_probe's e2e_send compose -- the ONLY difference is we send a 21// caller-built request so we can carry the X-Nishi-Session header the baked builders (nx_http_client_build_request*) 22// cannot. Token extraction reuses nx_http_response_parse + nx_acme_json_str (fail-closed: a well-formed closing 23// quote is required, else empty -- no garbage). The connect+handshake path reuses nx_https_url_for_fetch / 24// nx_https_url_connect / nx_tls13_client_session_run with fresh nx_csprng ephemerals (forward secrecy per run). 25// 26// OPERATOR-DRIVEN by design: a human runs this; nothing acts autonomously. This file gate-proves its codec 27// in-process (nx_mgmt_client_gate.nx); the LIVE TLS round-trip is driven by the operator/parent. license_tier: ORIGINAL expect_exit: 0 28import "nx_acme_http.nx" // nx_acme_json_str + nx_http_response_parse/_alloc + full TLS-1.3 stack (transitive) 29import "nx_https_url_for_fetch.nx" // nx_https_url_for_fetch, NxUrl, nx_url_new, NxHttpsTarget, NX_HTTPS_URL_OK 30import "nx_https_url_connect.nx" // nx_https_url_connect, NX_HTTPS_CONNECT_OK 31import "nx_tls13_client_session_run.nx" // nx_tls13_client_session_run, TlsValidationContext 32import "nx_tls13_client_session.nx" // Tls13ClientSession, NX_TLS13_CSESSION_STATE_CONNECTED 33import "nx_tls13.nx" // NX_TLS13_CT_APPLICATION_DATA, NX_TLS13_CT_ALERT 34import "nx_tls13_record.nx" // nx_tls13_record_encrypt_v2/_decrypt_v2 + record header/tag consts 35import "nx_tls13_read_record_from_fd.nx" // nx_tls13_read_record_from_fd 36import "nx_csprng.nx" // nx_csprng_fill 37import "nx_sha256.nx" // sha256_digest -- the client hashes the artifact; the daemon verifies &sha256= on the final chunk (fail-closed integrity) 38const MCL_MAGIC_16384: i64 = 16384 39const MCL_MAGIC_16645: i64 = 16645 40const MCL_MAGIC_4194304: i64 = 4194304 41const MCL_MAGIC_4096: i64 = 4096 42const MCL_MAGIC_2097152: i64 = 2097152 43const MCL_MAGIC_2026: i64 = 2026 44const MCL_MAGIC_1048576: i64 = 1048576 45const MCL_MAGIC_4095: i64 = 4095 46const MCL_MAGIC_65536: i64 = 65536 47const MCL_MAGIC_2000: i64 = 2000 48const MCL_MAGIC_65535: i64 = 65535 49 50const MCL_TOKEN_OUT_MODE: i64 = 0x180 // 0600 -- session token is a secret; owner rw only 51const MCL_CONNECT_FAIL: i64 = 0 - 11 52const MCL_HANDSHAKE_FAIL: i64 = 0 - 12 53const MCL_URL_FAIL: i64 = 0 - 10 54const MCL_UPLOAD_RETRIES: i64 = 8 // per-chunk retry budget: transport rc<0 + lost-response status=0 + 5xx (fresh connection+ephemerals + EXPONENTIAL BACKOFF each attempt; safe via the daemon's idempotent replay-ack). 8 attempts w/ backoff caps ~10s of edge-wedge ride-out per chunk. 55const MCL_ARTIFACT_CAP: i64 = 134217728 // 128 MiB artifact read buffer (raised for build-over-API full-tree .pack ~80MB); a file that fills it is REFUSED (never silently truncate). Chunks still <=48KB (daemon 64KB read cap) so a big tree = many chunks; multi-part packs are the future optimization. 56 57// ---- tiny byte/string helpers (self-contained; no coupling to daemon internals) ---------------------- 58 59func mcl_puts(s: *u8) -> i64 { var n: i64 = 0; while s[n] != (0 as u8) { n = n + 1 } sys_write(1, s, n); return 0 } 60func mcl_putn(v: i64) -> i64 { 61 let b: *u8 = sys_mmap(28); var x: i64 = v 62 if x < 0 { b[0] = 45 as u8; sys_write(1, b, 1); x = 0 - x } 63 if x == 0 { b[0] = 48 as u8; sys_write(1, b, 1); return 0 } 64 var d: i64 = 0; var y: i64 = x 65 while y > 0 { d = d + 1; y = y / 10 } 66 var i: i64 = d - 1; y = x 67 while i >= 0 { b[i] = (48 + (y % 10)) as u8; y = y / 10; i = i - 1 } 68 sys_write(1, b, d); return 0 69} 70func mcl_slen(s: *u8) -> i64 { var n: i64 = 0; while s[n] != (0 as u8) { n = n + 1 } return n } 71func mcl_streq(a: *u8, b: *u8) -> i64 { 72 var i: i64 = 0 73 while a[i] != (0 as u8) { if a[i] != b[i] { return 0 } i = i + 1 } 74 if b[i] != (0 as u8) { return 0 } 75 return 1 76} 77// append a NUL-terminated cstr; returns new offset 78func mcl_cat(d: *u8, o: i64, s: *u8) -> i64 { var i: i64 = 0; while s[i] != (0 as u8) { d[o + i] = s[i]; i = i + 1 } return o + i } 79// append n raw bytes (slice not required NUL-terminated); returns new offset 80func mcl_catb(d: *u8, o: i64, s: *u8, n: i64) -> i64 { var i: i64 = 0; while i < n { d[o + i] = s[i]; i = i + 1 } return o + i } 81// append v as decimal ASCII; returns new offset 82func mcl_catn(d: *u8, o: i64, v: i64) -> i64 { 83 let t: *u8 = sys_mmap(24); var m: i64 = v; var k: i64 = 0 84 if m == 0 { t[0] = 48 as u8; k = 1 } 85 while m > 0 { t[k] = (48 + (m % 10)) as u8; m = m / 10; k = k + 1 } 86 var w: i64 = o; var i: i64 = 0 87 while i < k { d[w] = t[k - 1 - i]; w = w + 1; i = i + 1 } 88 return w 89} 90func mcl_hexdigit(v: i64) -> i64 { if v < 10 { return 48 + v } return 55 + v } // 0-9 then A-F (uppercase) 91// lowercase-hex of a 32-byte digest into out[0..64) + NUL. Matches the daemon's mau_hex32 so its case-insensitive 92// sha256 compare succeeds. Attaches &sha256=<hex> to the FINAL upload chunk = stage-time fail-closed integrity. 93func mcl_hex32(dig: *u8, out: *u8) -> i64 { 94 let hx: *u8 = "0123456789abcdef" as *u8 95 var i: i64 = 0 96 while i < 32 { 97 out[i*2] = hx[((dig[i] as i64) >> 4) & 0xf] as u8 98 out[i*2 + 1] = hx[(dig[i] as i64) & 0xf] as u8 99 i = i + 1 100 } 101 out[64] = 0 as u8 102 return 64 103} 104 105// ---- PURE builders + parser (the gate drives these directly, no socket) ------------------------------ 106 107// RFC 3986 unreserved set: A-Z a-z 0-9 - _ . ~ pass through literally; everything else is %XX-encoded. 108func mcl_is_unreserved(c: i64) -> i64 { 109 if c >= 0x41 { if c <= 0x5A { return 1 } } // A-Z 110 if c >= 0x61 { if c <= 0x7A { return 1 } } // a-z 111 if c >= 0x30 { if c <= 0x39 { return 1 } } // 0-9 112 if c == 0x2D { return 1 } // - 113 if c == 0x5F { return 1 } // _ 114 if c == 0x2E { return 1 } // . 115 if c == 0x7E { return 1 } // ~ 116 return 0 117} 118 119// url-encode src[0..n) into out (percent-encoding). Returns bytes written. THE fix that makes an arbitrary 120// passphrase/handle safe inside the x-www-form-urlencoded body: an unencoded '&'/'=' would split the form and 121// silently truncate the passphrase the daemon validates (the exact class of silent-corruption the rules forbid). 122func mcl_url_encode(src: *u8, n: i64, out: *u8) -> i64 { 123 var i: i64 = 0 124 var o: i64 = 0 125 while i < n { 126 let c: i64 = src[i] & 0xff 127 if mcl_is_unreserved(c) == 1 { 128 out[o] = c as u8; o = o + 1 129 } else { 130 out[o] = 0x25 as u8; o = o + 1 // '%' 131 out[o] = mcl_hexdigit((c >> 4) & 0xf) as u8; o = o + 1 132 out[o] = mcl_hexdigit(c & 0xf) as u8; o = o + 1 133 } 134 i = i + 1 135 } 136 return o 137} 138 139// Build "handle=<enc>&passphrase=<enc>" into out (both values url-encoded). Returns body length. 140func mcl_build_login_body(handle: *u8, hn: i64, pass: *u8, pn: i64, out: *u8) -> i64 { 141 var o: i64 = 0 142 o = mcl_cat(out, o, "handle=" as *u8) 143 o = o + mcl_url_encode(handle, hn, ((out as i64) + o) as *u8) 144 o = mcl_cat(out, o, "&passphrase=" as *u8) 145 o = o + mcl_url_encode(pass, pn, ((out as i64) + o) as *u8) 146 return o 147} 148 149// Build a full HTTP/1.1 request into out: 150// "<METHOD> <path> HTTP/1.1\r\nHost: <host>\r\n" 151// ["X-Nishi-Session: <token>\r\n"] iff token_len > 0 152// ["Content-Type: <ct>\r\nContent-Length: <body_len>\r\n"] iff body_len > 0 153// "Connection: close\r\n\r\n" [ <body> ] 154// Returns total request length. token_len==0 -> no session header (unauth, e.g. the login POST); body_len==0 -> 155// no body (e.g. an authed GET). Origin-form request target = <path> (not the absolute URL) per RFC 9112. 156func mcl_build_request( 157 method: *u8, method_len: i64, 158 path: *u8, path_len: i64, 159 host: *u8, host_len: i64, 160 token: *u8, token_len: i64, 161 ct: *u8, ct_len: i64, 162 body: *u8, body_len: i64, 163 out: *u8 164) -> i64 { 165 var o: i64 = 0 166 o = mcl_catb(out, o, method, method_len) 167 out[o] = 0x20 as u8; o = o + 1 // ' ' 168 o = mcl_catb(out, o, path, path_len) 169 o = mcl_cat(out, o, " HTTP/1.1\r\nHost: " as *u8) 170 o = mcl_catb(out, o, host, host_len) 171 o = mcl_cat(out, o, "\r\n" as *u8) 172 if token_len > 0 { 173 o = mcl_cat(out, o, "X-Nishi-Session: " as *u8) 174 o = mcl_catb(out, o, token, token_len) 175 o = mcl_cat(out, o, "\r\n" as *u8) 176 } 177 if body_len > 0 { 178 o = mcl_cat(out, o, "Content-Type: " as *u8) 179 o = mcl_catb(out, o, ct, ct_len) 180 o = mcl_cat(out, o, "\r\nContent-Length: " as *u8) 181 o = mcl_catn(out, o, body_len) 182 o = mcl_cat(out, o, "\r\n" as *u8) 183 } 184 o = mcl_cat(out, o, "Connection: close\r\n\r\n" as *u8) 185 if body_len > 0 { 186 o = mcl_catb(out, o, body, body_len) 187 } 188 return o 189} 190 191// Extract the session token from a login HTTP response into out (cap out_cap, NUL-terminated on success). 192// FAIL-CLOSED: returns 0 (empty) unless the response PARSES and its JSON body carries a well-formed 193// "token":"<...>" with a closing quote. out_status[0] <- HTTP status (0 if the response won't parse). Reuses 194// nx_http_response_parse (status + body offset) + nx_acme_json_str (the closing-quote-required string reader). 195func mcl_extract_token(resp: *u8, total: i64, out_status: *i64, out: *u8, out_cap: i64) -> i64 { 196 out_status[0] = 0 197 let r: *i64 = nx_http_resp_alloc() 198 if nx_http_response_parse(resp, total, r) != 0 { return 0 } // NX_HTTP_RESP_OK == 0 199 out_status[0] = r[1] 200 let body_off: i64 = r[6] 201 let body_len: i64 = total - body_off 202 if body_len <= 0 { return 0 } 203 let voff: *i64 = sys_mmap(8) as *i64 204 let vlen: *i64 = sys_mmap(8) as *i64 205 if nx_acme_json_str(resp, body_off, body_len, "token" as *u8, 5, voff, vlen) != 1 { return 0 } 206 let vl: i64 = vlen[0] 207 if vl <= 0 { return 0 } 208 if vl >= out_cap { return 0 } // need room for the NUL terminator 209 var k: i64 = 0 210 while k < vl { out[k] = resp[voff[0] + k]; k = k + 1 } 211 out[vl] = 0 as u8 212 return vl 213} 214 215// ---- LIVE transport (compile-only here; the operator/parent drives the real handshake) --------------- 216 217func mcl_write_n(fd: i64, buf: *u8, n: i64) -> i64 { 218 var off: i64 = 0 219 while off < n { 220 let w: i64 = sys_write(fd, ((buf as i64) + off) as *u8, n - off) 221 if w <= 0 { return 0 - 1 } 222 off = off + w 223 } 224 return 0 225} 226 227// Send a PRE-BUILT request over a CONNECTED TLS-1.3 session, then drain the full response into out. Composes the 228// shipped record primitives (encrypt_v2 / read_record_from_fd / decrypt_v2) -- the same send+drain shape as 229// nx_https_post_complete / e2e_send, but carrying our own request bytes (so the X-Nishi-Session header rides). 230// Returns bytes drained (>=0) or a negative on a bad session state / encrypt failure. 231func mcl_send_drain(s: *Tls13ClientSession, fd: i64, req: *u8, req_len: i64, out: *u8, out_cap: i64) -> i64 { 232 if s.state != NX_TLS13_CSESSION_STATE_CONNECTED { return 0 - 1 } 233 // FRAGMENT the request into <=16KB TLS records (RFC 8446 max plaintext 2^14). The old single-record 234 // encrypt made any request >16KB PROTOCOL-INVALID on the wire -- the true root of the historic 235 // "64KB/1MB chunk -> transport rc=-2". <=16KB requests still go as exactly one record (bit-identical 236 // behavior); bigger ones now fragment, pairing with the edge's sd2_fill_body reassembly. 237 let rec_buf: *u8 = sys_mmap(MCL_MAGIC_16384 + 64) 238 var snd_off: i64 = 0 239 var first_frag: i64 = 1 240 while first_frag == 1 { first_frag = 0 // do-while shape: a 0-len request still sends one record 241 var frag: i64 = req_len - snd_off 242 if frag > MCL_MAGIC_16384 { frag = MCL_MAGIC_16384 } 243 let header_out: *u8 = rec_buf 244 let ct_out: *u8 = ((rec_buf as i64) + NX_TLS13_RECORD_HEADER_LEN) as *u8 245 let tag_out: *u8 = ((rec_buf as i64) + NX_TLS13_RECORD_HEADER_LEN + frag + 1) as *u8 246 let enc_v: i64 = nx_tls13_record_encrypt_v2(s.cipher_suite, s.client_app_traffic_key, s.client_app_iv, s.client_app_seq, ((req as i64) + snd_off) as *u8, frag, NX_TLS13_CT_APPLICATION_DATA, 0, header_out, ct_out, tag_out) 247 s.client_app_seq = s.client_app_seq + 1 248 if enc_v != NX_TLS13_REC_VERDICT_OK { return 0 - 2 } 249 let total: i64 = NX_TLS13_RECORD_HEADER_LEN + frag + 1 + NX_TLS13_RECORD_TAG_LEN 250 if mcl_write_n(fd, rec_buf, total) < 0 { return 0 - 3 } 251 snd_off = snd_off + frag 252 if snd_off < req_len { first_frag = 1 } 253 } 254 var acc: i64 = 0 255 let rec_in: *u8 = sys_mmap(MCL_MAGIC_16645) 256 let plain: *u8 = sys_mmap(MCL_MAGIC_16645) 257 let cttype: *i64 = sys_mmap(16) as *i64 258 let ptlen: *i64 = sys_mmap(16) as *i64 259 while acc < out_cap { 260 let rin: i64 = nx_tls13_read_record_from_fd(fd, rec_in, MCL_MAGIC_16645) 261 if rin < 0 { return acc } // EOF / peer close -> return what we have 262 let ctlen: i64 = rin - NX_TLS13_RECORD_HEADER_LEN - NX_TLS13_RECORD_TAG_LEN 263 let rin_ct: *u8 = ((rec_in as i64) + NX_TLS13_RECORD_HEADER_LEN) as *u8 264 let rin_tag: *u8 = ((rec_in as i64) + rin - NX_TLS13_RECORD_TAG_LEN) as *u8 265 let dv: i64 = nx_tls13_record_decrypt_v2(s.cipher_suite, s.server_app_traffic_key, s.server_app_iv, s.server_app_seq, rec_in, rin_ct, ctlen, rin_tag, plain, cttype, ptlen) 266 s.server_app_seq = s.server_app_seq + 1 267 if dv != NX_TLS13_REC_VERDICT_OK { return acc } 268 if cttype[0] == NX_TLS13_CT_ALERT { return acc } // close_notify (or any alert) ends the stream 269 if cttype[0] == NX_TLS13_CT_APPLICATION_DATA { 270 let tc: i64 = ptlen[0] 271 if acc + tc > out_cap { return acc } 272 var i: i64 = 0 273 while i < tc { out[acc + i] = plain[i]; i = i + 1 } 274 acc = acc + tc 275 } 276 } 277 return acc 278} 279 280// Connect to full_url's host:443, run a validated TLS-1.3 handshake, send `req`, drain the response into out. 281// base-url-AGNOSTIC (host is taken from the parsed URL, never hardcoded). `target` must already be filled by 282// nx_https_url_for_fetch(full_url, target) so the caller can reuse the parsed host/path for the request line. 283// Returns bytes (>=0) or MCL_CONNECT_FAIL / MCL_HANDSHAKE_FAIL. 284func mcl_req(store: *TrustStore, full_url: *u8, target: *NxHttpsTarget, req: *u8, req_len: i64, out: *u8, out_cap: i64, vc_in: *TlsValidationContext) -> i64 { 285 let now: i64 = sys_now_realtime_sec() 286 let fd_p: *i64 = sys_mmap(16) as *i64 287 if nx_https_url_connect(target, full_url, now, fd_p) != NX_HTTPS_CONNECT_OK { return MCL_CONNECT_FAIL } 288 let fd: i64 = fd_p[0] 289 let host: *u8 = ((full_url as i64) + target.url.host_off) as *u8 290 let hlen: i64 = target.url.host_len 291 let cr: *u8 = sys_mmap(32) 292 let priv: *u8 = sys_mmap(32) 293 nx_csprng_fill(cr, 32) // fresh ephemerals -> forward secrecy per run 294 nx_csprng_fill(priv, 32) 295 // Optional PERSISTENT validation context: a caller doing many connections to ONE host (the chunked upload) passes 296 // a vc it keeps across calls, pre-set with cached_cert/cert_out, so the ~350ms cert-chain crypto runs ONCE. A 297 // null vc_in => a fresh zeroed context => classic full validation every call (login/call are single-shot). 298 var vc: *TlsValidationContext = vc_in 299 if vc == (0 as *TlsValidationContext) { vc = sys_mmap(128) as *TlsValidationContext } 300 vc.store = store 301 vc.sni_host = host 302 vc.sni_host_len = hlen 303 vc.now_epoch = now 304 let sr: i64 = nx_tls13_client_session_run(fd, host, hlen, cr, priv, vc) 305 if sr <= 0 { sys_close(fd); return MCL_HANDSHAKE_FAIL } 306 let session: *Tls13ClientSession = sr as *Tls13ClientSession 307 let n: i64 = mcl_send_drain(session, fd, req, req_len, out, out_cap) 308 sys_close(fd) 309 return n 310} 311 312// ---- file I/O (secrets stay in files, never argv) ---------------------------------------------------- 313 314// read the whole file at `path` into out (cap bytes). Returns bytes read (>=0) or -1 if it can't be opened. 315func mcl_read_file(path: *u8, out: *u8, cap: i64) -> i64 { 316 let fd: i64 = sys_openat_rd(path) 317 if fd <= 0 { return 0 - 1 } 318 var off: i64 = 0 319 var go: i64 = 1 320 while go == 1 { 321 if off >= cap { go = 0 } 322 else { 323 let r: i64 = sys_read(fd, ((out as i64) + off) as *u8, cap - off) 324 if r <= 0 { go = 0 } else { off = off + r } 325 } 326 } 327 sys_close(fd) 328 return off 329} 330// strip trailing CR/LF (files usually carry a trailing newline). Returns the trimmed length. 331func mcl_rtrim_nl(buf: *u8, n: i64) -> i64 { 332 var m: i64 = n 333 var go: i64 = 1 334 while go == 1 { 335 if m <= 0 { go = 0 } 336 else { 337 let c: i64 = buf[m - 1] as i64 338 if c == 10 { m = m - 1 } else { if c == 13 { m = m - 1 } else { go = 0 } } 339 } 340 } 341 return m 342} 343 344// Compose full_url = base_url (trailing '/' trimmed) + suffix (leading '/' ensured). NUL-terminates. Returns length. 345func mcl_join_url(base_url: *u8, suffix: *u8, out: *u8) -> i64 { 346 var o: i64 = mcl_cat(out, 0, base_url) 347 if o > 0 { if out[o - 1] == (47 as u8) { o = o - 1 } } // drop a trailing '/' 348 if suffix[0] != (47 as u8) { out[o] = 47 as u8; o = o + 1 } // ensure a leading '/' 349 o = mcl_cat(out, o, suffix) 350 out[o] = 0 as u8 351 return o 352} 353 354// ---- CLI --------------------------------------------------------------------------------------------- 355 356func mcl_usage() -> i64 { 357 mcl_puts("usage:\n" as *u8) 358 mcl_puts(" nx_mgmt_client <base_url> login <handle> <passphrase_file> <token_out_file>\n" as *u8) 359 mcl_puts(" nx_mgmt_client <base_url> call <METHOD> <path> <token_file> [body_file]\n" as *u8) 360 mcl_puts(" nx_mgmt_client <base_url> upload <target> <artifact_file> <token_file>\n" as *u8) 361 return 0 362} 363 364// build the origin-form request target for one upload chunk into out (NUL-terminated): 365// <base_path>/api/upload?target=<t>&seq=<i>&final=<f> (base_path = the parsed URL path, usually "/") 366// Returns the length. base_path lets a reverse-proxy prefix survive; here it's the joined URL's path. 367func mcl_upload_qpath(base_path: *u8, base_plen: i64, target: *u8, seq: i64, final: i64, sha_hex: *u8, sha_len: i64, out: *u8) -> i64 { 368 var o: i64 = 0 369 // base_path already ends the route root; append "/api/upload" ensuring exactly one '/' 370 o = mcl_catb(out, o, base_path, base_plen) 371 if o > 0 { if out[o - 1] == (47 as u8) { o = o - 1 } } // drop a trailing '/' 372 o = mcl_cat(out, o, "/api/upload?target=" as *u8) 373 o = mcl_cat(out, o, target) 374 o = mcl_cat(out, o, "&seq=" as *u8) 375 o = mcl_catn(out, o, seq) 376 o = mcl_cat(out, o, "&final=" as *u8) 377 o = mcl_catn(out, o, final) 378 // integrity: carry the full-artifact hash ONLY on the final chunk -- the daemon hashes the reassembled file and 379 // fail-closed-rejects a mismatch, so a truncated/garbled upload is caught at STAGE time and never promoted. 380 if final == 1 { if sha_len > 0 { 381 o = mcl_cat(out, o, "&sha256=" as *u8) 382 o = mcl_catb(out, o, sha_hex, sha_len) 383 } } 384 out[o] = 0 as u8 385 return o 386} 387 388func main(argc: i64, argv: *i64) -> i64 { 389 if argc < 3 { mcl_usage(); sys_exit(1); return 1 } 390 let base_url: *u8 = argv[1] as *u8 391 let mode: *u8 = argv[2] as *u8 392 393 let r: i64 = nx_trust_store_load_from_certdata("data/mozilla_certdata.txt" as *u8, 512, MCL_MAGIC_4194304) 394 if r <= 0 { mcl_puts("certdata load failed (run from the nxc2 dir)\n" as *u8); sys_exit(1); return 1 } 395 let store: *TrustStore = r as *TrustStore 396 397 let full: *u8 = sys_mmap(MCL_MAGIC_4096) 398 let req: *u8 = sys_mmap(MCL_MAGIC_2097152) // 2 MiB: headers + one 1MiB chunk body (the edge sd2_fill_body loop-read landed MCL_MAGIC_2026-07-10; matches edge NX_SD2_PLAINCAP + daemon MA_UPLOAD_REQCAP = 2MiB) 399 let out: *u8 = sys_mmap(MCL_MAGIC_1048576) 400 let target_raw: *u8 = sys_mmap(64) 401 let target: *NxHttpsTarget = target_raw as *NxHttpsTarget 402 403 let ct: *u8 = "application/x-www-form-urlencoded" as *u8 404 let ct_len: i64 = 33 405 406 // ================= LOGIN ================= 407 if mcl_streq(mode, "login" as *u8) == 1 { 408 if argc < 6 { mcl_usage(); sys_exit(1); return 1 } 409 let handle: *u8 = argv[3] as *u8 410 let passfile: *u8 = argv[4] as *u8 411 let tokfile: *u8 = argv[5] as *u8 412 413 let pbuf: *u8 = sys_mmap(MCL_MAGIC_4096) 414 let praw: i64 = mcl_read_file(passfile, pbuf, MCL_MAGIC_4095) 415 if praw < 0 { mcl_puts("cannot read passphrase file\n" as *u8); sys_exit(1); return 1 } 416 let pn: i64 = mcl_rtrim_nl(pbuf, praw) 417 418 let body: *u8 = sys_mmap(MCL_MAGIC_16384) 419 let body_len: i64 = mcl_build_login_body(handle, mcl_slen(handle), pbuf, pn, body) 420 421 let ulen: i64 = mcl_join_url(base_url, "/api/login" as *u8, full) 422 target.url = nx_url_new(); target.port = 0 423 if nx_https_url_for_fetch(full, target) != NX_HTTPS_URL_OK { mcl_puts("bad base_url\n" as *u8); sys_exit(1); return 1 } 424 let host: *u8 = ((full as i64) + target.url.host_off) as *u8 425 let hlen: i64 = target.url.host_len 426 let rpath: *u8 = ((full as i64) + target.url.path_off) as *u8 427 let rplen: i64 = target.url.path_len 428 429 let req_len: i64 = mcl_build_request("POST" as *u8, 4, rpath, rplen, host, hlen, 0 as *u8, 0, ct, ct_len, body, body_len, req) 430 let n: i64 = mcl_req(store, full, target, req, req_len, out, MCL_MAGIC_1048576, (0 as *TlsValidationContext)) 431 if n < 0 { mcl_puts("LOGIN FAIL status=0 TRANSPORT-FAIL rc=" as *u8); mcl_putn(n); mcl_puts(" -- connect/handshake/drain never completed\n" as *u8); sys_exit(1); return 1 } 432 // Same three-way conflation as the call path: a zero-byte reply is an UPSTREAM verdict, not a bad 433 // passphrase. Telling a user their credentials failed when the backend never answered sends them to 434 // re-mint tokens that were never the problem -- I burned three token files on exactly that today. 435 if n == 0 { mcl_puts("LOGIN FAIL status=0 EMPTY-RESPONSE -- TLS completed and the peer returned ZERO bytes; the UPSTREAM did not answer. Your credentials were never evaluated -- do not re-mint tokens, probe the backend.\n" as *u8); sys_exit(1); return 1 } 436 437 let stbox: *i64 = sys_mmap(8) as *i64 438 let tok: *u8 = sys_mmap(MCL_MAGIC_4096) 439 let tn: i64 = mcl_extract_token(out, n, stbox, tok, MCL_MAGIC_4096) 440 if tn > 0 { 441 let fd: i64 = sys_openat_wr(tokfile, MCL_TOKEN_OUT_MODE) 442 if fd < 0 { mcl_puts("LOGIN OK but cannot write token file\n" as *u8); sys_exit(1); return 1 } 443 sys_write(fd, tok, tn) 444 sys_close(fd) 445 mcl_puts("LOGIN OK\n" as *u8) 446 sys_exit(0); return 0 447 } 448 mcl_puts("LOGIN FAIL status=" as *u8); mcl_putn(stbox[0]); mcl_puts("\n" as *u8) 449 sys_exit(1); return 1 450 } 451 452 // ================= UPLOAD ================= 453 if mcl_streq(mode, "upload" as *u8) == 1 { 454 if argc < 6 { mcl_usage(); sys_exit(1); return 1 } 455 let tgtname: *u8 = argv[3] as *u8 456 let artfile: *u8 = argv[4] as *u8 457 let tokfile: *u8 = argv[5] as *u8 458 459 // token (secret, from file -- never argv) 460 let tbuf: *u8 = sys_mmap(MCL_MAGIC_4096) 461 let traw: i64 = mcl_read_file(tokfile, tbuf, MCL_MAGIC_4095) 462 if traw < 0 { mcl_puts("cannot read token file\n" as *u8); sys_exit(1); return 1 } 463 let tn: i64 = mcl_rtrim_nl(tbuf, traw) 464 465 // the artifact may be several MB -- size the buffer accordingly (8 MB) 466 let art: *u8 = sys_mmap(MCL_ARTIFACT_CAP) 467 let artn: i64 = mcl_read_file(artfile, art, MCL_ARTIFACT_CAP) 468 if artn < 0 { mcl_puts("cannot read artifact file\n" as *u8); sys_exit(1); return 1 } 469 if artn == 0 { mcl_puts("UPLOAD FAIL (artifact is empty)\n" as *u8); sys_exit(1); return 1 } 470 // never silently truncate: a read that FILLS the whole buffer may be a larger file clipped -> REFUSE loudly. 471 if artn >= MCL_ARTIFACT_CAP { mcl_puts("UPLOAD FAIL (artifact >= 8MiB cap; raise MCL_ARTIFACT_CAP)\n" as *u8); sys_exit(1); return 1 } 472 473 // integrity: hash the WHOLE artifact up front; the hex rides the final chunk's &sha256= so the daemon 474 // verifies the reassembled file BEFORE renaming to <target>.new (stage-time fail-closed, not deploy-time). 475 let dig: *u8 = sys_mmap(32) 476 sha256_digest(art, artn, dig) 477 let shahex: *u8 = sys_mmap(72) 478 mcl_hex32(dig, shahex) 479 480 // parse the base URL once (host/path). We connect fresh per chunk (mcl_req connects+handshakes+drains). 481 let ulen: i64 = mcl_join_url(base_url, "/" as *u8, full) 482 target.url = nx_url_new(); target.port = 0 483 if nx_https_url_for_fetch(full, target) != NX_HTTPS_URL_OK { mcl_puts("bad base_url\n" as *u8); sys_exit(1); return 1 } 484 let host: *u8 = ((full as i64) + target.url.host_off) as *u8 485 let hlen: i64 = target.url.host_len 486 let bpath: *u8 = ((full as i64) + target.url.path_off) as *u8 487 let bplen: i64 = target.url.path_len 488 489 let octet: *u8 = "application/octet-stream" as *u8 490 let octet_len: i64 = 24 491 492 let CHUNK: i64 = MCL_MAGIC_1048576 // 1MiB: the edge loop-read LANDED MCL_MAGIC_2026-07-10 (sd2_fill_body in nx_sites_daemon_v2 completes a proxied POST body across TLS records before forwarding; edge NX_SD2_PLAINCAP + daemon MA_UPLOAD_REQCAP both 2MiB). History: 8KB was the hard cap MCL_MAGIC_2026-07-07 (relay forwarded only 1 TLS record; 64KB/1MB -> transport rc=-2). A 655KB binary is now ONE chunk; the 80MB tree ~80. 493 var nchunks: i64 = artn / CHUNK 494 if artn % CHUNK != 0 { nchunks = nchunks + 1 } 495 496 let qpath: *u8 = sys_mmap(512) 497 let creq_full: *u8 = sys_mmap(MCL_MAGIC_4096) 498 let ctgt_raw: *u8 = sys_mmap(64) 499 let ctgt: *NxHttpsTarget = ctgt_raw as *NxHttpsTarget 500 var off: i64 = 0 501 // SESSION CERT-VALIDATION CACHE: sites.elf presents the SAME cert chain for every chunk. Validate it fully on 502 // chunk 0, capture it, then from chunk 1 on skip the ~350ms ECDSA chain crypto (N certloops -> 1). SAFE: the 503 // per-connection CertificateVerify possession proof is untouched; single-host upload so the cache can't cross hosts. 504 let vcup: *TlsValidationContext = sys_mmap(128) as *TlsValidationContext // persistent across chunks; store/sni/now set per call 505 let cached_cert_buf: *u8 = sys_mmap(MCL_MAGIC_65536) // chunk-0's validated Certificate message 506 let cert_capture_buf: *u8 = sys_mmap(MCL_MAGIC_65536) // DISTINCT buffer the validator writes each chunk's cert into 507 vcup.cert_out = cert_capture_buf 508 vcup.cert_out_cap = MCL_MAGIC_65536 509 vcup.cached_cert = cached_cert_buf // valid ptr always; gated by cached_cert_len below 510 var vcup_cached_len: i64 = 0 511 var idx: i64 = 0 512 var ok: i64 = 1 513 while idx < nchunks { 514 vcup.cached_cert_len = vcup_cached_len // 0 => full validate (chunk 0); >0 => cache-hit skip 515 var clen: i64 = CHUNK 516 if off + clen > artn { clen = artn - off } 517 var final: i64 = 0 518 if idx == nchunks - 1 { final = 1 } 519 let qplen: i64 = mcl_upload_qpath(bpath, bplen, tgtname, idx, final, shahex, 64, qpath) 520 521 // build the connect target for THIS request's path (host identical; the request line carries qpath) 522 let cfull_len: i64 = mcl_join_url(base_url, qpath, creq_full) 523 ctgt.url = nx_url_new(); ctgt.port = 0 524 if nx_https_url_for_fetch(creq_full, ctgt) != NX_HTTPS_URL_OK { mcl_puts("UPLOAD FAIL (bad url for chunk)\n" as *u8); sys_exit(1); return 1 } 525 let cpath: *u8 = ((creq_full as i64) + ctgt.url.path_off) as *u8 526 let cplen: i64 = ctgt.url.path_len 527 528 let chunk_ptr: *u8 = ((art as i64) + off) as *u8 529 // Request-line target MUST be the full qpath (path+query); cpath came from nx_https_url_for_fetch which 530 // splits path from query (RFC 3986), dropping ?target=&seq=&final= -> server 400 "missing query params". 531 // ctgt (from the same parse) is still correct for the CONNECTION (host/port); only the request line changes. 532 let req_len: i64 = mcl_build_request("POST" as *u8, 4, qpath, qplen, host, hlen, tbuf, tn, octet, octet_len, chunk_ptr, clen, req) 533 // UNIFIED per-chunk retry (fresh connection+ephemerals each attempt, mcl_req re-CSPRNGs per call). 534 // Retryable: transport rc<0 (connect/handshake/drain), status=0 (the edge relay LOST/garbled the 535 // response after the daemon may have already applied the chunk), and 5xx. All are SAFE to re-POST 536 // because the daemon's idempotent replay-ack (ma_do_upload: seq==expected-1 acks without appending; 537 // completed-final re-acks against the staged sha) makes a duplicate seq a no-op. A 4xx is a 538 // DETERMINISTIC refusal (allowlist/auth/params) -> abort loudly with the server's body, no retry. 539 var n: i64 = 0 - 1 540 var st: i64 = 0 541 var body_off: i64 = 0 542 var attempt: i64 = 0 543 var trying: i64 = 1 544 while trying == 1 { 545 attempt = attempt + 1 546 n = mcl_req(store, creq_full, ctgt, req, req_len, out, MCL_MAGIC_1048576, vcup) 547 st = 0 548 body_off = 0 549 if n > 0 { 550 let rr: *i64 = nx_http_resp_alloc() 551 if nx_http_response_parse(out, n, rr) == 0 { st = rr[1]; body_off = rr[6] } 552 } 553 if st == 200 { trying = 0 } else { 554 var retryable: i64 = 0 555 if n < 0 { retryable = 1 } 556 if st == 0 { retryable = 1 } 557 if st >= 500 { retryable = 1 } 558 if retryable == 0 { trying = 0 } 559 if trying == 1 { 560 if attempt >= MCL_UPLOAD_RETRIES { trying = 0 } else { 561 // EXPONENTIAL BACKOFF (100ms<<attempt, capped): the edge relay wedges for whole 562 // SECONDS at a time; immediate retries burn the whole budget inside that one bad 563 // window. Backing off rides the hiccup out. The daemon's idempotent replay-ack makes 564 // the delayed re-POST a safe no-op even if the daemon already applied the chunk. 565 var backoff_ms: i64 = 100 566 var bshift: i64 = 1 567 while bshift < attempt { backoff_ms = backoff_ms * 2; bshift = bshift + 1 } 568 if backoff_ms > MCL_MAGIC_2000 { backoff_ms = MCL_MAGIC_2000 } 569 mcl_puts(" chunk " as *u8); mcl_putn(idx); mcl_puts(" rc=" as *u8); mcl_putn(n); mcl_puts(" status=" as *u8); mcl_putn(st); mcl_puts(" -> backoff " as *u8); mcl_putn(backoff_ms); mcl_puts("ms replay-retry\n" as *u8) 570 sys_sleep_ms(backoff_ms) 571 } 572 } 573 } 574 } 575 if st != 200 { 576 mcl_puts("UPLOAD FAIL chunk " as *u8); mcl_putn(idx); mcl_puts("/" as *u8); mcl_putn(nchunks); mcl_puts(" status=" as *u8); mcl_putn(st); mcl_puts(" (after " as *u8); mcl_putn(attempt); mcl_puts(" attempts)\n" as *u8) 577 if n > 0 { if st > 0 { sys_write(1, ((out as i64) + body_off) as *u8, n - body_off) } } 578 mcl_puts("\n" as *u8) 579 ok = 0 580 idx = nchunks // abort the loop 581 } else { 582 mcl_puts("uploaded chunk " as *u8); mcl_putn(idx + 1); mcl_puts("/" as *u8); mcl_putn(nchunks); mcl_puts("\n" as *u8) 583 if idx == 0 { 584 // chunk 0 fully validated + succeeded -> cache its Certificate message so chunks 1..N skip the chain crypto. 585 let cl0: i64 = vcup.cert_out_len 586 if cl0 > 0 { var cc0: i64 = 0; while cc0 < cl0 { cached_cert_buf[cc0] = cert_capture_buf[cc0]; cc0 = cc0 + 1 } vcup_cached_len = cl0 } 587 } 588 if final == 1 { 589 sys_write(1, ((out as i64) + body_off) as *u8, n - body_off) // print the {"staged":...} final ack 590 mcl_puts("\n" as *u8) 591 } 592 off = off + clen 593 idx = idx + 1 594 } 595 } 596 if ok == 1 { 597 mcl_puts("UPLOAD OK " as *u8); mcl_puts(tgtname); mcl_puts(".new (" as *u8); mcl_putn(artn); mcl_puts(" bytes)\n" as *u8) 598 sys_exit(0); return 0 599 } 600 sys_exit(1); return 1 601 } 602 603 // ================= CALL ================= 604 if mcl_streq(mode, "call" as *u8) == 1 { 605 if argc < 6 { mcl_usage(); sys_exit(1); return 1 } 606 let method: *u8 = argv[3] as *u8 607 let path: *u8 = argv[4] as *u8 608 let tokfile: *u8 = argv[5] as *u8 609 610 let tbuf: *u8 = sys_mmap(MCL_MAGIC_4096) 611 let traw: i64 = mcl_read_file(tokfile, tbuf, MCL_MAGIC_4095) 612 if traw < 0 { mcl_puts("cannot read token file\n" as *u8); sys_exit(1); return 1 } 613 let tn: i64 = mcl_rtrim_nl(tbuf, traw) 614 615 // optional body from file 616 let body: *u8 = sys_mmap(MCL_MAGIC_65536) 617 var body_len: i64 = 0 618 if argc >= 7 { 619 let bfile: *u8 = argv[6] as *u8 620 let braw: i64 = mcl_read_file(bfile, body, MCL_MAGIC_65535) 621 if braw < 0 { mcl_puts("cannot read body file\n" as *u8); sys_exit(1); return 1 } 622 body_len = mcl_rtrim_nl(body, braw) 623 } 624 625 let ulen: i64 = mcl_join_url(base_url, path, full) 626 target.url = nx_url_new(); target.port = 0 627 if nx_https_url_for_fetch(full, target) != NX_HTTPS_URL_OK { mcl_puts("bad base_url/path\n" as *u8); sys_exit(1); return 1 } 628 let host: *u8 = ((full as i64) + target.url.host_off) as *u8 629 let hlen: i64 = target.url.host_len 630 let rpath: *u8 = ((full as i64) + target.url.path_off) as *u8 631 let rplen: i64 = target.url.path_len 632 633 // body present -> send Content-Type + Content-Length; absent -> pure method (GET/no-body) 634 var req_len: i64 = 0 635 if body_len > 0 { 636 req_len = mcl_build_request(method, mcl_slen(method), rpath, rplen, host, hlen, tbuf, tn, ct, ct_len, body, body_len, req) 637 } else { 638 req_len = mcl_build_request(method, mcl_slen(method), rpath, rplen, host, hlen, tbuf, tn, ct, ct_len, 0 as *u8, 0, req) 639 } 640 let n: i64 = mcl_req(store, full, target, req, req_len, out, MCL_MAGIC_1048576, (0 as *TlsValidationContext)) 641 // ---- `status=0` MEANT THREE DIFFERENT THINGS AND NAMED NONE OF THEM (fixed 2026-07-31) ---------- 642 // `st` below starts at 0 and is assigned ONLY when the parse SUCCEEDS. So a peer that returned ZERO 643 // bytes, a response that failed to parse, and a genuine zero all printed an identical bare 644 // `status=0`. MEASURED COST: during the mgmt/tools wedge the edge completed TLS while its UPSTREAM 645 // did not answer, and the only signal available was `status=0` -- which reads as "this client is 646 // broken". I filed a sev-7 against this binary on that basis and had to retract it. An agent that 647 // believes it reaches for ssh/scp instead, which is the THIRD WRITE PATH already filed at sev-9 648 // (id 1785439172) as the thing that bypasses both sovereign guards. 649 // LAW: A CLIENT THAT FAILS WITHOUT A REASON TRAINS ITS USERS TO ABANDON IT. 650 if n < 0 { 651 mcl_puts("status=0 TRANSPORT-FAIL rc=" as *u8); mcl_putn(n) 652 mcl_puts(" -- connect/handshake/drain never completed; nothing came back at the socket layer\n" as *u8) 653 sys_exit(1); return 1 654 } 655 if n == 0 { 656 mcl_puts("status=0 EMPTY-RESPONSE -- TLS completed and the peer returned ZERO bytes. The edge is UP and its UPSTREAM did not answer (wedged backend or dead proxy target). This is NOT a client fault: probe the upstream, do not rebuild this binary.\n" as *u8) 657 sys_exit(1); return 1 658 } 659 let rr: *i64 = nx_http_resp_alloc() 660 var st: i64 = 0 661 var body_off: i64 = 0 662 var parsed: i64 = 0 663 if nx_http_response_parse(out, n, rr) == 0 { st = rr[1]; body_off = rr[6]; parsed = 1 } 664 if parsed == 0 { 665 // SHOW WHAT ARRIVED. A parse failure that withholds the bytes is unactionable; with the first 666 // bytes in hand the caller tells an HTML error page from a truncated TLS record in one look. 667 mcl_puts("status=0 UNPARSEABLE-RESPONSE bytes=" as *u8); mcl_putn(n) 668 mcl_puts(" -- data arrived but is not a parseable HTTP response; first bytes follow:\n" as *u8) 669 var shown: i64 = n 670 if shown > 512 { shown = 512 } 671 sys_write(1, out, shown) 672 mcl_puts("\n" as *u8) 673 sys_exit(1); return 1 674 } 675 mcl_puts("status=" as *u8); mcl_putn(st); mcl_puts("\n" as *u8) 676 sys_write(1, ((out as i64) + body_off) as *u8, n - body_off) 677 mcl_puts("\n" as *u8) 678 var ok: i64 = 0 679 if st >= 200 { if st < 300 { ok = 1 } } 680 if ok == 1 { sys_exit(0); return 0 } 681 sys_exit(1); return 1 682 } 683 684 mcl_usage() 685 sys_exit(1) 686 return 1 687}