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
@@ -256,6 +256,29 @@ type StatusResponse struct {
|
||||
// BlockingPausedIndefinitely while it is paused until resumed.
|
||||
BlockingPausedUntil *time.Time `json:"blocking_paused_until,omitempty"`
|
||||
BlockingPausedIndefinitely bool `json:"blocking_paused_indefinitely,omitempty"`
|
||||
// Certificates sums up the TLS certificates Jarl manages
|
||||
// (administrators only, when there are any).
|
||||
Certificates *CertSummary `json:"certificates,omitempty"`
|
||||
}
|
||||
|
||||
// CertSummary is what the dashboard reports about certificates.
|
||||
type CertSummary struct {
|
||||
Total int `json:"total"`
|
||||
// Problems are the certificates that aren't fine: failing, expiring,
|
||||
// expired (not ones still being obtained for the first time).
|
||||
Problems []CertProblem `json:"problems"`
|
||||
// Next is the certificate that expires first, and when.
|
||||
Next string `json:"next,omitempty"`
|
||||
NextAfter *time.Time `json:"next_after,omitempty"`
|
||||
// Serving is the certificate jarld serves HTTPS with ("" for none or tls.cert_file).
|
||||
Serving string `json:"serving,omitempty"`
|
||||
}
|
||||
|
||||
// CertProblem is a certificate that needs attention.
|
||||
type CertProblem struct {
|
||||
Name string `json:"name"`
|
||||
State string `json:"state"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
// BlocklistError is a blocklist whose last update failed. UpdatedAt is
|
||||
|
||||
@@ -0,0 +1,406 @@
|
||||
package certs
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto"
|
||||
"crypto/ecdsa"
|
||||
"crypto/elliptic"
|
||||
"crypto/rand"
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"encoding/json"
|
||||
"encoding/pem"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"regexp"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/go-acme/lego/v4/certcrypto"
|
||||
"github.com/go-acme/lego/v4/certificate"
|
||||
"github.com/go-acme/lego/v4/challenge/dns01"
|
||||
"github.com/go-acme/lego/v4/lego"
|
||||
"github.com/go-acme/lego/v4/registration"
|
||||
|
||||
"jarl/internal/acme"
|
||||
"jarl/internal/auth"
|
||||
"jarl/internal/engine"
|
||||
"jarl/internal/model"
|
||||
)
|
||||
|
||||
// Account is the ACME account certificates are obtained with. Its key is
|
||||
// kept in Jarl's database (and so in backups), like TSIG secrets.
|
||||
type Account struct {
|
||||
Directory string `json:"directory"`
|
||||
Email string `json:"email,omitempty"`
|
||||
URI string `json:"uri,omitempty"` // the account at the CA
|
||||
RegisteredAt time.Time `json:"registered_at"`
|
||||
KeyPEM string `json:"key_pem,omitempty"`
|
||||
}
|
||||
|
||||
const metaAccount = "acme.account"
|
||||
|
||||
// account returns the stored account (nil when there is none).
|
||||
func (m *Manager) account(ctx context.Context) *Account {
|
||||
raw, err := m.store.GetMeta(ctx, metaAccount)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
var a Account
|
||||
if json.Unmarshal([]byte(raw), &a) != nil {
|
||||
return nil
|
||||
}
|
||||
return &a
|
||||
}
|
||||
|
||||
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 }
|
||||
|
||||
// ErrTermsNotAgreed means acme.account.agree_tos isn't set.
|
||||
var ErrTermsNotAgreed = errors.New("acme.account.agree_tos isn't set")
|
||||
|
||||
func (m *Manager) httpClient() (*http.Client, error) {
|
||||
tr := http.DefaultTransport.(*http.Transport).Clone()
|
||||
tr.TLSClientConfig = &tls.Config{MinVersion: tls.VersionTLS12}
|
||||
if m.cfg.CABundle != "" {
|
||||
pem, err := os.ReadFile(m.cfg.CABundle)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("acme.ca_bundle: %w", err)
|
||||
}
|
||||
pool, err := x509.SystemCertPool()
|
||||
if err != nil || pool == nil {
|
||||
pool = x509.NewCertPool()
|
||||
}
|
||||
if !pool.AppendCertsFromPEM(pem) {
|
||||
return nil, fmt.Errorf("acme.ca_bundle: no certificates in %s", m.cfg.CABundle)
|
||||
}
|
||||
tr.TLSClientConfig.RootCAs = pool
|
||||
}
|
||||
return &http.Client{Timeout: 2 * time.Minute, Transport: tr}, nil
|
||||
}
|
||||
|
||||
// client returns a lego client for the account (registering it when
|
||||
// needed) that makes keys of keyType.
|
||||
func (m *Manager) client(ctx context.Context, keyType string) (*lego.Client, error) {
|
||||
dir := m.cfg.Directory
|
||||
hc, err := m.httpClient()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
a := m.account(ctx)
|
||||
user := &legoUser{email: m.cfg.Email}
|
||||
if a != nil && a.Directory == dir && a.KeyPEM != "" {
|
||||
if user.key, err = parseKey(a.KeyPEM); err != nil {
|
||||
return nil, fmt.Errorf("the stored ACME account key: %w", err)
|
||||
}
|
||||
if a.URI != "" {
|
||||
user.reg = ®istration.Resource{URI: a.URI}
|
||||
}
|
||||
} else {
|
||||
k, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
user.key = k
|
||||
}
|
||||
cfg := lego.NewConfig(user)
|
||||
cfg.CADirURL, cfg.HTTPClient, cfg.UserAgent = dir, hc, "jarl/"+m.cfg.Version
|
||||
cfg.Certificate.KeyType = legoKeyType(keyType)
|
||||
cfg.Certificate.Timeout = 90 * time.Second
|
||||
c, err := lego.NewClient(cfg)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("ACME server %s: %w", dir, err)
|
||||
}
|
||||
if user.reg != nil && (a.Email == m.cfg.Email) {
|
||||
return c, nil
|
||||
}
|
||||
if user.reg != nil {
|
||||
// Only the email changed: update the account.
|
||||
if reg, err := c.Registration.UpdateRegistration(registration.RegisterOptions{TermsOfServiceAgreed: true}); err == nil {
|
||||
user.reg = reg
|
||||
return c, m.saveAccount(ctx, user, dir)
|
||||
}
|
||||
}
|
||||
if !m.cfg.AgreeTOS {
|
||||
tos := c.GetToSURL()
|
||||
if tos == "" {
|
||||
tos = "on the CA's website"
|
||||
}
|
||||
return nil, fmt.Errorf("%w: Jarl registers an account with %s only after you accept its terms of service (%s): set acme.account.agree_tos: true in jarl.yaml and restart jarld", ErrTermsNotAgreed, dir, tos)
|
||||
}
|
||||
var reg *registration.Resource
|
||||
if m.cfg.EABKeyID != "" {
|
||||
reg, err = c.Registration.RegisterWithExternalAccountBinding(registration.RegisterEABOptions{TermsOfServiceAgreed: true, Kid: m.cfg.EABKeyID, HmacEncoded: m.cfg.EABHMAC})
|
||||
} else {
|
||||
if c.GetExternalAccountRequired() {
|
||||
return nil, fmt.Errorf("%s needs External Account Binding: set acme.account.eab_kid and eab_hmac in jarl.yaml (from the CA's dashboard)", dir)
|
||||
}
|
||||
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
|
||||
m.log.Info("ACME account registered", "directory", dir, "account", reg.URI)
|
||||
return c, m.saveAccount(ctx, user, dir)
|
||||
}
|
||||
|
||||
func (m *Manager) saveAccount(ctx context.Context, u *legoUser, dir string) error {
|
||||
der, err := x509.MarshalPKCS8PrivateKey(u.key)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
a := Account{Directory: dir, Email: u.email, URI: u.reg.URI, RegisteredAt: time.Now().UTC(),
|
||||
KeyPEM: string(pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: der}))}
|
||||
b, _ := json.Marshal(a)
|
||||
return m.store.SetMeta(ctx, metaAccount, string(b))
|
||||
}
|
||||
|
||||
func parseKey(s string) (crypto.PrivateKey, error) {
|
||||
b, _ := pem.Decode([]byte(s))
|
||||
if b == nil {
|
||||
return nil, errors.New("not PEM")
|
||||
}
|
||||
return x509.ParsePKCS8PrivateKey(b.Bytes)
|
||||
}
|
||||
|
||||
func legoKeyType(t string) certcrypto.KeyType {
|
||||
switch t {
|
||||
case "ec384":
|
||||
return certcrypto.EC384
|
||||
case "rsa2048":
|
||||
return certcrypto.RSA2048
|
||||
case "rsa3072":
|
||||
return certcrypto.RSA3072
|
||||
case "rsa4096":
|
||||
return certcrypto.RSA4096
|
||||
}
|
||||
return certcrypto.EC256
|
||||
}
|
||||
|
||||
// KeyTypes are the key types a certificate Jarl obtains can have.
|
||||
var KeyTypes = []string{"ec256", "ec384", "rsa2048", "rsa3072", "rsa4096"}
|
||||
|
||||
// provider answers DNS-01 challenges for one issue: in a zone Jarl serves,
|
||||
// or at Porkbun, as route decides for each name.
|
||||
type provider struct {
|
||||
m *Manager
|
||||
ctx context.Context
|
||||
cert *model.Certificate
|
||||
mu sync.Mutex
|
||||
cleanups map[string]func() error
|
||||
routes []Route
|
||||
}
|
||||
|
||||
func (p *provider) Present(domain, token, keyAuth string) error {
|
||||
info := dns01.GetChallengeInfo(domain, keyAuth)
|
||||
st, err := p.m.store.LoadState(p.ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
r := p.m.route(p.ctx, st, p.cert.Challenge, domain, info.EffectiveFQDN)
|
||||
p.mu.Lock()
|
||||
p.routes = append(p.routes, r)
|
||||
p.mu.Unlock()
|
||||
if r.Error != "" {
|
||||
return errors.New(r.Error)
|
||||
}
|
||||
key := info.EffectiveFQDN + " " + info.Value
|
||||
var cleanup func() error
|
||||
switch r.Route {
|
||||
case model.ChallengeJarl:
|
||||
if err := p.m.publish(p.ctx, p.cert, info.EffectiveFQDN, info.Value, true); err != nil {
|
||||
return err
|
||||
}
|
||||
cleanup = func() error {
|
||||
return p.m.publish(context.WithoutCancel(p.ctx), p.cert, info.EffectiveFQDN, info.Value, false)
|
||||
}
|
||||
case model.ChallengePorkbun:
|
||||
zone := strings.TrimSuffix(r.Zone, ".")
|
||||
id, err := p.m.cfg.Porkbun.CreateTXT(p.ctx, zone, strings.TrimSuffix(info.EffectiveFQDN, "."), info.Value)
|
||||
if err != nil {
|
||||
return fmt.Errorf("publishing the challenge for %s at Porkbun: %w", domain, err)
|
||||
}
|
||||
cleanup = func() error { return p.m.cfg.Porkbun.DeleteRecord(context.WithoutCancel(p.ctx), zone, id) }
|
||||
}
|
||||
p.mu.Lock()
|
||||
p.cleanups[key] = cleanup
|
||||
p.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *provider) CleanUp(domain, token, keyAuth string) error {
|
||||
info := dns01.GetChallengeInfo(domain, keyAuth)
|
||||
key := info.EffectiveFQDN + " " + info.Value
|
||||
p.mu.Lock()
|
||||
f := p.cleanups[key]
|
||||
delete(p.cleanups, key)
|
||||
p.mu.Unlock()
|
||||
if f == nil {
|
||||
return nil
|
||||
}
|
||||
if err := f(); err != nil {
|
||||
p.m.log.Warn("removing an ACME challenge record failed", "certificate", p.cert.Name, "record", info.EffectiveFQDN, "err", err)
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Timeout is how long lego waits for the record to reach every name
|
||||
// server of the zone: Porkbun's take a few minutes.
|
||||
func (p *provider) Timeout() (timeout, interval time.Duration) {
|
||||
if p.m.cfg.PropagationTimeout > 0 {
|
||||
return p.m.cfg.PropagationTimeout, 2 * time.Second
|
||||
}
|
||||
return 10 * time.Minute, 10 * time.Second
|
||||
}
|
||||
|
||||
// publish writes (or removes) a challenge TXT record in the Jarl zone that
|
||||
// holds name, in every view that has it, and returns once named serves it.
|
||||
func (m *Manager) publish(ctx context.Context, c *model.Certificate, name, value string, set bool) error {
|
||||
st, err := m.store.LoadState(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
zones := acme.Zones(name, primaryZones(st), "")
|
||||
if len(zones) == 0 {
|
||||
return fmt.Errorf("no primary zone in Jarl contains %s", strings.TrimSuffix(name, "."))
|
||||
}
|
||||
var reqs []engine.Request
|
||||
for _, r := range acme.Requests(name, zones, []string{value}, set, acme.DefaultTTL) {
|
||||
reqs = append(reqs, engine.Request{Op: r.Op, ID: r.ID, BaseVersion: r.BaseVersion, Data: r.Data})
|
||||
}
|
||||
desc := acme.Description(name, set) + " (certificate " + c.Name + ")"
|
||||
if _, err := m.eng.Submit(ctx, reqs, auth.System, "acme", desc, engine.ApplyOptions{}); err != nil {
|
||||
return fmt.Errorf("writing the challenge record %s: %w", strings.TrimSuffix(name, "."), err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// obtain gets a new certificate for c from the CA. old is the certificate
|
||||
// it replaces (nil for the first), so the CA can link them (ARI).
|
||||
func (m *Manager) obtain(ctx context.Context, c *model.Certificate, old *x509.Certificate) (*certificate.Resource, []Route, error) {
|
||||
m.legoMu.Lock()
|
||||
defer m.legoMu.Unlock()
|
||||
client, err := m.client(ctx, c.KeyType)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
p := &provider{m: m, ctx: ctx, cert: c, cleanups: map[string]func() error{}}
|
||||
opts := []dns01.ChallengeOption{dns01.AddRecursiveNameservers(m.cfg.Resolvers), dns01.AddDNSTimeout(10 * time.Second)}
|
||||
if m.cfg.SkipPropagationCheck {
|
||||
opts = append(opts, dns01.DisableAuthoritativeNssPropagationRequirement())
|
||||
}
|
||||
if err := client.Challenge.SetDNS01Provider(p, opts...); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
req := certificate.ObtainRequest{Domains: c.Domains, Bundle: true}
|
||||
if old != nil {
|
||||
if id, err := certificate.MakeARICertID(old); err == nil {
|
||||
req.ReplacesCertID = id
|
||||
}
|
||||
}
|
||||
res, err := client.Certificate.Obtain(req)
|
||||
if err != nil && req.ReplacesCertID != "" && len(p.routes) == 0 && ctx.Err() == nil {
|
||||
// The CA refused the order itself, before any challenge: it may
|
||||
// not link the new certificate to the old one (another CA, one
|
||||
// already replaced, or its own bug). Ask for a plain new one.
|
||||
m.log.Info("the CA refused to link the renewal to the old certificate; asking for a new one", "certificate", c.Name, "err", cleanLegoError(err))
|
||||
req.ReplacesCertID = ""
|
||||
res, err = client.Certificate.Obtain(req)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, p.routes, cleanLegoError(err)
|
||||
}
|
||||
return res, p.routes, nil
|
||||
}
|
||||
|
||||
// renewalInfo asks the CA when to renew (ARI, RFC 9773). nil, nil when
|
||||
// it doesn't say.
|
||||
func (m *Manager) renewalInfo(ctx context.Context, c *model.Certificate, leaf *x509.Certificate) (*time.Time, error) {
|
||||
m.legoMu.Lock()
|
||||
defer m.legoMu.Unlock()
|
||||
client, err := m.client(ctx, c.KeyType)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ri, err := client.Certificate.GetRenewalInfo(certificate.RenewalInfoRequest{Cert: leaf})
|
||||
if err != nil {
|
||||
if strings.Contains(err.Error(), "does not advertise a renewal info endpoint") {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
// A time within the CA's window (now if it has started); never later
|
||||
// than two thirds into the lifetime, in case the window is odd.
|
||||
at := ri.ShouldRenewAt(time.Now(), 10*365*24*time.Hour)
|
||||
if at == nil {
|
||||
return nil, nil
|
||||
}
|
||||
t := at.UTC().Truncate(time.Second)
|
||||
if limit := twoThirds(leaf.NotBefore, leaf.NotAfter); t.After(limit) {
|
||||
t = limit
|
||||
}
|
||||
return &t, nil
|
||||
}
|
||||
|
||||
func twoThirds(nb, na time.Time) time.Time {
|
||||
return nb.Add(na.Sub(nb) * 2 / 3).UTC().Truncate(time.Second)
|
||||
}
|
||||
|
||||
// cleanLegoError shortens lego's errors, which wrap each name's problem in
|
||||
// several prefixes and the whole ACME problem document:
|
||||
//
|
||||
// error: one or more domains had a problem:
|
||||
// [a.example.com] [a.example.com] acme: error presenting token: MSG
|
||||
// [b.example.com] acme: error: 403 :: urn:ietf:params:acme:error:unauthorized :: MSG
|
||||
//
|
||||
// becomes "a.example.com: MSG; b.example.com: 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 m := acmeProblemRe.FindStringSubmatch(line); m != nil {
|
||||
line = "the CA refused (" + m[2] + "): " + m[3]
|
||||
}
|
||||
if domain != "" {
|
||||
line = domain + ": " + line
|
||||
}
|
||||
out = append(out, line)
|
||||
}
|
||||
if len(out) == 0 {
|
||||
return err
|
||||
}
|
||||
return errors.New(strings.Join(out, "; "))
|
||||
}
|
||||
|
||||
// acmeProblemRe matches "403 :: urn:ietf:params:acme:error:unauthorized :: detail".
|
||||
var acmeProblemRe = regexp.MustCompile(`^(\d+) :: urn:ietf:params:acme:error:(\w+) :: (.*)$`)
|
||||
@@ -0,0 +1,490 @@
|
||||
package certs
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/ecdsa"
|
||||
"crypto/elliptic"
|
||||
"crypto/rand"
|
||||
"crypto/x509"
|
||||
"crypto/x509/pkix"
|
||||
"encoding/pem"
|
||||
"errors"
|
||||
"io"
|
||||
"log/slog"
|
||||
"math/big"
|
||||
"net"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/go-acme/lego/v4/challenge/dns01"
|
||||
"github.com/miekg/dns"
|
||||
|
||||
"jarl/internal/auth"
|
||||
"jarl/internal/engine"
|
||||
"jarl/internal/model"
|
||||
"jarl/internal/porkbun"
|
||||
"jarl/internal/render"
|
||||
"jarl/internal/store"
|
||||
)
|
||||
|
||||
// fakeDNS answers like the internet would: zones with their public name
|
||||
// servers, CNAME records, and TXT records (Porkbun's, from the fake API).
|
||||
type fakeDNS struct {
|
||||
mu sync.Mutex
|
||||
zones map[string][]string // zone -> NS
|
||||
cnames map[string]string
|
||||
txt func(name string) []string
|
||||
addr string
|
||||
}
|
||||
|
||||
func newFakeDNS(t *testing.T) *fakeDNS {
|
||||
f := &fakeDNS{zones: map[string][]string{}, cnames: map[string]string{}, txt: func(string) []string { return nil }}
|
||||
pc, err := net.ListenPacket("udp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
srv := &dns.Server{PacketConn: pc, Handler: f}
|
||||
go srv.ActivateAndServe()
|
||||
t.Cleanup(func() { srv.Shutdown() })
|
||||
f.addr = pc.LocalAddr().String()
|
||||
return f
|
||||
}
|
||||
|
||||
func (f *fakeDNS) ServeDNS(w dns.ResponseWriter, r *dns.Msg) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
m := new(dns.Msg)
|
||||
m.SetReply(r)
|
||||
q := r.Question[0]
|
||||
name := strings.ToLower(q.Name)
|
||||
zone := ""
|
||||
for z := range f.zones {
|
||||
if dns.IsSubDomain(z, name) && len(z) > len(zone) {
|
||||
zone = z
|
||||
}
|
||||
}
|
||||
if zone == "" {
|
||||
m.Rcode = dns.RcodeNameError
|
||||
w.WriteMsg(m)
|
||||
return
|
||||
}
|
||||
soa := &dns.SOA{Hdr: dns.RR_Header{Name: zone, Rrtype: dns.TypeSOA, Class: dns.ClassINET, Ttl: 60}, Ns: f.zones[zone][0], Mbox: "h." + zone, Serial: 1, Refresh: 1, Retry: 1, Expire: 1, Minttl: 60}
|
||||
switch {
|
||||
case q.Qtype == dns.TypeSOA && name == zone:
|
||||
m.Answer = append(m.Answer, soa)
|
||||
case q.Qtype == dns.TypeNS && name == zone:
|
||||
for _, ns := range f.zones[zone] {
|
||||
m.Answer = append(m.Answer, &dns.NS{Hdr: dns.RR_Header{Name: zone, Rrtype: dns.TypeNS, Class: dns.ClassINET, Ttl: 60}, Ns: ns})
|
||||
}
|
||||
case f.cnames[name] != "":
|
||||
m.Answer = append(m.Answer, &dns.CNAME{Hdr: dns.RR_Header{Name: name, Rrtype: dns.TypeCNAME, Class: dns.ClassINET, Ttl: 60}, Target: f.cnames[name]})
|
||||
case q.Qtype == dns.TypeTXT && len(f.txt(name)) > 0:
|
||||
for _, v := range f.txt(name) {
|
||||
m.Answer = append(m.Answer, &dns.TXT{Hdr: dns.RR_Header{Name: name, Rrtype: dns.TypeTXT, Class: dns.ClassINET, Ttl: 60}, Txt: []string{v}})
|
||||
}
|
||||
default:
|
||||
m.Ns = append(m.Ns, soa)
|
||||
}
|
||||
w.WriteMsg(m)
|
||||
}
|
||||
|
||||
type env struct {
|
||||
t *testing.T
|
||||
m *Manager
|
||||
st *store.Store
|
||||
eng *engine.Engine
|
||||
dns *fakeDNS
|
||||
pb *porkbun.Fake
|
||||
dir string
|
||||
}
|
||||
|
||||
// newEnv sets up Jarl with zone example.com (name server
|
||||
// ns1.example.com, which the internet also uses), a fake internet and a
|
||||
// fake Porkbun account with pb.test.
|
||||
func newEnv(t *testing.T, withPorkbun bool) *env {
|
||||
dir := t.TempDir()
|
||||
st, err := store.Open(filepath.Join(dir, "jarl.db"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { st.Close() })
|
||||
quiet := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
eng := engine.New(st, nil, engine.Config{
|
||||
Paths: render.Paths{ConfigDir: filepath.Join(dir, "etc"), ZoneDir: filepath.Join(dir, "zones"), CacheDir: filepath.Join(dir, "cache")},
|
||||
StagingDir: filepath.Join(dir, "staging"), NoReload: true, SkipCheck: true,
|
||||
}, quiet)
|
||||
e := &env{t: t, st: st, eng: eng, dns: newFakeDNS(t), dir: dir}
|
||||
e.submit(engine.NewRequest(engine.OpSettingsUpdate, 0, engine.SettingsInput{Nameservers: &[]string{"ns1.example.net."}}),
|
||||
engine.NewRequest(engine.OpZoneCreate, 0, engine.ZoneInput{Name: model.Ptr("example.com")}))
|
||||
e.dns.mu.Lock()
|
||||
e.dns.zones["example.com."] = []string{"ns1.example.net."}
|
||||
e.dns.zones["pb.test."] = []string{"curitiba.ns.porkbun.com.", "fortaleza.ns.porkbun.com."}
|
||||
e.dns.zones["other.test."] = []string{"a.ns.cloudflare.com."}
|
||||
e.dns.mu.Unlock()
|
||||
cfg := Config{Resolvers: []string{e.dns.addr}, Dir: filepath.Join(dir, "tls"), Directory: "https://acme.invalid/dir"}
|
||||
if withPorkbun {
|
||||
e.pb = porkbun.NewFake("pk1_test", "sk1_test", "pb.test")
|
||||
ts := httptest.NewServer(e.pb)
|
||||
t.Cleanup(ts.Close)
|
||||
cfg.Porkbun = &porkbun.Client{BaseURL: ts.URL + "/api/json/v3", APIKey: "pk1_test", Secret: "sk1_test"}
|
||||
e.dns.mu.Lock()
|
||||
e.dns.txt = func(name string) []string { return e.pb.TXT(name) }
|
||||
e.dns.mu.Unlock()
|
||||
}
|
||||
e.m = New(cfg, st, eng, quiet)
|
||||
// lego's own lookups (CNAME following) use the fake internet too.
|
||||
dns01.AddRecursiveNameservers([]string{e.dns.addr})(nil)
|
||||
return e
|
||||
}
|
||||
|
||||
func (e *env) submit(reqs ...engine.Request) {
|
||||
e.t.Helper()
|
||||
if _, err := e.eng.Submit(context.Background(), reqs, auth.System, "test", "", engine.ApplyOptions{}); err != nil {
|
||||
e.t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func (e *env) state() *model.State {
|
||||
st, err := e.st.LoadState(context.Background())
|
||||
if err != nil {
|
||||
e.t.Fatal(err)
|
||||
}
|
||||
return st
|
||||
}
|
||||
|
||||
func TestRoute(t *testing.T) {
|
||||
e := newEnv(t, true)
|
||||
ctx := context.Background()
|
||||
e.dns.mu.Lock()
|
||||
e.dns.cnames["_acme-challenge.www.other.test."] = "www-other.acme.example.com."
|
||||
e.dns.mu.Unlock()
|
||||
cases := []struct {
|
||||
challenge, domain string
|
||||
route string // "" for an error
|
||||
target string
|
||||
errHas string
|
||||
}{
|
||||
{"auto", "www.example.com", "jarl", "_acme-challenge.www.example.com.", ""},
|
||||
{"auto", "*.example.com", "jarl", "_acme-challenge.example.com.", ""},
|
||||
{"auto", "ns1.pb.test", "porkbun", "_acme-challenge.ns1.pb.test.", ""},
|
||||
{"auto", "www.other.test", "jarl", "www-other.acme.example.com.", ""},
|
||||
{"auto", "mail.other.test", "", "", "Point _acme-challenge.mail.other.test with a CNAME"},
|
||||
{"porkbun", "www.example.com", "", "", "not Porkbun"},
|
||||
{"jarl", "ns1.pb.test", "", "", "no primary zone in Jarl is pb.test"},
|
||||
{"jarl", "www.example.com", "jarl", "_acme-challenge.www.example.com.", ""},
|
||||
}
|
||||
for _, c := range cases {
|
||||
r := e.m.route(ctx, e.state(), c.challenge, c.domain, "")
|
||||
if r.Route != c.route || c.target != "" && r.Target != c.target || c.errHas != "" && !strings.Contains(r.Error, c.errHas) {
|
||||
t.Errorf("%s %s: route %q target %q error %q", c.challenge, c.domain, r.Route, r.Target, r.Error)
|
||||
}
|
||||
}
|
||||
|
||||
// The move from Porkbun to Jarl: while the internet still asks
|
||||
// Porkbun, auto goes there even though Jarl has the zone; once the
|
||||
// name servers change, it comes to Jarl.
|
||||
e.submit(engine.NewRequest(engine.OpZoneCreate, 0, engine.ZoneInput{Name: model.Ptr("pb.test")}))
|
||||
if r := e.m.route(ctx, e.state(), "auto", "ns1.pb.test", ""); r.Route != "porkbun" {
|
||||
t.Errorf("before the move: %+v", r)
|
||||
}
|
||||
if r := e.m.route(ctx, e.state(), "jarl", "ns1.pb.test", ""); !strings.Contains(r.Error, "not by the name servers in Jarl's zone (ns1.example.net)") {
|
||||
t.Errorf("jarl before the move: %+v", r)
|
||||
}
|
||||
e.dns.mu.Lock()
|
||||
e.dns.zones["pb.test."] = []string{"ns1.example.net."}
|
||||
e.dns.mu.Unlock()
|
||||
if r := e.m.route(ctx, e.state(), "auto", "ns1.pb.test", ""); r.Route != "jarl" {
|
||||
t.Errorf("after the move: %+v", r)
|
||||
}
|
||||
if r := e.m.route(ctx, e.state(), "porkbun", "ns1.pb.test", ""); !strings.Contains(r.Error, "not Porkbun") {
|
||||
t.Errorf("porkbun after the move: %+v", r)
|
||||
}
|
||||
|
||||
// Without a Porkbun key, a Porkbun domain says what is missing.
|
||||
e2 := newEnv(t, false)
|
||||
if r := e2.m.route(ctx, e2.state(), "auto", "ns1.pb.test", ""); !strings.Contains(r.Error, "no porkbun.api_key") {
|
||||
t.Errorf("without a key: %+v", r)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProvider(t *testing.T) {
|
||||
e := newEnv(t, true)
|
||||
ctx := context.Background()
|
||||
c := &model.Certificate{Name: "t", Challenge: "auto"}
|
||||
p := &provider{m: e.m, ctx: ctx, cert: c, cleanups: map[string]func() error{}}
|
||||
|
||||
// Porkbun: the record goes in through the API and is removed again.
|
||||
info := dns01.GetChallengeInfo("ns1.pb.test", "key-auth-1")
|
||||
if err := p.Present("ns1.pb.test", "tok", "key-auth-1"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := e.pb.TXT("_acme-challenge.ns1.pb.test"); len(got) != 1 || got[0] != info.Value {
|
||||
t.Fatalf("Porkbun TXT = %v, want %s", got, info.Value)
|
||||
}
|
||||
if err := p.CleanUp("ns1.pb.test", "tok", "key-auth-1"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := e.pb.TXT("_acme-challenge.ns1.pb.test"); len(got) != 0 {
|
||||
t.Fatalf("Porkbun TXT after cleanup = %v", got)
|
||||
}
|
||||
|
||||
// Jarl: the record goes into the zone, in a changeset by the system.
|
||||
txt := func() []string {
|
||||
var out []string
|
||||
st := e.state()
|
||||
for _, r := range st.Records {
|
||||
if r.Type == "TXT" && r.Name == "_acme-challenge.www.example.com." {
|
||||
out = append(out, r.Data)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
info = dns01.GetChallengeInfo("www.example.com", "key-auth-2")
|
||||
if err := p.Present("www.example.com", "tok", "key-auth-2"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := txt(); len(got) != 1 || !strings.Contains(got[0], info.Value) {
|
||||
t.Fatalf("Jarl TXT = %v, want %s", got, info.Value)
|
||||
}
|
||||
if err := p.CleanUp("www.example.com", "tok", "key-auth-2"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := txt(); len(got) != 0 {
|
||||
t.Fatalf("Jarl TXT after cleanup = %v", got)
|
||||
}
|
||||
if len(p.routes) != 2 || p.routes[0].Route != "porkbun" || p.routes[1].Route != "jarl" {
|
||||
t.Errorf("routes = %+v", p.routes)
|
||||
}
|
||||
|
||||
// A name nobody can answer for fails before anything is published.
|
||||
if err := p.Present("mail.other.test", "tok", "k"); err == nil || !strings.Contains(err.Error(), "CNAME") {
|
||||
t.Errorf("other.test: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// makeCert returns a certificate for names (signed by a CA unless
|
||||
// selfSigned) and its key, as PEM; withCA appends the CA certificate.
|
||||
func makeCert(t *testing.T, names []string, notBefore, notAfter time.Time, withCA bool) (certPEM, keyPEM []byte) {
|
||||
t.Helper()
|
||||
caKey, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||
ca := &x509.Certificate{SerialNumber: big.NewInt(1), Subject: pkix.Name{CommonName: "Test CA", Organization: []string{"Jarl"}},
|
||||
NotBefore: notBefore, NotAfter: notAfter.Add(time.Hour), IsCA: true, BasicConstraintsValid: true, KeyUsage: x509.KeyUsageCertSign}
|
||||
caDER, _ := x509.CreateCertificate(rand.Reader, ca, ca, &caKey.PublicKey, caKey)
|
||||
caCert, _ := x509.ParseCertificate(caDER)
|
||||
key, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||
leaf := &x509.Certificate{SerialNumber: big.NewInt(time.Now().UnixNano()), Subject: pkix.Name{CommonName: names[0]}, DNSNames: names,
|
||||
NotBefore: notBefore, NotAfter: notAfter, ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}}
|
||||
der, err := x509.CreateCertificate(rand.Reader, leaf, caCert, &key.PublicKey, caKey)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
certPEM = pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der})
|
||||
if withCA {
|
||||
certPEM = append(certPEM, pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: caDER})...)
|
||||
}
|
||||
kd, _ := x509.MarshalPKCS8PrivateKey(key)
|
||||
return certPEM, pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: kd})
|
||||
}
|
||||
|
||||
func TestInspect(t *testing.T) {
|
||||
now := time.Now()
|
||||
cert, key := makeCert(t, []string{"ns1.example.com", "*.example.com"}, now, now.Add(90*24*time.Hour), true)
|
||||
in, pair, err := Inspect(cert, key)
|
||||
if err != nil || pair == nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if strings.Join(in.Names, " ") != "ns1.example.com *.example.com" || in.Issuer != "Jarl Test CA" || in.KeyType != "ec256" || len(in.Warnings) != 0 {
|
||||
t.Errorf("info = %+v", in)
|
||||
}
|
||||
leafOnly, _ := makeCert(t, []string{"a.example.com"}, now, now.Add(time.Hour), false)
|
||||
_, otherKey := makeCert(t, []string{"b.example.com"}, now, now.Add(time.Hour), false)
|
||||
if _, _, err := Inspect(cert, otherKey); err == nil || !strings.Contains(err.Error(), "doesn't belong") {
|
||||
t.Errorf("mismatched key: %v", err)
|
||||
}
|
||||
leafCert, leafKey := makeCert(t, []string{"a.example.com"}, now, now.Add(time.Hour), false)
|
||||
if in, _, err := Inspect(leafCert, leafKey); err != nil || len(in.Warnings) != 1 || !strings.Contains(in.Warnings[0], "full chain") {
|
||||
t.Errorf("leaf only: %+v %v", in, err)
|
||||
}
|
||||
_ = leafOnly
|
||||
if !Covers([]string{"*.example.com"}, []string{"www.example.com"}) || Covers([]string{"*.example.com"}, []string{"a.b.example.com"}) || Covers([]string{"example.com"}, []string{"www.example.com"}) {
|
||||
t.Error("Covers")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDueAndStatus(t *testing.T) {
|
||||
m := &Manager{now: time.Now}
|
||||
now := time.Now().UTC().Truncate(time.Second)
|
||||
nb, na := now.Add(-80*24*time.Hour), now.Add(10*24*time.Hour)
|
||||
c := &model.Certificate{Source: model.CertSourceACME, CreatedAt: now.Add(-time.Hour)}
|
||||
if got := m.Due(c); !got.Equal(c.CreatedAt) {
|
||||
t.Errorf("never obtained: due %v", got)
|
||||
}
|
||||
if s := m.StatusOf(c); s.State != "pending" {
|
||||
t.Errorf("pending: %+v", s)
|
||||
}
|
||||
c.CertPEM, c.NotBefore, c.NotAfter = "x", &nb, &na
|
||||
if got, want := m.Due(c), twoThirds(nb, na); !got.Equal(want) {
|
||||
t.Errorf("due %v, want two thirds %v", got, want)
|
||||
}
|
||||
ari := now.Add(time.Hour)
|
||||
c.RenewAt = &ari
|
||||
if got := m.Due(c); !got.Equal(ari) {
|
||||
t.Errorf("due %v, want the CA's %v", got, ari)
|
||||
}
|
||||
// 10 days left of 90 is less than a sixth: expiring.
|
||||
if s := m.StatusOf(c); s.State != "expiring" {
|
||||
t.Errorf("status %+v", s)
|
||||
}
|
||||
// After three failures it waits four hours from the last attempt.
|
||||
last := now.Add(-time.Hour)
|
||||
c.Failures, c.LastAttempt, c.LastError = 3, &last, "boom"
|
||||
if got := m.Due(c); !got.Equal(last.Add(4 * time.Hour)) {
|
||||
t.Errorf("backoff: due %v", got)
|
||||
}
|
||||
if s := m.StatusOf(c); s.State != "failing" || !strings.Contains(s.Message, "boom") {
|
||||
t.Errorf("failing: %+v", s)
|
||||
}
|
||||
c.Failures = 30
|
||||
if got := m.Due(c); !got.Equal(last.Add(24 * time.Hour)) {
|
||||
t.Errorf("backoff is at most a day: due %v", got)
|
||||
}
|
||||
old := now.Add(-time.Hour)
|
||||
c.NotAfter = &old
|
||||
if s := m.StatusOf(c); s.State != "expired" {
|
||||
t.Errorf("expired: %+v", s)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInput(t *testing.T) {
|
||||
e := newEnv(t, false)
|
||||
ctx := context.Background()
|
||||
admin := &auth.Principal{User: &model.User{Username: "admin", Admin: true}}
|
||||
bad := []Input{
|
||||
{Name: model.Ptr("Bad Name"), Domains: &[]string{"a.example.com"}},
|
||||
{Name: model.Ptr("x"), Domains: &[]string{"localhost"}},
|
||||
{Name: model.Ptr("x"), Domains: &[]string{"a.*.example.com"}},
|
||||
{Name: model.Ptr("x"), Domains: &[]string{}},
|
||||
{Name: model.Ptr("x"), Domains: &[]string{"a.example.com"}, Uses: &[]string{"mail"}},
|
||||
{Name: model.Ptr("x"), Domains: &[]string{"a.example.com"}, Challenge: model.Ptr("http")},
|
||||
{Name: model.Ptr("x"), Domains: &[]string{"a.example.com"}, KeyType: model.Ptr("rsa1024")},
|
||||
{Name: model.Ptr("x"), Source: model.Ptr("file"), CertFile: model.Ptr("rel.pem"), KeyFile: model.Ptr("/k.pem")},
|
||||
{Name: model.Ptr("x"), Domains: &[]string{"a.example.com"}, CertFile: model.Ptr("/c.pem")},
|
||||
}
|
||||
for _, in := range bad {
|
||||
if _, err := e.m.Create(ctx, in, admin); err == nil {
|
||||
t.Errorf("accepted %+v", in)
|
||||
}
|
||||
}
|
||||
c, err := e.m.Create(ctx, Input{Name: model.Ptr("web"), Domains: &[]string{"WWW.Example.com.", "www.example.com", "*.example.com"}, Uses: &[]string{"web", "dns", "web"}}, admin)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if strings.Join(c.Domains, " ") != "www.example.com *.example.com" || strings.Join(c.Uses, ",") != "dns,web" || c.Challenge != "auto" || c.KeyType != "ec256" {
|
||||
t.Errorf("normalized: %+v", c)
|
||||
}
|
||||
// Only one certificate serves jarld's HTTPS.
|
||||
if _, err := e.m.Create(ctx, Input{Name: model.Ptr("web2"), Domains: &[]string{"a.example.com"}, Uses: &[]string{"web"}}, admin); err == nil || !strings.Contains(err.Error(), "already used for jarld's HTTPS") {
|
||||
t.Errorf("second web certificate: %v", err)
|
||||
}
|
||||
if _, err := e.m.Create(ctx, Input{Name: model.Ptr("web"), Domains: &[]string{"a.example.com"}}, admin); err != ErrConflict {
|
||||
t.Errorf("duplicate name: %v", err)
|
||||
}
|
||||
if _, err := e.m.Update(ctx, "web", Input{Name: model.Ptr("renamed")}, admin); err == nil {
|
||||
t.Error("rename accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFileSource(t *testing.T) {
|
||||
e := newEnv(t, false)
|
||||
ctx := context.Background()
|
||||
admin := &auth.Principal{User: &model.User{Username: "admin", Admin: true}}
|
||||
now := time.Now()
|
||||
certFile, keyFile := filepath.Join(e.dir, "fullchain.pem"), filepath.Join(e.dir, "privkey.pem")
|
||||
cert, key := makeCert(t, []string{"ns1.example.com"}, now.Add(-time.Hour), now.Add(60*24*time.Hour), true)
|
||||
os.WriteFile(certFile, cert, 0o600)
|
||||
os.WriteFile(keyFile, key, 0o600)
|
||||
|
||||
c, err := e.m.Create(ctx, Input{Name: model.Ptr("own"), Source: model.Ptr("file"), CertFile: &certFile, KeyFile: &keyFile, Uses: &[]string{"web", "dns"}}, admin)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
c, _ = e.m.Get(ctx, "own")
|
||||
if strings.Join(c.Domains, " ") != "ns1.example.com" || c.NotAfter == nil || c.CertPEM != "" {
|
||||
t.Errorf("read: %+v", c)
|
||||
}
|
||||
if e.m.WebCertificate() == nil || e.m.WebName() != "own" {
|
||||
t.Error("not served for HTTPS")
|
||||
}
|
||||
chain := filepath.Join(e.m.FilesDir("own"), ChainFile)
|
||||
if b, err := os.ReadFile(chain); err != nil || string(b) != string(cert) {
|
||||
t.Fatalf("files for named: %v", err)
|
||||
}
|
||||
if fi, err := os.Stat(filepath.Join(e.m.FilesDir("own"), KeyFile)); err != nil || fi.Mode().Perm() != 0o640 {
|
||||
t.Errorf("key file: %v %v", fi, err)
|
||||
}
|
||||
|
||||
// Renewed by its owner: the loop notices and puts the new one in place.
|
||||
cert2, key2 := makeCert(t, []string{"ns1.example.com", "dns.example.com"}, now, now.Add(90*24*time.Hour), true)
|
||||
os.WriteFile(certFile, cert2, 0o600)
|
||||
os.WriteFile(keyFile, key2, 0o600)
|
||||
future := now.Add(time.Minute)
|
||||
os.Chtimes(certFile, future, future)
|
||||
e.m.Tick(ctx)
|
||||
c, _ = e.m.Get(ctx, "own")
|
||||
if strings.Join(c.Domains, " ") != "ns1.example.com dns.example.com" {
|
||||
t.Errorf("not reread: %v", c.Domains)
|
||||
}
|
||||
if b, _ := os.ReadFile(chain); string(b) != string(cert2) {
|
||||
t.Error("files for named not updated")
|
||||
}
|
||||
if got := e.m.WebCertificate(); got == nil || len(got.Leaf.DNSNames) != 2 {
|
||||
t.Error("HTTPS still serves the old certificate")
|
||||
}
|
||||
|
||||
// A broken renewal keeps the last good certificate in place.
|
||||
os.WriteFile(keyFile, key, 0o600)
|
||||
os.Chtimes(keyFile, future.Add(time.Minute), future.Add(time.Minute))
|
||||
e.m.Tick(ctx)
|
||||
c, _ = e.m.Get(ctx, "own")
|
||||
if !strings.Contains(c.LastError, "doesn't belong") || e.m.StatusOf(c).State != "failing" {
|
||||
t.Errorf("mismatch not reported: %+v", c)
|
||||
}
|
||||
if b, _ := os.ReadFile(chain); string(b) != string(cert2) {
|
||||
t.Error("files for named replaced by a broken pair")
|
||||
}
|
||||
|
||||
// Without the dns use the files go; deleting stops HTTPS.
|
||||
if _, err := e.m.Update(ctx, "own", Input{Uses: &[]string{"web"}}, admin); err != nil {
|
||||
// The pair is broken now; put the good key back first.
|
||||
t.Log(err)
|
||||
}
|
||||
os.WriteFile(keyFile, key2, 0o600)
|
||||
if _, err := e.m.Update(ctx, "own", Input{Uses: &[]string{"web"}}, admin); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := os.Stat(e.m.FilesDir("own")); !os.IsNotExist(err) {
|
||||
t.Errorf("files for named left behind: %v", err)
|
||||
}
|
||||
if err := e.m.Delete(ctx, "own", admin); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if e.m.WebCertificate() != nil {
|
||||
t.Error("still serving a deleted certificate")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCleanLegoError(t *testing.T) {
|
||||
in := errors.New("error: one or more domains had a problem:\n[www.example.org] [www.example.org] acme: error presenting token: example.org is served by x, which Jarl can't write to\n[a.example.com] acme: error: 403 :: urn:ietf:params:acme:error:unauthorized :: Incorrect TXT record \"abc\" found at _acme-challenge.a.example.com\n")
|
||||
want := `www.example.org: example.org is served by x, which Jarl can't write to; a.example.com: the CA refused (unauthorized): Incorrect TXT record "abc" found at _acme-challenge.a.example.com`
|
||||
if got := cleanLegoError(in).Error(); got != want {
|
||||
t.Errorf("got %s\nwant %s", got, want)
|
||||
}
|
||||
if got := cleanLegoError(errors.New("acme: error: 429 :: urn:ietf:params:acme:error:rateLimited :: too many")).Error(); got != "the CA refused (rateLimited): too many" {
|
||||
t.Errorf("rate limit: %s", got)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
package certs
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/miekg/dns"
|
||||
)
|
||||
|
||||
// resolver asks recursive resolvers (acme.resolvers: public ones by
|
||||
// default), to see a domain as the CA does rather than as this server's
|
||||
// own views answer it.
|
||||
type resolver struct {
|
||||
servers []string
|
||||
timeout time.Duration
|
||||
}
|
||||
|
||||
func (r resolver) query(ctx context.Context, name string, qtype uint16) (*dns.Msg, error) {
|
||||
if len(r.servers) == 0 {
|
||||
return nil, errors.New("no resolvers configured (acme.resolvers)")
|
||||
}
|
||||
m := new(dns.Msg)
|
||||
m.SetQuestion(dns.Fqdn(name), qtype)
|
||||
m.SetEdns0(1232, false)
|
||||
m.RecursionDesired = true
|
||||
timeout := r.timeout
|
||||
if timeout == 0 {
|
||||
timeout = 5 * time.Second
|
||||
}
|
||||
var errs []error
|
||||
for _, s := range r.servers {
|
||||
c := &dns.Client{Timeout: timeout}
|
||||
in, _, err := c.ExchangeContext(ctx, m, s)
|
||||
if err == nil && in.Truncated {
|
||||
c.Net = "tcp"
|
||||
in, _, err = c.ExchangeContext(ctx, m, s)
|
||||
}
|
||||
if err != nil {
|
||||
errs = append(errs, fmt.Errorf("%s: %w", s, err))
|
||||
continue
|
||||
}
|
||||
if in.Rcode == dns.RcodeServerFailure || in.Rcode == dns.RcodeRefused {
|
||||
errs = append(errs, fmt.Errorf("%s answered %s", s, dns.RcodeToString[in.Rcode]))
|
||||
continue
|
||||
}
|
||||
return in, nil
|
||||
}
|
||||
return nil, fmt.Errorf("looking up %s %s: %w", strings.TrimSuffix(name, "."), dns.TypeToString[qtype], errors.Join(errs...))
|
||||
}
|
||||
|
||||
// zoneOf returns the zone that name is in on the internet (the owner of
|
||||
// the SOA record that answers for it).
|
||||
func (r resolver) zoneOf(ctx context.Context, name string) (string, error) {
|
||||
name = dns.Fqdn(strings.ToLower(name))
|
||||
for cand := name; ; {
|
||||
in, err := r.query(ctx, cand, dns.TypeSOA)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
for _, sec := range [][]dns.RR{in.Answer, in.Ns} {
|
||||
for _, rr := range sec {
|
||||
if soa, ok := rr.(*dns.SOA); ok && dns.IsSubDomain(soa.Hdr.Name, name) {
|
||||
return strings.ToLower(soa.Hdr.Name), nil
|
||||
}
|
||||
}
|
||||
}
|
||||
i, end := dns.NextLabel(cand, 0)
|
||||
if end {
|
||||
return "", fmt.Errorf("no zone found for %s", strings.TrimSuffix(name, "."))
|
||||
}
|
||||
cand = cand[i:]
|
||||
}
|
||||
}
|
||||
|
||||
// nameServers returns the zone's name servers on the internet (lowercase,
|
||||
// without trailing dots, sorted).
|
||||
func (r resolver) nameServers(ctx context.Context, zone string) ([]string, error) {
|
||||
in, err := r.query(ctx, zone, dns.TypeNS)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var out []string
|
||||
for _, rr := range in.Answer {
|
||||
if ns, ok := rr.(*dns.NS); ok && strings.EqualFold(ns.Hdr.Name, dns.Fqdn(zone)) {
|
||||
out = append(out, normName(ns.Ns))
|
||||
}
|
||||
}
|
||||
if len(out) == 0 {
|
||||
return nil, fmt.Errorf("%s has no name servers on the internet (%s)", strings.TrimSuffix(zone, "."), dns.RcodeToString[in.Rcode])
|
||||
}
|
||||
slices.Sort(out)
|
||||
return slices.Compact(out), nil
|
||||
}
|
||||
|
||||
// follow returns where name leads after its CNAME records (name itself
|
||||
// when it has none).
|
||||
func (r resolver) follow(ctx context.Context, name string) (string, error) {
|
||||
name = dns.Fqdn(strings.ToLower(name))
|
||||
for range 10 {
|
||||
in, err := r.query(ctx, name, dns.TypeCNAME)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
next := ""
|
||||
for _, rr := range in.Answer {
|
||||
if c, ok := rr.(*dns.CNAME); ok && strings.EqualFold(c.Hdr.Name, name) {
|
||||
next = strings.ToLower(c.Target)
|
||||
}
|
||||
}
|
||||
if next == "" {
|
||||
return name, nil
|
||||
}
|
||||
name = next
|
||||
}
|
||||
return "", fmt.Errorf("%s: more than 10 CNAME records in a row", name)
|
||||
}
|
||||
|
||||
func normName(n string) string { return strings.ToLower(strings.TrimSuffix(n, ".")) }
|
||||
|
||||
// challengeName is the DNS-01 record name for a certificate name (a
|
||||
// wildcard shares its base name's).
|
||||
func challengeName(domain string) string {
|
||||
return "_acme-challenge." + dns.Fqdn(strings.TrimPrefix(strings.ToLower(domain), "*."))
|
||||
}
|
||||
@@ -0,0 +1,923 @@
|
||||
package certs
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"log"
|
||||
"log/slog"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"slices"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
legolog "github.com/go-acme/lego/v4/log"
|
||||
|
||||
"jarl/internal/auth"
|
||||
"jarl/internal/engine"
|
||||
"jarl/internal/model"
|
||||
"jarl/internal/porkbun"
|
||||
"jarl/internal/store"
|
||||
)
|
||||
|
||||
// Config configures the manager (acme and certificates in jarl.yaml).
|
||||
type Config struct {
|
||||
Directory string // ACME directory URL
|
||||
Email string
|
||||
AgreeTOS bool
|
||||
EABKeyID string
|
||||
EABHMAC string
|
||||
Resolvers []string
|
||||
CABundle string
|
||||
// Dir is where certificates with the dns use are written for named.
|
||||
Dir string
|
||||
Version string
|
||||
// Porkbun publishes challenges for domains Porkbun serves (nil
|
||||
// without an API key); PorkbunAllows limits it to porkbun.domains.
|
||||
Porkbun *porkbun.Client
|
||||
PorkbunAllows func(domain string) bool
|
||||
// CheckPropagation makes lego wait until every name server of the
|
||||
// zone serves the challenge before telling the CA (acme.check_propagation).
|
||||
CheckPropagation bool
|
||||
// PropagationTimeout is how long to wait for that (default 10 minutes).
|
||||
PropagationTimeout time.Duration
|
||||
// SkipPropagationCheck is !CheckPropagation (set by New).
|
||||
SkipPropagationCheck bool
|
||||
}
|
||||
|
||||
// Manager keeps certificates current and serves jarld's own.
|
||||
type Manager struct {
|
||||
cfg Config
|
||||
store *store.Store
|
||||
eng *engine.Engine
|
||||
log *slog.Logger
|
||||
res resolver
|
||||
|
||||
// legoMu: lego keeps its DNS settings in package variables, so one
|
||||
// client works at a time. issueMu: one issue at a time.
|
||||
legoMu sync.Mutex
|
||||
issueMu sync.Mutex
|
||||
|
||||
web atomic.Pointer[webCert]
|
||||
mu sync.Mutex
|
||||
files map[int64]fileStamp // file-source certificates: what was read last
|
||||
kick chan struct{}
|
||||
now func() time.Time
|
||||
// issuing holds the names being obtained now, with when it started.
|
||||
issuing sync.Map
|
||||
// base is the context background issues run in (jarld's lifetime).
|
||||
base context.Context
|
||||
}
|
||||
|
||||
type webCert struct {
|
||||
name string
|
||||
pair *tls.Certificate
|
||||
}
|
||||
|
||||
type fileStamp struct {
|
||||
certMod, keyMod time.Time
|
||||
certSize int64
|
||||
}
|
||||
|
||||
// New returns a manager. Call Start before serving, then Run.
|
||||
func New(cfg Config, st *store.Store, eng *engine.Engine, logger *slog.Logger) *Manager {
|
||||
if logger == nil {
|
||||
logger = slog.Default()
|
||||
}
|
||||
cfg.SkipPropagationCheck = !cfg.CheckPropagation
|
||||
m := &Manager{cfg: cfg, store: st, eng: eng, log: logger, res: resolver{servers: cfg.Resolvers},
|
||||
files: map[int64]fileStamp{}, kick: make(chan struct{}, 1), now: time.Now, base: context.Background()}
|
||||
// lego logs every step; keep it at debug level.
|
||||
legolog.Logger = log.New(slogWriter{logger}, "", 0)
|
||||
return m
|
||||
}
|
||||
|
||||
type slogWriter struct{ l *slog.Logger }
|
||||
|
||||
func (w slogWriter) Write(p []byte) (int, error) {
|
||||
w.l.Debug("lego: " + strings.TrimSpace(string(p)))
|
||||
return len(p), nil
|
||||
}
|
||||
|
||||
// Errors for the API.
|
||||
var (
|
||||
ErrNotFound = errors.New("no such certificate")
|
||||
ErrConflict = errors.New("a certificate with that name exists")
|
||||
)
|
||||
|
||||
// InputError is a request Jarl refuses.
|
||||
type InputError struct{ Msg string }
|
||||
|
||||
func (e *InputError) Error() string { return e.Msg }
|
||||
|
||||
func inputErr(format string, a ...any) error { return &InputError{Msg: fmt.Sprintf(format, a...)} }
|
||||
|
||||
// Input creates or changes a certificate; nil fields are left alone.
|
||||
type Input struct {
|
||||
Name *string `json:"name"`
|
||||
Source *string `json:"source"`
|
||||
Domains *[]string `json:"domains"`
|
||||
CertFile *string `json:"cert_file"`
|
||||
KeyFile *string `json:"key_file"`
|
||||
Uses *[]string `json:"uses"`
|
||||
Challenge *string `json:"challenge"`
|
||||
KeyType *string `json:"key_type"`
|
||||
Comment *string `json:"comment"`
|
||||
}
|
||||
|
||||
var nameRe = regexp.MustCompile(`^[a-z0-9][a-z0-9_-]{0,62}$`)
|
||||
var labelRe = regexp.MustCompile(`^[a-z0-9_]([a-z0-9_-]{0,61}[a-z0-9_])?$`)
|
||||
|
||||
func normDomains(in []string) ([]string, error) {
|
||||
var out []string
|
||||
for _, d := range in {
|
||||
d = strings.ToLower(strings.TrimSuffix(strings.TrimSpace(d), "."))
|
||||
if d == "" {
|
||||
continue
|
||||
}
|
||||
labels := strings.Split(d, ".")
|
||||
if len(labels) < 2 {
|
||||
return nil, inputErr("%q isn't a domain name a CA will sign (it needs at least two labels)", d)
|
||||
}
|
||||
for i, l := range labels {
|
||||
if l == "*" && i == 0 {
|
||||
continue
|
||||
}
|
||||
if !labelRe.MatchString(l) {
|
||||
return nil, inputErr("%q isn't a valid domain name (a wildcard is only allowed as the first label: *.example.com)", d)
|
||||
}
|
||||
}
|
||||
if len(d) > 253 {
|
||||
return nil, inputErr("%q is too long", d)
|
||||
}
|
||||
if !slices.Contains(out, d) {
|
||||
out = append(out, d)
|
||||
}
|
||||
}
|
||||
if len(out) == 0 {
|
||||
return nil, inputErr("give at least one domain name")
|
||||
}
|
||||
if len(out) > 100 {
|
||||
return nil, inputErr("at most 100 names per certificate")
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (m *Manager) apply(c *model.Certificate, in Input, creating bool) error {
|
||||
if in.Name != nil {
|
||||
if !creating && *in.Name != c.Name {
|
||||
return inputErr("a certificate can't be renamed (its files for named are named after it): create a new one and delete this one")
|
||||
}
|
||||
c.Name = *in.Name
|
||||
}
|
||||
if !nameRe.MatchString(c.Name) {
|
||||
return inputErr("name %q: use lowercase letters, digits, - and _ (at most 63)", c.Name)
|
||||
}
|
||||
if in.Source != nil {
|
||||
if !creating && *in.Source != c.Source {
|
||||
return inputErr("a certificate's source can't change: create a new one")
|
||||
}
|
||||
c.Source = *in.Source
|
||||
}
|
||||
if c.Source == "" {
|
||||
c.Source = model.CertSourceACME
|
||||
}
|
||||
if in.Uses != nil {
|
||||
c.Uses = nil
|
||||
for _, u := range *in.Uses {
|
||||
u = strings.ToLower(strings.TrimSpace(u))
|
||||
if u != model.CertUseWeb && u != model.CertUseDNS {
|
||||
return inputErr("unknown use %q: web (jarld's HTTPS) or dns (for named: DNS over TLS and HTTPS)", u)
|
||||
}
|
||||
if !slices.Contains(c.Uses, u) {
|
||||
c.Uses = append(c.Uses, u)
|
||||
}
|
||||
}
|
||||
slices.Sort(c.Uses)
|
||||
}
|
||||
if c.Uses == nil {
|
||||
c.Uses = []string{}
|
||||
}
|
||||
if in.Comment != nil {
|
||||
c.Comment = *in.Comment
|
||||
}
|
||||
switch c.Source {
|
||||
case model.CertSourceACME:
|
||||
if in.CertFile != nil && *in.CertFile != "" || in.KeyFile != nil && *in.KeyFile != "" {
|
||||
return inputErr("cert_file and key_file are for --source file; Jarl keeps the files of certificates it obtains itself")
|
||||
}
|
||||
if in.Domains != nil {
|
||||
d, err := normDomains(*in.Domains)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
c.Domains = d
|
||||
}
|
||||
if len(c.Domains) == 0 {
|
||||
return inputErr("give the domain names the certificate is for")
|
||||
}
|
||||
if in.Challenge != nil {
|
||||
c.Challenge = *in.Challenge
|
||||
}
|
||||
switch c.Challenge {
|
||||
case "":
|
||||
c.Challenge = model.ChallengeAuto
|
||||
case model.ChallengeAuto, model.ChallengeJarl, model.ChallengePorkbun:
|
||||
default:
|
||||
return inputErr("challenge must be auto, jarl or porkbun")
|
||||
}
|
||||
if in.KeyType != nil {
|
||||
c.KeyType = *in.KeyType
|
||||
}
|
||||
if c.KeyType == "" {
|
||||
c.KeyType = "ec256"
|
||||
}
|
||||
if !slices.Contains(KeyTypes, c.KeyType) {
|
||||
return inputErr("key type must be one of %s", strings.Join(KeyTypes, ", "))
|
||||
}
|
||||
case model.CertSourceFile:
|
||||
if in.Challenge != nil && *in.Challenge != "" || in.KeyType != nil && *in.KeyType != "" {
|
||||
return inputErr("challenge and key type are for certificates Jarl obtains (--source acme)")
|
||||
}
|
||||
if in.CertFile != nil {
|
||||
c.CertFile = *in.CertFile
|
||||
}
|
||||
if in.KeyFile != nil {
|
||||
c.KeyFile = *in.KeyFile
|
||||
}
|
||||
if !filepath.IsAbs(c.CertFile) || !filepath.IsAbs(c.KeyFile) {
|
||||
return inputErr("cert_file and key_file must be absolute paths (the full chain, and its key)")
|
||||
}
|
||||
c.Challenge, c.KeyType = "", ""
|
||||
default:
|
||||
return inputErr("source must be acme (Jarl obtains and renews it) or file (you renew it)")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// checkUses refuses a second certificate for jarld's HTTPS.
|
||||
func (m *Manager) checkUses(ctx context.Context, c *model.Certificate) error {
|
||||
if !c.HasUse(model.CertUseWeb) {
|
||||
return nil
|
||||
}
|
||||
all, err := m.store.Certificates(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, o := range all {
|
||||
if o.ID != c.ID && o.HasUse(model.CertUseWeb) {
|
||||
return inputErr("certificate %s is already used for jarld's HTTPS; take the web use off it first (jarl cert update %s --use ...)", o.Name, o.Name)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// List returns every certificate.
|
||||
func (m *Manager) List(ctx context.Context) ([]*model.Certificate, error) {
|
||||
return m.store.Certificates(ctx)
|
||||
}
|
||||
|
||||
// Get returns one certificate.
|
||||
func (m *Manager) Get(ctx context.Context, name string) (*model.Certificate, error) {
|
||||
c, err := m.store.CertificateByName(ctx, name)
|
||||
if errors.Is(err, store.ErrNotFound) {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
return c, err
|
||||
}
|
||||
|
||||
// Create adds a certificate. A file-source certificate is read at once;
|
||||
// one Jarl obtains is left for Issue (or the background loop).
|
||||
func (m *Manager) Create(ctx context.Context, in Input, p *auth.Principal) (*model.Certificate, error) {
|
||||
c := &model.Certificate{CreatedBy: p.Name(), UpdatedBy: p.Name()}
|
||||
if err := m.apply(c, in, true); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := m.checkUses(ctx, c); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if c.Source == model.CertSourceFile {
|
||||
if _, err := m.readFiles(c); err != nil {
|
||||
return nil, inputErr("%v", err)
|
||||
}
|
||||
}
|
||||
if err := m.store.CreateCertificate(ctx, c); err != nil {
|
||||
if errors.Is(err, store.ErrConflict) {
|
||||
return nil, ErrConflict
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
m.eng.Audit(ctx, p, "certificate.create", "certificate", c.ID, c.Name, "ok", describe(c))
|
||||
if c.Source == model.CertSourceFile {
|
||||
m.refreshFile(ctx, c, true)
|
||||
}
|
||||
return c, nil
|
||||
}
|
||||
|
||||
// Update changes a certificate. New names or a new key type make one Jarl
|
||||
// obtains due at once.
|
||||
func (m *Manager) Update(ctx context.Context, name string, in Input, p *auth.Principal) (*model.Certificate, error) {
|
||||
c, err := m.Get(ctx, name)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
before := *c
|
||||
if err := m.apply(c, in, false); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := m.checkUses(ctx, c); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if c.Source == model.CertSourceFile && (c.CertFile != before.CertFile || c.KeyFile != before.KeyFile) {
|
||||
if _, err := m.readFiles(c); err != nil {
|
||||
return nil, inputErr("%v", err)
|
||||
}
|
||||
}
|
||||
if c.Source == model.CertSourceACME && c.CertPEM != "" && (!slices.Equal(c.Domains, before.Domains) || c.KeyType != before.KeyType) {
|
||||
now := m.now().UTC()
|
||||
c.RenewAt, c.Failures = &now, 0
|
||||
}
|
||||
c.UpdatedBy = p.Name()
|
||||
if err := m.store.UpdateCertificate(ctx, c); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
m.eng.Audit(ctx, p, "certificate.update", "certificate", c.ID, c.Name, "ok", describe(c))
|
||||
if c.Source == model.CertSourceFile {
|
||||
m.mu.Lock()
|
||||
delete(m.files, c.ID)
|
||||
m.mu.Unlock()
|
||||
m.refreshFile(ctx, c, true)
|
||||
} else if c.CertPEM != "" {
|
||||
if err := m.deploy(ctx, c, []byte(c.CertPEM), []byte(c.KeyPEM)); err != nil {
|
||||
m.log.Warn("writing certificate files failed", "certificate", c.Name, "err", err)
|
||||
}
|
||||
}
|
||||
if before.HasUse(model.CertUseWeb) && !c.HasUse(model.CertUseWeb) {
|
||||
m.dropWeb(c.Name)
|
||||
}
|
||||
m.Kick()
|
||||
return c, nil
|
||||
}
|
||||
|
||||
// Delete removes a certificate and its files for named.
|
||||
func (m *Manager) Delete(ctx context.Context, name string, p *auth.Principal) error {
|
||||
c, err := m.Get(ctx, name)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := m.store.DeleteCertificate(ctx, c.ID); err != nil {
|
||||
return err
|
||||
}
|
||||
m.dropWeb(c.Name)
|
||||
if err := m.removeFiles(c.Name); err != nil {
|
||||
m.log.Warn("removing certificate files failed", "certificate", c.Name, "err", err)
|
||||
}
|
||||
m.mu.Lock()
|
||||
delete(m.files, c.ID)
|
||||
m.mu.Unlock()
|
||||
m.eng.Audit(ctx, p, "certificate.delete", "certificate", c.ID, c.Name, "ok", "")
|
||||
return nil
|
||||
}
|
||||
|
||||
func describe(c *model.Certificate) string {
|
||||
s := c.Source
|
||||
if c.Source == model.CertSourceACME {
|
||||
s += " " + strings.Join(c.Domains, " ") + ", challenge " + c.Challenge
|
||||
} else {
|
||||
s += " " + c.CertFile
|
||||
}
|
||||
if len(c.Uses) > 0 {
|
||||
s += ", uses " + strings.Join(c.Uses, ",")
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// Preview checks a new certificate's fields without saving it.
|
||||
func (m *Manager) Preview(in Input) (*model.Certificate, error) {
|
||||
c := &model.Certificate{}
|
||||
if err := m.apply(c, in, true); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return c, nil
|
||||
}
|
||||
|
||||
// Check works out where each name's challenge would go, without asking
|
||||
// the CA for anything.
|
||||
func (m *Manager) Check(ctx context.Context, c *model.Certificate) ([]Route, error) {
|
||||
if c.Source != model.CertSourceACME {
|
||||
return nil, inputErr("certificate %s is renewed by you (source file); there is nothing to check", c.Name)
|
||||
}
|
||||
st, err := m.store.LoadState(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var out []Route
|
||||
for _, d := range c.Domains {
|
||||
out = append(out, m.route(ctx, st, c.Challenge, d, ""))
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// IssueResult is the outcome of an issue.
|
||||
type IssueResult struct {
|
||||
Certificate *model.Certificate `json:"certificate"`
|
||||
Routes []Route `json:"routes"`
|
||||
}
|
||||
|
||||
// Issuing reports since when the certificate is being obtained (zero when
|
||||
// it isn't).
|
||||
func (m *Manager) Issuing(name string) time.Time {
|
||||
if v, ok := m.issuing.Load(name); ok {
|
||||
return v.(time.Time)
|
||||
}
|
||||
return time.Time{}
|
||||
}
|
||||
|
||||
// IssueAsync starts obtaining a certificate in the background and returns
|
||||
// at once: talking to the CA and waiting for every name server to serve
|
||||
// the challenge can take minutes. Issuing tells when it is done; the
|
||||
// outcome is in the certificate (last_success, last_error).
|
||||
func (m *Manager) IssueAsync(ctx context.Context, name string, p *auth.Principal) (*model.Certificate, error) {
|
||||
c, err := m.Get(ctx, name)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if c.Source == model.CertSourceFile {
|
||||
res, err := m.Issue(ctx, name, p)
|
||||
if res != nil {
|
||||
return res.Certificate, err
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
if _, busy := m.issuing.LoadOrStore(name, m.now()); busy {
|
||||
return c, nil
|
||||
}
|
||||
go func() {
|
||||
defer m.issuing.Delete(name)
|
||||
m.issue(m.base, c, p)
|
||||
}()
|
||||
return c, nil
|
||||
}
|
||||
|
||||
// Issue obtains a new certificate for c now (a file-source certificate is
|
||||
// reread instead).
|
||||
func (m *Manager) Issue(ctx context.Context, name string, p *auth.Principal) (*IssueResult, error) {
|
||||
c, err := m.Get(ctx, name)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if c.Source == model.CertSourceFile {
|
||||
m.mu.Lock()
|
||||
delete(m.files, c.ID)
|
||||
m.mu.Unlock()
|
||||
if err := m.refreshFile(ctx, c, true); err != nil {
|
||||
return &IssueResult{Certificate: c}, err
|
||||
}
|
||||
return &IssueResult{Certificate: c}, nil
|
||||
}
|
||||
return m.issue(ctx, c, p)
|
||||
}
|
||||
|
||||
func (m *Manager) issue(ctx context.Context, c *model.Certificate, p *auth.Principal) (*IssueResult, error) {
|
||||
m.issueMu.Lock()
|
||||
defer m.issueMu.Unlock()
|
||||
// Reload: another issue may have finished while this one waited.
|
||||
if fresh, err := m.Get(ctx, c.Name); err == nil {
|
||||
c = fresh
|
||||
}
|
||||
var old *x509.Certificate
|
||||
if c.CertPEM != "" {
|
||||
if in, _, err := Inspect([]byte(c.CertPEM), []byte(c.KeyPEM)); err == nil {
|
||||
old = in.Leaf
|
||||
}
|
||||
}
|
||||
m.log.Info("obtaining certificate", "certificate", c.Name, "domains", strings.Join(c.Domains, " "), "by", p.Name())
|
||||
now := m.now().UTC()
|
||||
c.LastAttempt = &now
|
||||
res, routes, err := m.obtain(ctx, c, old)
|
||||
c.LastRoutes = nil
|
||||
for _, r := range routes {
|
||||
if r.Error == "" {
|
||||
c.LastRoutes = append(c.LastRoutes, r.String())
|
||||
}
|
||||
}
|
||||
var info *Info
|
||||
if err == nil {
|
||||
info, _, err = Inspect(res.Certificate, res.PrivateKey)
|
||||
if err != nil {
|
||||
err = fmt.Errorf("the CA's certificate is unusable: %w", err)
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
c.Failures++
|
||||
c.LastError = err.Error()
|
||||
if serr := m.store.UpdateCertificate(ctx, c); serr != nil {
|
||||
m.log.Error("saving certificate state failed", "certificate", c.Name, "err", serr)
|
||||
}
|
||||
m.log.Warn("obtaining certificate failed", "certificate", c.Name, "failures", c.Failures, "err", err)
|
||||
m.eng.Audit(ctx, p, "certificate.issue", "certificate", c.ID, c.Name, "error", err.Error())
|
||||
return &IssueResult{Certificate: c, Routes: routes}, err
|
||||
}
|
||||
c.CertPEM, c.KeyPEM = string(res.Certificate), string(res.PrivateKey)
|
||||
setInfo(c, info)
|
||||
done := m.now().UTC()
|
||||
c.LastSuccess, c.Failures, c.LastError = &done, 0, ""
|
||||
at := twoThirds(info.NotBefore, info.NotAfter)
|
||||
c.RenewAt, c.ARICheckedAt = &at, nil
|
||||
if t, err := m.renewalInfo(ctx, c, info.Leaf); err == nil && t != nil {
|
||||
c.RenewAt, c.ARICheckedAt = t, &done
|
||||
}
|
||||
if err := m.store.UpdateCertificate(ctx, c); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
m.log.Info("certificate obtained", "certificate", c.Name, "expires", info.NotAfter.Format(time.DateOnly), "renew_at", c.RenewAt.Format(time.RFC3339), "routes", strings.Join(c.LastRoutes, "; "))
|
||||
m.eng.Audit(ctx, p, "certificate.issue", "certificate", c.ID, c.Name, "ok", fmt.Sprintf("%s, valid until %s, from %s", strings.Join(info.Names, " "), info.NotAfter.Format(time.DateOnly), info.Issuer))
|
||||
if err := m.deploy(ctx, c, res.Certificate, res.PrivateKey); err != nil {
|
||||
m.log.Error("writing certificate files failed", "certificate", c.Name, "err", err)
|
||||
return &IssueResult{Certificate: c, Routes: routes}, fmt.Errorf("the certificate was obtained, but writing it for named failed: %w", err)
|
||||
}
|
||||
return &IssueResult{Certificate: c, Routes: routes}, nil
|
||||
}
|
||||
|
||||
func setInfo(c *model.Certificate, in *Info) {
|
||||
nb, na := in.NotBefore.UTC(), in.NotAfter.UTC()
|
||||
c.Names, c.NotBefore, c.NotAfter, c.Issuer, c.Serial = in.Names, &nb, &na, in.Issuer, in.Serial
|
||||
}
|
||||
|
||||
// readFiles reads a file-source certificate and checks it.
|
||||
func (m *Manager) readFiles(c *model.Certificate) (*Info, error) {
|
||||
cert, err := os.ReadFile(c.CertFile)
|
||||
if err != nil {
|
||||
return nil, fileErr(err)
|
||||
}
|
||||
key, err := os.ReadFile(c.KeyFile)
|
||||
if err != nil {
|
||||
return nil, fileErr(err)
|
||||
}
|
||||
in, _, err := Inspect(cert, key)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%s and %s: %w", c.CertFile, c.KeyFile, err)
|
||||
}
|
||||
return in, nil
|
||||
}
|
||||
|
||||
func fileErr(err error) error {
|
||||
if errors.Is(err, fs.ErrPermission) {
|
||||
return fmt.Errorf("%w: jarld runs as its own user (jarl) and must be able to read the certificate and its key; e.g. give the files group jarl (chgrp jarl FILE; chmod 640 FILE), or copy them somewhere it can read in your renewal hook", err)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// refreshFile rereads a file-source certificate when its files changed
|
||||
// (always with force) and deploys it.
|
||||
func (m *Manager) refreshFile(ctx context.Context, c *model.Certificate, force bool) error {
|
||||
cs, err1 := os.Stat(c.CertFile)
|
||||
ks, err2 := os.Stat(c.KeyFile)
|
||||
var stamp fileStamp
|
||||
if err1 == nil && err2 == nil {
|
||||
stamp = fileStamp{certMod: cs.ModTime(), keyMod: ks.ModTime(), certSize: cs.Size()}
|
||||
m.mu.Lock()
|
||||
prev, ok := m.files[c.ID]
|
||||
m.mu.Unlock()
|
||||
if ok && prev == stamp && !force {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
in, err := m.readFiles(c)
|
||||
now := m.now().UTC()
|
||||
c.LastAttempt = &now
|
||||
if err != nil {
|
||||
if c.LastError != err.Error() {
|
||||
m.log.Warn("reading certificate failed", "certificate", c.Name, "err", err)
|
||||
}
|
||||
c.LastError = err.Error()
|
||||
c.Failures = max(c.Failures, 1)
|
||||
m.store.UpdateCertificate(ctx, c)
|
||||
return err
|
||||
}
|
||||
if c.Serial != in.Serial {
|
||||
m.log.Info("certificate read", "certificate", c.Name, "file", c.CertFile, "expires", in.NotAfter.Format(time.DateOnly))
|
||||
}
|
||||
setInfo(c, in)
|
||||
c.Domains = in.Names
|
||||
c.LastSuccess, c.LastError, c.Failures = &now, "", 0
|
||||
if len(in.Warnings) > 0 {
|
||||
c.LastError = in.Warnings[0]
|
||||
}
|
||||
if err := m.store.UpdateCertificate(ctx, c); err != nil {
|
||||
return err
|
||||
}
|
||||
cert, _ := os.ReadFile(c.CertFile)
|
||||
key, _ := os.ReadFile(c.KeyFile)
|
||||
if err := m.deploy(ctx, c, cert, key); err != nil {
|
||||
return err
|
||||
}
|
||||
m.mu.Lock()
|
||||
m.files[c.ID] = stamp
|
||||
m.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
// FilesDir is where a certificate with the dns use is written for named.
|
||||
func (m *Manager) FilesDir(name string) string { return filepath.Join(m.cfg.Dir, name) }
|
||||
|
||||
// Files names a certificate's files for named.
|
||||
const (
|
||||
ChainFile = "fullchain.pem"
|
||||
KeyFile = "privkey.pem"
|
||||
)
|
||||
|
||||
// deploy puts a certificate where it is used: jarld's HTTPS and/or the
|
||||
// files named reads. When those files change and named's configuration
|
||||
// names them, named rereads it.
|
||||
func (m *Manager) deploy(ctx context.Context, c *model.Certificate, cert, key []byte) error {
|
||||
_, pair, err := Inspect(cert, key)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if c.HasUse(model.CertUseWeb) {
|
||||
m.web.Store(&webCert{name: c.Name, pair: pair})
|
||||
} else {
|
||||
m.dropWeb(c.Name)
|
||||
}
|
||||
if !c.HasUse(model.CertUseDNS) {
|
||||
return m.removeFiles(c.Name)
|
||||
}
|
||||
dir := m.FilesDir(c.Name)
|
||||
changed := false
|
||||
for _, f := range []struct {
|
||||
name string
|
||||
content []byte
|
||||
mode fs.FileMode
|
||||
}{{ChainFile, cert, 0o644}, {KeyFile, key, 0o640}} {
|
||||
path := filepath.Join(dir, f.name)
|
||||
if old, err := os.ReadFile(path); err == nil && bytes.Equal(old, f.content) {
|
||||
continue
|
||||
}
|
||||
if err := m.eng.WriteFile(path, f.content, f.mode); err != nil {
|
||||
return err
|
||||
}
|
||||
changed = true
|
||||
}
|
||||
if changed && m.namedUses(ctx, dir) {
|
||||
if err := m.eng.Reconfig(ctx); err != nil {
|
||||
return fmt.Errorf("named didn't reread its configuration for the new certificate: %w", err)
|
||||
}
|
||||
m.log.Info("named rereads the new certificate", "certificate", c.Name)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// namedUses reports whether named's configuration names files in dir.
|
||||
func (m *Manager) namedUses(ctx context.Context, dir string) bool {
|
||||
st, err := m.store.LoadState(ctx)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
for _, o := range st.Options {
|
||||
if strings.Contains(o.Value, dir+"/") {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (m *Manager) removeFiles(name string) error {
|
||||
if name == "" || strings.ContainsAny(name, "/.") {
|
||||
return nil
|
||||
}
|
||||
dir := m.FilesDir(name)
|
||||
if _, err := os.Stat(dir); err != nil {
|
||||
return nil
|
||||
}
|
||||
return os.RemoveAll(dir)
|
||||
}
|
||||
|
||||
func (m *Manager) dropWeb(name string) {
|
||||
if w := m.web.Load(); w != nil && w.name == name {
|
||||
m.web.CompareAndSwap(w, nil)
|
||||
}
|
||||
}
|
||||
|
||||
// WebCertificate is the certificate for jarld's HTTPS (nil when no
|
||||
// certificate has the web use, or it has none yet).
|
||||
func (m *Manager) WebCertificate() *tls.Certificate {
|
||||
if w := m.web.Load(); w != nil {
|
||||
return w.pair
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// WebName is the name of the certificate jarld serves ("" for none).
|
||||
func (m *Manager) WebName() string {
|
||||
if w := m.web.Load(); w != nil {
|
||||
return w.name
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// HasWeb reports whether a certificate has the web use (whether or not
|
||||
// it has been obtained yet).
|
||||
func (m *Manager) HasWeb(ctx context.Context) bool {
|
||||
all, err := m.store.Certificates(ctx)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return slices.ContainsFunc(all, func(c *model.Certificate) bool { return c.HasUse(model.CertUseWeb) })
|
||||
}
|
||||
|
||||
// Kick makes the loop look at the certificates now.
|
||||
func (m *Manager) Kick() {
|
||||
select {
|
||||
case m.kick <- struct{}{}:
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
// Start loads the certificates and puts them in place (jarld's HTTPS, the
|
||||
// files for named). Call it before serving.
|
||||
func (m *Manager) Start(ctx context.Context) {
|
||||
all, err := m.store.Certificates(ctx)
|
||||
if err != nil {
|
||||
m.log.Error("loading certificates failed", "err", err)
|
||||
return
|
||||
}
|
||||
for _, c := range all {
|
||||
switch {
|
||||
case c.Source == model.CertSourceFile:
|
||||
m.refreshFile(ctx, c, true)
|
||||
case c.CertPEM != "":
|
||||
if err := m.deploy(ctx, c, []byte(c.CertPEM), []byte(c.KeyPEM)); err != nil {
|
||||
m.log.Warn("putting certificate in place failed", "certificate", c.Name, "err", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Run renews certificates when they are due and rereads file-source ones
|
||||
// when their files change, until ctx ends.
|
||||
func (m *Manager) Run(ctx context.Context) {
|
||||
m.base = ctx
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-time.After(20 * time.Second): // let jarld finish starting
|
||||
}
|
||||
for {
|
||||
m.Tick(ctx)
|
||||
t := time.NewTimer(10 * time.Minute)
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
t.Stop()
|
||||
return
|
||||
case <-t.C:
|
||||
case <-m.kick:
|
||||
t.Stop()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Tick does one round of the loop.
|
||||
func (m *Manager) Tick(ctx context.Context) {
|
||||
all, err := m.store.Certificates(ctx)
|
||||
if err != nil {
|
||||
m.log.Error("loading certificates failed", "err", err)
|
||||
return
|
||||
}
|
||||
now := m.now()
|
||||
for _, c := range all {
|
||||
if ctx.Err() != nil {
|
||||
return
|
||||
}
|
||||
if c.Source == model.CertSourceFile {
|
||||
m.refreshFile(ctx, c, false)
|
||||
continue
|
||||
}
|
||||
if at := m.Due(c); !at.After(now) {
|
||||
if _, busy := m.issuing.LoadOrStore(c.Name, now); !busy {
|
||||
m.issue(ctx, c, auth.System)
|
||||
m.issuing.Delete(c.Name)
|
||||
}
|
||||
continue
|
||||
}
|
||||
// Ask the CA about the renewal window twice a day (it moves it
|
||||
// earlier when a certificate must be replaced, e.g. revoked).
|
||||
if c.CertPEM != "" && (c.ARICheckedAt == nil || now.Sub(*c.ARICheckedAt) > 12*time.Hour) {
|
||||
in, _, err := Inspect([]byte(c.CertPEM), []byte(c.KeyPEM))
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
t, err := m.renewalInfo(ctx, c, in.Leaf)
|
||||
checked := now.UTC()
|
||||
c.ARICheckedAt = &checked
|
||||
if err == nil && t != nil && (c.RenewAt == nil || !t.Equal(*c.RenewAt)) {
|
||||
m.log.Info("renewal time from the CA", "certificate", c.Name, "renew_at", t.Format(time.RFC3339))
|
||||
c.RenewAt = t
|
||||
}
|
||||
m.store.UpdateCertificate(ctx, c)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Due is when c should be obtained (again): at once when it has no
|
||||
// certificate yet or its names changed; else at its renewal time. After
|
||||
// failures it waits 1, 2, 4 ... hours (at most a day) between attempts.
|
||||
func (m *Manager) Due(c *model.Certificate) time.Time {
|
||||
var at time.Time
|
||||
switch {
|
||||
case c.CertPEM == "":
|
||||
at = c.CreatedAt
|
||||
case c.RenewAt != nil:
|
||||
at = *c.RenewAt
|
||||
case c.NotBefore != nil && c.NotAfter != nil:
|
||||
at = twoThirds(*c.NotBefore, *c.NotAfter)
|
||||
}
|
||||
if c.Failures > 0 && c.LastAttempt != nil {
|
||||
wait := time.Hour << min(c.Failures-1, 5)
|
||||
at = maxTime(at, c.LastAttempt.Add(min(wait, 24*time.Hour)))
|
||||
}
|
||||
return at
|
||||
}
|
||||
|
||||
func maxTime(a, b time.Time) time.Time {
|
||||
if a.After(b) {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
// Status is a certificate's health.
|
||||
type Status struct {
|
||||
State string `json:"state"` // ok, pending, failing, expiring, expired
|
||||
Message string `json:"message,omitempty"`
|
||||
}
|
||||
|
||||
// StatusOf says how c is doing.
|
||||
func (m *Manager) StatusOf(c *model.Certificate) Status {
|
||||
now := m.now()
|
||||
if c.NotAfter == nil {
|
||||
if c.Failures > 0 {
|
||||
return Status{"failing", "not obtained yet: " + c.LastError}
|
||||
}
|
||||
return Status{"pending", "not obtained yet"}
|
||||
}
|
||||
left := c.NotAfter.Sub(now)
|
||||
life := c.NotAfter.Sub(*c.NotBefore)
|
||||
switch {
|
||||
case left <= 0:
|
||||
return Status{"expired", "expired on " + c.NotAfter.Format(time.DateOnly)}
|
||||
case c.Failures > 0 && c.Source == model.CertSourceACME:
|
||||
return Status{"failing", fmt.Sprintf("renewing failed %d times (next try %s): %s", c.Failures, m.Due(c).Local().Format("2 Jan 15:04"), c.LastError)}
|
||||
case c.Failures > 0:
|
||||
return Status{"failing", c.LastError}
|
||||
case left < life/6 || (c.Source == model.CertSourceFile && left < 14*24*time.Hour):
|
||||
return Status{"expiring", fmt.Sprintf("expires in %s (%s)", days(left), c.NotAfter.Format(time.DateOnly))}
|
||||
}
|
||||
return Status{"ok", ""}
|
||||
}
|
||||
|
||||
func days(d time.Duration) string {
|
||||
n := int(d.Hours() / 24)
|
||||
if n == 1 {
|
||||
return "1 day"
|
||||
}
|
||||
if n < 1 {
|
||||
return fmt.Sprintf("%d hours", int(d.Hours()))
|
||||
}
|
||||
return fmt.Sprintf("%d days", n)
|
||||
}
|
||||
|
||||
// AccountInfo is the ACME account as the API shows it.
|
||||
type AccountInfo struct {
|
||||
Directory string `json:"directory"`
|
||||
Email string `json:"email,omitempty"`
|
||||
AgreeTOS bool `json:"agree_tos"`
|
||||
EAB bool `json:"eab"`
|
||||
Registered bool `json:"registered"`
|
||||
URI string `json:"uri,omitempty"`
|
||||
RegisteredAt *time.Time `json:"registered_at,omitempty"`
|
||||
Porkbun bool `json:"porkbun"`
|
||||
Resolvers []string `json:"resolvers"`
|
||||
Dir string `json:"dir"`
|
||||
ActiveWebCert string `json:"active_web_certificate,omitempty"`
|
||||
}
|
||||
|
||||
// AccountInfo describes the ACME account and settings.
|
||||
func (m *Manager) AccountInfo(ctx context.Context) AccountInfo {
|
||||
ai := AccountInfo{Directory: m.cfg.Directory, Email: m.cfg.Email, AgreeTOS: m.cfg.AgreeTOS, EAB: m.cfg.EABKeyID != "",
|
||||
Porkbun: m.cfg.Porkbun != nil, Resolvers: m.cfg.Resolvers, Dir: m.cfg.Dir, ActiveWebCert: m.WebName()}
|
||||
if a := m.account(ctx); a != nil && a.Directory == m.cfg.Directory && a.URI != "" {
|
||||
ai.Registered, ai.URI = true, a.URI
|
||||
t := a.RegisteredAt
|
||||
ai.RegisteredAt = &t
|
||||
}
|
||||
return ai
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
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
|
||||
}
|
||||
+138
-13
@@ -40,6 +40,17 @@ type Config struct {
|
||||
Porkbun Porkbun `yaml:"porkbun"`
|
||||
Blocking Blocking `yaml:"blocking"`
|
||||
Metrics Metrics `yaml:"metrics"`
|
||||
// Certificates configures TLS certificates Jarl manages
|
||||
// (docs/certificates.md); the ACME account is under acme.
|
||||
Certificates Certificates `yaml:"certificates"`
|
||||
}
|
||||
|
||||
// Certificates configures the certificates Jarl manages.
|
||||
type Certificates struct {
|
||||
// Dir is where certificates are written for named (DNS over TLS and
|
||||
// HTTPS): one directory per certificate, readable by bind.file_group.
|
||||
// "" is <bind.config_dir>/tls.
|
||||
Dir string `yaml:"dir"`
|
||||
}
|
||||
|
||||
// Metrics configures the Prometheus endpoint /metrics (docs/metrics.md).
|
||||
@@ -183,9 +194,85 @@ type Backup struct {
|
||||
WarnAfter Duration `yaml:"warn_after"`
|
||||
}
|
||||
|
||||
// ACME configures the ACME helpers.
|
||||
// ACME configures the ACME helpers and the account Jarl uses to obtain
|
||||
// certificates itself.
|
||||
type ACME struct {
|
||||
HTTPReq HTTPReq `yaml:"httpreq"`
|
||||
HTTPReq HTTPReq `yaml:"httpreq"`
|
||||
Account ACMEAccount `yaml:"account"`
|
||||
// Resolvers are the recursive resolvers used to see a domain as the
|
||||
// CA does: its zone, its public name servers and any CNAME on
|
||||
// _acme-challenge. Public resolvers by default, because this server's
|
||||
// own answers may come from an internal view.
|
||||
Resolvers []string `yaml:"resolvers"`
|
||||
// CABundle is a PEM file of extra CA certificates to trust when
|
||||
// talking to the ACME server (a private CA such as step-ca).
|
||||
CABundle string `yaml:"ca_bundle"`
|
||||
// CheckPropagation waits until every name server of the zone serves
|
||||
// a challenge before telling the CA to look.
|
||||
CheckPropagation bool `yaml:"check_propagation"`
|
||||
}
|
||||
|
||||
// ACMEAccount is the ACME account certificates are obtained with.
|
||||
type ACMEAccount struct {
|
||||
// Directory is letsencrypt, letsencrypt-staging or an ACME directory URL.
|
||||
Directory string `yaml:"directory"`
|
||||
Email string `yaml:"email"`
|
||||
// AgreeTOS says you accept the CA's terms of service. Jarl doesn't
|
||||
// register an account without it.
|
||||
AgreeTOS bool `yaml:"agree_tos"`
|
||||
// EABKeyID and EABHMAC are External Account Binding credentials, for
|
||||
// CAs that need them (ZeroSSL, Google).
|
||||
EABKeyID string `yaml:"eab_kid"`
|
||||
EABHMAC string `yaml:"eab_hmac"`
|
||||
}
|
||||
|
||||
// Directory URLs for the names acme.account.directory accepts.
|
||||
const (
|
||||
LetsEncrypt = "https://acme-v02.api.letsencrypt.org/directory"
|
||||
LetsEncryptStaging = "https://acme-staging-v02.api.letsencrypt.org/directory"
|
||||
)
|
||||
|
||||
// DirectoryURL is the account's ACME directory URL.
|
||||
func (a ACMEAccount) DirectoryURL() string {
|
||||
switch a.Directory {
|
||||
case "", "letsencrypt":
|
||||
return LetsEncrypt
|
||||
case "letsencrypt-staging":
|
||||
return LetsEncryptStaging
|
||||
}
|
||||
return a.Directory
|
||||
}
|
||||
|
||||
func (a *ACME) check() error {
|
||||
if d := a.Account.Directory; d != "" && d != "letsencrypt" && d != "letsencrypt-staging" {
|
||||
u, err := url.Parse(d)
|
||||
if err != nil || u.Scheme != "https" || u.Host == "" {
|
||||
return fmt.Errorf("account.directory must be letsencrypt, letsencrypt-staging or an https:// URL")
|
||||
}
|
||||
}
|
||||
if a.Account.Email != "" && !strings.Contains(a.Account.Email, "@") {
|
||||
return fmt.Errorf("account.email %q isn't an email address", a.Account.Email)
|
||||
}
|
||||
if (a.Account.EABKeyID == "") != (a.Account.EABHMAC == "") {
|
||||
return fmt.Errorf("account: set both eab_kid and eab_hmac, or neither")
|
||||
}
|
||||
for _, r := range a.Resolvers {
|
||||
if _, _, err := net.SplitHostPort(r); err != nil {
|
||||
return fmt.Errorf("resolvers: %q must be address:port, e.g. 1.1.1.1:53 or [2606:4700:4700::1111]:53", r)
|
||||
}
|
||||
}
|
||||
if a.CABundle != "" && !filepath.IsAbs(a.CABundle) {
|
||||
return fmt.Errorf("ca_bundle must be an absolute path")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// CertDir is certificates.dir, or <bind.config_dir>/tls.
|
||||
func (c *Config) CertDir() string {
|
||||
if c.Certificates.Dir != "" {
|
||||
return c.Certificates.Dir
|
||||
}
|
||||
return filepath.Join(c.BIND.ConfigDir, "tls")
|
||||
}
|
||||
|
||||
// HTTPReq configures the endpoint for lego's httpreq DNS provider
|
||||
@@ -372,7 +459,8 @@ func Default() *Config {
|
||||
Retention: StatsRetention{Minute: Duration(7 * 24 * time.Hour), Hour: Duration(90 * 24 * time.Hour), Day: Duration(730 * 24 * time.Hour)},
|
||||
TopN: 100,
|
||||
},
|
||||
ACME: ACME{HTTPReq: HTTPReq{RequireHTTPS: true}},
|
||||
ACME: ACME{HTTPReq: HTTPReq{RequireHTTPS: true}, Account: ACMEAccount{Directory: "letsencrypt"},
|
||||
Resolvers: []string{"1.1.1.1:53", "9.9.9.9:53", "8.8.8.8:53"}, CheckPropagation: true},
|
||||
Blocking: Blocking{Refresh: Duration(24 * time.Hour), MaxDownloadMB: 64, MaxEntries: 1_000_000},
|
||||
Metrics: Metrics{Allow: []string{"127.0.0.1", "::1"}},
|
||||
Backup: Backup{Dir: "/var/backups/jarl", Retention: Duration(30 * 24 * time.Hour), KeepAtLeast: 7, WarnAfter: Duration(2 * 24 * time.Hour)},
|
||||
@@ -449,6 +537,12 @@ func (c *Config) check() error {
|
||||
if err := c.Metrics.check(); err != nil {
|
||||
return fmt.Errorf("metrics: %w", err)
|
||||
}
|
||||
if err := c.ACME.check(); err != nil {
|
||||
return fmt.Errorf("acme: %w", err)
|
||||
}
|
||||
if d := c.Certificates.Dir; d != "" && !filepath.IsAbs(d) {
|
||||
return fmt.Errorf("certificates.dir must be an absolute path")
|
||||
}
|
||||
switch c.Log.Format {
|
||||
case "text", "json":
|
||||
default:
|
||||
@@ -552,8 +646,10 @@ const Example = `# Jarl daemon configuration (/etc/jarl/jarl.yaml).
|
||||
listen:
|
||||
- "127.0.0.1:8053"
|
||||
|
||||
# HTTPS. The certificate is reread when the file changes (or on SIGHUP),
|
||||
# so renewed Let's Encrypt certificates are picked up automatically.
|
||||
# HTTPS from a certificate file you renew yourself. The certificate is
|
||||
# reread when the file changes (or on SIGHUP). Easier: let Jarl obtain one
|
||||
# ('jarl cert create ... --use web', docs/certificates.md), which then
|
||||
# takes over from this.
|
||||
tls:
|
||||
cert_file: ""
|
||||
key_file: ""
|
||||
@@ -667,15 +763,44 @@ acme:
|
||||
# this server. Leave false if a reverse proxy runs on this server but
|
||||
# isn't in trusted_proxies: its requests would look local.
|
||||
allow_loopback_http: false
|
||||
# The account Jarl obtains certificates with (docs/certificates.md).
|
||||
# Nothing is registered until you create a certificate. Read the CA's
|
||||
# terms (https://letsencrypt.org/repository/) and set agree_tos: true.
|
||||
# Try letsencrypt-staging first: its certificates aren't trusted, but
|
||||
# its rate limits are generous.
|
||||
account:
|
||||
directory: letsencrypt # letsencrypt, letsencrypt-staging or a directory URL
|
||||
email: "" # optional
|
||||
agree_tos: false
|
||||
eab_kid: "" # External Account Binding, only for CAs that need it
|
||||
eab_hmac: ""
|
||||
# Resolvers to see your domains as the CA does (their public name
|
||||
# servers, a CNAME on _acme-challenge). Not this server: it may answer
|
||||
# from an internal view.
|
||||
resolvers: ["1.1.1.1:53", "9.9.9.9:53", "8.8.8.8:53"]
|
||||
ca_bundle: "" # extra CA certificates for a private ACME server
|
||||
# Wait until every name server of the zone serves a challenge before
|
||||
# telling the CA to look (they are asked directly, on port 53).
|
||||
check_propagation: true
|
||||
|
||||
# The registrar Porkbun (docs/dnssec.md#porkbun): with an API key, Jarl can
|
||||
# show and set a domain's name servers, glue records and DNSSEC DS records
|
||||
# there, after checking that the change won't break the domain. Off without
|
||||
# a key. Create the key at porkbun.com (Account -> API Access), switch on
|
||||
# "API Access" for the domain, and limit the key to that domain if you can.
|
||||
# The key can move your domain elsewhere: it lives only in this file (keep
|
||||
# it readable by root and jarld only: chown root:jarl, chmod 640), never in
|
||||
# Jarl's database or backups.
|
||||
# Where certificates with the "dns" use are written for named, one
|
||||
# directory per certificate (fullchain.pem, privkey.pem), readable by
|
||||
# bind.file_group. Default: <bind.config_dir>/tls.
|
||||
certificates:
|
||||
dir: ""
|
||||
|
||||
# Porkbun, as registrar and as DNS host. With an API key, Jarl can:
|
||||
# - show and set a domain's name servers, glue records and DNSSEC DS
|
||||
# records there, after checking that the change won't break the domain
|
||||
# (docs/dnssec.md#porkbun);
|
||||
# - publish the DNS-01 challenges of certificates Jarl obtains while
|
||||
# Porkbun still serves the domain's DNS (docs/certificates.md): it adds
|
||||
# and removes TXT records at _acme-challenge names only.
|
||||
# Off without a key. Create the key at porkbun.com (Account -> API Access),
|
||||
# switch on "API Access" for the domain, and limit the key to that domain
|
||||
# if you can. The key can move your domain elsewhere: it lives only in this
|
||||
# file (keep it readable by root and jarld only: chown root:jarl, chmod
|
||||
# 640), never in Jarl's database or backups.
|
||||
porkbun:
|
||||
api_key: "" # pk1_...
|
||||
secret_api_key: "" # sk1_...
|
||||
|
||||
@@ -3,6 +3,7 @@ package config
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
@@ -18,7 +19,7 @@ func TestExampleParses(t *testing.T) {
|
||||
if c.BIND.ZoneDir != d.BIND.ZoneDir || time.Duration(c.Session.IdleTimeout) != 2*time.Hour || c.Listen[0] != "127.0.0.1:8053" {
|
||||
t.Errorf("example differs from defaults: %+v", c)
|
||||
}
|
||||
if c.ACME != d.ACME || c.ACME.HTTPReq.Enabled || !c.ACME.HTTPReq.RequireHTTPS {
|
||||
if !reflect.DeepEqual(c.ACME, d.ACME) || !reflect.DeepEqual(c.Certificates, d.Certificates) || c.ACME.HTTPReq.Enabled || !c.ACME.HTTPReq.RequireHTTPS {
|
||||
t.Errorf("acme = %+v, want the defaults %+v", c.ACME, d.ACME)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
package engine
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io/fs"
|
||||
)
|
||||
|
||||
// WriteFile writes a file outside the rendered configuration (a
|
||||
// certificate for named, docs/certificates.md) the way deployed files are
|
||||
// written: atomically, with bind.file_group, creating its directory.
|
||||
func (e *Engine) WriteFile(path string, content []byte, mode fs.FileMode) error {
|
||||
return e.deploy.writeAtomic(path, content, mode)
|
||||
}
|
||||
|
||||
// Reconfig makes named reread its configuration, and with it the files
|
||||
// its tls blocks name (nothing with bind.no_reload).
|
||||
func (e *Engine) Reconfig(ctx context.Context) error {
|
||||
if e.cfg.NoReload {
|
||||
return nil
|
||||
}
|
||||
return e.tools.Reconfig(ctx)
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"slices"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Certificate sources.
|
||||
const (
|
||||
CertSourceACME = "acme" // Jarl obtains and renews it (docs/certificates.md)
|
||||
CertSourceFile = "file" // you renew it; Jarl reads it from cert_file and key_file
|
||||
)
|
||||
|
||||
// Certificate uses.
|
||||
const (
|
||||
CertUseWeb = "web" // jarld's own HTTPS
|
||||
CertUseDNS = "dns" // written where named can read it, for DNS over TLS/HTTPS
|
||||
)
|
||||
|
||||
// Challenge routes: where Jarl publishes the DNS-01 challenge.
|
||||
const (
|
||||
ChallengeAuto = "auto" // decided at every issue from the domain's public name servers
|
||||
ChallengeJarl = "jarl" // in a zone Jarl serves (following a CNAME on _acme-challenge)
|
||||
ChallengePorkbun = "porkbun" // through Porkbun's DNS API
|
||||
)
|
||||
|
||||
// Certificate is a TLS certificate Jarl manages. Certificates are not
|
||||
// named configuration: they are kept outside changesets, like users.
|
||||
type Certificate struct {
|
||||
ID int64 `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Source string `json:"source"`
|
||||
Domains []string `json:"domains"` // acme: the names to ask for; file: the names in the certificate
|
||||
CertFile string `json:"cert_file,omitempty"` // file source
|
||||
KeyFile string `json:"key_file,omitempty"` // file source
|
||||
Uses []string `json:"uses"`
|
||||
Challenge string `json:"challenge,omitempty"` // acme source
|
||||
KeyType string `json:"key_type,omitempty"` // acme source: ec256, ec384, rsa2048, rsa3072, rsa4096
|
||||
Comment string `json:"comment"`
|
||||
|
||||
// The current certificate: acme source only (a file source's is read
|
||||
// from its files). The key never leaves the server through the API.
|
||||
CertPEM string `json:"-"`
|
||||
KeyPEM string `json:"-"`
|
||||
|
||||
// What the current certificate says (both sources).
|
||||
Names []string `json:"names,omitempty"` // the names in it
|
||||
NotBefore *time.Time `json:"not_before,omitempty"`
|
||||
NotAfter *time.Time `json:"not_after,omitempty"`
|
||||
Issuer string `json:"issuer,omitempty"`
|
||||
Serial string `json:"serial,omitempty"`
|
||||
|
||||
// Renewal (acme source). RenewAt is the CA's suggested time (ARI) or,
|
||||
// without one, two thirds into the certificate's lifetime.
|
||||
RenewAt *time.Time `json:"renew_at,omitempty"`
|
||||
ARICheckedAt *time.Time `json:"-"`
|
||||
LastAttempt *time.Time `json:"last_attempt,omitempty"`
|
||||
LastSuccess *time.Time `json:"last_success,omitempty"`
|
||||
LastError string `json:"last_error,omitempty"`
|
||||
Failures int `json:"failures,omitempty"`
|
||||
// LastRoutes says where the challenges went the last time ("adbraun.lol: porkbun").
|
||||
LastRoutes []string `json:"last_routes,omitempty"`
|
||||
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
CreatedBy string `json:"created_by"`
|
||||
UpdatedBy string `json:"updated_by"`
|
||||
}
|
||||
|
||||
// HasUse reports whether c is used for use.
|
||||
func (c *Certificate) HasUse(use string) bool { return slices.Contains(c.Uses, use) }
|
||||
@@ -2,6 +2,7 @@ package porkbun
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
@@ -15,7 +16,8 @@ type Fake struct {
|
||||
mu sync.Mutex
|
||||
Domains map[string]*FakeDomain
|
||||
// Calls lists the paths called, in order.
|
||||
Calls []string
|
||||
Calls []string
|
||||
nextID int
|
||||
}
|
||||
|
||||
// FakeDomain is a domain in a Fake.
|
||||
@@ -24,6 +26,14 @@ type FakeDomain struct {
|
||||
Glue map[string][]string // host -> addresses
|
||||
DS map[uint16]DS
|
||||
APIAccess bool
|
||||
// Records are the DNS records created through the API, by ID.
|
||||
Records map[string]FakeRecord
|
||||
}
|
||||
|
||||
// FakeRecord is a DNS record in a Fake.
|
||||
type FakeRecord struct {
|
||||
Name, Type, Content string // Name is the full name, without a trailing dot
|
||||
TTL int
|
||||
}
|
||||
|
||||
// NewFake returns a Fake with the given domains (API access on, Porkbun's
|
||||
@@ -31,7 +41,7 @@ type FakeDomain struct {
|
||||
func NewFake(key, secret string, domains ...string) *Fake {
|
||||
f := &Fake{APIKey: key, Secret: secret, Domains: map[string]*FakeDomain{}}
|
||||
for _, d := range domains {
|
||||
f.Domains[d] = &FakeDomain{NS: []string{"curitiba.ns.porkbun.com", "fortaleza.ns.porkbun.com"}, Glue: map[string][]string{}, DS: map[uint16]DS{}, APIAccess: true}
|
||||
f.Domains[d] = &FakeDomain{NS: []string{"curitiba.ns.porkbun.com", "fortaleza.ns.porkbun.com"}, Glue: map[string][]string{}, DS: map[uint16]DS{}, APIAccess: true, Records: map[string]FakeRecord{}}
|
||||
}
|
||||
return f
|
||||
}
|
||||
@@ -158,7 +168,48 @@ func (f *Fake) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
tag, _ := strconv.Atoi(parts[3])
|
||||
delete(d.DS, uint16(tag))
|
||||
reply(200, map[string]any{"status": "SUCCESS", "message": "Deleted."})
|
||||
case "dns/create":
|
||||
name, _ := body["name"].(string)
|
||||
full := domain
|
||||
if name != "" {
|
||||
full = name + "." + domain
|
||||
}
|
||||
ttl, _ := strconv.Atoi(fmt.Sprint(body["ttl"]))
|
||||
if ttl < MinTTL {
|
||||
ttl = MinTTL
|
||||
}
|
||||
f.nextID++
|
||||
id := strconv.Itoa(f.nextID)
|
||||
d.Records[id] = FakeRecord{Name: full, Type: fmt.Sprint(body["type"]), Content: fmt.Sprint(body["content"]), TTL: ttl}
|
||||
reply(200, map[string]any{"status": "SUCCESS", "id": f.nextID})
|
||||
case "dns/delete":
|
||||
if len(parts) < 4 || d.Records == nil {
|
||||
fail(400, "INVALID_RECORD_ID", "Invalid record ID.")
|
||||
return
|
||||
}
|
||||
if _, ok := d.Records[parts[3]]; !ok {
|
||||
fail(400, "INVALID_RECORD_ID", "Invalid record ID.")
|
||||
return
|
||||
}
|
||||
delete(d.Records, parts[3])
|
||||
reply(200, map[string]any{"status": "SUCCESS"})
|
||||
default:
|
||||
fail(404, "NOT_FOUND", "Unknown endpoint.")
|
||||
}
|
||||
}
|
||||
|
||||
// TXT returns the values of the TXT records at name (without a trailing
|
||||
// dot) in the fake's domains.
|
||||
func (f *Fake) TXT(name string) []string {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
var out []string
|
||||
for _, d := range f.Domains {
|
||||
for _, r := range d.Records {
|
||||
if r.Type == "TXT" && strings.EqualFold(r.Name, strings.TrimSuffix(name, ".")) {
|
||||
out = append(out, r.Content)
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -1,7 +1,8 @@
|
||||
// Package porkbun is a small client for the parts of Porkbun's API v3
|
||||
// (https://porkbun.com/api/json/v3/documentation) that Jarl uses to manage
|
||||
// a domain's delegation: its name servers, glue records (host objects at
|
||||
// the registry) and DNSSEC DS records.
|
||||
// the registry) and DNSSEC DS records, and the TXT records of DNS-01
|
||||
// challenges while Porkbun still serves a domain (docs/certificates.md).
|
||||
//
|
||||
// Every call is a POST with the API key and secret in the JSON body. API
|
||||
// access must be switched on for each domain in Porkbun's web interface,
|
||||
@@ -79,6 +80,7 @@ type response struct {
|
||||
Valid bool `json:"credentialsValid"`
|
||||
Account string `json:"account"`
|
||||
Scopes []string `json:"scopes"`
|
||||
ID json.Number `json:"id"`
|
||||
}
|
||||
|
||||
func (c *Client) call(ctx context.Context, path string, body map[string]any) (*response, error) {
|
||||
@@ -282,6 +284,37 @@ func (c *Client) DeleteDS(ctx context.Context, domain string, keyTag uint16) err
|
||||
return err
|
||||
}
|
||||
|
||||
// MinTTL is the lowest TTL Porkbun accepts for a DNS record.
|
||||
const MinTTL = 600
|
||||
|
||||
// CreateTXT adds a TXT record at name (a name inside domain, or the
|
||||
// domain itself) in Porkbun's DNS and returns its ID.
|
||||
func (c *Client) CreateTXT(ctx context.Context, domain, name, value string) (string, error) {
|
||||
sub := ""
|
||||
if normName(name) != normName(domain) {
|
||||
var err error
|
||||
if sub, err = subdomain(domain, name); err != nil {
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
r, err := c.call(ctx, domainPath("/dns/create", domain), map[string]any{
|
||||
"name": sub, "type": "TXT", "content": value, "ttl": strconv.Itoa(MinTTL),
|
||||
})
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if r.ID == "" {
|
||||
return "", &Error{Message: "the record was created but Porkbun didn't say its ID"}
|
||||
}
|
||||
return r.ID.String(), nil
|
||||
}
|
||||
|
||||
// DeleteRecord deletes the DNS record with id.
|
||||
func (c *Client) DeleteRecord(ctx context.Context, domain, id string) error {
|
||||
_, err := c.call(ctx, domainPath("/dns/delete", domain, id), nil)
|
||||
return err
|
||||
}
|
||||
|
||||
// subdomain returns host's label(s) below domain ("ns1" for
|
||||
// ns1.example.com in example.com).
|
||||
func subdomain(domain, host string) (string, error) {
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/ecdsa"
|
||||
"crypto/elliptic"
|
||||
"crypto/rand"
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"crypto/x509/pkix"
|
||||
"encoding/pem"
|
||||
"io"
|
||||
"log/slog"
|
||||
"math/big"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"jarl/internal/auth"
|
||||
"jarl/internal/certs"
|
||||
"jarl/internal/model"
|
||||
)
|
||||
|
||||
// selfSigned writes a self-signed certificate for names into dir.
|
||||
func selfSigned(t *testing.T, dir string, names ...string) (certFile, keyFile string) {
|
||||
t.Helper()
|
||||
key, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||
tmpl := &x509.Certificate{SerialNumber: big.NewInt(time.Now().UnixNano()), Subject: pkix.Name{CommonName: names[0]}, DNSNames: names,
|
||||
NotBefore: time.Now().Add(-time.Hour), NotAfter: time.Now().Add(30 * 24 * time.Hour), ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}}
|
||||
der, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &key.PublicKey, key)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
kd, _ := x509.MarshalPKCS8PrivateKey(key)
|
||||
certFile, keyFile = filepath.Join(dir, "cert.pem"), filepath.Join(dir, "key.pem")
|
||||
os.WriteFile(certFile, pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der}), 0o600)
|
||||
os.WriteFile(keyFile, pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: kd}), 0o600)
|
||||
return
|
||||
}
|
||||
|
||||
func withCerts(env *apiEnv) *certs.Manager {
|
||||
quiet := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
m := certs.New(certs.Config{Dir: filepath.Join(env.t.TempDir(), "tls"), Directory: "https://acme.invalid/dir"}, env.st, env.srv.eng, quiet)
|
||||
env.srv.cfg.Certs = m
|
||||
return m
|
||||
}
|
||||
|
||||
func TestCertificatesAPI(t *testing.T) {
|
||||
env := newAPIEnv(t)
|
||||
m := withCerts(env)
|
||||
certFile, keyFile := selfSigned(t, t.TempDir(), "jarl.example.com")
|
||||
|
||||
// A user who isn't an administrator sees nothing.
|
||||
hash, _ := auth.HashPassword("pw-pw-pw-pw")
|
||||
u := &model.User{Username: "ops", PasswordHash: hash, Grants: []model.Grant{{Resource: "*", Access: "write"}}}
|
||||
env.st.CreateUser(context.Background(), u)
|
||||
tok, pid, sh := auth.NewToken()
|
||||
env.st.CreateToken(context.Background(), &model.Token{UserID: u.ID, Name: "t", PublicID: pid, SecretHash: sh})
|
||||
env.do("GET", "/certificates", tok, nil, 403)
|
||||
|
||||
out := env.do("POST", "/certificates", env.token, map[string]any{"name": "own", "source": "file", "cert_file": certFile, "key_file": keyFile, "uses": []string{"web"}}, 201)
|
||||
c := out["certificate"].(map[string]any)
|
||||
if c["serving"] != true || c["status"].(map[string]any)["state"] != "ok" || c["domains"].([]any)[0] != "jarl.example.com" {
|
||||
t.Errorf("created: %v", c)
|
||||
}
|
||||
if _, has := c["key_pem"]; has {
|
||||
t.Error("the key left the server")
|
||||
}
|
||||
if m.WebCertificate() == nil {
|
||||
t.Error("not serving")
|
||||
}
|
||||
env.do("POST", "/certificates", env.token, map[string]any{"name": "own", "domains": []string{"a.example.com"}}, 409)
|
||||
env.do("POST", "/certificates", env.token, map[string]any{"name": "x", "domains": []string{"a.example.com"}, "uses": []string{"web"}}, 400)
|
||||
env.do("POST", "/certificates", env.token, map[string]any{"name": "x", "source": "file", "cert_file": "/nonexistent.pem", "key_file": keyFile}, 400)
|
||||
list := env.do("GET", "/certificates", env.token, nil, 200)
|
||||
if n := len(list["certificates"].([]any)); n != 1 || list["account"].(map[string]any)["directory"] != "https://acme.invalid/dir" {
|
||||
t.Errorf("list: %v", list)
|
||||
}
|
||||
env.do("PATCH", "/certificates/own", env.token, map[string]any{"uses": []string{"dns"}}, 200)
|
||||
if m.WebCertificate() != nil {
|
||||
t.Error("still serving after the web use was taken off")
|
||||
}
|
||||
got := env.do("GET", "/certificates/own", env.token, nil, 200)
|
||||
if files, ok := got["files"].(map[string]any); !ok || !strings.HasSuffix(files["key"].(string), "/own/privkey.pem") {
|
||||
t.Errorf("files: %v", got["files"])
|
||||
}
|
||||
env.do("POST", "/certificates/own/issue", env.token, map[string]any{}, 200)
|
||||
env.do("DELETE", "/certificates/own", env.token, nil, 200)
|
||||
env.do("GET", "/certificates/own", env.token, nil, 404)
|
||||
}
|
||||
|
||||
func TestHTTPSAndPlainOnOnePort(t *testing.T) {
|
||||
env := newAPIEnv(t)
|
||||
certFile, keyFile := selfSigned(t, t.TempDir(), "jarl.example.com")
|
||||
s := env.srv
|
||||
cr, err := newCertReloader(certFile, keyFile, s.log)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s.certs = cr
|
||||
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cfg := &tls.Config{GetCertificate: s.webCertificate, NextProtos: []string{"h2", "http/1.1"}}
|
||||
hs := &http.Server{Handler: s.requireHTTPS(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.TLS != nil {
|
||||
io.WriteString(w, "tls "+r.Proto)
|
||||
} else {
|
||||
io.WriteString(w, "plain")
|
||||
}
|
||||
}))}
|
||||
go hs.Serve(newSniffListener(ln, cfg))
|
||||
t.Cleanup(func() { hs.Close() })
|
||||
addr := ln.Addr().String()
|
||||
|
||||
get := func(c *http.Client, url string) string {
|
||||
t.Helper()
|
||||
resp, err := c.Get(url)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
b, _ := io.ReadAll(resp.Body)
|
||||
return string(b)
|
||||
}
|
||||
tlsClient := &http.Client{Transport: &http.Transport{TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, ForceAttemptHTTP2: true}}
|
||||
if got := get(tlsClient, "https://"+addr+"/"); got != "tls HTTP/2.0" {
|
||||
t.Errorf("HTTPS: %q", got)
|
||||
}
|
||||
// Loopback may still use plain HTTP (tools on the server itself).
|
||||
if got := get(http.DefaultClient, "http://"+addr+"/"); got != "plain" {
|
||||
t.Errorf("plain from loopback: %q", got)
|
||||
}
|
||||
|
||||
// From elsewhere: browsers are redirected, API calls refused.
|
||||
h := s.requireHTTPS(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { io.WriteString(w, "served") }))
|
||||
req := httptest.NewRequest("GET", "http://dns.example.com:8053/zones?x=1", nil)
|
||||
req.RemoteAddr = "192.0.2.10:5555"
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusPermanentRedirect || rec.Header().Get("Location") != "https://dns.example.com:8053/zones?x=1" {
|
||||
t.Errorf("GET from elsewhere: %d %s", rec.Code, rec.Header().Get("Location"))
|
||||
}
|
||||
req = httptest.NewRequest("POST", "http://dns.example.com:8053/api/v1/records", strings.NewReader("{}"))
|
||||
req.RemoteAddr = "192.0.2.10:5555"
|
||||
req.Header.Set("Authorization", "Bearer x")
|
||||
rec = httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusBadRequest || !strings.Contains(rec.Body.String(), "https_required") || !strings.Contains(rec.Body.String(), "rotate the token") {
|
||||
t.Errorf("POST from elsewhere: %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
// Without any certificate, plain HTTP is served as before.
|
||||
s.certs = nil
|
||||
rec = httptest.NewRecorder()
|
||||
req = httptest.NewRequest("GET", "http://dns.example.com:8053/", nil)
|
||||
req.RemoteAddr = "192.0.2.10:5555"
|
||||
h.ServeHTTP(rec, req)
|
||||
if rec.Body.String() != "served" {
|
||||
t.Errorf("no certificate: %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,243 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"jarl/internal/api"
|
||||
"jarl/internal/auth"
|
||||
"jarl/internal/certs"
|
||||
"jarl/internal/model"
|
||||
)
|
||||
|
||||
// TLS certificates (docs/certificates.md). Administrators only: a
|
||||
// certificate's key can impersonate the server.
|
||||
|
||||
type certView struct {
|
||||
*model.Certificate
|
||||
Status certs.Status `json:"status"`
|
||||
NextTry *time.Time `json:"next_attempt,omitempty"` // acme: when the loop obtains it next
|
||||
Files *certFiles `json:"files,omitempty"` // dns use: where named reads it
|
||||
Serving bool `json:"serving,omitempty"` // jarld serves it now (web use)
|
||||
// IssuingSince is set while Jarl is obtaining it.
|
||||
IssuingSince *time.Time `json:"issuing_since,omitempty"`
|
||||
}
|
||||
|
||||
type certFiles struct {
|
||||
Chain string `json:"chain"`
|
||||
Key string `json:"key"`
|
||||
}
|
||||
|
||||
func (s *Server) presentCert(c *model.Certificate) certView {
|
||||
m := s.cfg.Certs
|
||||
v := certView{Certificate: c, Status: m.StatusOf(c), Serving: m.WebName() == c.Name && c.HasUse(model.CertUseWeb)}
|
||||
if c.Source == model.CertSourceACME {
|
||||
t := m.Due(c)
|
||||
v.NextTry = &t
|
||||
}
|
||||
if t := m.Issuing(c.Name); !t.IsZero() {
|
||||
v.IssuingSince = &t
|
||||
}
|
||||
if c.HasUse(model.CertUseDNS) && c.NotAfter != nil {
|
||||
dir := m.FilesDir(c.Name)
|
||||
v.Files = &certFiles{Chain: dir + "/" + certs.ChainFile, Key: dir + "/" + certs.KeyFile}
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
func (s *Server) certsReady(p *auth.Principal) error {
|
||||
if err := requireAdmin(p); err != nil {
|
||||
return err
|
||||
}
|
||||
if s.cfg.Certs == nil {
|
||||
return &httpError{http.StatusNotFound, "disabled", "certificates aren't available in this jarld"}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func certErr(err error) error {
|
||||
var ie *certs.InputError
|
||||
switch {
|
||||
case errors.As(err, &ie):
|
||||
return badRequest("%s", ie.Msg)
|
||||
case errors.Is(err, certs.ErrNotFound):
|
||||
return notFound("no such certificate")
|
||||
case errors.Is(err, certs.ErrConflict):
|
||||
return conflict("a certificate with that name exists")
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// certList: GET /certificates.
|
||||
func (s *Server) certList(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
||||
if err := s.certsReady(p); err != nil {
|
||||
return err
|
||||
}
|
||||
all, err := s.cfg.Certs.List(r.Context())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
out := []certView{}
|
||||
for _, c := range all {
|
||||
out = append(out, s.presentCert(c))
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"certificates": out, "account": s.cfg.Certs.AccountInfo(r.Context())})
|
||||
return nil
|
||||
}
|
||||
|
||||
// certGet: GET /certificates/{name}.
|
||||
func (s *Server) certGet(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
||||
if err := s.certsReady(p); err != nil {
|
||||
return err
|
||||
}
|
||||
c, err := s.cfg.Certs.Get(r.Context(), r.PathValue("name"))
|
||||
if err != nil {
|
||||
return certErr(err)
|
||||
}
|
||||
writeJSON(w, http.StatusOK, s.presentCert(c))
|
||||
return nil
|
||||
}
|
||||
|
||||
// certCreate: POST /certificates. With "issue": true (the default for
|
||||
// certificates Jarl obtains) it is obtained before the answer.
|
||||
func (s *Server) certCreate(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
||||
if err := s.certsReady(p); err != nil {
|
||||
return err
|
||||
}
|
||||
var in struct {
|
||||
certs.Input
|
||||
Issue *bool `json:"issue"`
|
||||
}
|
||||
if err := decodeJSON(r, &in); err != nil {
|
||||
return err
|
||||
}
|
||||
c, err := s.cfg.Certs.Create(r.Context(), in.Input, p)
|
||||
if err != nil {
|
||||
return certErr(err)
|
||||
}
|
||||
if c.Source == model.CertSourceACME && (in.Issue == nil || *in.Issue) {
|
||||
return s.writeIssue(w, r, c.Name, p, http.StatusCreated)
|
||||
}
|
||||
if c.Source == model.CertSourceACME {
|
||||
s.cfg.Certs.Kick()
|
||||
}
|
||||
writeJSON(w, http.StatusCreated, map[string]any{"certificate": s.presentCert(c)})
|
||||
return nil
|
||||
}
|
||||
|
||||
// certUpdate: PATCH /certificates/{name}.
|
||||
func (s *Server) certUpdate(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
||||
if err := s.certsReady(p); err != nil {
|
||||
return err
|
||||
}
|
||||
var in certs.Input
|
||||
if err := decodeJSON(r, &in); err != nil {
|
||||
return err
|
||||
}
|
||||
c, err := s.cfg.Certs.Update(r.Context(), r.PathValue("name"), in, p)
|
||||
if err != nil {
|
||||
return certErr(err)
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"certificate": s.presentCert(c)})
|
||||
return nil
|
||||
}
|
||||
|
||||
// certDelete: DELETE /certificates/{name}.
|
||||
func (s *Server) certDelete(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
||||
if err := s.certsReady(p); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.cfg.Certs.Delete(r.Context(), r.PathValue("name"), p); err != nil {
|
||||
return certErr(err)
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
|
||||
return nil
|
||||
}
|
||||
|
||||
// certIssue: POST /certificates/{name}/issue starts obtaining it now
|
||||
// (202), or rereads the files of a file-source certificate (200).
|
||||
func (s *Server) certIssue(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
||||
if err := s.certsReady(p); err != nil {
|
||||
return err
|
||||
}
|
||||
return s.writeIssue(w, r, r.PathValue("name"), p, http.StatusOK)
|
||||
}
|
||||
|
||||
// writeIssue starts obtaining the certificate (it takes a while: poll
|
||||
// GET /certificates/{name} until issuing_since is gone).
|
||||
func (s *Server) writeIssue(w http.ResponseWriter, r *http.Request, name string, p *auth.Principal, status int) error {
|
||||
c, err := s.cfg.Certs.IssueAsync(r.Context(), name, p)
|
||||
if c == nil {
|
||||
return certErr(err)
|
||||
}
|
||||
if c.Source == model.CertSourceACME && status == http.StatusOK {
|
||||
status = http.StatusAccepted
|
||||
}
|
||||
out := map[string]any{"certificate": s.presentCert(c)}
|
||||
if err != nil {
|
||||
out["error"] = err.Error()
|
||||
}
|
||||
writeJSON(w, status, out)
|
||||
return nil
|
||||
}
|
||||
|
||||
// certCheck: POST /certificates/{name}/check (or POST /certificates/check
|
||||
// with a certificate's fields) says where each challenge would go, without
|
||||
// asking the CA for anything.
|
||||
func (s *Server) certCheck(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
||||
if err := s.certsReady(p); err != nil {
|
||||
return err
|
||||
}
|
||||
var c *model.Certificate
|
||||
if name := r.PathValue("name"); name != "" {
|
||||
var err error
|
||||
if c, err = s.cfg.Certs.Get(r.Context(), name); err != nil {
|
||||
return certErr(err)
|
||||
}
|
||||
} else {
|
||||
var in certs.Input
|
||||
if err := decodeJSON(r, &in); err != nil {
|
||||
return err
|
||||
}
|
||||
if in.Name == nil {
|
||||
in.Name = model.Ptr("check")
|
||||
}
|
||||
var err error
|
||||
if c, err = s.cfg.Certs.Preview(in); err != nil {
|
||||
return certErr(err)
|
||||
}
|
||||
}
|
||||
routes, err := s.cfg.Certs.Check(r.Context(), c)
|
||||
if err != nil {
|
||||
return certErr(err)
|
||||
}
|
||||
ok := true
|
||||
for _, rt := range routes {
|
||||
ok = ok && rt.Error == ""
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"ok": ok, "routes": routes, "account": s.cfg.Certs.AccountInfo(r.Context())})
|
||||
return nil
|
||||
}
|
||||
|
||||
// certSummary sums up the certificates for the dashboard (nil without any).
|
||||
func (s *Server) certSummary(ctx context.Context) *api.CertSummary {
|
||||
if s.cfg.Certs == nil {
|
||||
return nil
|
||||
}
|
||||
all, err := s.cfg.Certs.List(ctx)
|
||||
if err != nil || len(all) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := &api.CertSummary{Total: len(all), Problems: []api.CertProblem{}, Serving: s.cfg.Certs.WebName()}
|
||||
for _, c := range all {
|
||||
if st := s.cfg.Certs.StatusOf(c); st.State != "ok" && st.State != "pending" {
|
||||
out.Problems = append(out.Problems, api.CertProblem{Name: c.Name, State: st.State, Message: st.Message})
|
||||
}
|
||||
if c.NotAfter != nil && (out.NextAfter == nil || c.NotAfter.Before(*out.NextAfter)) {
|
||||
out.Next, out.NextAfter = c.Name, c.NotAfter
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -283,6 +283,7 @@ func (s *Server) status(w http.ResponseWriter, r *http.Request, p *auth.Principa
|
||||
if p.IsAdmin() {
|
||||
resp.Backup = s.backupStatus(r.Context(), now)
|
||||
resp.TOTP = s.totpOverview(r.Context())
|
||||
resp.Certificates = s.certSummary(r.Context())
|
||||
}
|
||||
if p.Can(auth.Action{Resource: auth.ResBlocking}) {
|
||||
resp.BlocklistErrors = s.blocklistErrors(r.Context())
|
||||
|
||||
@@ -274,6 +274,21 @@ func (s *Server) collectState(ctx context.Context, pw *promWriter, now time.Time
|
||||
"zone", engine.Display(z.Name), "view", state.ViewName(z), "phase", ph.Code)
|
||||
}
|
||||
|
||||
if s.cfg.Certs != nil {
|
||||
if list, err := s.cfg.Certs.List(ctx); err == nil {
|
||||
for _, c := range list {
|
||||
if c.NotAfter != nil {
|
||||
pw.gauge("jarl_certificate_expiry_timestamp_seconds", "When each certificate Jarl manages expires (Unix time; docs/certificates.md).", unix(*c.NotAfter), "certificate", c.Name, "source", c.Source)
|
||||
}
|
||||
st := s.cfg.Certs.StatusOf(c)
|
||||
pw.gauge("jarl_certificate_failing", "1 when obtaining or reading a certificate failed the last time.", b2f(st.State == "failing" || st.State == "expired"), "certificate", c.Name, "source", c.Source)
|
||||
if c.Source == model.CertSourceACME {
|
||||
pw.gauge("jarl_certificate_renewal_failures", "Failed attempts to obtain the certificate since the last success.", float64(c.Failures), "certificate", c.Name)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if tokens, err := s.store.ListTokens(ctx, 0); err == nil {
|
||||
for _, t := range tokens {
|
||||
if t.ExpiresAt != nil && t.RevokedAt == nil {
|
||||
|
||||
@@ -162,6 +162,14 @@ func (s *Server) routes() {
|
||||
m.HandleFunc("POST "+p+"/acme/httpreq/view/{view}/present", s.httpreq(true))
|
||||
m.HandleFunc("POST "+p+"/acme/httpreq/view/{view}/cleanup", s.httpreq(false))
|
||||
// Query statistics
|
||||
m.HandleFunc("GET "+p+"/certificates", s.authed(s.certList))
|
||||
m.HandleFunc("POST "+p+"/certificates", s.authed(s.certCreate))
|
||||
m.HandleFunc("POST "+p+"/certificates/check", s.authed(s.certCheck))
|
||||
m.HandleFunc("GET "+p+"/certificates/{name}", s.authed(s.certGet))
|
||||
m.HandleFunc("PATCH "+p+"/certificates/{name}", s.authed(s.certUpdate))
|
||||
m.HandleFunc("DELETE "+p+"/certificates/{name}", s.authed(s.certDelete))
|
||||
m.HandleFunc("POST "+p+"/certificates/{name}/issue", s.authed(s.certIssue))
|
||||
m.HandleFunc("POST "+p+"/certificates/{name}/check", s.authed(s.certCheck))
|
||||
m.HandleFunc("GET "+p+"/exposure", s.authed(s.exposure))
|
||||
m.HandleFunc("POST "+p+"/exposure/fix", s.authed(s.exposureFix))
|
||||
m.HandleFunc("POST "+p+"/exposure/ack", s.authed(s.exposureAck(true)))
|
||||
|
||||
+23
-13
@@ -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()
|
||||
}
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"crypto/tls"
|
||||
"errors"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/netip"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// sniffListener serves TLS and plain HTTP on the same port: it looks at a
|
||||
// connection's first byte and hands the server a *tls.Conn when it is a
|
||||
// TLS handshake (0x16), the plain connection otherwise. That way jarld
|
||||
// can start serving HTTPS when a certificate turns up (one Jarl obtains,
|
||||
// docs/certificates.md) without a restart, and a plain-HTTP bookmark gets
|
||||
// a redirect instead of an error.
|
||||
type sniffListener struct {
|
||||
net.Listener
|
||||
cfg *tls.Config
|
||||
ch chan net.Conn
|
||||
errc chan error
|
||||
done chan struct{}
|
||||
once sync.Once
|
||||
}
|
||||
|
||||
func newSniffListener(ln net.Listener, cfg *tls.Config) *sniffListener {
|
||||
l := &sniffListener{Listener: ln, cfg: cfg, ch: make(chan net.Conn), errc: make(chan error, 1), done: make(chan struct{})}
|
||||
go l.loop()
|
||||
return l
|
||||
}
|
||||
|
||||
func (l *sniffListener) loop() {
|
||||
for {
|
||||
c, err := l.Listener.Accept()
|
||||
if err != nil {
|
||||
var ne net.Error
|
||||
if errors.As(err, &ne) && ne.Timeout() {
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
continue
|
||||
}
|
||||
select {
|
||||
case l.errc <- err:
|
||||
default:
|
||||
}
|
||||
return
|
||||
}
|
||||
go l.classify(c)
|
||||
}
|
||||
}
|
||||
|
||||
func (l *sniffListener) classify(c net.Conn) {
|
||||
c.SetReadDeadline(time.Now().Add(10 * time.Second))
|
||||
br := bufio.NewReader(c)
|
||||
b, err := br.Peek(1)
|
||||
c.SetReadDeadline(time.Time{})
|
||||
if err != nil {
|
||||
c.Close()
|
||||
return
|
||||
}
|
||||
var out net.Conn = &peekedConn{Conn: c, r: br}
|
||||
if b[0] == 0x16 {
|
||||
out = tls.Server(out, l.cfg)
|
||||
}
|
||||
select {
|
||||
case l.ch <- out:
|
||||
case <-l.done:
|
||||
c.Close()
|
||||
}
|
||||
}
|
||||
|
||||
func (l *sniffListener) Accept() (net.Conn, error) {
|
||||
select {
|
||||
case c := <-l.ch:
|
||||
return c, nil
|
||||
case err := <-l.errc:
|
||||
return nil, err
|
||||
case <-l.done:
|
||||
return nil, net.ErrClosed
|
||||
}
|
||||
}
|
||||
|
||||
func (l *sniffListener) Close() error {
|
||||
l.once.Do(func() { close(l.done) })
|
||||
return l.Listener.Close()
|
||||
}
|
||||
|
||||
// peekedConn reads through the buffer that holds the peeked byte.
|
||||
type peekedConn struct {
|
||||
net.Conn
|
||||
r *bufio.Reader
|
||||
}
|
||||
|
||||
func (c *peekedConn) Read(p []byte) (int, error) { return c.r.Read(p) }
|
||||
|
||||
var errNoCertificate = errors.New("no certificate for HTTPS yet")
|
||||
|
||||
// webCertificate is the certificate jarld serves: one Jarl manages with
|
||||
// the web use, else tls.cert_file.
|
||||
func (s *Server) webCertificate(hello *tls.ClientHelloInfo) (*tls.Certificate, error) {
|
||||
if s.cfg.Certs != nil {
|
||||
if c := s.cfg.Certs.WebCertificate(); c != nil {
|
||||
return c, nil
|
||||
}
|
||||
}
|
||||
if s.certs != nil {
|
||||
return s.certs.get(hello)
|
||||
}
|
||||
return nil, errNoCertificate
|
||||
}
|
||||
|
||||
// httpsAvailable reports whether jarld has a certificate to serve.
|
||||
func (s *Server) httpsAvailable() bool {
|
||||
return s.certs != nil || s.cfg.Certs != nil && s.cfg.Certs.WebCertificate() != nil
|
||||
}
|
||||
|
||||
// requireHTTPS sends plain-HTTP requests to HTTPS once jarld has a
|
||||
// certificate: browsers (GET and HEAD) get a redirect, anything else an
|
||||
// error, so tokens and passwords aren't sent in the clear again. Requests
|
||||
// from loopback (tools on this server) and from trusted proxies (which
|
||||
// terminate TLS themselves) are served as they are.
|
||||
func (s *Server) requireHTTPS(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.TLS != nil || !s.httpsAvailable() {
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
host, _, _ := net.SplitHostPort(r.RemoteAddr)
|
||||
if a, err := netip.ParseAddr(host); err == nil && (a.Unmap().IsLoopback() || s.trusted(a.Unmap())) {
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
target := "https://" + r.Host + r.URL.RequestURI()
|
||||
if r.Method == http.MethodGet || r.Method == http.MethodHead {
|
||||
http.Redirect(w, r, target, http.StatusPermanentRedirect)
|
||||
return
|
||||
}
|
||||
msg := "jarld serves HTTPS on this port; use " + strings.TrimSuffix(target, r.URL.RequestURI())
|
||||
if r.Header.Get("Authorization") != "" {
|
||||
msg += ". This request's token crossed the network unencrypted; if that network isn't trusted, rotate the token"
|
||||
}
|
||||
writeError(w, http.StatusBadRequest, "https_required", msg)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"jarl/internal/model"
|
||||
)
|
||||
|
||||
const certCols = `id, name, source, domains, cert_file, key_file, uses, challenge, key_type, comment, cert_pem, key_pem, names,
|
||||
not_before, not_after, issuer, serial, renew_at, ari_checked_at, last_attempt, last_success, last_error, failures, last_routes,
|
||||
created_at, updated_at, created_by, updated_by`
|
||||
|
||||
func scanCert(sc interface{ Scan(...any) error }) (*model.Certificate, error) {
|
||||
c := &model.Certificate{}
|
||||
var domains, uses, names, routes, ca, ua string
|
||||
var nb, na, ra, ac, la, ls sql.NullString
|
||||
if err := sc.Scan(&c.ID, &c.Name, &c.Source, &domains, &c.CertFile, &c.KeyFile, &uses, &c.Challenge, &c.KeyType, &c.Comment,
|
||||
&c.CertPEM, &c.KeyPEM, &names, &nb, &na, &c.Issuer, &c.Serial, &ra, &ac, &la, &ls, &c.LastError, &c.Failures, &routes,
|
||||
&ca, &ua, &c.CreatedBy, &c.UpdatedBy); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, f := range []struct {
|
||||
raw string
|
||||
dst *[]string
|
||||
}{{domains, &c.Domains}, {uses, &c.Uses}, {names, &c.Names}, {routes, &c.LastRoutes}} {
|
||||
if err := json.Unmarshal([]byte(f.raw), f.dst); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if *f.dst == nil {
|
||||
*f.dst = []string{}
|
||||
}
|
||||
}
|
||||
c.NotBefore, c.NotAfter, c.RenewAt, c.ARICheckedAt = parseTSPtr(nb), parseTSPtr(na), parseTSPtr(ra), parseTSPtr(ac)
|
||||
c.LastAttempt, c.LastSuccess = parseTSPtr(la), parseTSPtr(ls)
|
||||
c.CreatedAt, c.UpdatedAt = parseTS(ca), parseTS(ua)
|
||||
return c, nil
|
||||
}
|
||||
|
||||
// Certificates returns every certificate, by name.
|
||||
func (s *Store) Certificates(ctx context.Context) ([]*model.Certificate, error) {
|
||||
rows, err := s.db.QueryContext(ctx, `SELECT `+certCols+` FROM certificates ORDER BY name`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := []*model.Certificate{}
|
||||
for rows.Next() {
|
||||
c, err := scanCert(rows)
|
||||
if err != nil {
|
||||
rows.Close()
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, c)
|
||||
}
|
||||
return out, closeRows(rows)
|
||||
}
|
||||
|
||||
// CertificateByName returns the certificate called name.
|
||||
func (s *Store) CertificateByName(ctx context.Context, name string) (*model.Certificate, error) {
|
||||
c, err := scanCert(s.db.QueryRowContext(ctx, `SELECT `+certCols+` FROM certificates WHERE name = ?`, name))
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
return c, err
|
||||
}
|
||||
|
||||
// CreateCertificate adds a certificate (ErrConflict when the name is taken).
|
||||
func (s *Store) CreateCertificate(ctx context.Context, c *model.Certificate) error {
|
||||
now := time.Now().UTC()
|
||||
c.CreatedAt, c.UpdatedAt = now, now
|
||||
res, err := s.db.ExecContext(ctx, `INSERT INTO certificates (`+certCols[len("id, "):]+`) VALUES (`+placeholders(27)+`)`, certArgs(c)...)
|
||||
if isUnique(err) {
|
||||
return ErrConflict
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
c.ID, err = res.LastInsertId()
|
||||
return err
|
||||
}
|
||||
|
||||
// UpdateCertificate saves every field of c.
|
||||
func (s *Store) UpdateCertificate(ctx context.Context, c *model.Certificate) error {
|
||||
c.UpdatedAt = time.Now().UTC()
|
||||
res, err := s.db.ExecContext(ctx, `UPDATE certificates SET name=?, source=?, domains=?, cert_file=?, key_file=?, uses=?, challenge=?, key_type=?,
|
||||
comment=?, cert_pem=?, key_pem=?, names=?, not_before=?, not_after=?, issuer=?, serial=?, renew_at=?, ari_checked_at=?, last_attempt=?,
|
||||
last_success=?, last_error=?, failures=?, last_routes=?, created_at=?, updated_at=?, created_by=?, updated_by=? WHERE id=?`,
|
||||
append(certArgs(c), c.ID)...)
|
||||
if isUnique(err) {
|
||||
return ErrConflict
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if n, _ := res.RowsAffected(); n == 0 {
|
||||
return ErrNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeleteCertificate deletes a certificate.
|
||||
func (s *Store) DeleteCertificate(ctx context.Context, id int64) error {
|
||||
res, err := s.db.ExecContext(ctx, `DELETE FROM certificates WHERE id = ?`, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if n, _ := res.RowsAffected(); n == 0 {
|
||||
return ErrNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func certArgs(c *model.Certificate) []any {
|
||||
list := func(l []string) string {
|
||||
if l == nil {
|
||||
l = []string{}
|
||||
}
|
||||
return jsonText(l)
|
||||
}
|
||||
return []any{c.Name, c.Source, list(c.Domains), c.CertFile, c.KeyFile, list(c.Uses), c.Challenge, c.KeyType, c.Comment,
|
||||
c.CertPEM, c.KeyPEM, list(c.Names), tsPtr(c.NotBefore), tsPtr(c.NotAfter), c.Issuer, c.Serial, tsPtr(c.RenewAt),
|
||||
tsPtr(c.ARICheckedAt), tsPtr(c.LastAttempt), tsPtr(c.LastSuccess), c.LastError, c.Failures, list(c.LastRoutes),
|
||||
ts(c.CreatedAt), ts(c.UpdatedAt), c.CreatedBy, c.UpdatedBy}
|
||||
}
|
||||
@@ -332,6 +332,35 @@ CREATE TABLE client_groups (
|
||||
`
|
||||
ALTER TABLE views ADD COLUMN exposure TEXT NOT NULL DEFAULT '';
|
||||
ALTER TABLE views ADD COLUMN acknowledged TEXT NOT NULL DEFAULT '[]';
|
||||
`,
|
||||
// 10: TLS certificates (docs/certificates.md). cert_pem and key_pem
|
||||
// hold the current certificate of one Jarl obtains.
|
||||
`
|
||||
CREATE TABLE certificates (
|
||||
id INTEGER PRIMARY KEY,
|
||||
name TEXT NOT NULL UNIQUE,
|
||||
source TEXT NOT NULL,
|
||||
domains TEXT NOT NULL DEFAULT '[]',
|
||||
cert_file TEXT NOT NULL DEFAULT '',
|
||||
key_file TEXT NOT NULL DEFAULT '',
|
||||
uses TEXT NOT NULL DEFAULT '[]',
|
||||
challenge TEXT NOT NULL DEFAULT '',
|
||||
key_type TEXT NOT NULL DEFAULT '',
|
||||
comment TEXT NOT NULL DEFAULT '',
|
||||
cert_pem TEXT NOT NULL DEFAULT '',
|
||||
key_pem TEXT NOT NULL DEFAULT '',
|
||||
names TEXT NOT NULL DEFAULT '[]',
|
||||
not_before TEXT, not_after TEXT,
|
||||
issuer TEXT NOT NULL DEFAULT '',
|
||||
serial TEXT NOT NULL DEFAULT '',
|
||||
renew_at TEXT, ari_checked_at TEXT,
|
||||
last_attempt TEXT, last_success TEXT,
|
||||
last_error TEXT NOT NULL DEFAULT '',
|
||||
failures INTEGER NOT NULL DEFAULT 0,
|
||||
last_routes TEXT NOT NULL DEFAULT '[]',
|
||||
created_at TEXT NOT NULL, updated_at TEXT NOT NULL,
|
||||
created_by TEXT NOT NULL DEFAULT '', updated_by TEXT NOT NULL DEFAULT ''
|
||||
);
|
||||
`,
|
||||
}
|
||||
|
||||
|
||||
@@ -19,6 +19,7 @@ import * as search from "./pages/search.js";
|
||||
import * as stats from "./pages/stats.js";
|
||||
import * as blocking from "./pages/blocking.js";
|
||||
import * as exposure from "./pages/exposure.js";
|
||||
import * as certificates from "./pages/certificates.js";
|
||||
|
||||
const routes = [
|
||||
{ pattern: /^$/, page: dashboard, nav: "dashboard" },
|
||||
@@ -39,6 +40,7 @@ const routes = [
|
||||
{ pattern: /^stats$/, page: stats, nav: "stats" },
|
||||
{ pattern: /^blocking$/, page: blocking, nav: "blocking" },
|
||||
{ pattern: /^exposure$/, page: exposure, nav: "exposure" },
|
||||
{ pattern: /^certificates$/, page: certificates, nav: "certificates" },
|
||||
];
|
||||
|
||||
const navItems = [
|
||||
@@ -48,6 +50,7 @@ const navItems = [
|
||||
["blocking", "#/blocking", "Blocking"],
|
||||
["views", "#/views", "Views"],
|
||||
["exposure", "#/exposure", "Exposure"],
|
||||
["certificates", "#/certificates", "Certificates", "admin"],
|
||||
["acls", "#/acls", "ACLs"],
|
||||
["keys", "#/keys", "TSIG keys"],
|
||||
["options", "#/options", "Server options"],
|
||||
@@ -190,7 +193,7 @@ function footer(b) {
|
||||
}
|
||||
|
||||
function renderNav() {
|
||||
mount(navEl, navItems.map(([key, href, label]) => h("a", { href, class: current.nav === key ? "active" : "" }, label)));
|
||||
mount(navEl, navItems.filter((n) => n[3] !== "admin" || state.me.admin).map(([key, href, label]) => h("a", { href, class: current.nav === key ? "active" : "" }, label)));
|
||||
}
|
||||
|
||||
// renderBar shows the pending-changes bar (only while there are pending
|
||||
|
||||
@@ -0,0 +1,220 @@
|
||||
// TLS certificates (docs/certificates.md): ones Jarl obtains and renews
|
||||
// over ACME, answering the DNS-01 challenges in its own zones or at
|
||||
// Porkbun, and ones you renew yourself. Each is used for jarld's HTTPS
|
||||
// ("web") and/or named's DNS over TLS and HTTPS ("dns").
|
||||
import { h, mount, table, field, input, select, textarea, toast, errorBox, confirmDialog, modal, lines, checkbox, time, fmtTime, badge, copyBox } from "../ui.js";
|
||||
import { api, get, state } from "../api.js";
|
||||
import { rerender } from "../app.js";
|
||||
|
||||
const stateKind = { ok: "ok", pending: "", obtaining: "accent", failing: "err", expiring: "warn", expired: "err" };
|
||||
let poll = null;
|
||||
|
||||
export async function render(el) {
|
||||
clearTimeout(poll);
|
||||
if (!state.me.admin) {
|
||||
mount(el, h("h1", null, "Certificates"), h("div", { class: "msg warn" }, "Only administrators can manage certificates."));
|
||||
return;
|
||||
}
|
||||
const r = await get("/certificates");
|
||||
const a = r.account;
|
||||
const parts = [h("div", { class: "page-head" },
|
||||
h("div", { class: "grow" }, h("h1", null, "Certificates"),
|
||||
h("div", { class: "muted" }, "TLS certificates for Jarl's web UI and for named's DNS over TLS and HTTPS. Jarl obtains them from the CA and renews them itself, answering the DNS-01 challenge in a zone it serves or through Porkbun's API while Porkbun serves the domain. A certificate you renew yourself works too: Jarl rereads it when the files change.")),
|
||||
h("div", { class: "actions" }, h("button", { class: "primary", onclick: () => createDialog(a) }, "New certificate")))];
|
||||
|
||||
if (!a.agree_tos) {
|
||||
parts.push(h("div", { class: "msg warn" },
|
||||
h("strong", null, "Jarl can't register with the CA yet. "),
|
||||
"Read the CA's terms of service, then set ", h("code", null, "acme.account.agree_tos: true"), " in /etc/jarl/jarl.yaml and restart jarld (", h("code", null, "systemctl restart jarl"), "). Certificates you renew yourself work without it."));
|
||||
}
|
||||
|
||||
parts.push(h("div", { class: "panel" }, table([
|
||||
{ label: "Name", render: (c) => h("a", { href: "#", onclick: (e) => { e.preventDefault(); details(c); } }, h("strong", null, c.name)) },
|
||||
{ label: "Names", render: (c) => h("div", { class: "mono small" }, (c.names && c.names.length ? c.names : c.domains).map((n) => h("div", null, n))) },
|
||||
{ label: "Used for", render: (c) => [
|
||||
c.uses.includes("web") ? badge(c.serving ? "web UI (serving)" : "web UI", c.serving ? "ok" : "") : null, " ",
|
||||
c.uses.includes("dns") ? badge("named", "") : null,
|
||||
!c.uses.length ? h("span", { class: "muted" }, "nothing yet") : null] },
|
||||
{ label: "Source", render: (c) => c.source === "acme" ? h("span", null, "Jarl", h("div", { class: "small muted" }, "challenge " + c.challenge)) : h("span", null, "your files", h("div", { class: "small muted mono" }, c.cert_file)) },
|
||||
{ label: "Expires", render: (c) => c.not_after ? h("span", null, time(c.not_after), c.source === "acme" && c.next_attempt ? h("div", { class: "small muted" }, "renews " + fmtTime(c.next_attempt)) : null) : h("span", { class: "muted" }, "–") },
|
||||
{ label: "Status", render: (c) => {
|
||||
const st = c.issuing_since ? "obtaining" : c.status.state;
|
||||
return h("div", null, badge(st, stateKind[st]), c.status.message && !c.issuing_since ? h("div", { class: "small" }, c.status.message) : null);
|
||||
} },
|
||||
{ label: "", render: (c) => h("span", null,
|
||||
h("button", { class: "small", disabled: !!c.issuing_since, title: c.source === "acme" ? "Obtain a new certificate now" : "Reread the files now", onclick: () => issue(c) }, c.source === "acme" ? "Renew" : "Reread"), " ",
|
||||
h("button", { class: "small", onclick: () => editDialog(c, a) }, "Edit"), " ",
|
||||
h("button", { class: "small danger", onclick: () => del(c) }, "Delete")), tdClass: "actions-cell" },
|
||||
], r.certificates, { empty: "No certificates yet." })));
|
||||
|
||||
parts.push(h("div", { class: "panel" }, h("h2", null, "ACME account"), h("dl", { class: "kv" },
|
||||
h("dt", null, "CA"), h("dd", { class: "mono" }, a.directory),
|
||||
h("dt", null, "Account"), h("dd", null, a.registered ? [h("span", { class: "mono small" }, a.uri), " since ", time(a.registered_at)] : h("span", { class: "muted" }, "registered with the first certificate")),
|
||||
h("dt", null, "Email"), h("dd", null, a.email || h("span", { class: "muted" }, "none")),
|
||||
h("dt", null, "Porkbun"), h("dd", null, a.porkbun ? "can publish challenges for domains Porkbun serves" : h("span", { class: "muted" }, "no API key in jarl.yaml")),
|
||||
h("dt", null, "Seen through"), h("dd", { class: "mono small" }, (a.resolvers || []).join(" ")),
|
||||
h("dt", null, "Files for named"), h("dd", { class: "mono small" }, a.dir + "/NAME/fullchain.pem, privkey.pem")),
|
||||
h("div", { class: "small muted" }, "These are set in /etc/jarl/jarl.yaml under acme and certificates.")));
|
||||
mount(el, parts);
|
||||
|
||||
// While a certificate is being obtained, look again every few seconds.
|
||||
if (r.certificates.some((c) => c.issuing_since)) {
|
||||
poll = setTimeout(() => { if (location.hash.startsWith("#/certificates")) rerender(); }, 3000);
|
||||
}
|
||||
}
|
||||
|
||||
function details(c) {
|
||||
const rows = [
|
||||
["Source", c.source === "acme" ? "Jarl obtains and renews it (" + c.key_type + " key)" : "your files: " + c.cert_file + ", " + c.key_file],
|
||||
c.source === "acme" ? ["Asked for", c.domains.join(" ")] : null,
|
||||
c.names ? ["Names in it", c.names.join(" ")] : null,
|
||||
c.not_after ? ["Valid", fmtTime(c.not_before) + " to " + fmtTime(c.not_after)] : null,
|
||||
c.issuer ? ["Issuer", c.issuer] : null,
|
||||
c.serial ? ["Serial", c.serial] : null,
|
||||
c.source === "acme" && c.next_attempt ? [c.not_after ? "Renews" : "Next try", fmtTime(c.next_attempt)] : null,
|
||||
c.last_routes && c.last_routes.length ? ["Challenges went", c.last_routes.join("; ")] : null,
|
||||
c.last_success ? ["Last success", fmtTime(c.last_success)] : null,
|
||||
c.last_error ? ["Last error", c.last_error] : null,
|
||||
].filter(Boolean);
|
||||
modal("Certificate " + c.name, h("div", null,
|
||||
h("dl", { class: "kv" }, rows.map(([k, v]) => [h("dt", null, k), h("dd", null, v)])),
|
||||
c.files ? h("div", null, h("h3", null, "For named"),
|
||||
h("p", { class: "small muted" }, "Point a tls block in named's configuration at these files; Jarl rewrites them on renewal and has named reread them."),
|
||||
copyBox(c.files.chain), copyBox(c.files.key)) : null), [{ label: "Close", value: null }], { wide: true });
|
||||
}
|
||||
|
||||
async function issue(c) {
|
||||
try {
|
||||
const r = await api("POST", "/certificates/" + encodeURIComponent(c.name) + "/issue", { body: {} });
|
||||
if (r.error) toast(r.error, "err", 10000);
|
||||
else toast(c.source === "acme" ? "Obtaining " + c.name + " from the CA…" : "Reread " + c.name);
|
||||
rerender();
|
||||
} catch (e) { toast(e.message, "err", 10000); }
|
||||
}
|
||||
|
||||
async function del(c) {
|
||||
const msg = "Delete certificate " + c.name + "?" +
|
||||
(c.uses.includes("dns") ? " Its files for named are removed: take it out of any tls block first." : "") +
|
||||
(c.serving ? " Jarl's web UI goes back to tls.cert_file in jarl.yaml, or to plain HTTP." : "");
|
||||
if (!await confirmDialog("Delete certificate", msg, "Delete", "danger")) return;
|
||||
try {
|
||||
await api("DELETE", "/certificates/" + encodeURIComponent(c.name));
|
||||
toast("Deleted " + c.name);
|
||||
rerender();
|
||||
} catch (e) { toast(e.message, "err", 8000); }
|
||||
}
|
||||
|
||||
// usesBoxes returns checkboxes for a certificate's uses.
|
||||
function usesBoxes(uses) {
|
||||
const web = checkbox("Jarl's web UI and API (HTTPS)", uses.includes("web"));
|
||||
const dns = checkbox("named: DNS over TLS and HTTPS", uses.includes("dns"));
|
||||
return { el: h("div", null, h("div", null, web), h("div", null, dns)), value: () => [web.input.checked ? "web" : null, dns.input.checked ? "dns" : null].filter(Boolean) };
|
||||
}
|
||||
|
||||
function challengeSelect(v, porkbun) {
|
||||
return select([["auto", "Work it out at every renewal (recommended)"], ["jarl", "A zone this server serves"], ["porkbun", "Porkbun's API" + (porkbun ? "" : " (no key in jarl.yaml)")]], v || "auto");
|
||||
}
|
||||
|
||||
const keyTypes = [["ec256", "ECDSA P-256 (recommended)"], ["ec384", "ECDSA P-384"], ["rsa2048", "RSA 2048 (old clients)"], ["rsa3072", "RSA 3072"], ["rsa4096", "RSA 4096"]];
|
||||
|
||||
async function createDialog(a) {
|
||||
const name = input({ placeholder: "e.g. ns1", pattern: "[a-z0-9][a-z0-9_-]*" });
|
||||
const src = select([["acme", "Jarl obtains and renews it"], ["file", "I renew it myself (files on the server)"]], "acme");
|
||||
const domains = textarea("", { rows: 3, placeholder: "ns1.adbraun.lol\n*.adbraun.lol", class: "code" });
|
||||
const challenge = challengeSelect("auto", a.porkbun);
|
||||
const keyType = select(keyTypes, "ec256");
|
||||
const certFile = input({ placeholder: "/etc/ssl/ns1/fullchain.pem", class: "code" });
|
||||
const keyFile = input({ placeholder: "/etc/ssl/ns1/privkey.pem", class: "code" });
|
||||
const uses = usesBoxes([]);
|
||||
const comment = input();
|
||||
const checkOut = h("div");
|
||||
const err = h("div");
|
||||
const acmeFields = h("div", { class: "form-grid" },
|
||||
field("Names", domains, "One per line. *.example.com for a wildcard (which also needs example.com if you want that name too)."),
|
||||
field("Challenge", challenge, "Where the DNS-01 challenge goes. Work it out: Porkbun while the domain's name servers are Porkbun's, this server once they are its own."),
|
||||
field("Key", keyType));
|
||||
const fileFields = h("div", { class: "form-grid" },
|
||||
field("Certificate", certFile, "The full chain (certificate and intermediates), PEM. jarld must be able to read it."),
|
||||
field("Key", keyFile, "Its private key, PEM."));
|
||||
// display, not hidden: .form-grid sets display itself.
|
||||
const sync = () => {
|
||||
acmeFields.style.display = checkRow.style.display = src.value === "acme" ? "" : "none";
|
||||
fileFields.style.display = src.value === "file" ? "" : "none";
|
||||
};
|
||||
const body = () => {
|
||||
const b = { name: name.value.trim(), source: src.value, uses: uses.value(), comment: comment.value };
|
||||
if (src.value === "acme") Object.assign(b, { domains: lines(domains.value), challenge: challenge.value, key_type: keyType.value });
|
||||
else Object.assign(b, { cert_file: certFile.value.trim(), key_file: keyFile.value.trim() });
|
||||
return b;
|
||||
};
|
||||
const check = h("button", { type: "button", class: "small", onclick: async () => {
|
||||
mount(checkOut, h("div", { class: "muted small" }, "Looking the names up as the CA would…"));
|
||||
try {
|
||||
const r = await api("POST", "/certificates/check", { body: { domains: lines(domains.value), challenge: challenge.value } });
|
||||
mount(checkOut, routesTable(r.routes));
|
||||
} catch (e) { mount(checkOut, errorBox(e)); }
|
||||
} }, "Check the names");
|
||||
const checkRow = h("div", { class: "check-names" }, check, checkOut);
|
||||
src.addEventListener("change", sync);
|
||||
sync();
|
||||
await modal("New certificate", h("div", null,
|
||||
h("div", { class: "form-grid" },
|
||||
field("Name", name, "Lowercase letters, digits, - and _. Also the directory of its files for named."),
|
||||
field("Source", src)),
|
||||
acmeFields, checkRow,
|
||||
fileFields,
|
||||
h("div", { class: "form-grid" }, field("Used for", uses.el, "Only one certificate can serve the web UI. Taking it on switches jarld to HTTPS on its ports at once (plain HTTP from this server itself keeps working)."), field("Comment", comment)),
|
||||
err), [
|
||||
{ label: "Cancel", value: null },
|
||||
{ label: "Create", kind: "primary", onclick: async () => {
|
||||
try {
|
||||
const r = await api("POST", "/certificates", { body: body() });
|
||||
toast(r.certificate.issuing_since ? "Obtaining " + r.certificate.name + " from the CA…" : "Added " + r.certificate.name);
|
||||
rerender();
|
||||
return true;
|
||||
} catch (e) { mount(err, errorBox(e)); return false; }
|
||||
} },
|
||||
], { wide: true });
|
||||
}
|
||||
|
||||
function routesTable(routes) {
|
||||
return table([
|
||||
{ label: "Name", render: (r) => h("span", { class: "mono" }, r.domain) },
|
||||
{ label: "Goes to", render: (r) => r.error ? badge("nowhere", "err") : badge(r.route === "jarl" ? "this server" : "Porkbun", "ok") },
|
||||
{ label: "Why", render: (r) => h("div", { class: "small" }, r.error || r.why,
|
||||
r.target && r.target !== r.record ? h("div", { class: "muted" }, "via CNAME to " + r.target.replace(/\.$/, "")) : null) },
|
||||
], routes);
|
||||
}
|
||||
|
||||
async function editDialog(c, a) {
|
||||
const domains = textarea((c.domains || []).join("\n"), { rows: 3, class: "code" });
|
||||
const challenge = challengeSelect(c.challenge, a.porkbun);
|
||||
const keyType = select(keyTypes, c.key_type);
|
||||
const certFile = input({ value: c.cert_file || "", class: "code" });
|
||||
const keyFile = input({ value: c.key_file || "", class: "code" });
|
||||
const uses = usesBoxes(c.uses);
|
||||
const comment = input({ value: c.comment || "" });
|
||||
const err = h("div");
|
||||
const acme = c.source === "acme";
|
||||
await modal("Edit certificate " + c.name, h("div", null,
|
||||
acme ? h("div", { class: "form-grid" },
|
||||
field("Names", domains, "New names or a new key make Jarl obtain it again soon."),
|
||||
field("Challenge", challenge),
|
||||
field("Key", keyType))
|
||||
: h("div", { class: "form-grid" }, field("Certificate", certFile), field("Key", keyFile)),
|
||||
h("div", { class: "form-grid" }, field("Used for", uses.el), field("Comment", comment)),
|
||||
err), [
|
||||
{ label: "Cancel", value: null },
|
||||
{ label: "Save", kind: "primary", onclick: async () => {
|
||||
const b = { uses: uses.value(), comment: comment.value };
|
||||
if (acme) Object.assign(b, { domains: lines(domains.value), challenge: challenge.value, key_type: keyType.value });
|
||||
else Object.assign(b, { cert_file: certFile.value.trim(), key_file: keyFile.value.trim() });
|
||||
try {
|
||||
await api("PATCH", "/certificates/" + encodeURIComponent(c.name), { body: b });
|
||||
toast("Saved " + c.name);
|
||||
rerender();
|
||||
return true;
|
||||
} catch (e) { mount(err, errorBox(e)); return false; }
|
||||
} },
|
||||
], { wide: true });
|
||||
}
|
||||
@@ -17,6 +17,7 @@ export async function render(el) {
|
||||
card("Options", (s.counts || {}).options ?? "–", "server, view, zone and top-level"),
|
||||
st.backup ? backupCard(st.backup) : null,
|
||||
exposure ? exposureCard(exposure) : null,
|
||||
st.certificates ? certCard(st.certificates) : null,
|
||||
);
|
||||
const last = s.last_applied;
|
||||
const parts = [h("div", { class: "page-head" },
|
||||
@@ -42,6 +43,11 @@ export async function render(el) {
|
||||
h("strong", null, "The last backup failed "), time(b.last_error_at), ": " + b.last_error,
|
||||
h("div", { class: "small" }, "See 'journalctl -u jarl-backup' on the server.")));
|
||||
}
|
||||
for (const c of (st.certificates || {}).problems || []) {
|
||||
parts.push(h("div", { class: "msg " + (c.state === "expiring" ? "warn" : "err") },
|
||||
h("strong", null, "Certificate " + c.name + " " + (c.state === "expired" ? "has expired" : c.state === "expiring" ? "expires soon" : "can't be renewed") + ": "), c.message, " ",
|
||||
h("a", { href: "#/certificates" }, "Certificates")));
|
||||
}
|
||||
if (st.blocking_paused_until) {
|
||||
parts.push(h("div", { class: "msg info" },
|
||||
h("strong", null, "Blocking is paused until " + fmtTime(st.blocking_paused_until) + "."), " It resumes by itself. ", h("a", { href: "#/blocking" }, "Blocking")));
|
||||
@@ -117,6 +123,14 @@ function exposureCard(r) {
|
||||
return h("a", { class: "card link", href: "#/exposure" }, h("div", { class: "label" }, "Exposure"), h("div", { class: "value" }, value), h("div", { class: "sub" }, sub));
|
||||
}
|
||||
|
||||
// certCard sums up the certificates Jarl manages.
|
||||
function certCard(c) {
|
||||
const bad = c.problems.length;
|
||||
const value = bad ? badge(bad === 1 ? "1 needs attention" : bad + " need attention", c.problems.some((p) => p.state !== "expiring") ? "err" : "warn") : badge(c.total === 1 ? "1 current" : c.total + " current", "ok");
|
||||
const sub = c.next ? ["next to expire: " + c.next + " ", time(c.next_after)] : "none obtained yet";
|
||||
return h("a", { class: "card link", href: "#/certificates" }, h("div", { class: "label" }, "Certificates"), h("div", { class: "value" }, value), h("div", { class: "sub" }, sub));
|
||||
}
|
||||
|
||||
function card(label, value, sub) {
|
||||
return h("div", { class: "card" }, h("div", { class: "label" }, label), h("div", { class: "value" }, value), sub ? h("div", { class: "sub" }, sub) : null);
|
||||
}
|
||||
@@ -509,3 +509,7 @@ pre.optval { padding: 5px 8px; border: 1px solid transparent; background: none;
|
||||
/* ...and the editor in a table cell gets the same width, so the column
|
||||
doesn't jump when Edit opens. */
|
||||
td textarea.code { min-width: min(72ch, 50vw); }
|
||||
|
||||
/* Certificates: the "check the names" result under the ACME fields. */
|
||||
.check-names { margin: 6px 0 14px; }
|
||||
.check-names .table-wrap { margin-top: 8px; }
|
||||
Reference in new issue
Block a user