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>
147 lines
5.6 KiB
Go
147 lines
5.6 KiB
Go
package certs
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"slices"
|
|
"strings"
|
|
|
|
"jarl/internal/acme"
|
|
"jarl/internal/model"
|
|
)
|
|
|
|
// Route says where the DNS-01 challenge for one certificate name goes,
|
|
// and why.
|
|
type Route struct {
|
|
Domain string `json:"domain"` // the name on the certificate
|
|
Record string `json:"record"` // _acme-challenge.<domain>
|
|
// Target is where the TXT record is written: Record, or where its
|
|
// CNAME points.
|
|
Target string `json:"target"`
|
|
Route string `json:"route,omitempty"` // jarl or porkbun; "" when there is none
|
|
Zone string `json:"zone,omitempty"` // the zone on the internet that holds Target
|
|
PublicNS []string `json:"public_ns,omitempty"`
|
|
Why string `json:"why"`
|
|
Error string `json:"error,omitempty"`
|
|
}
|
|
|
|
func (r Route) String() string {
|
|
s := r.Domain + ": " + r.Route
|
|
if r.Target != r.Record {
|
|
s += " via CNAME " + strings.TrimSuffix(r.Target, ".")
|
|
}
|
|
return s + " (zone " + strings.TrimSuffix(r.Zone, ".") + ")"
|
|
}
|
|
|
|
func isPorkbunNS(ns []string) bool {
|
|
for _, n := range ns {
|
|
if strings.HasSuffix(n, ".porkbun.com") {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// route decides where the challenge for domain goes. challenge is the
|
|
// certificate's setting: auto picks from the zone's public name servers.
|
|
// target is the record name after CNAMEs ("" to look it up).
|
|
func (m *Manager) route(ctx context.Context, st *model.State, challenge, domain, target string) Route {
|
|
r := Route{Domain: domain, Record: challengeName(domain)}
|
|
fail := func(format string, a ...any) Route {
|
|
r.Error = fmt.Sprintf(format, a...)
|
|
return r
|
|
}
|
|
if target == "" {
|
|
t, err := m.res.follow(ctx, r.Record)
|
|
if err != nil {
|
|
return fail("%v", err)
|
|
}
|
|
target = t
|
|
}
|
|
r.Target = target
|
|
zone, err := m.res.zoneOf(ctx, target)
|
|
if err != nil {
|
|
return fail("%v", err)
|
|
}
|
|
r.Zone = zone
|
|
ns, err := m.res.nameServers(ctx, zone)
|
|
if err != nil {
|
|
return fail("%v", err)
|
|
}
|
|
r.PublicNS = ns
|
|
zoneName, nsList := strings.TrimSuffix(zone, "."), strings.Join(ns, ", ")
|
|
|
|
// Jarl can answer when it has the zone and the internet asks the name
|
|
// servers listed in it.
|
|
var jarlNS []string
|
|
jarlHas := false
|
|
for _, z := range st.SortedZones() {
|
|
if z.Type != model.ZonePrimary || z.Disabled || normName(z.Name) != normName(zone) {
|
|
continue
|
|
}
|
|
jarlHas = true
|
|
for _, rec := range st.ZoneRecords(z.ID) {
|
|
if rec.Type == "NS" && !rec.Disabled && normName(rec.Name) == normName(zone) {
|
|
jarlNS = append(jarlNS, normName(rec.Data))
|
|
}
|
|
}
|
|
}
|
|
slices.Sort(jarlNS)
|
|
jarlNS = slices.Compact(jarlNS)
|
|
servedByJarl := jarlHas && slices.ContainsFunc(ns, func(n string) bool { return slices.Contains(jarlNS, n) })
|
|
porkbunOK := m.cfg.Porkbun != nil && (m.cfg.PorkbunAllows == nil || m.cfg.PorkbunAllows(zoneName))
|
|
|
|
porkbun := func() Route {
|
|
r.Route, r.Why = model.ChallengePorkbun, fmt.Sprintf("%s is served by Porkbun (%s), so the record goes in through Porkbun's API", zoneName, nsList)
|
|
return r
|
|
}
|
|
jarl := func() Route {
|
|
r.Route, r.Why = model.ChallengeJarl, fmt.Sprintf("%s is served by this server's name servers (%s), so Jarl writes the record itself", zoneName, nsList)
|
|
return r
|
|
}
|
|
switch challenge {
|
|
case model.ChallengePorkbun:
|
|
switch {
|
|
case m.cfg.Porkbun == nil:
|
|
return fail("the challenge is set to porkbun, but jarl.yaml has no porkbun.api_key")
|
|
case !porkbunOK:
|
|
return fail("the challenge is set to porkbun, but %s isn't in porkbun.domains in jarl.yaml", zoneName)
|
|
case !isPorkbunNS(ns):
|
|
return fail("the challenge is set to porkbun, but on the internet %s is served by %s, not Porkbun: the CA would never see the record (set the challenge to auto)", zoneName, nsList)
|
|
}
|
|
return porkbun()
|
|
case model.ChallengeJarl:
|
|
switch {
|
|
case !jarlHas:
|
|
return fail("the challenge is set to jarl, but no primary zone in Jarl is %s (where %s is on the internet)", zoneName, strings.TrimSuffix(target, "."))
|
|
case !servedByJarl:
|
|
return fail("the challenge is set to jarl, but on the internet %s is served by %s, not by the name servers in Jarl's zone (%s): the CA would never see the record (set the challenge to auto)", zoneName, nsList, strings.Join(jarlNS, ", "))
|
|
}
|
|
return jarl()
|
|
}
|
|
switch {
|
|
case isPorkbunNS(ns) && porkbunOK:
|
|
return porkbun()
|
|
case servedByJarl:
|
|
return jarl()
|
|
case isPorkbunNS(ns) && m.cfg.Porkbun == nil:
|
|
return fail("%s is served by Porkbun (%s) and jarl.yaml has no porkbun.api_key. Add the key, or point %s with a CNAME at a name in a zone this server serves on the internet", zoneName, nsList, strings.TrimSuffix(r.Record, "."))
|
|
case isPorkbunNS(ns):
|
|
return fail("%s is served by Porkbun (%s) but isn't in porkbun.domains in jarl.yaml", zoneName, nsList)
|
|
case jarlHas:
|
|
return fail("on the internet %s is served by %s, not by the name servers in Jarl's zone (%s), so the CA would never see a record Jarl writes. Delegate the zone to this server, or point %s with a CNAME at a name in a zone it serves", zoneName, nsList, strings.Join(jarlNS, ", "), strings.TrimSuffix(r.Record, "."))
|
|
}
|
|
return fail("%s is served by %s, which Jarl can't write to. Point %s with a CNAME at a name in a zone this server serves on the internet (for example %s.acme.<your domain>, in a zone acme.<your domain> delegated here): Jarl follows the CNAME and answers there", zoneName, nsList, strings.TrimSuffix(r.Record, "."), strings.ReplaceAll(strings.TrimSuffix(strings.TrimPrefix(r.Record, "_acme-challenge."), "."), ".", "-"))
|
|
}
|
|
|
|
// primaryZones lists the zones Jarl can write challenge records into.
|
|
func primaryZones(st *model.State) []acme.Zone {
|
|
var out []acme.Zone
|
|
for _, z := range st.SortedZones() {
|
|
if z.Type == model.ZonePrimary && !z.Disabled {
|
|
out = append(out, acme.Zone{Name: z.Name, View: st.ViewName(z)})
|
|
}
|
|
}
|
|
return out
|
|
}
|