code wiki / (root) / nx_mgmt_client.nx

nx_mgmt_client.nx source

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