Jarl is now an ACME client (lego's core, no DNS providers). It answers DNS-01 challenges in a zone it serves, or through Porkbun's DNS API while Porkbun serves the domain; "auto" decides from the domain's public name servers at every renewal, and a CNAME on _acme-challenge is followed. Certificates are used for jarld's HTTPS (served without a restart; each port speaks HTTPS and plain HTTP, plain HTTP from loopback and trusted proxies still served) and/or written for named's DNS over TLS/HTTPS, with rndc reconfig on renewal. Renewal follows the CA's ARI window or two thirds of the lifetime, with back-off. Certificates you renew yourself can be used too. Certificates page and dashboard card, jarl cert, /api/v1/certificates, Prometheus gauges, docs/certificates.md, and an e2e section against Pebble. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
216 lines
6.4 KiB
Go
216 lines
6.4 KiB
Go
package porkbun
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/http"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
)
|
|
|
|
// Fake is an in-memory stand-in for the Porkbun API, for tests: it knows
|
|
// one account's domains and checks the key like Porkbun does.
|
|
type Fake struct {
|
|
APIKey, Secret string
|
|
mu sync.Mutex
|
|
Domains map[string]*FakeDomain
|
|
// Calls lists the paths called, in order.
|
|
Calls []string
|
|
nextID int
|
|
}
|
|
|
|
// FakeDomain is a domain in a Fake.
|
|
type FakeDomain struct {
|
|
NS []string
|
|
Glue map[string][]string // host -> addresses
|
|
DS map[uint16]DS
|
|
APIAccess bool
|
|
// Records are the DNS records created through the API, by ID.
|
|
Records map[string]FakeRecord
|
|
}
|
|
|
|
// FakeRecord is a DNS record in a Fake.
|
|
type FakeRecord struct {
|
|
Name, Type, Content string // Name is the full name, without a trailing dot
|
|
TTL int
|
|
}
|
|
|
|
// NewFake returns a Fake with the given domains (API access on, Porkbun's
|
|
// own name servers, no glue or DS).
|
|
func NewFake(key, secret string, domains ...string) *Fake {
|
|
f := &Fake{APIKey: key, Secret: secret, Domains: map[string]*FakeDomain{}}
|
|
for _, d := range domains {
|
|
f.Domains[d] = &FakeDomain{NS: []string{"curitiba.ns.porkbun.com", "fortaleza.ns.porkbun.com"}, Glue: map[string][]string{}, DS: map[uint16]DS{}, APIAccess: true, Records: map[string]FakeRecord{}}
|
|
}
|
|
return f
|
|
}
|
|
|
|
func (f *Fake) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
reply := func(status int, v map[string]any) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(status)
|
|
json.NewEncoder(w).Encode(v)
|
|
}
|
|
fail := func(status int, code, msg string) {
|
|
reply(status, map[string]any{"status": "ERROR", "code": code, "message": msg})
|
|
}
|
|
var body map[string]any
|
|
json.NewDecoder(r.Body).Decode(&body)
|
|
path := strings.TrimPrefix(r.URL.Path, "/api/json/v3")
|
|
f.Calls = append(f.Calls, path)
|
|
if body["apikey"] != f.APIKey || body["secretapikey"] != f.Secret {
|
|
fail(http.StatusForbidden, "INVALID_API_KEYS_001", "Invalid API key.")
|
|
return
|
|
}
|
|
if path == "/ping" {
|
|
reply(200, map[string]any{"status": "SUCCESS", "yourIp": "192.0.2.1", "credentialsValid": true, "account": "tester", "scopes": []string{"full"}})
|
|
return
|
|
}
|
|
parts := strings.Split(strings.Trim(path, "/"), "/")
|
|
if len(parts) < 3 {
|
|
fail(404, "NOT_FOUND", "Unknown endpoint.")
|
|
return
|
|
}
|
|
op, domain := parts[0]+"/"+parts[1], parts[2]
|
|
d := f.Domains[domain]
|
|
if d == nil {
|
|
fail(400, "DOMAIN_NOT_FOUND", "Domain not found in this account.")
|
|
return
|
|
}
|
|
if !d.APIAccess {
|
|
fail(403, "API_ACCESS_DISABLED", "The domain is not opted in to API access.")
|
|
return
|
|
}
|
|
host := ""
|
|
if len(parts) > 3 {
|
|
host = parts[3] + "." + domain
|
|
}
|
|
ips := func() []string {
|
|
var out []string
|
|
if l, ok := body["ips"].([]any); ok {
|
|
for _, v := range l {
|
|
out = append(out, v.(string))
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
switch op {
|
|
case "domain/getNs":
|
|
reply(200, map[string]any{"status": "SUCCESS", "ns": d.NS})
|
|
case "domain/updateNs":
|
|
var ns []string
|
|
for _, v := range body["ns"].([]any) {
|
|
n := v.(string)
|
|
if strings.HasSuffix(n, "."+domain) && d.Glue[n] == nil {
|
|
fail(400, "INVALID_NAMESERVER", "Host "+n+" doesn't exist at the registry.")
|
|
return
|
|
}
|
|
ns = append(ns, n)
|
|
}
|
|
d.NS = ns
|
|
reply(200, map[string]any{"status": "SUCCESS"})
|
|
case "domain/getGlue":
|
|
hosts := []any{}
|
|
for h, a := range d.Glue {
|
|
v4, v6 := []string{}, []string{}
|
|
for _, ip := range a {
|
|
if strings.Contains(ip, ":") {
|
|
v6 = append(v6, ip)
|
|
} else {
|
|
v4 = append(v4, ip)
|
|
}
|
|
}
|
|
hosts = append(hosts, []any{h, map[string]any{"v4": v4, "v6": v6}})
|
|
}
|
|
reply(200, map[string]any{"status": "SUCCESS", "hosts": hosts})
|
|
case "domain/createGlue":
|
|
if d.Glue[host] != nil {
|
|
fail(400, "GLUE_EXISTS", "Host already exists.")
|
|
return
|
|
}
|
|
d.Glue[host] = ips()
|
|
reply(200, map[string]any{"status": "SUCCESS"})
|
|
case "domain/updateGlue":
|
|
if d.Glue[host] == nil {
|
|
fail(400, "GLUE_NOT_FOUND", "Host not found.")
|
|
return
|
|
}
|
|
d.Glue[host] = ips()
|
|
reply(200, map[string]any{"status": "SUCCESS"})
|
|
case "domain/deleteGlue":
|
|
for _, n := range d.NS {
|
|
if n == host {
|
|
fail(400, "GLUE_IN_USE", "Host is in use as a name server.")
|
|
return
|
|
}
|
|
}
|
|
delete(d.Glue, host)
|
|
reply(200, map[string]any{"status": "SUCCESS"})
|
|
case "dns/getDnssecRecords":
|
|
if len(d.DS) == 0 {
|
|
reply(200, map[string]any{"status": "SUCCESS", "records": []any{}})
|
|
return
|
|
}
|
|
recs := map[string]any{}
|
|
for tag, ds := range d.DS {
|
|
recs[strconv.Itoa(int(tag))] = map[string]any{"keyTag": strconv.Itoa(int(tag)), "alg": strconv.Itoa(int(ds.Algorithm)), "digestType": strconv.Itoa(int(ds.DigestType)), "digest": strings.ToLower(ds.Digest)}
|
|
}
|
|
reply(200, map[string]any{"status": "SUCCESS", "records": recs})
|
|
case "dns/createDnssecRecord":
|
|
num := func(k string) int { n, _ := strconv.Atoi(body[k].(string)); return n }
|
|
tag := uint16(num("keyTag"))
|
|
d.DS[tag] = DS{KeyTag: tag, Algorithm: uint8(num("alg")), DigestType: uint8(num("digestType")), Digest: strings.ToUpper(body["digest"].(string))}
|
|
reply(200, map[string]any{"status": "SUCCESS"})
|
|
case "dns/deleteDnssecRecord":
|
|
tag, _ := strconv.Atoi(parts[3])
|
|
delete(d.DS, uint16(tag))
|
|
reply(200, map[string]any{"status": "SUCCESS", "message": "Deleted."})
|
|
case "dns/create":
|
|
name, _ := body["name"].(string)
|
|
full := domain
|
|
if name != "" {
|
|
full = name + "." + domain
|
|
}
|
|
ttl, _ := strconv.Atoi(fmt.Sprint(body["ttl"]))
|
|
if ttl < MinTTL {
|
|
ttl = MinTTL
|
|
}
|
|
f.nextID++
|
|
id := strconv.Itoa(f.nextID)
|
|
d.Records[id] = FakeRecord{Name: full, Type: fmt.Sprint(body["type"]), Content: fmt.Sprint(body["content"]), TTL: ttl}
|
|
reply(200, map[string]any{"status": "SUCCESS", "id": f.nextID})
|
|
case "dns/delete":
|
|
if len(parts) < 4 || d.Records == nil {
|
|
fail(400, "INVALID_RECORD_ID", "Invalid record ID.")
|
|
return
|
|
}
|
|
if _, ok := d.Records[parts[3]]; !ok {
|
|
fail(400, "INVALID_RECORD_ID", "Invalid record ID.")
|
|
return
|
|
}
|
|
delete(d.Records, parts[3])
|
|
reply(200, map[string]any{"status": "SUCCESS"})
|
|
default:
|
|
fail(404, "NOT_FOUND", "Unknown endpoint.")
|
|
}
|
|
}
|
|
|
|
// TXT returns the values of the TXT records at name (without a trailing
|
|
// dot) in the fake's domains.
|
|
func (f *Fake) TXT(name string) []string {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
var out []string
|
|
for _, d := range f.Domains {
|
|
for _, r := range d.Records {
|
|
if r.Type == "TXT" && strings.EqualFold(r.Name, strings.TrimSuffix(name, ".")) {
|
|
out = append(out, r.Content)
|
|
}
|
|
}
|
|
}
|
|
return out
|
|
}
|