Zones, address sets (IPv4 and IPv6 mixed, dynamic sets with timeouts) and services, ordered rules in the early, input, forward and output chains, port forwards with hairpin NAT and IPv6 pinholes, source NAT, redirects, and switchable built-ins (loopback, conntrack, ICMPv6, antispoofing, MSS clamping, DHCPv6 client, flowtable). Every change is validated, checked with nft -c and applied in one transaction to Housecarl's own table, inet housecarl; changes made over the network roll back unless confirmed, and the confirmed ruleset is loaded at boot by housecarl-ruleset.service. An empty configuration applies nothing. Also: hit counters and the drop log (nflog), 'hc import' of an existing nftables ruleset and 'hc takeover', the hc CLI, the REST API and the web UI, users, scoped tokens (sets:write:NAME), the audit log, the web UI's certificate through Jarl, Debian packages, docs, unit tests with real nft 1.1.3, and an e2e test with real traffic (71 checks). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
485 lines
14 KiB
Go
485 lines
14 KiB
Go
// Package certs obtains and renews the web UI's certificate from an ACME CA
|
|
// (Let's Encrypt), with Jarl publishing the DNS-01 challenge
|
|
// (docs/certificates.md). Before every order a preflight check proves the
|
|
// challenge can be published and seen, so a broken setup never spends the
|
|
// CA's rate limits.
|
|
package certs
|
|
|
|
import (
|
|
"context"
|
|
"crypto"
|
|
"crypto/ecdsa"
|
|
"crypto/elliptic"
|
|
"crypto/rand"
|
|
"crypto/tls"
|
|
"crypto/x509"
|
|
"encoding/json"
|
|
"encoding/pem"
|
|
"errors"
|
|
"fmt"
|
|
"log/slog"
|
|
"net/http"
|
|
"os"
|
|
"regexp"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/go-acme/lego/v4/certificate"
|
|
"github.com/go-acme/lego/v4/challenge/dns01"
|
|
"github.com/go-acme/lego/v4/lego"
|
|
legolog "github.com/go-acme/lego/v4/log"
|
|
"github.com/go-acme/lego/v4/registration"
|
|
|
|
"housecarl/internal/api"
|
|
"housecarl/internal/config"
|
|
"housecarl/internal/jarl"
|
|
)
|
|
|
|
// Store keeps the account and certificate (the database's meta table).
|
|
type Store interface {
|
|
GetMeta(ctx context.Context, key string) (string, error)
|
|
SetMeta(ctx context.Context, key, value string) error
|
|
}
|
|
|
|
const (
|
|
metaAccount = "acme.account"
|
|
metaCert = "acme.certificate"
|
|
)
|
|
|
|
// Config configures the manager.
|
|
type Config struct {
|
|
ACME config.ACME
|
|
Version string
|
|
}
|
|
|
|
// Status is api.CertStatus.
|
|
type Status = api.CertStatus
|
|
|
|
// Manager keeps the web UI's certificate.
|
|
type Manager struct {
|
|
cfg Config
|
|
jarl *jarl.Client
|
|
store Store
|
|
log *slog.Logger
|
|
now func() time.Time
|
|
|
|
mu sync.Mutex
|
|
cert *tls.Certificate
|
|
leaf *x509.Certificate
|
|
issuedBy string
|
|
working bool
|
|
lastErr string
|
|
lastAttempt time.Time
|
|
nextAttempt time.Time
|
|
lastCheck *Check
|
|
failures int
|
|
force bool // renew now, even if not due (Kick)
|
|
|
|
lego sync.Mutex // one ACME order at a time
|
|
kick chan struct{}
|
|
}
|
|
|
|
// New makes a manager and loads the stored certificate.
|
|
func New(ctx context.Context, cfg Config, j *jarl.Client, st Store, log *slog.Logger) *Manager {
|
|
if log == nil {
|
|
log = slog.Default()
|
|
}
|
|
// lego logs each step of an order; send it to the journal with the rest.
|
|
legolog.Logger = slog.NewLogLogger(log.With("component", "acme").Handler(), slog.LevelInfo)
|
|
m := &Manager{cfg: cfg, jarl: j, store: st, log: log, now: time.Now, kick: make(chan struct{}, 1)}
|
|
m.load(ctx)
|
|
return m
|
|
}
|
|
|
|
type stored struct {
|
|
Domain string `json:"domain"`
|
|
Directory string `json:"directory"`
|
|
CertPEM string `json:"cert_pem"`
|
|
KeyPEM string `json:"key_pem"`
|
|
Obtained time.Time `json:"obtained"`
|
|
}
|
|
|
|
func (m *Manager) load(ctx context.Context) {
|
|
raw, err := m.store.GetMeta(ctx, metaCert)
|
|
if err != nil || raw == "" {
|
|
return
|
|
}
|
|
var s stored
|
|
if json.Unmarshal([]byte(raw), &s) != nil {
|
|
return
|
|
}
|
|
if err := m.use(s); err != nil {
|
|
m.log.Warn("the stored certificate can't be used; getting a new one", "err", err)
|
|
}
|
|
}
|
|
|
|
// use makes a stored certificate the one served.
|
|
func (m *Manager) use(s stored) error {
|
|
c, err := tls.X509KeyPair([]byte(s.CertPEM), []byte(s.KeyPEM))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
leaf, err := x509.ParseCertificate(c.Certificate[0])
|
|
if err != nil {
|
|
return err
|
|
}
|
|
c.Leaf = leaf
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
// A certificate for another name isn't served.
|
|
if !strings.EqualFold(s.Domain, m.cfg.ACME.Domain) {
|
|
m.cert, m.leaf = nil, nil
|
|
return fmt.Errorf("it is for %s, not %s", s.Domain, m.cfg.ACME.Domain)
|
|
}
|
|
m.cert, m.leaf, m.issuedBy = &c, leaf, s.Directory
|
|
return nil
|
|
}
|
|
|
|
// Certificate is the certificate to serve (nil when there is none yet).
|
|
func (m *Manager) Certificate() *tls.Certificate {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
if m.cert == nil || m.now().After(m.leaf.NotAfter) {
|
|
return nil
|
|
}
|
|
return m.cert
|
|
}
|
|
|
|
// Status describes the certificate.
|
|
func (m *Manager) Status() Status {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
st := Status{Domain: m.cfg.ACME.Domain, Directory: m.cfg.ACME.DirectoryURL(), Staging: m.cfg.ACME.Staging(), State: "none",
|
|
IssuedBy: m.issuedBy, LastError: m.lastErr, Check: m.lastCheck}
|
|
if m.leaf != nil {
|
|
nb, na := m.leaf.NotBefore, m.leaf.NotAfter
|
|
ra := m.renewAt()
|
|
st.NotBefore, st.NotAfter, st.RenewAt, st.Issuer, st.State = &nb, &na, &ra, m.leaf.Issuer.CommonName, "ok"
|
|
}
|
|
if m.lastErr != "" {
|
|
st.State = "error"
|
|
}
|
|
if m.working {
|
|
st.State = "working"
|
|
}
|
|
if !m.lastAttempt.IsZero() {
|
|
t := m.lastAttempt
|
|
st.LastAttempt = &t
|
|
}
|
|
if !m.nextAttempt.IsZero() {
|
|
t := m.nextAttempt
|
|
st.NextAttempt = &t
|
|
}
|
|
return st
|
|
}
|
|
|
|
// renewAt is when the certificate is renewed: a third of its lifetime
|
|
// before it ends (30 days for Let's Encrypt's 90), m.mu held.
|
|
func (m *Manager) renewAt() time.Time {
|
|
life := m.leaf.NotAfter.Sub(m.leaf.NotBefore)
|
|
return m.leaf.NotAfter.Add(-life / 3).UTC().Truncate(time.Second)
|
|
}
|
|
|
|
// due reports whether a new certificate is needed: none yet, another
|
|
// directory configured, or renewal time.
|
|
func (m *Manager) due() bool {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
return m.force || m.leaf == nil || m.issuedBy != m.cfg.ACME.DirectoryURL() || !m.now().Before(m.renewAt())
|
|
}
|
|
|
|
// Run keeps the certificate: it gets one when needed, checks twice a day
|
|
// whether it is due, and retries failures with a growing wait.
|
|
func (m *Manager) Run(ctx context.Context) {
|
|
timer := time.NewTimer(5 * time.Second)
|
|
defer timer.Stop()
|
|
for {
|
|
select {
|
|
case <-ctx.Done():
|
|
return
|
|
case <-m.kick:
|
|
case <-timer.C:
|
|
}
|
|
wait := 12 * time.Hour
|
|
if m.due() {
|
|
if err := m.Renew(ctx); err != nil {
|
|
m.mu.Lock()
|
|
m.failures++
|
|
// 1h, 3h, 6h, then 12h.
|
|
waits := []time.Duration{time.Hour, 3 * time.Hour, 6 * time.Hour, 12 * time.Hour}
|
|
wait = waits[min(m.failures, len(waits))-1]
|
|
m.mu.Unlock()
|
|
m.log.Warn("getting the web UI's certificate failed; trying again later", "domain", m.cfg.ACME.Domain, "err", err, "retry_in", wait)
|
|
}
|
|
}
|
|
m.mu.Lock()
|
|
m.nextAttempt = m.now().Add(wait).UTC()
|
|
m.mu.Unlock()
|
|
if !timer.Stop() {
|
|
select {
|
|
case <-timer.C:
|
|
default:
|
|
}
|
|
}
|
|
timer.Reset(wait)
|
|
}
|
|
}
|
|
|
|
// Kick asks Run to renew now, even if the certificate isn't due.
|
|
func (m *Manager) Kick() {
|
|
m.mu.Lock()
|
|
m.force, m.failures = true, 0
|
|
m.mu.Unlock()
|
|
select {
|
|
case m.kick <- struct{}{}:
|
|
default:
|
|
}
|
|
}
|
|
|
|
// ErrBusy is returned while another order runs.
|
|
var ErrBusy = errors.New("a certificate order is already running")
|
|
|
|
// Renew gets a new certificate now: the preflight check, then the order.
|
|
func (m *Manager) Renew(ctx context.Context) error {
|
|
if !m.lego.TryLock() {
|
|
return ErrBusy
|
|
}
|
|
defer m.lego.Unlock()
|
|
m.mu.Lock()
|
|
m.working, m.lastAttempt = true, m.now().UTC()
|
|
m.mu.Unlock()
|
|
err := m.renew(ctx)
|
|
m.mu.Lock()
|
|
m.working, m.force = false, false
|
|
if err != nil {
|
|
m.lastErr = err.Error()
|
|
} else {
|
|
m.lastErr, m.failures = "", 0
|
|
}
|
|
m.mu.Unlock()
|
|
return err
|
|
}
|
|
|
|
func (m *Manager) renew(ctx context.Context) error {
|
|
c := m.Preflight(ctx)
|
|
if !c.OK {
|
|
for _, s := range c.Steps {
|
|
if s.Status == "fail" {
|
|
return fmt.Errorf("the check before asking the CA failed at %s: %s", strings.ToLower(s.Name), s.Detail)
|
|
}
|
|
}
|
|
}
|
|
m.log.Info("the check passed; asking the CA for a certificate", "domain", m.cfg.ACME.Domain, "directory", m.cfg.ACME.DirectoryURL())
|
|
res, err := m.obtain(ctx)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
s := stored{Domain: m.cfg.ACME.Domain, Directory: m.cfg.ACME.DirectoryURL(), CertPEM: string(res.Certificate), KeyPEM: string(res.PrivateKey), Obtained: m.now().UTC()}
|
|
if err := m.use(s); err != nil {
|
|
return fmt.Errorf("the CA's certificate can't be used: %w", err)
|
|
}
|
|
b, _ := json.Marshal(s)
|
|
if err := m.store.SetMeta(ctx, metaCert, string(b)); err != nil {
|
|
return err
|
|
}
|
|
m.mu.Lock()
|
|
na := m.leaf.NotAfter
|
|
m.mu.Unlock()
|
|
m.log.Info("got a new certificate for the web UI", "domain", s.Domain, "until", na.Format(time.RFC3339), "staging", m.cfg.ACME.Staging())
|
|
return nil
|
|
}
|
|
|
|
// ---- ACME
|
|
|
|
type account struct {
|
|
Directory string `json:"directory"`
|
|
Email string `json:"email,omitempty"`
|
|
URI string `json:"uri,omitempty"`
|
|
KeyPEM string `json:"key_pem"`
|
|
}
|
|
|
|
type legoUser struct {
|
|
email string
|
|
reg *registration.Resource
|
|
key crypto.PrivateKey
|
|
}
|
|
|
|
func (u *legoUser) GetEmail() string { return u.email }
|
|
func (u *legoUser) GetRegistration() *registration.Resource { return u.reg }
|
|
func (u *legoUser) GetPrivateKey() crypto.PrivateKey { return u.key }
|
|
|
|
func (m *Manager) httpClient() (*http.Client, error) {
|
|
tr := http.DefaultTransport.(*http.Transport).Clone()
|
|
tr.TLSClientConfig = &tls.Config{MinVersion: tls.VersionTLS12}
|
|
if f := m.cfg.ACME.CABundle; f != "" {
|
|
pemData, err := os.ReadFile(f)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("tls.acme.ca_bundle: %w", err)
|
|
}
|
|
pool, err := x509.SystemCertPool()
|
|
if err != nil || pool == nil {
|
|
pool = x509.NewCertPool()
|
|
}
|
|
if !pool.AppendCertsFromPEM(pemData) {
|
|
return nil, fmt.Errorf("tls.acme.ca_bundle: no certificates in %s", f)
|
|
}
|
|
tr.TLSClientConfig.RootCAs = pool
|
|
}
|
|
return &http.Client{Timeout: 2 * time.Minute, Transport: tr}, nil
|
|
}
|
|
|
|
// client returns a lego client for the account, registering it if needed.
|
|
func (m *Manager) client(ctx context.Context) (*lego.Client, error) {
|
|
dir := m.cfg.ACME.DirectoryURL()
|
|
hc, err := m.httpClient()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
user := &legoUser{email: m.cfg.ACME.Email}
|
|
var a account
|
|
if raw, _ := m.store.GetMeta(ctx, metaAccount); raw != "" && json.Unmarshal([]byte(raw), &a) == nil && a.Directory == dir && a.KeyPEM != "" {
|
|
b, _ := pem.Decode([]byte(a.KeyPEM))
|
|
if b == nil {
|
|
return nil, errors.New("the stored ACME account key isn't PEM")
|
|
}
|
|
if user.key, err = x509.ParsePKCS8PrivateKey(b.Bytes); err != nil {
|
|
return nil, fmt.Errorf("the stored ACME account key: %w", err)
|
|
}
|
|
if a.URI != "" {
|
|
user.reg = ®istration.Resource{URI: a.URI}
|
|
}
|
|
} else {
|
|
if user.key, err = ecdsa.GenerateKey(elliptic.P256(), rand.Reader); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
cfg := lego.NewConfig(user)
|
|
cfg.CADirURL, cfg.HTTPClient, cfg.UserAgent = dir, hc, "housecarl/"+m.cfg.Version
|
|
cfg.Certificate.Timeout = 90 * time.Second
|
|
c, err := lego.NewClient(cfg)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("the ACME server %s: %w", dir, err)
|
|
}
|
|
if user.reg != nil {
|
|
return c, nil
|
|
}
|
|
if !m.cfg.ACME.AgreeTOS {
|
|
tos := c.GetToSURL()
|
|
if tos == "" {
|
|
tos = "on the CA's website"
|
|
}
|
|
return nil, fmt.Errorf("Housecarl registers an account with %s only after you accept its terms of service (%s): set tls.acme.agree_tos: true in housecarl.yaml and restart housecarld", dir, tos)
|
|
}
|
|
reg, err := c.Registration.Register(registration.RegisterOptions{TermsOfServiceAgreed: true})
|
|
if err != nil {
|
|
return nil, fmt.Errorf("registering the ACME account: %w", err)
|
|
}
|
|
user.reg = reg
|
|
der, err := x509.MarshalPKCS8PrivateKey(user.key)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
b, _ := json.Marshal(account{Directory: dir, Email: user.email, URI: reg.URI,
|
|
KeyPEM: string(pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: der}))})
|
|
if err := m.store.SetMeta(ctx, metaAccount, string(b)); err != nil {
|
|
return nil, err
|
|
}
|
|
m.log.Info("ACME account registered", "directory", dir, "account", reg.URI)
|
|
return c, nil
|
|
}
|
|
|
|
// provider publishes the challenge through Jarl.
|
|
type provider struct {
|
|
m *Manager
|
|
ctx context.Context
|
|
}
|
|
|
|
func (p *provider) set(domain, keyAuth string, set bool) error {
|
|
info := dns01.GetChallengeInfo(domain, keyAuth)
|
|
ctx := p.ctx
|
|
if !set {
|
|
ctx = context.WithoutCancel(ctx)
|
|
}
|
|
all, err := p.m.jarl.Zones(ctx)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
zones := jarl.ZonesFor(info.EffectiveFQDN, all, "")
|
|
if len(zones) == 0 {
|
|
return fmt.Errorf("no primary zone in Jarl holds %s", strings.TrimSuffix(info.EffectiveFQDN, "."))
|
|
}
|
|
return p.m.jarl.SetTXT(ctx, info.EffectiveFQDN, info.Value, zones, set, false, "Housecarl's certificate")
|
|
}
|
|
|
|
func (p *provider) Present(domain, token, keyAuth string) error { return p.set(domain, keyAuth, true) }
|
|
func (p *provider) CleanUp(domain, token, keyAuth string) error { return p.set(domain, keyAuth, false) }
|
|
|
|
// Timeout is how long lego waits for the record to reach the name servers.
|
|
func (p *provider) Timeout() (timeout, interval time.Duration) {
|
|
return 5 * time.Minute, 5 * time.Second
|
|
}
|
|
|
|
func (m *Manager) obtain(ctx context.Context) (*certificate.Resource, error) {
|
|
c, err := m.client(ctx)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
opts := []dns01.ChallengeOption{dns01.AddDNSTimeout(10 * time.Second)}
|
|
if len(m.cfg.ACME.Resolvers) > 0 {
|
|
opts = append(opts, dns01.AddRecursiveNameservers(m.cfg.ACME.Resolvers))
|
|
}
|
|
if !m.cfg.ACME.CheckPropagation {
|
|
opts = append(opts, dns01.DisableAuthoritativeNssPropagationRequirement())
|
|
}
|
|
if err := c.Challenge.SetDNS01Provider(&provider{m: m, ctx: ctx}, opts...); err != nil {
|
|
return nil, err
|
|
}
|
|
res, err := c.Certificate.Obtain(certificate.ObtainRequest{Domains: []string{m.cfg.ACME.Domain}, Bundle: true})
|
|
if err != nil {
|
|
return nil, cleanLegoError(err)
|
|
}
|
|
return res, nil
|
|
}
|
|
|
|
// cleanLegoError shortens lego's errors (as Jarl does): "[name] acme:
|
|
// error: 403 :: urn:ietf:params:acme:error:unauthorized :: MSG" becomes
|
|
// "name: the CA refused (unauthorized): MSG".
|
|
func cleanLegoError(err error) error {
|
|
msg := strings.TrimSpace(strings.Replace(err.Error(), "error: one or more domains had a problem:", "", 1))
|
|
var out []string
|
|
for _, line := range strings.Split(msg, "\n") {
|
|
line = strings.TrimSpace(line)
|
|
if line == "" {
|
|
continue
|
|
}
|
|
domain := ""
|
|
for strings.HasPrefix(line, "[") {
|
|
end := strings.Index(line, "] ")
|
|
if end < 0 {
|
|
break
|
|
}
|
|
if domain == "" {
|
|
domain = line[1:end]
|
|
}
|
|
line = line[end+2:]
|
|
}
|
|
for _, p := range []string{"acme: error presenting token: ", "acme: error: ", "acme: "} {
|
|
line = strings.TrimPrefix(line, p)
|
|
}
|
|
if mm := problemRe.FindStringSubmatch(line); mm != nil {
|
|
line = "the CA refused (" + mm[2] + "): " + mm[3]
|
|
}
|
|
if domain != "" {
|
|
line = domain + ": " + line
|
|
}
|
|
out = append(out, line)
|
|
}
|
|
if len(out) == 0 {
|
|
return err
|
|
}
|
|
return errors.New(strings.Join(out, "; "))
|
|
}
|
|
|
|
var problemRe = regexp.MustCompile(`^(\d+) :: urn:ietf:params:acme:error:(\w+) :: (.*)$`)
|