Files
rollcall/cmd/rollcall/ca.go
T
Ilker ManapandClaude Opus 5.5 77894b7d57
CI / agent (push) Successful in 3s
CI / site (push) Successful in 2s
CI / go (push) Successful in 35s
CA delegation: name-constrained intermediates, root rotation, ACME node certificates
New roots carry path length 1. `rollcall ca intermediate` signs a CSR (or a
generated key) as an intermediate limited to the permitted domains and
address ranges; a root with path length 0 refuses and `rollcall ca rotate`
replaces it. deploy/tags/acme-cert.yaml gets a node certificate from an
ACME server under such an intermediate and renews it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 10:23:26 +01:00

788 lines
26 KiB
Go

package main
// The region is the certificate authority of its installation, the way the
// llmgw control plane is: one root every part trusts, leaves issued from it,
// identity in the subject.
//
// - the region serves its API and UI over HTTPS with a certificate from it;
// - rack controllers join with a one-time code (CA fingerprint, secret,
// expiry, address in one word) and get a client+server certificate whose
// common name is their name; from then on they talk to the region over
// mutual TLS and the shared rack_secret is not needed;
// - deployed machines trust it (cloud-init ca_certs), stateless overlays get
// it as .CA, and `rollcall ca issue` hands out leaves for anything else.
//
// Nodes still boot over plain HTTP: PXE firmware and the live image have no
// way to check a certificate before they have one.
import (
"context"
"crypto"
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/sha256"
"crypto/tls"
"crypto/x509"
"crypto/x509/pkix"
"encoding/base64"
"encoding/binary"
"encoding/hex"
"encoding/json"
"encoding/pem"
"errors"
"fmt"
"log"
"math/big"
"net"
"net/http"
"os"
"path/filepath"
"sort"
"strings"
"syscall"
"time"
)
const (
caValidity = 10 * 365 * 24 * time.Hour
leafValidity = 365 * 24 * time.Hour
backdate = time.Hour
renewBefore = 30 * 24 * time.Hour
joinPrefix = "rollcall-join1-"
)
type CA struct {
Cert *x509.Certificate
Key *ecdsa.PrivateKey
PEM []byte
}
func randomSerial() (*big.Int, error) {
return rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 128))
}
func pemEncode(kind string, der []byte) []byte {
return pem.EncodeToMemory(&pem.Block{Type: kind, Bytes: der})
}
func newCA(cn string) (*CA, error) {
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
return nil, err
}
serial, err := randomSerial()
if err != nil {
return nil, err
}
tmpl := &x509.Certificate{SerialNumber: serial, Subject: pkix.Name{CommonName: cn, Organization: []string{"rollcall"}},
NotBefore: time.Now().Add(-backdate), NotAfter: time.Now().Add(caValidity),
KeyUsage: x509.KeyUsageCertSign | x509.KeyUsageCRLSign, BasicConstraintsValid: true, IsCA: true, MaxPathLen: 1}
der, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &key.PublicKey, key)
if err != nil {
return nil, err
}
cert, err := x509.ParseCertificate(der)
if err != nil {
return nil, err
}
return &CA{Cert: cert, Key: key, PEM: pemEncode("CERTIFICATE", der)}, nil
}
func parseCertPEM(b []byte) (*x509.Certificate, error) {
blk, _ := pem.Decode(b)
if blk == nil {
return nil, errors.New("not PEM")
}
return x509.ParseCertificate(blk.Bytes)
}
func parseKeyPEM(b []byte) (*ecdsa.PrivateKey, error) {
blk, _ := pem.Decode(b)
if blk == nil {
return nil, errors.New("not PEM")
}
return x509.ParseECPrivateKey(blk.Bytes)
}
// loadOrCreateCA keeps the authority in dir as ca.pem / ca-key.pem.
func loadOrCreateCA(dir, cn string) (*CA, error) {
cp, kp := filepath.Join(dir, "ca.pem"), filepath.Join(dir, "ca-key.pem")
if cb, err := os.ReadFile(cp); err == nil {
kb, err := os.ReadFile(kp)
if err != nil {
return nil, err
}
cert, err := parseCertPEM(cb)
if err != nil {
return nil, fmt.Errorf("%s: %w", cp, err)
}
key, err := parseKeyPEM(kb)
if err != nil {
return nil, fmt.Errorf("%s: %w", kp, err)
}
return &CA{Cert: cert, Key: key, PEM: cb}, nil
}
ca, err := newCA(cn)
if err != nil {
return nil, err
}
if err := os.MkdirAll(dir, 0o750); err != nil {
return nil, err
}
kder, _ := x509.MarshalECPrivateKey(ca.Key)
if err := os.WriteFile(kp, pemEncode("EC PRIVATE KEY", kder), 0o600); err != nil {
return nil, err
}
if err := os.WriteFile(cp, ca.PEM, 0o644); err != nil {
return nil, err
}
log.Printf("created certificate authority %q in %s (fingerprint %s)", cn, dir, ca.Fingerprint())
return ca, nil
}
func certFingerprint(c *x509.Certificate) [32]byte { return sha256.Sum256(c.Raw) }
// Fingerprint is the SHA-256 of the CA certificate, as people compare it.
func (ca *CA) Fingerprint() string { return fpString(certFingerprint(ca.Cert)) }
func fpString(sum [32]byte) string {
h := hex.EncodeToString(sum[:])
var p []string
for i := 0; i < len(h); i += 2 {
p = append(p, strings.ToUpper(h[i:i+2]))
}
return "sha256:" + strings.Join(p, ":")
}
func splitHosts(hosts []string) (ips []net.IP, dns []string) {
seen := map[string]bool{}
for _, h := range hosts {
h = strings.Trim(strings.TrimSpace(h), "[]")
if h == "" || seen[h] {
continue
}
seen[h] = true
if ip := net.ParseIP(h); ip != nil {
ips = append(ips, ip)
} else {
dns = append(dns, h)
}
}
return
}
// sign issues a certificate for pub with the given name, hosts and usages.
func (ca *CA) sign(pub crypto.PublicKey, cn string, hosts []string, usage ...x509.ExtKeyUsage) ([]byte, *x509.Certificate, error) {
serial, err := randomSerial()
if err != nil {
return nil, nil, err
}
tmpl := &x509.Certificate{SerialNumber: serial, Subject: pkix.Name{CommonName: cn, Organization: []string{"rollcall"}},
NotBefore: time.Now().Add(-backdate), NotAfter: time.Now().Add(leafValidity),
KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageKeyEncipherment, ExtKeyUsage: usage}
tmpl.IPAddresses, tmpl.DNSNames = splitHosts(hosts)
der, err := x509.CreateCertificate(rand.Reader, tmpl, ca.Cert, pub, ca.Key)
if err != nil {
return nil, nil, err
}
cert, err := x509.ParseCertificate(der)
return pemEncode("CERTIFICATE", der), cert, err
}
// canDelegate: the root may sign intermediate CAs (roots before path length 1 cannot).
func (ca *CA) canDelegate() bool {
return !(ca.Cert.MaxPathLen == 0 && ca.Cert.MaxPathLenZero)
}
// parsePermits splits "int.example.com,10.213.0.0/16" into DNS and IP constraints.
func parsePermits(list string) (dns []string, nets []*net.IPNet, err error) {
for _, p := range strings.Split(list, ",") {
p = strings.TrimSpace(p)
if p == "" {
continue
}
if _, n, e := net.ParseCIDR(p); e == nil {
nets = append(nets, n)
continue
}
if net.ParseIP(p) != nil {
return nil, nil, fmt.Errorf("%s: give an address range as CIDR", p)
}
dns = append(dns, strings.TrimPrefix(strings.ToLower(p), "*."))
}
return dns, nets, nil
}
// intermediate signs a CA certificate for pub that may issue only below the
// permitted DNS names and inside the permitted address ranges, one level deep.
func (ca *CA) intermediate(pub crypto.PublicKey, cn string, dns []string, nets []*net.IPNet, validity time.Duration) ([]byte, *x509.Certificate, error) {
if !ca.canDelegate() {
return nil, nil, errors.New("this root was created with path length 0 and cannot sign intermediate CAs; `rollcall ca rotate` replaces it with one that can")
}
serial, err := randomSerial()
if err != nil {
return nil, nil, err
}
if validity <= 0 || validity > caValidity {
validity = 365 * 24 * time.Hour
}
if na := ca.Cert.NotAfter; time.Now().Add(validity).After(na) {
validity = time.Until(na)
}
tmpl := &x509.Certificate{SerialNumber: serial, Subject: pkix.Name{CommonName: cn, Organization: []string{"rollcall"}},
NotBefore: time.Now().Add(-backdate), NotAfter: time.Now().Add(validity),
KeyUsage: x509.KeyUsageCertSign | x509.KeyUsageCRLSign | x509.KeyUsageDigitalSignature, BasicConstraintsValid: true, IsCA: true, MaxPathLenZero: true,
PermittedDNSDomainsCritical: len(dns)+len(nets) > 0, PermittedDNSDomains: dns, PermittedIPRanges: nets}
der, err := x509.CreateCertificate(rand.Reader, tmpl, ca.Cert, pub, ca.Key)
if err != nil {
return nil, nil, err
}
cert, err := x509.ParseCertificate(der)
return pemEncode("CERTIFICATE", der), cert, err
}
// issue makes a key and a certificate for it.
func (ca *CA) issue(cn string, hosts []string, usage ...x509.ExtKeyUsage) (certPEM, keyPEM []byte, err error) {
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
return nil, nil, err
}
certPEM, _, err = ca.sign(&key.PublicKey, cn, hosts, usage...)
if err != nil {
return nil, nil, err
}
kder, _ := x509.MarshalECPrivateKey(key)
return certPEM, pemEncode("EC PRIVATE KEY", kder), nil
}
// hostAddresses: names and addresses this machine answers on (no link-local).
func hostAddresses() []string {
h, _ := os.Hostname()
out := []string{"localhost", "127.0.0.1", "::1"}
if h != "" {
out = append(out, h)
}
ifs, _ := net.Interfaces()
for _, i := range ifs {
addrs, _ := i.Addrs()
for _, a := range addrs {
if ipn, ok := a.(*net.IPNet); ok && !ipn.IP.IsLinkLocalUnicast() && !ipn.IP.IsLoopback() {
out = append(out, ipn.IP.String())
}
}
}
return out
}
// regionCert keeps dir/region.pem valid for every name and address the region has.
func regionCert(ca *CA, dir string, extra []string) (tls.Certificate, error) {
cp, kp := filepath.Join(dir, "region.pem"), filepath.Join(dir, "region-key.pem")
want := append(hostAddresses(), extra...)
if cb, err := os.ReadFile(cp); err == nil {
if cert, err := parseCertPEM(cb); err == nil && time.Until(cert.NotAfter) > renewBefore && cert.CheckSignatureFrom(ca.Cert) == nil && coversHosts(cert, want) {
if pair, err := tls.LoadX509KeyPair(cp, kp); err == nil {
pair.Certificate = append(pair.Certificate, ca.Cert.Raw)
return pair, nil
}
}
}
h, _ := os.Hostname()
certPEM, keyPEM, err := ca.issue("rollcall region "+h, want, x509.ExtKeyUsageServerAuth)
if err != nil {
return tls.Certificate{}, err
}
if err := os.WriteFile(kp, keyPEM, 0o600); err != nil {
return tls.Certificate{}, err
}
if err := os.WriteFile(cp, certPEM, 0o644); err != nil {
return tls.Certificate{}, err
}
log.Printf("issued the region's TLS certificate for %s", strings.Join(want, " "))
pair, err := tls.X509KeyPair(certPEM, keyPEM)
if err != nil {
return pair, err
}
pair.Certificate = append(pair.Certificate, ca.Cert.Raw) // clients that pin the CA find it in the chain
return pair, nil
}
func coversHosts(c *x509.Certificate, hosts []string) bool {
for _, h := range hosts {
if c.VerifyHostname(strings.Trim(h, "[]")) != nil {
return false
}
}
return true
}
// ---------------------------------------------------------------- join codes
type joinCode struct {
Fingerprint [16]byte // first half of the CA certificate's SHA-256
Secret [16]byte
Expires int64
Addr string // host:port of the region's HTTPS listener
}
func (c joinCode) String() string {
b := make([]byte, 0, 36+len(c.Addr))
b = append(b, c.Fingerprint[:]...)
b = append(b, c.Secret[:]...)
b = binary.BigEndian.AppendUint32(b, uint32(c.Expires))
b = append(b, c.Addr...)
return joinPrefix + base64.RawURLEncoding.EncodeToString(b)
}
func parseJoinCode(s string) (joinCode, error) {
raw, ok := strings.CutPrefix(strings.TrimSpace(s), joinPrefix)
if !ok {
return joinCode{}, errors.New("not a join code: it starts " + joinPrefix)
}
b, err := base64.RawURLEncoding.DecodeString(raw)
if err != nil || len(b) < 36+3 {
return joinCode{}, errors.New("the join code is damaged: copy it whole")
}
var c joinCode
copy(c.Fingerprint[:], b[:16])
copy(c.Secret[:], b[16:32])
c.Expires = int64(binary.BigEndian.Uint32(b[32:36]))
c.Addr = string(b[36:])
if _, _, err := net.SplitHostPort(c.Addr); err != nil {
return joinCode{}, errors.New("the join code is damaged: copy it whole")
}
if time.Now().Unix() > c.Expires {
return joinCode{}, fmt.Errorf("the join code expired at %s; ask for a new one", time.Unix(c.Expires, 0).Format(time.RFC3339))
}
return c, nil
}
// pinnedTLS trusts a server whose chain carries the CA with this fingerprint.
func pinnedTLS(fp [16]byte) *tls.Config {
return &tls.Config{MinVersion: tls.VersionTLS12, InsecureSkipVerify: true, //nolint:gosec // verified below against the pinned CA
VerifyPeerCertificate: func(raw [][]byte, _ [][]*x509.Certificate) error {
var leaf *x509.Certificate
for i, r := range raw {
c, err := x509.ParseCertificate(r)
if err != nil {
return err
}
if i == 0 {
leaf = c
}
sum := sha256.Sum256(r)
if c.IsCA && string(sum[:16]) == string(fp[:]) {
pool := x509.NewCertPool()
pool.AddCert(c)
_, err := leaf.Verify(x509.VerifyOptions{Roots: pool})
return err
}
}
return errors.New("the server's certificate is not from the authority this code is for")
}}
}
func (s *Store) PutJoinToken(ctx context.Context, hash, name string, expires time.Time) error {
_, err := s.pool.Exec(ctx, `INSERT INTO join_tokens(hash, name, expires) VALUES ($1,nullif($2,''),$3)`, hash, name, expires)
return err
}
// UseJoinToken consumes a token; it returns the name it was bound to.
func (s *Store) UseJoinToken(ctx context.Context, hash string) (string, error) {
var name string
err := s.pool.QueryRow(ctx, `UPDATE join_tokens SET used=now() WHERE hash=$1 AND used IS NULL AND expires > now() RETURNING coalesce(name,'')`, hash).Scan(&name)
if err != nil {
return "", errors.New("unknown, used or expired join code")
}
return name, nil
}
func (s *Store) SetRackCert(ctx context.Context, name, serial string, expires time.Time) error {
_, err := s.pool.Exec(ctx, `UPDATE rack_controllers SET cert_serial=$2, cert_expires=$3 WHERE name=$1`, name, serial, expires)
return err
}
func (s *Store) RackCertSerial(ctx context.Context, name string) string {
var serial string
_ = s.pool.QueryRow(ctx, `SELECT coalesce(cert_serial,'') FROM rack_controllers WHERE name=$1`, name).Scan(&serial)
return serial
}
// ---------------------------------------------------------------- HTTP
// peerRack is the rack controller a mutual-TLS request comes from, if any:
// a verified client certificate whose serial is the one on record.
func (a *App) peerRack(r *http.Request) string {
if r.TLS == nil || len(r.TLS.VerifiedChains) == 0 || len(r.TLS.PeerCertificates) == 0 {
return ""
}
c := r.TLS.PeerCertificates[0]
name := strings.TrimPrefix(c.Subject.CommonName, "rack:")
if name == c.Subject.CommonName { // only rack certificates
return ""
}
if a.store.RackCertSerial(r.Context(), name) != c.SerialNumber.Text(16) {
return ""
}
return name
}
func (a *App) handleCAPEM(w http.ResponseWriter, r *http.Request) {
if a.ca == nil {
http.NotFound(w, r)
return
}
w.Header().Set("Content-Type", "application/x-pem-file")
w.Header().Set("Content-Disposition", `attachment; filename="rollcall-ca.pem"`)
w.Header().Set("X-Rollcall-CA-Fingerprint", a.ca.Fingerprint())
_, _ = w.Write(a.ca.PEM)
}
// handleJoinCode makes a one-time code for a rack controller (admin).
func (a *App) handleJoinCode(w http.ResponseWriter, r *http.Request) {
if a.ca == nil || a.cfg.TLSListen == "" {
fail(w, 409, errors.New("the region has no TLS listener (tls_listen)"))
return
}
var req struct {
Name string `json:"name"`
TTL int `json:"ttl_minutes"`
Addr string `json:"addr"`
}
_ = json.NewDecoder(r.Body).Decode(&req)
if req.TTL <= 0 {
req.TTL = 60
}
addr := req.Addr
if addr == "" {
host := r.Host
if h, _, err := net.SplitHostPort(host); err == nil {
host = h
}
if ip := net.ParseIP(strings.Trim(host, "[]")); (ip != nil && ip.IsLoopback()) || host == "localhost" {
fail(w, 400, errors.New("say where rack controllers reach the region's HTTPS: addr (CLI -addr host:443)"))
return
}
_, port, _ := net.SplitHostPort(a.cfg.TLSListen)
addr = net.JoinHostPort(strings.Trim(host, "[]"), port)
}
var c joinCode
sum := certFingerprint(a.ca.Cert)
copy(c.Fingerprint[:], sum[:16])
_, _ = rand.Read(c.Secret[:])
c.Expires = time.Now().Add(time.Duration(req.TTL) * time.Minute).Unix()
c.Addr = addr
h := sha256.Sum256(c.Secret[:])
if err := a.store.PutJoinToken(r.Context(), hex.EncodeToString(h[:]), req.Name, time.Unix(c.Expires, 0)); err != nil {
fail(w, 500, err)
return
}
writeJSON(w, 200, obj{"code": c.String(), "expires": time.Unix(c.Expires, 0), "addr": addr, "name": req.Name, "ca_fingerprint": a.ca.Fingerprint()})
}
type enrollRequest struct {
Secret string `json:"secret"` // base64 of the code's secret
Name string `json:"name"`
CSR string `json:"csr"`
Rack RackController `json:"rack"`
}
func (a *App) signRackCSR(csrPEM, name string, rc RackController) ([]byte, *x509.Certificate, error) {
blk, _ := pem.Decode([]byte(csrPEM))
if blk == nil {
return nil, nil, errors.New("csr is not PEM")
}
csr, err := x509.ParseCertificateRequest(blk.Bytes)
if err != nil {
return nil, nil, err
}
if err := csr.CheckSignature(); err != nil {
return nil, nil, err
}
hosts := []string{name}
if u := strings.TrimPrefix(strings.TrimPrefix(rc.URL, "http://"), "https://"); u != "" {
if h, _, err := net.SplitHostPort(u); err == nil {
hosts = append(hosts, h)
} else {
hosts = append(hosts, strings.Trim(u, "/[]"))
}
}
for _, i := range rc.Interfaces {
for _, ad := range i.Addrs {
hosts = append(hosts, stripPrefix(ad))
}
}
return a.ca.sign(csr.PublicKey, "rack:"+name, hosts, x509.ExtKeyUsageClientAuth, x509.ExtKeyUsageServerAuth)
}
// handleRackEnroll: a rack controller presents a join code's secret and a CSR
// over TLS; it gets a certificate and the CA. No admin token, no client cert.
func (a *App) handleRackEnroll(w http.ResponseWriter, r *http.Request) {
if r.TLS == nil || a.ca == nil {
fail(w, 400, errors.New("enrollment needs HTTPS"))
return
}
var req enrollRequest
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20)).Decode(&req); err != nil {
fail(w, 400, err)
return
}
sec, err := base64.RawURLEncoding.DecodeString(req.Secret)
if err != nil {
fail(w, 400, err)
return
}
h := sha256.Sum256(sec)
bound, err := a.store.UseJoinToken(r.Context(), hex.EncodeToString(h[:]))
if err != nil {
fail(w, 401, err)
return
}
name := req.Name
if bound != "" {
name = bound
}
if !tagNameRe.MatchString(name) || name == a.cfg.RackName {
fail(w, 400, fmt.Errorf("bad rack controller name %q", name))
return
}
certPEM, cert, err := a.signRackCSR(req.CSR, name, req.Rack)
if err != nil {
fail(w, 400, err)
return
}
rc := req.Rack
rc.Name, rc.Kind = name, "remote"
if err := a.store.PutRackController(r.Context(), rc); err != nil {
fail(w, 500, err)
return
}
if err := a.store.SetRackCert(r.Context(), name, cert.SerialNumber.Text(16), cert.NotAfter); err != nil {
fail(w, 500, err)
return
}
a.reloadRacks(r.Context())
a.hub.bump()
log.Printf("rack controller %s enrolled from %s, certificate until %s", name, r.RemoteAddr, cert.NotAfter.Format("2006-01-02"))
writeJSON(w, 200, obj{"name": name, "cert": string(certPEM), "ca": string(a.ca.PEM)})
}
// handleRackRenew issues a new certificate to a rack controller that still has a valid one.
func (a *App) handleRackRenew(w http.ResponseWriter, r *http.Request, rack string) {
if a.peerRack(r) != rack {
fail(w, 401, errors.New("renewal needs the current certificate"))
return
}
var req enrollRequest
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20)).Decode(&req); err != nil {
fail(w, 400, err)
return
}
rc := a.racksSnapshot()[rack]
certPEM, cert, err := a.signRackCSR(req.CSR, rack, rc)
if err != nil {
fail(w, 400, err)
return
}
if err := a.store.SetRackCert(r.Context(), rack, cert.SerialNumber.Text(16), cert.NotAfter); err != nil {
fail(w, 500, err)
return
}
log.Printf("rack controller %s renewed its certificate, until %s", rack, cert.NotAfter.Format("2006-01-02"))
writeJSON(w, 200, obj{"name": rack, "cert": string(certPEM), "ca": string(a.ca.PEM)})
}
// ---------------------------------------------------------------- CLI: rollcall ca (on the region host)
func caCmd(args []string) error {
cfg, err := loadConfig(envOr("ROLLCALL_CONFIG", "/etc/rollcall/config.json"))
if err != nil {
return err
}
if len(args) == 0 || args[0] == "show" {
cb, err := os.ReadFile(filepath.Join(cfg.CADir, "ca.pem"))
if err != nil {
return fmt.Errorf("no CA yet (it is created when rollcall serve starts): %w", err)
}
c, err := parseCertPEM(cb)
if err != nil {
return err
}
fmt.Printf("%s\n subject %s\n valid until %s\n fingerprint %s\n", filepath.Join(cfg.CADir, "ca.pem"), c.Subject.CommonName,
c.NotAfter.Format("2006-01-02"), fpString(certFingerprint(c)))
if rb, err := os.ReadFile(filepath.Join(cfg.CADir, "region.pem")); err == nil {
if rc, err := parseCertPEM(rb); err == nil {
names := append([]string{}, rc.DNSNames...)
for _, ip := range rc.IPAddresses {
names = append(names, ip.String())
}
sort.Strings(names)
fmt.Printf("region certificate until %s for %s\n", rc.NotAfter.Format("2006-01-02"), strings.Join(names, " "))
}
}
return nil
}
switch {
case args[0] == "intermediate" && len(args) >= 2:
return caIntermediateCmd(cfg, args[1], args[2:])
case args[0] == "rotate":
return caRotateCmd(cfg, args[1:])
case args[0] != "issue" || len(args) < 2:
usage()
}
fs := newFlags("ca issue")
hosts := fs.String("host", "", "comma-separated DNS names and addresses")
client := fs.Bool("client", false, "client certificate (identifies its holder) instead of a server one")
out := fs.String("out", ".", "directory for NAME.pem and NAME-key.pem")
_ = fs.Parse(args[2:])
ca, err := loadOrCreateCA(cfg.CADir, "rollcall CA")
if err != nil {
return err
}
var hs []string
if *hosts != "" {
hs = strings.Split(*hosts, ",")
}
usage := x509.ExtKeyUsageServerAuth
if *client {
usage = x509.ExtKeyUsageClientAuth
} else if len(hs) == 0 {
return errors.New("a server certificate needs -host")
}
certPEM, keyPEM, err := ca.issue(args[1], hs, usage)
if err != nil {
return err
}
if err := os.WriteFile(filepath.Join(*out, args[1]+"-key.pem"), keyPEM, 0o600); err != nil {
return err
}
if err := os.WriteFile(filepath.Join(*out, args[1]+".pem"), append(certPEM, ca.PEM...), 0o644); err != nil {
return err
}
fmt.Printf("wrote %s.pem (with the CA) and %s-key.pem in %s, valid until %s\n", args[1], args[1], *out, time.Now().Add(leafValidity).Format("2006-01-02"))
return nil
}
// rollcall ca intermediate NAME -permit DOMAIN,CIDR [-csr FILE] [-days N] [-out DIR]
func caIntermediateCmd(cfg *Config, name string, args []string) error {
fs := newFlags("ca intermediate")
permit := fs.String("permit", "", "comma-separated DNS domains and CIDRs it may issue for (required)")
csrFile := fs.String("csr", "", "sign this CSR (the key stays where it was made); without it a key is generated")
days := fs.Int("days", 365, "validity in days")
out := fs.String("out", ".", "directory for NAME.pem (+ NAME-key.pem without -csr)")
unconstrained := fs.Bool("unconstrained", false, "allow an intermediate without name constraints")
_ = fs.Parse(args)
dns, nets, err := parsePermits(*permit)
if err != nil {
return err
}
if len(dns)+len(nets) == 0 && !*unconstrained {
return errors.New("an intermediate needs -permit (e.g. -permit int.example.com,10.213.0.0/16); -unconstrained to override")
}
ca, err := loadOrCreateCA(cfg.CADir, "rollcall CA")
if err != nil {
return err
}
var pub crypto.PublicKey
var keyPEM []byte
if *csrFile != "" {
b, err := os.ReadFile(*csrFile)
if err != nil {
return err
}
blk, _ := pem.Decode(b)
if blk == nil {
return fmt.Errorf("%s: not PEM", *csrFile)
}
csr, err := x509.ParseCertificateRequest(blk.Bytes)
if err != nil {
return err
}
if err := csr.CheckSignature(); err != nil {
return fmt.Errorf("CSR signature: %w", err)
}
pub = csr.PublicKey
} else {
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
return err
}
pub = &key.PublicKey
kder, _ := x509.MarshalECPrivateKey(key)
keyPEM = pemEncode("EC PRIVATE KEY", kder)
}
certPEM, cert, err := ca.intermediate(pub, name, dns, nets, time.Duration(*days)*24*time.Hour)
if err != nil {
return err
}
if keyPEM != nil {
if err := os.WriteFile(filepath.Join(*out, name+"-key.pem"), keyPEM, 0o600); err != nil {
return err
}
}
if err := os.WriteFile(filepath.Join(*out, name+".pem"), certPEM, 0o644); err != nil {
return err
}
var permits []string
permits = append(permits, dns...)
for _, n := range nets {
permits = append(permits, n.String())
}
fmt.Printf("wrote %s.pem in %s: intermediate CA %q until %s, may issue for %s\nroot: %s (%s)\n", name, *out, name,
cert.NotAfter.Format("2006-01-02"), orDash(strings.Join(permits, " ")), filepath.Join(cfg.CADir, "ca.pem"), ca.Fingerprint())
return nil
}
// rollcall ca rotate -yes: a new root (the old one is kept as a backup). Racks
// must join again and clients trust the new fingerprint.
func caRotateCmd(cfg *Config, args []string) error {
fs := newFlags("ca rotate")
yes := fs.Bool("yes", false, "really replace the root")
_ = fs.Parse(args)
old, err := loadOrCreateCA(cfg.CADir, "rollcall CA")
if err != nil {
return err
}
if !*yes {
return fmt.Errorf("this replaces the root %s (delegates: %v); rack controllers then join again and clients trust the new one. Run with -yes", old.Fingerprint(), old.canDelegate())
}
bak := cfg.CADir + ".bak-" + time.Now().Format("20060102-150405")
if err := os.Rename(cfg.CADir, bak); err != nil {
return err
}
ca, err := loadOrCreateCA(cfg.CADir, "rollcall CA")
if err != nil {
_ = os.Rename(bak, cfg.CADir)
return err
}
if st, err := os.Stat(bak); err == nil {
_ = os.Chmod(cfg.CADir, st.Mode().Perm())
if own, ok := fileOwner(bak); ok {
_ = chownTree(cfg.CADir, own[0], own[1])
}
}
fmt.Printf("new root %s\nold root kept in %s\nnext: restart rollcall; on each rack controller `rollcall rack join` with a new join code; clients trust the new fingerprint\n", ca.Fingerprint(), bak)
return nil
}
// fileOwner returns uid, gid of path (unix).
func fileOwner(path string) ([2]int, bool) {
st, err := os.Stat(path)
if err != nil {
return [2]int{}, false
}
if s, ok := st.Sys().(*syscall.Stat_t); ok {
return [2]int{int(s.Uid), int(s.Gid)}, true
}
return [2]int{}, false
}
func chownTree(root string, uid, gid int) error {
return filepath.Walk(root, func(p string, _ os.FileInfo, err error) error {
if err != nil {
return err
}
return os.Lchown(p, uid, gid)
})
}