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
@@ -0,0 +1,118 @@
|
||||
// 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
|
||||
}
|
||||
Reference in new issue
Block a user