code wiki / _hdl_build / nx_site_intake.nx
nx_site_intake.nx source
↩ module page · 221 lines · 9850 B
1// nx_site_intake.nx -- PUBLIC contact-intake route core for GENERATED SITES (uigen lane, debt 1785934266;
2// the buildable half of R0a "a dead form is a fake"). PURE CORE, NO MAIN -- the nx_office_serve/
3// nx_survey_serve discipline: a host daemon mounts si_route behind the sovereign edge, and that mount +
4// the edge route row are the coordinated deployment hook, NOT this file. Once mounted, a generated
5// site's req names contact|action|/intake/<slug> and the T15 form goes live with ZERO emitter rebuild.
6// PUBLIC + ANONYMOUS => hostile by default (rule 12):
7// - SI_MAX_BODY cap at the door (the oversized-proxied-POST class that once destabilized sites.elf)
8// - honeypot `website` field: a filled honeypot gets the SAME 303 as success and records NOTHING
9// (a rejection a bot can measure is a rejection it can learn around)
10// - plane-grammar defense: TAB/CR/LF in decoded fields become spaces BEFORE the append (the clockjobs
11// writer-vs-plane lesson enforced at write time, gate-proven)
12// - `back` redirect is PATH-ONLY (leading '/', no ':', no '//') so no open redirect; else "/"
13// Rows land append-only on the caller-supplied plane as id<TAB>slug<TAB>name<TAB>email<TAB>message
14// (nx_store_put-load readable; id=epoch-sec -- the BYTES are the ledger, a load view collapses
15// same-second ids). Caller supplies plane + log paths: the gate passes per-run /tmp fixtures
16// (idempotent-fixture law), the real host passes knowledge/store/siteintake- +
17// knowledge/status/site_intake.log. A message longer than SI_MAX_MSG reads as absent (the form must
18// carry maxlength); only RECORDED submissions write a log line, so a honeypot flood cannot grow the
19// status log. No floats, no hardware writes, nothing here binds a socket. license_tier: ORIGINAL
20import "nx_syscalls.nx"
21import "nx_http_form.nx"
22
23const SI_MAX_BODY: i64 = 4096
24const SI_MAX_FIELD: i64 = 200
25const SI_MAX_MSG: i64 = 1000
26const SI_MAX_SLUG: i64 = 40
27const SI_ROW: i64 = 2048
28const SI_SCRATCH: i64 = 256
29const SI_MSGBUF: i64 = 1032
30const SI_HPBUF: i64 = 128
31const SI_SLUGBUF: i64 = 64
32const SI_TMP: i64 = 28
33const SI_MODE: i64 = 420
34const SI_TEN: i64 = 10
35const SI_B_TAB: i64 = 9
36const SI_B_LF: i64 = 10
37const SI_B_CR: i64 = 13
38const SI_B_SP: i64 = 32
39const SI_B_DASH: i64 = 45
40const SI_B_SLASH: i64 = 47
41const SI_B_COLON: i64 = 58
42const SI_B_0: i64 = 48
43const SI_B_9: i64 = 57
44const SI_B_A: i64 = 97
45const SI_B_Z: i64 = 122
46
47func si_len(s: *u8) -> i64 { var n: i64 = 0; while s[n] != (0 as u8) { n = n + 1 } return n }
48func si_cat(dst: *u8, off: i64, s: *u8) -> i64 {
49 var o: i64 = off
50 var i: i64 = 0
51 while s[i] != (0 as u8) { dst[o] = s[i]; o = o + 1; i = i + 1 }
52 return o
53}
54func si_putn(buf: *u8, off: i64, v: i64) -> i64 {
55 if v == 0 { buf[off] = SI_B_0 as u8; return off + 1 }
56 var m: i64 = v
57 var o: i64 = off
58 if m < 0 { buf[o] = SI_B_DASH as u8; o = o + 1; m = 0 - m }
59 let t: *u8 = sys_mmap(SI_TMP)
60 var k: i64 = 0
61 while m > 0 { t[k] = (SI_B_0 + (m % SI_TEN)) as u8; m = m / SI_TEN; k = k + 1 }
62 while k > 0 { k = k - 1; buf[o] = t[k]; o = o + 1 }
63 return o
64}
65func si_starts(buf: *u8, n: i64, lit: *u8) -> i64 {
66 let ll: i64 = si_len(lit)
67 if n < ll { return 0 }
68 var i: i64 = 0
69 while i < ll { if buf[i] != lit[i] { return 0 } i = i + 1 }
70 return 1
71}
72// plane-grammar defense: the row grammar's separator bytes can never arrive from user input
73func si_sanitize(s: *u8) -> i64 {
74 var i: i64 = 0
75 while s[i] != (0 as u8) {
76 if s[i] == (SI_B_TAB as u8) { s[i] = SI_B_SP as u8 }
77 if s[i] == (SI_B_CR as u8) { s[i] = SI_B_SP as u8 }
78 if s[i] == (SI_B_LF as u8) { s[i] = SI_B_SP as u8 }
79 i = i + 1
80 }
81 return 0
82}
83// decoded + sanitized form field; returns value length (0 = absent/empty/overlong)
84func si_field(body: *u8, bn: i64, name: *u8, out: *u8, cap: i64) -> i64 {
85 let vl: *i64 = sys_mmap(16) as *i64
86 vl[0] = 0
87 nx_http_form_get_field(body, bn, name, si_len(name), out, cap, vl)
88 if vl[0] <= 0 { out[0] = 0 as u8; return 0 }
89 var n: i64 = vl[0]
90 if n >= cap { n = cap - 1 }
91 out[n] = 0 as u8
92 si_sanitize(out)
93 return n
94}
95// PATH-ONLY redirect target: leading '/', not '//', no ':' and no spaces anywhere; else refused
96func si_back_ok(b: *u8) -> i64 {
97 if b[0] != (SI_B_SLASH as u8) { return 0 }
98 if b[1] == (SI_B_SLASH as u8) { return 0 }
99 var i: i64 = 0
100 while b[i] != (0 as u8) {
101 if b[i] == (SI_B_COLON as u8) { return 0 }
102 if b[i] == (SI_B_SP as u8) { return 0 }
103 i = i + 1
104 }
105 return 1
106}
107// slug: [a-z0-9-], 1..SI_MAX_SLUG bytes -- anything else is not a site of ours
108func si_slug_ok(s: *u8) -> i64 {
109 let n: i64 = si_len(s)
110 if n < 1 { return 0 }
111 if n > SI_MAX_SLUG { return 0 }
112 var i: i64 = 0
113 while i < n {
114 let c: i64 = s[i] as i64
115 var ok: i64 = 0
116 if c >= SI_B_A { if c <= SI_B_Z { ok = 1 } }
117 if c >= SI_B_0 { if c <= SI_B_9 { ok = 1 } }
118 if c == SI_B_DASH { ok = 1 }
119 if ok == 0 { return 0 }
120 i = i + 1
121 }
122 return 1
123}
124func si_resp(resp: *u8, line: *u8) -> i64 {
125 var o: i64 = si_cat(resp, 0, line)
126 o = si_cat(resp, o, "\r\nContent-Length: 0\r\nCache-Control: no-store\r\nConnection: close\r\n\r\n" as *u8)
127 return o
128}
129func si_resp_303(resp: *u8, back: *u8) -> i64 {
130 var o: i64 = si_cat(resp, 0, "HTTP/1.1 303 See Other\r\nLocation: " as *u8)
131 o = si_cat(resp, o, back)
132 o = si_cat(resp, o, "\r\nContent-Length: 0\r\nCache-Control: no-store\r\nConnection: close\r\n\r\n" as *u8)
133 return o
134}
135
136func si_streq(a: *u8, b: *u8) -> i64 {
137 var i: i64 = 0
138 while a[i] != (0 as u8) { if a[i] != b[i] { return 0 } i = i + 1 }
139 if b[i] != (0 as u8) { return 0 }
140 return 1
141}
142// the daemon path's health surface (/api/deploy probes over HTTP; a core with no 200 anywhere
143// would make every deploy read as dead). GET-only; the slug 'health' is RESERVED from recording.
144func si_resp_health(resp: *u8) -> i64 {
145 return si_cat(resp, 0, "HTTP/1.1 200 OK\r\nContent-Type: text/plain\r\nContent-Length: 2\r\nCache-Control: no-store\r\nConnection: close\r\n\r\nok" as *u8)
146}
147
148// the route core: full raw request in, full raw response out (returns response length).
149// plane/logf are caller-supplied append targets -- dependency-injected so the gate can prove the
150// whole data path against per-run fixtures and the host decides real retention paths.
151func si_route(req: *u8, rn: i64, resp: *u8, plane: *u8, logf: *u8) -> i64 {
152 if si_starts(req, rn, "POST /intake/" as *u8) == 0 {
153 if si_starts(req, rn, "GET /intake/health " as *u8) == 1 { return si_resp_health(resp) }
154 if si_starts(req, rn, "GET /intake/" as *u8) == 1 { return si_resp(resp, "HTTP/1.1 405 Method Not Allowed" as *u8) }
155 return si_resp(resp, "HTTP/1.1 404 Not Found" as *u8)
156 }
157 let slug: *u8 = sys_mmap(SI_SLUGBUF)
158 let pfx: i64 = si_len("POST /intake/" as *u8)
159 var i: i64 = pfx
160 var so: i64 = 0
161 var stop: i64 = 0
162 while stop == 0 {
163 if i >= rn { stop = 1 } else {
164 if req[i] == (SI_B_SP as u8) { stop = 1 } else {
165 if so < SI_SLUGBUF - 1 { slug[so] = req[i]; so = so + 1 }
166 i = i + 1
167 }
168 }
169 }
170 slug[so] = 0 as u8
171 if si_slug_ok(slug) == 0 { return si_resp(resp, "HTTP/1.1 404 Not Found" as *u8) }
172 if si_streq(slug, "health" as *u8) == 1 { return si_resp(resp, "HTTP/1.1 404 Not Found" as *u8) }
173 var bo: i64 = 0 - 1
174 i = 0
175 while i + 4 <= rn {
176 if req[i] == (SI_B_CR as u8) { if req[i+1] == (SI_B_LF as u8) { if req[i+2] == (SI_B_CR as u8) { if req[i+3] == (SI_B_LF as u8) { bo = i + 4; i = rn } } } }
177 i = i + 1
178 }
179 if bo < 0 { return si_resp(resp, "HTTP/1.1 400 Bad Request" as *u8) }
180 let bn: i64 = rn - bo
181 if bn > SI_MAX_BODY { return si_resp(resp, "HTTP/1.1 413 Content Too Large" as *u8) }
182 let body: *u8 = ((req as i64) + bo) as *u8
183 let back: *u8 = sys_mmap(SI_SCRATCH)
184 si_field(body, bn, "back\x00" as *u8, back, SI_SCRATCH - 1)
185 if si_back_ok(back) == 0 { back[0] = SI_B_SLASH as u8; back[1] = 0 as u8 }
186 let hp: *u8 = sys_mmap(SI_HPBUF)
187 if si_field(body, bn, "website\x00" as *u8, hp, SI_HPBUF - 1) > 0 { return si_resp_303(resp, back) }
188 let nm: *u8 = sys_mmap(SI_SCRATCH)
189 let em: *u8 = sys_mmap(SI_SCRATCH)
190 let mg: *u8 = sys_mmap(SI_MSGBUF)
191 let nn: i64 = si_field(body, bn, "name\x00" as *u8, nm, SI_MAX_FIELD)
192 let en: i64 = si_field(body, bn, "email\x00" as *u8, em, SI_MAX_FIELD)
193 let mn: i64 = si_field(body, bn, "message\x00" as *u8, mg, SI_MAX_MSG)
194 if nn == 0 { if en == 0 { if mn == 0 { return si_resp(resp, "HTTP/1.1 400 Bad Request" as *u8) } } }
195 let row: *u8 = sys_mmap(SI_ROW)
196 let ts: i64 = sys_now_realtime_sec()
197 var ro: i64 = si_putn(row, 0, ts)
198 row[ro] = SI_B_TAB as u8; ro = ro + 1
199 ro = si_cat(row, ro, slug)
200 row[ro] = SI_B_TAB as u8; ro = ro + 1
201 ro = si_cat(row, ro, nm)
202 row[ro] = SI_B_TAB as u8; ro = ro + 1
203 ro = si_cat(row, ro, em)
204 row[ro] = SI_B_TAB as u8; ro = ro + 1
205 ro = si_cat(row, ro, mg)
206 row[ro] = SI_B_LF as u8; ro = ro + 1
207 let fd: i64 = sys_openat_append(plane, SI_MODE)
208 if fd < 0 { return si_resp(resp, "HTTP/1.1 500 Internal Server Error" as *u8) }
209 let wr: i64 = sys_write(fd, row, ro)
210 sys_close(fd)
211 if wr != ro { return si_resp(resp, "HTTP/1.1 500 Internal Server Error" as *u8) }
212 let lg: *u8 = sys_mmap(SI_SCRATCH)
213 var lo: i64 = si_cat(lg, 0, "ts=" as *u8)
214 lo = si_putn(lg, lo, ts)
215 lo = si_cat(lg, lo, " organ=nx_site_intake slug=" as *u8)
216 lo = si_cat(lg, lo, slug)
217 lo = si_cat(lg, lo, " recorded=1\n" as *u8)
218 let lfd: i64 = sys_openat_append(logf, SI_MODE)
219 if lfd >= 0 { sys_write(lfd, lg, lo); sys_close(lfd) }
220 return si_resp_303(resp, back)
221}