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}