code wiki / (root) / nx_acme_porkbun.nx

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}