jarld obtains and renews a certificate for another host; the host asks
for it with one command, waits, downloads it when it is newer than its
copy and runs a hook with the paths in JARL_CERT_* variables.
- Certificates have an owner user; another user can't take the name.
An existing one is obtained again only when a name is added or
removed. A new certificates grant (names=) says which names a user
may have; it is never part of "*". Users may own
certificates.max_per_user certificates (10) or their own cert_limit.
- POST /certificates/{name}/request; owners may list, show, export and
delete theirs; keys only over HTTPS, the socket or loopback; every
download is recorded and shown as "Pulled by".
- CLI: jarl cert get (saved settings, all saved when no name), cert
saved, cert forget, cert schedule (daily systemd timer, Linux, root).
- Migration 14. Tests: server, auth, and e2e against Pebble.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
158 lines
5.6 KiB
Go
158 lines
5.6 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, owner_id`
|
|
|
|
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, &c.OwnerID); 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(28)+`)`, 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=?, owner_id=? 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, c.OwnerID}
|
|
}
|
|
|
|
// RecordCertificatePull notes a download of a certificate, replacing the
|
|
// same downloader's earlier one.
|
|
func (s *Store) RecordCertificatePull(ctx context.Context, p *model.CertificatePull) error {
|
|
_, err := s.db.ExecContext(ctx, `INSERT INTO certificate_pulls (cert_id, who, user_id, token_id, serial, ip, at) VALUES (?,?,?,?,?,?,?)
|
|
ON CONFLICT (cert_id, who) DO UPDATE SET user_id = excluded.user_id, token_id = excluded.token_id, serial = excluded.serial, ip = excluded.ip, at = excluded.at`,
|
|
p.CertID, p.Who, p.UserID, p.TokenID, p.Serial, p.IP, ts(p.At))
|
|
return err
|
|
}
|
|
|
|
// CertificatePulls returns who downloaded each certificate, newest first,
|
|
// by certificate ID.
|
|
func (s *Store) CertificatePulls(ctx context.Context) (map[int64][]model.CertificatePull, error) {
|
|
rows, err := s.db.QueryContext(ctx, `SELECT cert_id, who, user_id, token_id, serial, ip, at FROM certificate_pulls ORDER BY at DESC`)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
out := map[int64][]model.CertificatePull{}
|
|
for rows.Next() {
|
|
var p model.CertificatePull
|
|
var at string
|
|
if err := rows.Scan(&p.CertID, &p.Who, &p.UserID, &p.TokenID, &p.Serial, &p.IP, &at); err != nil {
|
|
rows.Close()
|
|
return nil, err
|
|
}
|
|
p.At = parseTS(at)
|
|
out[p.CertID] = append(out[p.CertID], p)
|
|
}
|
|
return out, closeRows(rows)
|
|
}
|