Files
jarl/internal/store/certificates.go
T
adamandClaude Opus 5.5 88f63ffb0a 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>
2026-10-12 14:35:53 +01:00

128 lines
4.3 KiB
Go

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}
}