TLS certificates: Jarl obtains and renews them over ACME

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>
This commit is contained in:
adamandClaude Opus 5.5 committed 2026-10-12 14:35:53 +01:00
1 parent 88965096bb
commit 88f63ffb0a
43 files changed
+4584 -45

No files matched your search

+53 -2
View File
@@ -2,6 +2,7 @@ package porkbun
import (
"encoding/json"
"fmt"
"net/http"
"strconv"
"strings"
@@ -15,7 +16,8 @@ type Fake struct {
mu sync.Mutex
Domains map[string]*FakeDomain
// Calls lists the paths called, in order.
Calls []string
Calls []string
nextID int
}
// FakeDomain is a domain in a Fake.
@@ -24,6 +26,14 @@ type FakeDomain struct {
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
@@ -31,7 +41,7 @@ type FakeDomain struct {
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}
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
}
@@ -158,7 +168,48 @@ func (f *Fake) ServeHTTP(w http.ResponseWriter, r *http.Request) {
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
}