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}