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

+23 -13
View File
@@ -20,6 +20,7 @@ import (
"jarl/internal/api"
"jarl/internal/auth"
"jarl/internal/certs"
"jarl/internal/engine"
"jarl/internal/nscheck"
"jarl/internal/porkbun"
@@ -60,6 +61,9 @@ type Config struct {
TOTPIssuer string
// Metrics configures /metrics (metrics.go).
Metrics Metrics
// Certs manages TLS certificates (nil in tests that don't need it),
// including the one jarld serves (h_certs.go, sniff.go).
Certs *certs.Manager
}
// Server is the HTTP server.
@@ -143,14 +147,19 @@ func (s *Server) Serve(ctx context.Context, ready func()) error {
if len(s.cfg.Listen) == 0 {
return errors.New("no listen addresses configured")
}
var tlsCfg *tls.Config
if s.cfg.TLSCertFile != "" {
cr, err := newCertReloader(s.cfg.TLSCertFile, s.cfg.TLSKeyFile, s.log)
if err != nil {
return err
}
s.certs = cr
tlsCfg = &tls.Config{MinVersion: tls.VersionTLS12, GetCertificate: cr.get}
}
// With a certificate (tls.cert_file, or one Jarl manages, which may
// turn up later), the listeners serve HTTPS and plain HTTP side by
// side (sniff.go).
var tlsCfg *tls.Config
if s.certs != nil || s.cfg.Certs != nil {
tlsCfg = &tls.Config{MinVersion: tls.VersionTLS12, GetCertificate: s.webCertificate, NextProtos: []string{"h2", "http/1.1"}}
}
errc := make(chan error, len(s.cfg.Listen)+1)
var listeners []net.Listener
@@ -168,8 +177,12 @@ func (s *Server) Serve(ctx context.Context, ready func()) error {
listeners = append(listeners, ln)
}
for i, ln := range listeners {
handler := s.Handler()
if tlsCfg != nil {
handler = s.requireHTTPS(handler)
}
srv := &http.Server{
Handler: s.Handler(),
Handler: handler,
ReadHeaderTimeout: 10 * time.Second,
ReadTimeout: 2 * time.Minute,
WriteTimeout: 5 * time.Minute,
@@ -181,23 +194,20 @@ func (s *Server) Serve(ctx context.Context, ready func()) error {
s.srvs = append(s.srvs, srv)
s.mu.Unlock()
scheme := "http"
if tlsCfg != nil {
if s.httpsAvailable() {
scheme = "https"
}
s.log.Info("listening", "addr", s.cfg.Listen[i], "url", scheme+"://"+ln.Addr().String())
if tlsCfg != nil {
ln = newSniffListener(ln, tlsCfg)
}
go func(srv *http.Server, ln net.Listener) {
var err error
if tlsCfg != nil {
err = srv.ServeTLS(ln, "", "")
} else {
err = srv.Serve(ln)
}
if !errors.Is(err, http.ErrServerClosed) {
if err := srv.Serve(ln); !errors.Is(err, http.ErrServerClosed) {
errc <- err
}
}(srv, ln)
}
if tlsCfg == nil {
if !s.httpsAvailable() && (s.cfg.Certs == nil || !s.cfg.Certs.HasWeb(ctx)) {
for _, a := range s.cfg.Listen {
if host, _, err := net.SplitHostPort(a); err == nil {
if ip, err := netip.ParseAddr(host); host == "" || err == nil && !ip.IsLoopback() {
@@ -211,7 +221,7 @@ func (s *Server) Serve(ctx context.Context, ready func()) error {
return err
}
s.serveSocket(errc)
s.logHTTPReq(tlsCfg != nil)
s.logHTTPReq(s.httpsAvailable())
if ready != nil {
ready()
}