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:
1 parent
88965096bb
commit
88f63ffb0a
43 files changed
+4584
-45
No files matched your search
@@ -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
|
||||
}
|
||||
@@ -1,7 +1,8 @@
|
||||
// Package porkbun is a small client for the parts of Porkbun's API v3
|
||||
// (https://porkbun.com/api/json/v3/documentation) that Jarl uses to manage
|
||||
// a domain's delegation: its name servers, glue records (host objects at
|
||||
// the registry) and DNSSEC DS records.
|
||||
// the registry) and DNSSEC DS records, and the TXT records of DNS-01
|
||||
// challenges while Porkbun still serves a domain (docs/certificates.md).
|
||||
//
|
||||
// Every call is a POST with the API key and secret in the JSON body. API
|
||||
// access must be switched on for each domain in Porkbun's web interface,
|
||||
@@ -79,6 +80,7 @@ type response struct {
|
||||
Valid bool `json:"credentialsValid"`
|
||||
Account string `json:"account"`
|
||||
Scopes []string `json:"scopes"`
|
||||
ID json.Number `json:"id"`
|
||||
}
|
||||
|
||||
func (c *Client) call(ctx context.Context, path string, body map[string]any) (*response, error) {
|
||||
@@ -282,6 +284,37 @@ func (c *Client) DeleteDS(ctx context.Context, domain string, keyTag uint16) err
|
||||
return err
|
||||
}
|
||||
|
||||
// MinTTL is the lowest TTL Porkbun accepts for a DNS record.
|
||||
const MinTTL = 600
|
||||
|
||||
// CreateTXT adds a TXT record at name (a name inside domain, or the
|
||||
// domain itself) in Porkbun's DNS and returns its ID.
|
||||
func (c *Client) CreateTXT(ctx context.Context, domain, name, value string) (string, error) {
|
||||
sub := ""
|
||||
if normName(name) != normName(domain) {
|
||||
var err error
|
||||
if sub, err = subdomain(domain, name); err != nil {
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
r, err := c.call(ctx, domainPath("/dns/create", domain), map[string]any{
|
||||
"name": sub, "type": "TXT", "content": value, "ttl": strconv.Itoa(MinTTL),
|
||||
})
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if r.ID == "" {
|
||||
return "", &Error{Message: "the record was created but Porkbun didn't say its ID"}
|
||||
}
|
||||
return r.ID.String(), nil
|
||||
}
|
||||
|
||||
// DeleteRecord deletes the DNS record with id.
|
||||
func (c *Client) DeleteRecord(ctx context.Context, domain, id string) error {
|
||||
_, err := c.call(ctx, domainPath("/dns/delete", domain, id), nil)
|
||||
return err
|
||||
}
|
||||
|
||||
// subdomain returns host's label(s) below domain ("ns1" for
|
||||
// ns1.example.com in example.com).
|
||||
func subdomain(domain, host string) (string, error) {
|
||||
|
||||
Reference in new issue
Block a user