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>
119 lines
3.7 KiB
Go
119 lines
3.7 KiB
Go
// Package certs manages TLS certificates (docs/certificates.md): ones Jarl
|
|
// obtains and renews itself over ACME, answering the DNS-01 challenges in
|
|
// its own zones or at Porkbun, and ones you renew yourself that Jarl only
|
|
// reads. A certificate is used for jarld's HTTPS ("web") and/or written
|
|
// where named can read it ("dns", for DNS over TLS and HTTPS).
|
|
package certs
|
|
|
|
import (
|
|
"crypto/ecdsa"
|
|
"crypto/ed25519"
|
|
"crypto/rsa"
|
|
"crypto/tls"
|
|
"crypto/x509"
|
|
"errors"
|
|
"fmt"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
// Info is what a certificate says about itself.
|
|
type Info struct {
|
|
Names []string
|
|
NotBefore time.Time
|
|
NotAfter time.Time
|
|
Issuer string
|
|
Serial string
|
|
KeyType string // ec256, ec384, rsa2048, ...
|
|
Leaf *x509.Certificate
|
|
// Warnings are problems that don't stop it working everywhere (a
|
|
// missing intermediate certificate).
|
|
Warnings []string
|
|
}
|
|
|
|
// Inspect checks that certPEM (the certificate, then its intermediates)
|
|
// and keyPEM belong together and returns what the certificate says, and
|
|
// the pair ready to serve.
|
|
func Inspect(certPEM, keyPEM []byte) (*Info, *tls.Certificate, error) {
|
|
pair, err := tls.X509KeyPair(certPEM, keyPEM)
|
|
if err != nil {
|
|
msg := err.Error()
|
|
switch {
|
|
case strings.Contains(msg, "private key does not match public key"):
|
|
return nil, nil, errors.New("the key doesn't belong to the certificate")
|
|
case strings.Contains(msg, "failed to find any PEM data in certificate"):
|
|
return nil, nil, errors.New("no certificate found (expected PEM: -----BEGIN CERTIFICATE-----)")
|
|
case strings.Contains(msg, "failed to find any PEM data in key"), strings.Contains(msg, "failed to find PEM block with type ending in \"PRIVATE KEY\""):
|
|
return nil, nil, errors.New("no private key found (expected PEM: -----BEGIN ... PRIVATE KEY-----)")
|
|
}
|
|
return nil, nil, err
|
|
}
|
|
leaf := pair.Leaf
|
|
if leaf == nil {
|
|
if leaf, err = x509.ParseCertificate(pair.Certificate[0]); err != nil {
|
|
return nil, nil, err
|
|
}
|
|
pair.Leaf = leaf
|
|
}
|
|
in := &Info{NotBefore: leaf.NotBefore, NotAfter: leaf.NotAfter, Serial: fmt.Sprintf("%x", leaf.SerialNumber), Leaf: leaf, KeyType: keyType(leaf)}
|
|
in.Names = append(in.Names, leaf.DNSNames...)
|
|
for _, ip := range leaf.IPAddresses {
|
|
in.Names = append(in.Names, ip.String())
|
|
}
|
|
if len(in.Names) == 0 && leaf.Subject.CommonName != "" {
|
|
in.Names = []string{leaf.Subject.CommonName}
|
|
}
|
|
in.Issuer = leaf.Issuer.CommonName
|
|
if len(leaf.Issuer.Organization) > 0 {
|
|
if in.Issuer == "" {
|
|
in.Issuer = leaf.Issuer.Organization[0]
|
|
} else {
|
|
in.Issuer = leaf.Issuer.Organization[0] + " " + in.Issuer
|
|
}
|
|
}
|
|
selfSigned := leaf.Issuer.String() == leaf.Subject.String()
|
|
if len(pair.Certificate) == 1 && !selfSigned {
|
|
in.Warnings = append(in.Warnings, "the file holds only the certificate, without its intermediate certificate: many clients will reject it; use the full chain (fullchain.pem)")
|
|
}
|
|
return in, &pair, nil
|
|
}
|
|
|
|
func keyType(c *x509.Certificate) string {
|
|
switch k := c.PublicKey.(type) {
|
|
case *ecdsa.PublicKey:
|
|
return fmt.Sprintf("ec%d", k.Curve.Params().BitSize)
|
|
case *rsa.PublicKey:
|
|
return fmt.Sprintf("rsa%d", k.N.BitLen())
|
|
case ed25519.PublicKey:
|
|
return "ed25519"
|
|
}
|
|
return "unknown"
|
|
}
|
|
|
|
// Covers reports whether the certificate names cover every name in want
|
|
// (a wildcard covers one label).
|
|
func Covers(names, want []string) bool {
|
|
for _, w := range want {
|
|
if !covered(names, w) {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
func covered(names []string, want string) bool {
|
|
want = strings.ToLower(strings.TrimSuffix(want, "."))
|
|
for _, n := range names {
|
|
n = strings.ToLower(n)
|
|
if n == want {
|
|
return true
|
|
}
|
|
if base, ok := strings.CutPrefix(n, "*."); ok && !strings.HasPrefix(want, "*.") {
|
|
if _, rest, ok := strings.Cut(want, "."); ok && rest == base {
|
|
return true
|
|
}
|
|
}
|
|
}
|
|
return false
|
|
}
|