Renewal could not work: rack join writes the certificate and key as root, and the rack (user rollcall) failed to overwrite them. A lost renewal answer also locked the rack out, since the region accepted only the new serial. - the renewed certificate and key go into one file in /var/lib/rollcall/rack-cert (cert_state_dir), written atomically, and are preferred at start when they are this rack's, from the CA, newer - after a renewal the region accepts the previous serial for 7 days; a new join does not keep it - rollcall rack renew renews now (run as the service user) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
826 lines
28 KiB
Go
826 lines
28 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 (anchors in the image + bootcmd), 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)
|
|
}
|
|
d := strings.Trim(strings.TrimPrefix(strings.ToLower(p), "*."), ".")
|
|
if !validDomain(d) {
|
|
return nil, nil, fmt.Errorf("%s: give a domain (int.example.com) or a CIDR", p)
|
|
}
|
|
dns = append(dns, d)
|
|
}
|
|
if len(dns)+len(nets) == 0 {
|
|
return nil, nil, errors.New("give at least one permitted domain or CIDR")
|
|
}
|
|
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: true, PermittedDNSDomains: dns, PermittedIPRanges: nets,
|
|
// server certificates only: nothing it issues can be a client (admin, rack) identity
|
|
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}}
|
|
// a constraint on one name type leaves the other open: close it
|
|
if len(nets) == 0 {
|
|
tmpl.ExcludedIPRanges = []*net.IPNet{{IP: net.IPv4zero, Mask: net.CIDRMask(0, 32)}, {IP: net.IPv6zero, Mask: net.CIDRMask(0, 128)}}
|
|
}
|
|
if len(dns) == 0 {
|
|
tmpl.PermittedDNSDomains = []string{"invalid"} // RFC 6761: no name below it exists
|
|
}
|
|
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
|
|
}
|
|
|
|
// regionLeaf accepts the region's own certificate only: "rollcall region ...",
|
|
// signed by the root itself - not a rack's, not one from a delegated intermediate.
|
|
func regionLeaf(cs tls.ConnectionState) error {
|
|
for _, ch := range cs.VerifiedChains {
|
|
if len(ch) == 2 && strings.HasPrefix(ch[0].Subject.CommonName, "rollcall region ") {
|
|
return nil
|
|
}
|
|
}
|
|
return errors.New("the server's certificate is not the region's")
|
|
}
|
|
|
|
// 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)
|
|
chains, err := leaf.Verify(x509.VerifyOptions{Roots: pool})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return regionLeaf(tls.ConnectionState{VerifiedChains: chains})
|
|
}
|
|
}
|
|
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
|
|
}
|
|
|
|
// rackCertGrace: after a renewal the previous certificate still works this
|
|
// long, so a renewal answer lost on the way does not lock the rack out.
|
|
const rackCertGrace = 7 * 24 * time.Hour
|
|
|
|
// SetRackCert records a rack's certificate. A renewal keeps the previous
|
|
// serial for rackCertGrace; an enrollment (a new join) does not.
|
|
func (s *Store) SetRackCert(ctx context.Context, name, serial string, expires time.Time, renewal bool) error {
|
|
if renewal {
|
|
_, err := s.pool.Exec(ctx, `UPDATE rack_controllers SET prev_cert_serial=cert_serial, prev_cert_until=now()+$4::interval,
|
|
cert_serial=$2, cert_expires=$3 WHERE name=$1`, name, serial, expires, fmt.Sprintf("%d seconds", int(rackCertGrace.Seconds())))
|
|
return err
|
|
}
|
|
_, err := s.pool.Exec(ctx, `UPDATE rack_controllers SET cert_serial=$2, cert_expires=$3, prev_cert_serial=NULL, prev_cert_until=NULL WHERE name=$1`, name, serial, expires)
|
|
return err
|
|
}
|
|
|
|
// rackCertOK: the serial is the rack's current certificate, or the previous one within the grace period.
|
|
func (s *Store) rackCertOK(ctx context.Context, name, serial string) bool {
|
|
var cur, prev string
|
|
var until *time.Time
|
|
if err := s.pool.QueryRow(ctx, `SELECT coalesce(cert_serial,''), coalesce(prev_cert_serial,''), prev_cert_until FROM rack_controllers WHERE name=$1`, name).Scan(&cur, &prev, &until); err != nil {
|
|
return false
|
|
}
|
|
return serial != "" && (serial == cur || (serial == prev && until != nil && time.Now().Before(*until)))
|
|
}
|
|
|
|
// ---------------------------------------------------------------- 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 {
|
|
c := rootIssued(r)
|
|
if c == nil {
|
|
return ""
|
|
}
|
|
name := strings.TrimPrefix(c.Subject.CommonName, "rack:")
|
|
if name == c.Subject.CommonName { // only rack certificates
|
|
return ""
|
|
}
|
|
if !a.store.rackCertOK(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) ([]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
|
|
}
|
|
// racks serve no TLS: a client identity only, no names, so a rack cannot pose as the region
|
|
return a.ca.sign(csr.PublicKey, "rack:"+name, nil, x509.ExtKeyUsageClientAuth)
|
|
}
|
|
|
|
// 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
|
|
}
|
|
// a code made for one name may re-enroll that rack; an unbound code may not
|
|
// take over a name in use (its machines, BMC jobs and certificate)
|
|
if _, exists := a.racksSnapshot()[name]; exists && bound == "" {
|
|
fail(w, 409, fmt.Errorf("rack controller %s exists; make a code for it: rollcall controller join-code -name %s", name, name))
|
|
return
|
|
}
|
|
certPEM, cert, err := a.signRackCSR(req.CSR, name)
|
|
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, false); 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
|
|
}
|
|
certPEM, cert, err := a.signRackCSR(req.CSR, rack)
|
|
if err != nil {
|
|
fail(w, 400, err)
|
|
return
|
|
}
|
|
if err := a.store.SetRackCert(r.Context(), rack, cert.SerialNumber.Text(16), cert.NotAfter, true); 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)
|
|
})
|
|
}
|