nx_acme_porkbun.nx source
↩ module page · 250 lines · 11271 B
1// nx_acme_porkbun.nx -- Porkbun DNS API client for ACME DNS-01.
2//
3// Sets / deletes the _acme-challenge TXT record so Let's Encrypt can
4// validate domain control. Porkbun JSON API v3 (api.porkbun.com):
5// POST /api/json/v3/dns/create/{domain}
6// {"apikey","secretapikey","type":"TXT","name","content","ttl"}
7// POST /api/json/v3/dns/deleteByNameType/{domain}/TXT/{subdomain}
8// {"apikey","secretapikey"}
9// -> {"status":"SUCCESS",...} | {"status":"ERROR","message":...}
10//
11// Composes nx_acme_http (validated TLS POST + JSON reader). Porkbun's
12// cert chains to Amazon Root CA 1 (RSA-2048, in the Mozilla store) so the
13// sovereign TLS client validates it. Creds come from the sovereign vault
14// (caller passes them in; never hardcoded). Requires per-domain API access
15// toggled ON in Porkbun.
16//
17// license_tier: ORIGINAL
18
19import "nx_acme_http.nx"
20const NX_MAGIC_1024: i64 = 1024
21const NX_MAGIC_4194304: i64 = 4194304
22const NX_MAGIC_1779284141: i64 = 1779284141
23const NX_MAGIC_16384: i64 = 16384
24
25const NX_PORKBUN_OK: i64 = 0
26const NX_PORKBUN_HTTP_FAIL: i64 = 1
27const NX_PORKBUN_BAD_STATUS: i64 = 2
28const NX_PORKBUN_API_ERROR: i64 = 3
29
30// append a NUL-terminated cstr into out[*off]; returns new offset
31func pk_put(out: *u8, off: i64, s: *u8) -> i64 {
32 var i: i64 = 0
33 var o: i64 = off
34 while s[i] != 0 { out[o] = s[i]; o = o + 1; i = i + 1 }
35 return o
36}
37// append n raw bytes
38func pk_put_n(out: *u8, off: i64, src: *u8, n: i64) -> i64 {
39 var i: i64 = 0
40 var o: i64 = off
41 while i < n { out[o] = src[i]; o = o + 1; i = i + 1 }
42 return o
43}
44
45// Build the create/delete body shared prefix {"apikey":"..","secretapikey":".."
46func pk_creds(body: *u8, off: i64, ak: *u8, akn: i64, sk: *u8, skn: i64) -> i64 {
47 var o: i64 = off
48 o = pk_put(body, o, "{\"apikey\":\"" as *u8)
49 o = pk_put_n(body, o, ak, akn)
50 o = pk_put(body, o, "\",\"secretapikey\":\"" as *u8)
51 o = pk_put_n(body, o, sk, skn)
52 o = pk_put(body, o, "\"" as *u8)
53 return o
54}
55
56// Check an HTTP response is 200 and its JSON body has "status":"SUCCESS".
57func pk_check(resp: *u8, total: i64) -> i64 {
58 let r: *i64 = nx_http_resp_alloc()
59 if nx_http_response_parse(resp, total, r) != 0 { return NX_PORKBUN_BAD_STATUS }
60 let bo: i64 = r[6]
61 let bn: i64 = total - bo
62 let so: *i64 = sys_mmap(8) as *i64
63 let sl: *i64 = sys_mmap(8) as *i64
64 if nx_acme_json_str(resp, bo, bn, "status" as *u8, 6, so, sl) != 1 { return NX_PORKBUN_BAD_STATUS }
65 // compare value to "SUCCESS"
66 let want: *u8 = "SUCCESS" as *u8
67 if sl[0] != 7 { return NX_PORKBUN_API_ERROR }
68 var i: i64 = 0
69 while i < 7 { if resp[so[0] + i] != want[i] { return NX_PORKBUN_API_ERROR }; i = i + 1 }
70 return NX_PORKBUN_OK
71}
72
73// Set (create) the _acme-challenge TXT record to `value`.
74func nx_porkbun_set_txt(ak: *u8, akn: i64, sk: *u8, skn: i64,
75 domain: *u8, dn: i64,
76 value: *u8, vn: i64,
77 store: *TrustStore, now: i64,
78 resp: *u8, rcap: i64, rn: *i64) -> i64 {
79 let url: *u8 = sys_mmap(256)
80 var uo: i64 = 0
81 uo = pk_put(url, uo, "https://api.porkbun.com/api/json/v3/dns/create/" as *u8)
82 uo = pk_put_n(url, uo, domain, dn)
83 url[uo] = 0 as u8
84
85 let body: *u8 = sys_mmap(NX_MAGIC_1024)
86 var bo: i64 = pk_creds(body, 0, ak, akn, sk, skn)
87 bo = pk_put(body, bo, ",\"name\":\"_acme-challenge\",\"type\":\"TXT\",\"content\":\"" as *u8)
88 bo = pk_put_n(body, bo, value, vn)
89 bo = pk_put(body, bo, "\",\"ttl\":\"600\"}" as *u8)
90
91 let rc: i64 = nx_acme_req(url, uo, 1, "application/json" as *u8, 16, body, bo, store, now, resp, rcap, rn)
92 if rc != NX_ACME_HTTP_OK { return rc }
93 return pk_check(resp, rn[0])
94}
95
96// Set (create) an A record <sub>.<domain> -> <ip>. Mirrors nx_porkbun_set_txt: same dns/create endpoint,
97// name=<sub>, type=A, content=<ip>, ttl=600. Used to publish capability subdomains (admin./mail.) to the NAS.
98// NOTE: Porkbun dns/create ADDS a record (it does not replace) -> caller must ensure the record is absent first
99// (idempotency: a re-run adds a duplicate). ttl=600 keeps propagation fast.
100func nx_porkbun_set_a(ak: *u8, akn: i64, sk: *u8, skn: i64,
101 domain: *u8, dn: i64,
102 sub: *u8, sub_n: i64,
103 ip: *u8, ip_n: i64,
104 store: *TrustStore, now: i64,
105 resp: *u8, rcap: i64, rn: *i64) -> i64 {
106 let url: *u8 = sys_mmap(256)
107 var uo: i64 = 0
108 uo = pk_put(url, uo, "https://api.porkbun.com/api/json/v3/dns/create/" as *u8)
109 uo = pk_put_n(url, uo, domain, dn)
110 url[uo] = 0 as u8
111
112 let body: *u8 = sys_mmap(NX_MAGIC_1024)
113 var bo: i64 = pk_creds(body, 0, ak, akn, sk, skn)
114 bo = pk_put(body, bo, ",\"name\":\"" as *u8)
115 bo = pk_put_n(body, bo, sub, sub_n)
116 bo = pk_put(body, bo, "\",\"type\":\"A\",\"content\":\"" as *u8)
117 bo = pk_put_n(body, bo, ip, ip_n)
118 bo = pk_put(body, bo, "\",\"ttl\":\"600\"}" as *u8)
119
120 let rc: i64 = nx_acme_req(url, uo, 1, "application/json" as *u8, 16, body, bo, store, now, resp, rcap, rn)
121 if rc != NX_ACME_HTTP_OK { return rc }
122 return pk_check(resp, rn[0])
123}
124
125// RETRIEVE all DNS records for a domain (the AUTHORITATIVE published set -- what the public sees,
126// regardless of any LAN split-horizon). Returns the raw JSON in resp/rn; caller parses.
127// Porkbun: POST /api/json/v3/dns/retrieve/{domain} with creds. status=SUCCESS + "records":[...].
128func nx_porkbun_retrieve(ak: *u8, akn: i64, sk: *u8, skn: i64,
129 domain: *u8, dn: i64,
130 store: *TrustStore, now: i64,
131 resp: *u8, rcap: i64, rn: *i64) -> i64 {
132 let url: *u8 = sys_mmap(256)
133 var uo: i64 = 0
134 uo = pk_put(url, uo, "https://api.porkbun.com/api/json/v3/dns/retrieve/" as *u8)
135 uo = pk_put_n(url, uo, domain, dn)
136 url[uo] = 0 as u8
137 let body: *u8 = sys_mmap(512)
138 var bo: i64 = pk_creds(body, 0, ak, akn, sk, skn)
139 bo = pk_put(body, bo, "}" as *u8)
140 let rc: i64 = nx_acme_req(url, uo, 1, "application/json" as *u8, 16, body, bo, store, now, resp, rcap, rn)
141 if rc != NX_ACME_HTTP_OK { return rc }
142 return pk_check(resp, rn[0])
143}
144
145// GET the DNSSEC records Porkbun holds for a domain (the DS/DNSKEY published to the .com parent).
146// If DNSSEC is ENABLED, "records" is non-empty; if not, it's empty. A domain with a broken/mismatched
147// DS makes DNSSEC-validating resolvers return SERVFAIL = "resolves on some networks, not others" +
148// "worked forever then broke" -- the exact class of symptom. Raw JSON in resp/rn; caller inspects.
149// Porkbun: POST /api/json/v3/dns/getDnssecRecords/{domain} with creds.
150func nx_porkbun_get_dnssec(ak: *u8, akn: i64, sk: *u8, skn: i64,
151 domain: *u8, dn: i64,
152 store: *TrustStore, now: i64,
153 resp: *u8, rcap: i64, rn: *i64) -> i64 {
154 let url: *u8 = sys_mmap(256)
155 var uo: i64 = 0
156 uo = pk_put(url, uo, "https://api.porkbun.com/api/json/v3/dns/getDnssecRecords/" as *u8)
157 uo = pk_put_n(url, uo, domain, dn)
158 url[uo] = 0 as u8
159 let body: *u8 = sys_mmap(512)
160 var bo: i64 = pk_creds(body, 0, ak, akn, sk, skn)
161 bo = pk_put(body, bo, "}" as *u8)
162 let rc: i64 = nx_acme_req(url, uo, 1, "application/json" as *u8, 16, body, bo, store, now, resp, rcap, rn)
163 if rc != NX_ACME_HTTP_OK { return rc }
164 return pk_check(resp, rn[0])
165}
166
167// Delete all _acme-challenge TXT records (cleanup after validation).
168func nx_porkbun_delete_txt(ak: *u8, akn: i64, sk: *u8, skn: i64,
169 domain: *u8, dn: i64,
170 store: *TrustStore, now: i64,
171 resp: *u8, rcap: i64, rn: *i64) -> i64 {
172 let url: *u8 = sys_mmap(256)
173 var uo: i64 = 0
174 uo = pk_put(url, uo, "https://api.porkbun.com/api/json/v3/dns/deleteByNameType/" as *u8)
175 uo = pk_put_n(url, uo, domain, dn)
176 uo = pk_put(url, uo, "/TXT/_acme-challenge" as *u8)
177 url[uo] = 0 as u8
178
179 let body: *u8 = sys_mmap(512)
180 var bo: i64 = pk_creds(body, 0, ak, akn, sk, skn)
181 bo = pk_put(body, bo, "}" as *u8)
182
183 if nx_acme_req(url, uo, 1, "application/json" as *u8, 16, body, bo, store, now, resp, rcap, rn) != NX_ACME_HTTP_OK {
184 return NX_PORKBUN_HTTP_FAIL
185 }
186 return pk_check(resp, rn[0])
187}
188
189// read up to cap bytes of a file, strip trailing newline; return length
190func pk_readline_file(path: *u8, out: *u8, cap: i64) -> i64 {
191 let fd: i64 = sys_openat_rd(path)
192 if fd <= 0 { return 0 - 1 }
193 let n: i64 = sys_read(fd, out, cap)
194 sys_close(fd)
195 return n
196}
197
198// ---- live KAT: read creds from /tmp/porkbun.key, set a test TXT,
199// then delete it. Proves the Porkbun API round-trips through the
200// sovereign TLS client. NOT LE-rate-limited. ----
201func main() -> i64 {
202 let lr: i64 = nx_trust_store_load_from_certdata("/tmp/mozilla_certdata.txt\x00" as *u8, 300, NX_MAGIC_4194304)
203 if lr <= 0 { sys_write(2, "trust load FAIL\n" as *u8, 16); return 1 }
204 let store: *TrustStore = lr as *TrustStore
205 let now: i64 = NX_MAGIC_1779284141
206
207 // read creds (line1=apikey, line2=secret)
208 let raw: *u8 = sys_mmap(512)
209 let rawn: i64 = pk_readline_file("/tmp/porkbun.key\x00" as *u8, raw, 512)
210 if rawn <= 0 { sys_write(2, "creds read FAIL\n" as *u8, 16); return 2 }
211 // split on newline
212 var nl: i64 = 0
213 while nl < rawn { if raw[nl] == 0x0a { break }; nl = nl + 1 }
214 let ak: *u8 = raw
215 let akn: i64 = nl
216 var s2: i64 = nl + 1
217 var nl2: i64 = s2
218 while nl2 < rawn { if raw[nl2] == 0x0a { break }; nl2 = nl2 + 1 }
219 let sk: *u8 = (raw + s2) as *u8
220 let skn: i64 = nl2 - s2
221
222 let resp: *u8 = sys_mmap(NX_MAGIC_16384)
223 let rn: *i64 = sys_mmap(8) as *i64
224 let testval: *u8 = "nishi-porkbun-roundtrip-test-0001" as *u8
225 sys_write(1, "setting test TXT...\n" as *u8, 20)
226 let sv: i64 = nx_porkbun_set_txt(ak, akn, sk, skn, "andelinwest.com" as *u8, 15,
227 testval, 33, store, now, resp, NX_MAGIC_16384, rn)
228 if sv != NX_PORKBUN_OK {
229 sys_write(2, "set TXT FAIL; sv=" as *u8, 17)
230 var svv: i64 = sv
231 if svv < 0 { sys_write(2, "-" as *u8, 1); svv = 0 - svv }
232 let svb: *u8 = sys_mmap(8); svb[0] = (0x30 + svv) as u8; sys_write(2, svb, 1)
233 sys_write(2, " total=" as *u8, 7)
234 var t: i64 = rn[0]; let tb: *u8 = sys_mmap(16); var tp: i64 = 0
235 if t == 0 { tb[0]=0x30; tp=1 } else { let tt: *u8=sys_mmap(16); var tn: i64=0
236 while t>0 { tt[tn]=(0x30+(t%10)) as u8; t=t/10; tn=tn+1 }
237 while tn>0 { tb[tp]=tt[tn-1]; tp=tp+1; tn=tn-1 } }
238 sys_write(2, tb, tp); sys_write(2, "\n--- raw response ---\n" as *u8, 22)
239 sys_write(2, resp, rn[0]); sys_write(2, "\n--- end ---\n" as *u8, 13)
240 return 3
241 }
242 sys_write(1, "set TXT OK (SUCCESS)\n" as *u8, 21)
243
244 // clean it up
245 let dv: i64 = nx_porkbun_delete_txt(ak, akn, sk, skn, "andelinwest.com" as *u8, 15,
246 store, now, resp, NX_MAGIC_16384, rn)
247 if dv == NX_PORKBUN_OK { sys_write(1, "delete TXT OK\n" as *u8, 14) }
248 sys_write(1, "PORKBUN DNS-01 round-trip KAT PASS\n" as *u8, 35)
249 return 0
250}