nx_nishipages_serve.nx source
↩ module page · 326 lines · 11903 B
1// nx_nishipages_serve.nx -- sovereign self-contained static server for
2// nishi-pages.
3//
4// Built per operator directive 2026-06-30: "build the apis that will do
5// what you are trying to do in shell" + "sovereign live server first".
6//
7// WHY SELF-CONTAINED (not composing nx_pages_static / nx_http_server):
8// those modules pull nx_syscalls.nx, which needs nx_syscalls_x86_64.nx
9// for __syscall/sys_mmap -- but the two modules BOTH define 26 of the
10// same sys_* symbols, so any organ pulling both double-emits every
11// sys_* label and `as` rejects the duplicate (this is exactly why the
12// "shipped" nx_audit_server_* organs do not assemble on the current
13// toolchain). nx_syscalls.nx has 6098 importers, so it cannot be
14// edited. We therefore import ONLY the leaf x86_64 syscall layer
15// (every socket + file syscall, zero further imports) plus the pure,
16// import-free CWE-22 canonicalizer. Result: no symbol overlap, clean
17// link, fully sovereign.
18//
19// Serves the whole dist/ tree with clean URLs:
20// "/" -> dist/index.html
21// "/foo/" -> dist/foo/index.html
22// "/foo" -> dist/foo (file) else dist/foo/index.html else dist/foo.html
23// "/a/b.ext" -> dist/a/b.ext (literal; MIME by extension, incl. .wasm)
24// Path traversal (../, NUL, backslash, leading /) is rejected by
25// nx_path_canonicalize before any file is opened -- defense at the boundary.
26//
27// Build (x86_64 native Linux ELF):
28// cd nishi-core/nxc2
29// ./nxc2.exe --target x86_64 runtime/nx_nishipages_serve.nx > /tmp/nps.s
30// wsl bash -c "gcc -no-pie /tmp/nps.s -o /tmp/nx_nishipages_serve"
31// Run from the nishi-pages checkout so the relative "dist" root resolves:
32// cd nishi-pages && /tmp/nx_nishipages_serve # listens on :8090
33//
34// nx_capability_claims:
35// needs: [x86_64_syscalls, cwe_22_canonicalize]
36// provides: [nishipages_static_serve, clean_url_routing,
37// full_dist_tree_serve, wasm_mime]
38// safety: [cwe_22_canonicalize_at_boundary, get_only,
39// bounded_request_buffer, no_floating_point,
40// jpl_rule2_bounded_accept_loop]
41// verdict: [http_200_404_405]
42// license: ORIGINAL
43// kind: nishi_pages_specialist
44// layer: L3 (composite over L1 syscalls + L2 path canonicalize)
45// raci: [R=nishi_pages_builder, A=elder_west,
46// C=nx_path_canonicalize_owner, I=conductor]
47
48import "nx_syscalls_x86_64.nx"
49import "nx_path_canonicalize.nx"
50const NPS_MAGIC_2048: i64 = 2048
51const NPS_MAGIC_2040: i64 = 2040
52
53const NPS_PORT: i64 = 8090
54const NPS_BACKLOG: i64 = 128
55const NPS_REQ_CAP: i64 = 16384
56const NPS_BUDGET: i64 = 1000000 // JPL-rule-2 bounded accept loop
57
58// ---- small helpers ------------------------------------------------
59
60func nps_strlen(s: *u8) -> i64 {
61 var i: i64 = 0
62 while s[i] != 0 { i = i + 1 }
63 return i
64}
65
66// Write all n bytes to fd, looping over partial socket writes.
67func nps_write_all(fd: i64, buf: *u8, n: i64) -> i64 {
68 var off: i64 = 0
69 while off < n {
70 let p: *u8 = ((buf as i64) + off) as *u8
71 let w: i64 = sys_write(fd, p, n - off)
72 if w <= 0 { return 0 }
73 off = off + w
74 }
75 return 0
76}
77
78// Append NUL-terminated s into buf at off; return new off.
79func nps_puts(buf: *u8, off: i64, s: *u8) -> i64 {
80 var o: i64 = off
81 var i: i64 = 0
82 while s[i] != 0 {
83 buf[o] = s[i]
84 o = o + 1
85 i = i + 1
86 }
87 return o
88}
89
90// Append decimal v into buf at off; return new off. (nxc2 has no % ;
91// mod is x - (x/10)*10.)
92func nps_putdec(buf: *u8, off: i64, v: i64) -> i64 {
93 if v == 0 {
94 buf[off] = 0x30
95 return off + 1
96 }
97 let tmp: *u8 = sys_mmap(32)
98 var k: i64 = 0
99 var x: i64 = v
100 while x > 0 {
101 tmp[k] = 0x30 + (x - (x / 10) * 10)
102 x = x / 10
103 k = k + 1
104 }
105 var o: i64 = off
106 var ri: i64 = k - 1
107 while ri >= 0 {
108 buf[o] = tmp[ri]
109 o = o + 1
110 ri = ri - 1
111 }
112 return o
113}
114
115// dst = a[0..a_n) ++ b(NUL-terminated); return total length.
116func nps_cat(dst: *u8, a: *u8, a_n: i64, b: *u8) -> i64 {
117 var i: i64 = 0
118 while i < a_n {
119 dst[i] = a[i]
120 i = i + 1
121 }
122 var j: i64 = 0
123 while b[j] != 0 {
124 dst[i + j] = b[j]
125 j = j + 1
126 }
127 return i + j
128}
129
130func nps_ends_with(s: *u8, n: i64, suf: *u8, suf_n: i64) -> i64 {
131 if n < suf_n { return 0 }
132 var i: i64 = 0
133 while i < suf_n {
134 if (s[n - suf_n + i] as i64) != (suf[i] as i64) { return 0 }
135 i = i + 1
136 }
137 return 1
138}
139
140func nps_mime(path: *u8, n: i64) -> *u8 {
141 if nps_ends_with(path, n, ".html" as *u8, 5) == 1 { return "text/html; charset=utf-8" as *u8 }
142 if nps_ends_with(path, n, ".css" as *u8, 4) == 1 { return "text/css; charset=utf-8" as *u8 }
143 if nps_ends_with(path, n, ".mjs" as *u8, 4) == 1 { return "application/javascript" as *u8 }
144 if nps_ends_with(path, n, ".js" as *u8, 3) == 1 { return "application/javascript" as *u8 }
145 if nps_ends_with(path, n, ".json" as *u8, 5) == 1 { return "application/json" as *u8 }
146 if nps_ends_with(path, n, ".wasm" as *u8, 5) == 1 { return "application/wasm" as *u8 }
147 if nps_ends_with(path, n, ".wat" as *u8, 4) == 1 { return "text/plain; charset=utf-8" as *u8 }
148 if nps_ends_with(path, n, ".png" as *u8, 4) == 1 { return "image/png" as *u8 }
149 if nps_ends_with(path, n, ".jpg" as *u8, 4) == 1 { return "image/jpeg" as *u8 }
150 if nps_ends_with(path, n, ".svg" as *u8, 4) == 1 { return "image/svg+xml" as *u8 }
151 if nps_ends_with(path, n, ".ico" as *u8, 4) == 1 { return "image/x-icon" as *u8 }
152 return "application/octet-stream" as *u8
153}
154
155// 1 if rel has a file extension in its last path segment (a '.' after
156// the last '/'). Clean URLs without an extension ("/voxels") route to
157// dir-index instead of reading the bare directory (which on 9p succeeds
158// and returns garbage bytes).
159func nps_has_ext(rel: *u8, rel_n: i64) -> i64 {
160 var i: i64 = rel_n - 1
161 while i >= 0 {
162 let c: i64 = rel[i] as i64
163 if c == 0x2f { return 0 }
164 if c == 0x2e { return 1 }
165 i = i - 1
166 }
167 return 0
168}
169
170func nps_send_404(cfd: i64) -> i64 {
171 let r: *u8 = "HTTP/1.1 404 Not Found\r\nContent-Type: text/plain; charset=utf-8\r\nContent-Length: 14\r\nConnection: close\r\n\r\n404 Not Found\n" as *u8
172 nps_write_all(cfd, r, nps_strlen(r))
173 return 0
174}
175
176func nps_send_405(cfd: i64) -> i64 {
177 let r: *u8 = "HTTP/1.1 405 Method Not Allowed\r\nAllow: GET\r\nContent-Length: 0\r\nConnection: close\r\n\r\n" as *u8
178 nps_write_all(cfd, r, nps_strlen(r))
179 return 0
180}
181
182// Emit a 200 OK with body. Header built in a small buffer, then body
183// streamed (so a 4 MB wasm body needs no oversized header buffer).
184func nps_send_200(cfd: i64, mime: *u8, body: *u8, body_n: i64) -> i64 {
185 let hdr: *u8 = sys_mmap(512)
186 var o: i64 = 0
187 o = nps_puts(hdr, o, "HTTP/1.1 200 OK\r\nContent-Type: " as *u8)
188 o = nps_puts(hdr, o, mime)
189 o = nps_puts(hdr, o, "\r\nContent-Length: " as *u8)
190 o = nps_putdec(hdr, o, body_n)
191 // no-cache: browsers revalidate, so a freshly-published game wasm is
192 // picked up on next load -- server-side auto-update, zero client JS.
193 o = nps_puts(hdr, o, "\r\nCache-Control: no-cache\r\nConnection: close\r\n\r\n" as *u8)
194 nps_write_all(cfd, hdr, o)
195 nps_write_all(cfd, body, body_n)
196 return 0
197}
198
199// Canonicalize rel, join under dist/, read the file. Returns 1 + sets
200// body_pp/len_p on success, 0 (traversal / not-found) otherwise.
201func nps_try_file(rel: *u8, rel_n: i64, body_pp: **u8, len_p: *i64) -> i64 {
202 let canon: *u8 = sys_mmap(NPS_MAGIC_2048)
203 let canon_n: *i64 = sys_mmap(8) as *i64
204 let cv: i64 = nx_path_canonicalize(rel, rel_n, canon, NPS_MAGIC_2048, canon_n)
205 if cv != NXP_OK { return 0 }
206 let full: *u8 = sys_mmap(NPS_MAGIC_2048)
207 let full_n: *i64 = sys_mmap(8) as *i64
208 let jv: i64 = nx_path_join("dist" as *u8, 4, canon, canon_n[0], full, NPS_MAGIC_2040, full_n)
209 if jv != NXP_OK { return 0 }
210 full[full_n[0]] = 0
211 let len: *i64 = sys_mmap(8) as *i64
212 let body: *u8 = sys_read_file_x86_64(full, len)
213 if body == (0 as *u8) { return 0 }
214 body_pp[0] = body
215 len_p[0] = len[0]
216 return 1
217}
218
219// Clean-URL router for one GET request path.
220func nps_serve(cfd: i64, path: *u8, path_n: i64) -> i64 {
221 if path_n < 1 { nps_send_404(cfd); return 0 }
222 if (path[0] as i64) != 0x2f { nps_send_404(cfd); return 0 }
223
224 let rel: *u8 = ((path as i64) + 1) as *u8
225 let rel_n: i64 = path_n - 1
226
227 let cbuf: *u8 = sys_mmap(NPS_MAGIC_2048)
228 let body_pp: **u8 = sys_mmap(8) as **u8
229 let len_p: *i64 = sys_mmap(8) as *i64
230 var found: i64 = 0
231 var used_n: i64 = 0
232
233 // Probe candidates by URL shape, never reading a bare directory:
234 // "/" -> index.html
235 // ".../" -> .../index.html
236 // "/a/b.ext" -> literal (file with an extension)
237 // "/clean" -> /clean/index.html, then /clean.html
238 if rel_n == 0 {
239 used_n = nps_cat(cbuf, "index.html" as *u8, 10, "" as *u8)
240 if nps_try_file(cbuf, used_n, body_pp, len_p) == 1 { found = 1 }
241 } else {
242 if (path[path_n - 1] as i64) == 0x2f {
243 used_n = nps_cat(cbuf, rel, rel_n, "index.html" as *u8)
244 if nps_try_file(cbuf, used_n, body_pp, len_p) == 1 { found = 1 }
245 } else {
246 if nps_has_ext(rel, rel_n) == 1 {
247 used_n = nps_cat(cbuf, rel, rel_n, "" as *u8)
248 if nps_try_file(cbuf, used_n, body_pp, len_p) == 1 { found = 1 }
249 } else {
250 used_n = nps_cat(cbuf, rel, rel_n, "/index.html" as *u8)
251 if nps_try_file(cbuf, used_n, body_pp, len_p) == 1 { found = 1 }
252 if found == 0 {
253 used_n = nps_cat(cbuf, rel, rel_n, ".html" as *u8)
254 if nps_try_file(cbuf, used_n, body_pp, len_p) == 1 { found = 1 }
255 }
256 }
257 }
258 }
259
260 if found == 1 {
261 let mime: *u8 = nps_mime(cbuf, used_n)
262 nps_send_200(cfd, mime, body_pp[0], len_p[0])
263 } else {
264 nps_send_404(cfd)
265 }
266 return 0
267}
268
269// Read + parse one request on cfd, dispatch. GET only.
270func nps_handle(cfd: i64) -> i64 {
271 let req: *u8 = sys_mmap(NPS_REQ_CAP)
272 let n: i64 = sys_read(cfd, req, NPS_REQ_CAP)
273 if n < 5 { return 0 }
274 // Method must be "GET ".
275 if (req[0] as i64) != 0x47 { nps_send_405(cfd); return 0 }
276 if (req[1] as i64) != 0x45 { nps_send_405(cfd); return 0 }
277 if (req[2] as i64) != 0x54 { nps_send_405(cfd); return 0 }
278 if (req[3] as i64) != 0x20 { nps_send_405(cfd); return 0 }
279 // Path = req[4 .. first space].
280 let path: *u8 = ((req as i64) + 4) as *u8
281 var pe: i64 = 4
282 var path_end: i64 = 0 - 1
283 while pe < n {
284 if path_end < 0 {
285 if (req[pe] as i64) == 0x20 { path_end = pe }
286 }
287 pe = pe + 1
288 }
289 if path_end < 0 { path_end = n }
290 let path_n: i64 = path_end - 4
291 nps_serve(cfd, path, path_n)
292 return 0
293}
294
295func main() -> i64 {
296 let fd: i64 = sys_socket(AF_INET, SOCK_STREAM, 0)
297 if fd < 0 { return 10 }
298
299 // SO_REUSEADDR so a quick restart doesn't hit TIME_WAIT bind refusal.
300 let one: *u8 = sys_mmap(4)
301 one[0] = 1
302 one[1] = 0
303 one[2] = 0
304 one[3] = 0
305 sys_setsockopt(fd, SOL_SOCKET, SO_REUSEADDR, one, 4)
306
307 let addr: *u8 = sys_mmap(16)
308 sockaddr_in_init(addr, NPS_PORT)
309 if sys_bind(fd, addr, 16) < 0 { return 11 }
310 if sys_listen(fd, NPS_BACKLOG) < 0 { return 12 }
311
312 let banner: *u8 = "nx_nishipages_serve: listening on 0.0.0.0:8090 (root=dist/)\n" as *u8
313 sys_write(1, banner, nps_strlen(banner))
314
315 var served: i64 = 0
316 while served < NPS_BUDGET {
317 let cfd: i64 = sys_accept(fd)
318 if cfd >= 0 {
319 nps_handle(cfd)
320 sys_close(cfd)
321 }
322 served = served + 1
323 }
324 sys_close(fd)
325 return 0
326}