Files
rollcall/cmd/rollcall/admincert.go
T
Ilker ManapandClaude Opus 5.5 2f708e8f69
CI / agent (push) Successful in 3s
CI / site (push) Successful in 3s
CI / go (push) Successful in 47s
Root rotation without re-joining: prepare, switch, retire
The CA directory holds the active root and, during a rotation, the next
or the previous one; everything that trusts the region gets the trust
bundle (all of them). Rack controllers receive it in their state, signed
by the active root; the root update unit installs it only when the
signature verifies against the roots it already trusts, and binary
signatures verify against any of them. After switch racks renew their
client and server certificates from the new root by themselves and
report what they run on; switch and retire wait for every rack (-force).

/ca.pem stays one certificate, the root of that server's certificate, and
the PXE command line names that root, so agents in older images keep
working; /ca-bundle.pem is the bundle. Deployed machines get one file per
root and a daily timer that refreshes them from their rack over HTTPS.

Refs #12

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 15:37:44 +01:00

236 lines
6.7 KiB
Go

package main
// Admin client certificates: an alternative to the admin token. A browser or
// CLI that presents a certificate from the installation's CA with the name
// "admin:<name>", made for client authentication and with a serial the region
// recorded (and not revoked), is an admin. The key is made on the client; only
// a CSR travels. Issuing needs admin rights once (the token, or another admin
// certificate); revoking takes effect within the cache period (30 s).
import (
"context"
"crypto/x509"
"encoding/json"
"encoding/pem"
"errors"
"fmt"
"log"
"net/http"
"regexp"
"strings"
"sync"
"time"
)
const adminCertSchema = `
CREATE TABLE IF NOT EXISTS admin_certs (
serial text PRIMARY KEY,
name text NOT NULL,
expires timestamptz NOT NULL,
created timestamptz NOT NULL DEFAULT now(),
issued_by text,
revoked timestamptz
);
`
type AdminCert struct {
Serial string `json:"serial"`
Name string `json:"name"`
Expires time.Time `json:"expires"`
Created time.Time `json:"created"`
IssuedBy string `json:"issued_by"`
Revoked *time.Time `json:"revoked,omitempty"`
}
func (s *Store) AddAdminCert(ctx context.Context, c AdminCert) error {
_, err := s.pool.Exec(ctx, `INSERT INTO admin_certs(serial, name, expires, issued_by) VALUES ($1,$2,$3,$4)`, c.Serial, c.Name, c.Expires, c.IssuedBy)
return err
}
func (s *Store) AdminCerts(ctx context.Context) ([]AdminCert, error) {
rows, err := s.pool.Query(ctx, `SELECT serial, name, expires, created, coalesce(issued_by,''), revoked FROM admin_certs ORDER BY created`)
if err != nil {
return nil, err
}
defer rows.Close()
out := []AdminCert{}
for rows.Next() {
var c AdminCert
if err := rows.Scan(&c.Serial, &c.Name, &c.Expires, &c.Created, &c.IssuedBy, &c.Revoked); err != nil {
return nil, err
}
out = append(out, c)
}
return out, rows.Err()
}
// RevokeAdminCerts revokes by serial or by name (all of that name); returns how many.
func (s *Store) RevokeAdminCerts(ctx context.Context, ref string) (int64, error) {
tag, err := s.pool.Exec(ctx, `UPDATE admin_certs SET revoked=now() WHERE revoked IS NULL AND (serial=$1 OR name=$1)`, strings.ToLower(ref))
if err != nil {
return 0, err
}
return tag.RowsAffected(), nil
}
func (s *Store) validAdminSerials(ctx context.Context) (map[string]string, error) {
rows, err := s.pool.Query(ctx, `SELECT serial, name FROM admin_certs WHERE revoked IS NULL AND expires > now()`)
if err != nil {
return nil, err
}
defer rows.Close()
out := map[string]string{}
for rows.Next() {
var serial, name string
if err := rows.Scan(&serial, &name); err != nil {
return nil, err
}
out[serial] = name
}
return out, rows.Err()
}
type adminCertCache struct {
mu sync.Mutex
at time.Time
serial map[string]string
}
func (a *App) adminCertsCached(ctx context.Context) map[string]string {
a.adminCC.mu.Lock()
defer a.adminCC.mu.Unlock()
if a.adminCC.serial != nil && time.Since(a.adminCC.at) < 30*time.Second || a.store == nil {
return a.adminCC.serial
}
m, err := a.store.validAdminSerials(ctx)
if err != nil {
log.Printf("admin certificates: %v", err)
return a.adminCC.serial // keep the last good view on a database hiccup
}
a.adminCC.serial, a.adminCC.at = m, time.Now()
return m
}
func (a *App) dropAdminCertCache() {
a.adminCC.mu.Lock()
a.adminCC.serial = nil
a.adminCC.mu.Unlock()
}
// rootIssued is the client certificate when the Rollcall root signed it
// itself. Admin and rack identities are never accepted through an
// intermediate: a delegated (ACME) intermediate could otherwise mint
// CN=admin:... - its name constraints cover SANs, not the CN.
func rootIssued(r *http.Request) *x509.Certificate {
if r.TLS == nil {
return nil
}
for _, ch := range r.TLS.VerifiedChains {
if len(ch) == 2 {
return ch[0]
}
}
return nil
}
// peerAdmin is the admin name of a verified client certificate, "" otherwise.
func (a *App) peerAdmin(r *http.Request) string {
c := rootIssued(r)
if c == nil {
return ""
}
name, ok := strings.CutPrefix(c.Subject.CommonName, "admin:")
if !ok || name == "" {
return ""
}
clientAuth := false
for _, u := range c.ExtKeyUsage {
clientAuth = clientAuth || u == x509.ExtKeyUsageClientAuth
}
if !clientAuth {
return ""
}
if a.adminCertsCached(r.Context())[c.SerialNumber.Text(16)] != name {
return ""
}
return name
}
// who describes the admin making a request, for the log.
func (a *App) who(r *http.Request) string {
if n := a.peerAdmin(r); n != "" {
return "admin:" + n
}
return "token from " + r.RemoteAddr
}
var adminNameRe = regexp.MustCompile(`^[a-z0-9][a-z0-9._-]{0,62}$`)
func (a *App) handleAdminCerts(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
cs, err := a.store.AdminCerts(r.Context())
if err != nil {
fail(w, 500, err)
return
}
writeJSON(w, 200, cs)
case http.MethodPost:
var req struct {
Name string `json:"name"`
CSR string `json:"csr"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
fail(w, 400, err)
return
}
req.Name = strings.ToLower(strings.TrimSpace(req.Name))
if !adminNameRe.MatchString(req.Name) {
fail(w, 400, errors.New("name: lower-case letters, digits, . _ - (e.g. the laptop's or person's name)"))
return
}
blk, _ := pem.Decode([]byte(req.CSR))
if blk == nil || blk.Type != "CERTIFICATE REQUEST" {
fail(w, 400, errors.New("csr: a PEM CERTIFICATE REQUEST is required (the key stays on the client)"))
return
}
csr, err := x509.ParseCertificateRequest(blk.Bytes)
if err == nil {
err = csr.CheckSignature()
}
if err != nil {
fail(w, 400, fmt.Errorf("csr: %w", err))
return
}
certPEM, cert, err := a.ca.sign(csr.PublicKey, "admin:"+req.Name, nil, x509.ExtKeyUsageClientAuth)
if err != nil {
fail(w, 500, err)
return
}
by := a.who(r)
if err := a.store.AddAdminCert(r.Context(), AdminCert{Serial: cert.SerialNumber.Text(16), Name: req.Name, Expires: cert.NotAfter, IssuedBy: by}); err != nil {
fail(w, 500, err)
return
}
a.dropAdminCertCache()
log.Printf("admin certificate %q (serial %s, until %s) issued by %s", req.Name, cert.SerialNumber.Text(16), cert.NotAfter.Format("2006-01-02"), by)
writeJSON(w, 200, obj{"name": req.Name, "serial": cert.SerialNumber.Text(16), "expires": cert.NotAfter, "cert": string(certPEM), "ca": string(a.ca.TrustPEM)})
}
}
func (a *App) handleAdminCertRevoke(w http.ResponseWriter, r *http.Request) {
ref := r.PathValue("ref")
n, err := a.store.RevokeAdminCerts(r.Context(), ref)
if err != nil {
fail(w, 500, err)
return
}
if n == 0 {
fail(w, 404, fmt.Errorf("no valid admin certificate %q", ref))
return
}
a.dropAdminCertCache()
log.Printf("admin certificate %q revoked (%d) by %s", ref, n, a.who(r))
writeJSON(w, 200, obj{"revoked": n})
}