Agents over HTTPS: rack server certificates, fingerprinted CA
CI / agent (push) Successful in 3s
CI / site (push) Successful in 2s
CI / go (push) Failing after 20s

Rack controllers ask the region for a server certificate for their own
URL's host (POST /api/v1/rack/server-cert; CN rack-server:NAME,
ServerAuth only, never one of the region's names or addresses) and serve
the agents' endpoints and images over HTTPS too (tls_listen, default
<host>:443), reporting the HTTPS URL when they register (accepted only
on the rack's own host). The pxelinux command lines name it with the CA
fingerprint (rollcall.tls=, rollcall.ca=); the embedded rack uses the
region's listener. Agents fetch /ca.pem, accept it only by that
fingerprint and then use HTTPS with it, including the image download; a
job fetched over HTTPS gets an HTTPS image URL. No fallback to HTTP once
TLS was asked for; old agents ignore the new words. The CLI refuses a
rack controller's certificate as the region.

PXE itself (TFTP, kernel, initrd) stays unauthenticated: this protects
what agents send and receive from listeners and from an HTTP server
posing as the rack, not from an attacker rewriting boot files.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Ilker ManapandClaude Opus 5.5 committed 2026-10-29 22:51:17 +01:00
1 parent 74922f7491
commit a9ca326dd2
12 files changed
+443 -29

No files matched your search

+36 -3
View File
@@ -13,11 +13,13 @@ Kernel cmdline options:
rollcall.mode=deploy rollcall.job=.. run rollcall-deploy.py instead (OS deployment / release job) rollcall.mode=deploy rollcall.job=.. run rollcall-deploy.py instead (OS deployment / release job)
""" """
import glob import glob
import hashlib
import json import json
import os import os
import re import re
import secrets import secrets
import socket import socket
import ssl
import string import string
import subprocess import subprocess
import sys import sys
@@ -399,6 +401,36 @@ def bmc_inband(user_name):
AGENT_TOKEN = "" AGENT_TOKEN = ""
def tls_setup(opts, server):
"""rollcall.tls=URL rollcall.ca=SHA256: the CA certificate is fetched and
accepted only by that fingerprint; from then on everything goes over HTTPS
with it. When TLS was asked for and does not work, nothing falls back to HTTP."""
url, want = opts.get("tls"), str(opts.get("ca") or "").replace(":", "").lower()
if not isinstance(url, str) or not url.startswith("https://") or len(want) != 64:
return server, None, None
pem = None
for i in range(10):
try:
with urllib.request.urlopen(f"{server}/ca.pem", timeout=10) as r:
pem = r.read().decode()
break
except (urllib.error.URLError, OSError) as e:
log(f"ca.pem: {e}, retry {i + 1}/10")
time.sleep(3)
if pem is None:
raise RuntimeError("cannot fetch the CA certificate")
if hashlib.sha256(ssl.PEM_cert_to_DER_cert(pem)).hexdigest() != want:
raise RuntimeError("the CA certificate does not match rollcall.ca: not talking to this server")
path = "/run/rollcall-ca.pem"
with open(path, "w") as f:
f.write(pem)
log(f"talking to {url} over HTTPS (CA {want[:16]}...)")
return url.rstrip("/"), ssl.create_default_context(cafile=path), path
TLS = None # ssl context once tls_setup ran
def agent_headers(): def agent_headers():
h = {"Content-Type": "application/json"} h = {"Content-Type": "application/json"}
if AGENT_TOKEN: if AGENT_TOKEN:
@@ -409,7 +441,7 @@ def agent_headers():
def bmc_hint(server, macs): def bmc_hint(server, macs):
try: try:
req = urllib.request.Request(f"{server}/api/v1/bmc-hint?macs={','.join(macs)}", headers=agent_headers()) req = urllib.request.Request(f"{server}/api/v1/bmc-hint?macs={','.join(macs)}", headers=agent_headers())
with urllib.request.urlopen(req, timeout=10) as r: with urllib.request.urlopen(req, timeout=10, context=TLS) as r:
h = json.load(r) h = json.load(r)
except (urllib.error.URLError, json.JSONDecodeError, OSError): except (urllib.error.URLError, json.JSONDecodeError, OSError):
return None return None
@@ -439,7 +471,7 @@ def post(server, payload, tries=30):
req = urllib.request.Request(f"{server}/api/v1/commission", data=data, headers=agent_headers(), method="POST") req = urllib.request.Request(f"{server}/api/v1/commission", data=data, headers=agent_headers(), method="POST")
for i in range(tries): for i in range(tries):
try: try:
with urllib.request.urlopen(req, timeout=30) as r: with urllib.request.urlopen(req, timeout=30, context=TLS) as r:
return json.load(r) return json.load(r)
except (urllib.error.URLError, OSError) as e: except (urllib.error.URLError, OSError) as e:
log(f"post failed ({e}), retry {i + 1}/{tries}") log(f"post failed ({e}), retry {i + 1}/{tries}")
@@ -457,7 +489,8 @@ def main():
log("rollcall.server= missing on kernel cmdline; nothing to do") log("rollcall.server= missing on kernel cmdline; nothing to do")
return 2 return 2
server = server.rstrip("/") server = server.rstrip("/")
global AGENT_TOKEN global AGENT_TOKEN, TLS
server, TLS, _ = tls_setup(opts, server)
AGENT_TOKEN = opts.get("token") if isinstance(opts.get("token"), str) else "" AGENT_TOKEN = opts.get("token") if isinstance(opts.get("token"), str) else ""
user_name = opts.get("bmcuser") or "commissioner" user_name = opts.get("bmcuser") or "commissioner"
started = time.time() started = time.time()
+35 -2
View File
@@ -16,6 +16,7 @@ import hashlib
import json import json
import os import os
import re import re
import ssl
import subprocess import subprocess
import sys import sys
import threading import threading
@@ -62,6 +63,35 @@ def cmdline():
# --------------------------------------------------------------------------- server # --------------------------------------------------------------------------- server
TLS, CAFILE = None, None
def tls_setup(opts, server):
"""rollcall.tls=URL rollcall.ca=SHA256: the CA certificate is fetched and
accepted only by that fingerprint; from then on everything goes over HTTPS
with it. When TLS was asked for and does not work, nothing falls back to HTTP."""
url, want = opts.get("tls"), str(opts.get("ca") or "").replace(":", "").lower()
if not isinstance(url, str) or not url.startswith("https://") or len(want) != 64:
return server, None, None
pem = None
for i in range(10):
try:
with urllib.request.urlopen(f"{server}/ca.pem", timeout=10) as r:
pem = r.read().decode()
break
except (urllib.error.URLError, OSError) as e:
log(f"ca.pem: {e}, retry {i + 1}/10")
time.sleep(3)
if pem is None:
raise RuntimeError("cannot fetch the CA certificate")
if hashlib.sha256(ssl.PEM_cert_to_DER_cert(pem)).hexdigest() != want:
raise RuntimeError("the CA certificate does not match rollcall.ca: not talking to this server")
path = "/run/rollcall-ca.pem"
with open(path, "w") as f:
f.write(pem)
log(f"talking to {url} over HTTPS (CA {want[:16]}...)")
return url.rstrip("/"), ssl.create_default_context(cafile=path), path
def api(method, path, body=None, tries=20): def api(method, path, body=None, tries=20):
data = json.dumps(body).encode() if body is not None else None data = json.dumps(body).encode() if body is not None else None
req = urllib.request.Request(SERVER + path, data=data, method=method, req = urllib.request.Request(SERVER + path, data=data, method=method,
@@ -69,7 +99,7 @@ def api(method, path, body=None, tries=20):
last = None last = None
for i in range(tries): for i in range(tries):
try: try:
with urllib.request.urlopen(req, timeout=30) as r: with urllib.request.urlopen(req, timeout=30, context=TLS) as r:
return json.load(r) return json.load(r)
except urllib.error.HTTPError as e: except urllib.error.HTTPError as e:
if 400 <= e.code < 500: if 400 <= e.code < 500:
@@ -196,7 +226,8 @@ def write_image(spec, disk, prog):
["bash", "-c", 'set -o pipefail; pv -n -f -i 2 -s "$1" 2>"$2" | zstd -dc -T0 ' ["bash", "-c", 'set -o pipefail; pv -n -f -i 2 -s "$1" 2>"$2" | zstd -dc -T0 '
'| dd of="$3" bs=4M iflag=fullblock oflag=direct conv=fsync status=none', "write", str(size), pvf, disk], '| dd of="$3" bs=4M iflag=fullblock oflag=direct conv=fsync status=none', "write", str(size), pvf, disk],
stdin=subprocess.PIPE, stderr=subprocess.PIPE) stdin=subprocess.PIPE, stderr=subprocess.PIPE)
curl = subprocess.Popen(["curl", "-fsSL", "--retry", "5", "--retry-connrefused", "--", url], cacert = ["--cacert", CAFILE] if CAFILE and url.startswith("https://") else []
curl = subprocess.Popen(["curl", "-fsSL", "--retry", "5", "--retry-connrefused"] + cacert + ["--", url],
stdout=subprocess.PIPE, stderr=subprocess.PIPE) stdout=subprocess.PIPE, stderr=subprocess.PIPE)
log(f"writing {url} -> {disk}") log(f"writing {url} -> {disk}")
h, pct, shown = hashlib.sha256(), 0, 0.0 h, pct, shown = hashlib.sha256(), 0, 0.0
@@ -370,6 +401,8 @@ def main():
global SERVER, TOKEN global SERVER, TOKEN
opts = cmdline() opts = cmdline()
SERVER = (opts.get("server") or "").rstrip("/") SERVER = (opts.get("server") or "").rstrip("/")
global TLS, CAFILE
SERVER, TLS, CAFILE = tls_setup(opts, SERVER)
TOKEN = opts.get("job") if isinstance(opts.get("job"), str) else "" TOKEN = opts.get("job") if isinstance(opts.get("job"), str) else ""
if not SERVER or not TOKEN: if not SERVER or not TOKEN:
log("rollcall.server= and rollcall.job= required") log("rollcall.server= and rollcall.job= required")
+171
View File
@@ -0,0 +1,171 @@
package main
// TLS for the agents (commissioning, deploy, stateless nodes). Rack
// controllers serve the node API and images over HTTPS too, with a server
// certificate the region issues for the rack's own address only (never one
// of the region's). The pxelinux command line names the HTTPS URL and the
// CA's fingerprint (rollcall.tls=, rollcall.ca=); an agent fetches the CA
// certificate, accepts it only by that fingerprint, and then talks HTTPS
// with it. The embedded rack uses the region's own certificate.
//
// This keeps what agents send and receive (BMC passwords, seed files) from
// anyone who can only listen, and from an HTTP server posing as the rack.
// PXE itself (TFTP, the kernel, the initrd) stays unauthenticated: an
// attacker who can rewrite the boot files on the wire is not stopped here;
// that takes UEFI Secure Boot with signed images.
import (
"crypto/x509"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"log"
"net"
"net/http"
"net/url"
"strings"
)
// rackServerCN is the subject of a rack controller's server certificate;
// regionLeaf and the CLI refuse it as the region.
const rackServerCN = "rack-server:"
// handleRackServerCert issues a rack's server certificate for the host of its
// registered URL: {"csr"} -> {"cert"}.
func (a *App) handleRackServerCert(w http.ResponseWriter, r *http.Request, rack string) {
if a.peerRack(r) != rack || a.ca == nil {
fail(w, 401, errors.New("a server certificate needs the rack's own certificate"))
return
}
rc, ok := a.racksSnapshot()[rack]
if !ok {
fail(w, 409, fmt.Errorf("rack controller %s is not registered", rack))
return
}
host, err := rackHost(rc.URL)
if err != nil {
fail(w, 400, err)
return
}
if a.isRegionName(host) {
fail(w, 403, fmt.Errorf("%s is the region's own address: no rack certificate for it", host))
return
}
var req enrollRequest
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20)).Decode(&req); err != nil {
fail(w, 400, err)
return
}
csr, err := parseCSR(req.CSR)
if err != nil {
fail(w, 400, err)
return
}
certPEM, cert, err := a.ca.sign(csr.PublicKey, rackServerCN+rack, []string{host}, x509.ExtKeyUsageServerAuth)
if err != nil {
fail(w, 500, err)
return
}
log.Printf("rack controller %s: server certificate for %s until %s", rack, host, cert.NotAfter.Format("2006-01-02"))
writeJSON(w, 200, obj{"cert": string(certPEM), "ca": string(a.ca.PEM)})
}
// rackHost is the host part of a rack URL (an address or a name).
func rackHost(raw string) (string, error) {
u, err := url.Parse(raw)
if err != nil || u.Hostname() == "" {
return "", fmt.Errorf("rack URL %q has no host", raw)
}
return u.Hostname(), nil
}
// isRegionName: one of the names or addresses the region's own certificate covers.
func (a *App) isRegionName(host string) bool {
h := strings.ToLower(strings.Trim(host, "[]"))
if ad, ok := parseAddr(h); ok {
h = ad.String()
}
for _, n := range append(hostAddresses(), a.cfg.TLSNames...) {
n = strings.ToLower(n)
if ad, ok := parseAddr(n); ok {
n = ad.String()
}
if n == h {
return true
}
}
if su, err := url.Parse(a.cfg.ServerURL); err == nil && strings.EqualFold(su.Hostname(), h) {
return true
}
return false
}
// validTLSURL: what a rack reports must be https on its own URL's host.
func validTLSURL(rc RackController) string {
if rc.TLSURL == "" {
return ""
}
u, err := url.Parse(rc.TLSURL)
host, herr := rackHost(rc.URL)
if err != nil || herr != nil || u.Scheme != "https" || !strings.EqualFold(u.Hostname(), host) || u.Path != "" && u.Path != "/" {
return ""
}
return strings.TrimRight(rc.TLSURL, "/")
}
// agentTLS is the HTTPS base agents behind this base URL use, "" when none.
func (a *App) agentTLS(base string) string {
if a.ca == nil {
return ""
}
if base == a.cfg.ServerURL { // the embedded rack: the region's own listener
if a.cfg.TLSListen == "" {
return ""
}
u, err := url.Parse(base)
if err != nil || u.Hostname() == "" {
return ""
}
_, port, _ := net.SplitHostPort(a.cfg.TLSListen)
if port == "" || port == "443" {
return "https://" + hostPort(u.Hostname(), "")
}
return "https://" + hostPort(u.Hostname(), port)
}
for _, rc := range a.racksSnapshot() {
if strings.TrimRight(rc.URL, "/") == base {
return validTLSURL(rc)
}
}
return ""
}
func hostPort(h, port string) string {
if strings.Contains(h, ":") {
h = "[" + h + "]"
}
if port == "" {
return h
}
return h + ":" + port
}
// agentTLSArgs are the command-line words that switch an agent to HTTPS.
func (a *App) agentTLSArgs(base string) []string {
t := a.agentTLS(base)
if t == "" {
return nil
}
fp := certFingerprint(a.ca.Cert)
return []string{"rollcall.tls=" + t, "rollcall.ca=" + hex.EncodeToString(fp[:])}
}
// overTLS: the agent's request came over HTTPS (to the region, or to a rack
// controller that says so in X-Forwarded-Proto).
func (a *App) overTLS(r *http.Request) bool {
if rack := a.fromRack(r); rack != "" && rack != a.cfg.RackName {
return r.Header.Get("X-Forwarded-Proto") == "https"
}
return r.TLS != nil
}
+10 -4
View File
@@ -532,16 +532,22 @@ type enrollRequest struct {
Rack RackController `json:"rack"` Rack RackController `json:"rack"`
} }
func (a *App) signRackCSR(csrPEM, name string) ([]byte, *x509.Certificate, error) { // parseCSR reads a PEM certificate request; only its key is used.
func parseCSR(csrPEM string) (*x509.CertificateRequest, error) {
blk, _ := pem.Decode([]byte(csrPEM)) blk, _ := pem.Decode([]byte(csrPEM))
if blk == nil { if blk == nil {
return nil, nil, errors.New("csr is not PEM") return nil, errors.New("csr is not PEM")
} }
csr, err := x509.ParseCertificateRequest(blk.Bytes) csr, err := x509.ParseCertificateRequest(blk.Bytes)
if err != nil { if err != nil {
return nil, nil, err return nil, err
} }
if err := csr.CheckSignature(); err != nil { return csr, csr.CheckSignature()
}
func (a *App) signRackCSR(csrPEM, name string) ([]byte, *x509.Certificate, error) {
csr, err := parseCSR(csrPEM)
if err != nil {
return nil, nil, err return nil, nil, err
} }
// racks serve no TLS: a client identity only, no names, so a rack cannot pose as the region // racks serve no TLS: a client identity only, no names, so a rack cannot pose as the region
+10
View File
@@ -39,6 +39,16 @@ func clientTLS(caPEM string) *tls.Config {
} }
} }
tc := &tls.Config{MinVersion: tls.VersionTLS12} tc := &tls.Config{MinVersion: tls.VersionTLS12}
// a rack controller's certificate (its client one, or the server one it
// shows agents) is never the region, whatever names it carries
tc.VerifyConnection = func(cs tls.ConnectionState) error {
if len(cs.PeerCertificates) > 0 {
if cn := cs.PeerCertificates[0].Subject.CommonName; strings.HasPrefix(cn, "rack:") || strings.HasPrefix(cn, rackServerCN) {
return fmt.Errorf("the server shows the certificate of a rack controller (%s), not the region's", cn)
}
}
return nil
}
if cert := clientCertificate(); cert != nil { if cert := clientCertificate(); cert != nil {
tc.Certificates = []tls.Certificate{*cert} tc.Certificates = []tls.Certificate{*cert}
} }
+8
View File
@@ -716,6 +716,14 @@ func (a *App) handleJobSelf(w http.ResponseWriter, r *http.Request) {
return return
} }
sp := j.spec() sp := j.spec()
if a.overTLS(r) { // an agent speaking HTTPS gets the image over HTTPS too
if m, err := a.store.Get(r.Context(), j.MachineID); err == nil {
base := a.urlFor(m)
if t := a.agentTLS(base); t != "" && strings.HasPrefix(sp.ImageURL, base+"/") {
sp.ImageURL = t + strings.TrimPrefix(sp.ImageURL, base)
}
}
}
out := obj{"id": j.ID, "machine_id": j.MachineID, "kind": j.Kind, "spec": sp} out := obj{"id": j.ID, "machine_id": j.MachineID, "kind": j.Kind, "spec": sp}
if j.Kind == "deploy" { if j.Kind == "deploy" {
out["files"] = a.renderSeed(j, sp, tok) out["files"] = a.renderSeed(j, sp, tok)
+15 -9
View File
@@ -40,7 +40,8 @@ type RackIface struct {
type RackController struct { type RackController struct {
Name string `json:"name"` Name string `json:"name"`
URL string `json:"url"` // how machines reach it, e.g. http://10.213.78.1 URL string `json:"url"` // how machines reach it, e.g. http://10.213.78.1
TLSURL string `json:"tls_url,omitempty"` // the same host over HTTPS, when it serves it (agents)
Kind string `json:"kind"` // embedded | remote Kind string `json:"kind"` // embedded | remote
Version string `json:"version"` Version string `json:"version"`
Arch string `json:"arch,omitempty"` // GOARCH, for self-update Arch string `json:"arch,omitempty"` // GOARCH, for self-update
@@ -90,7 +91,7 @@ type rackResult struct {
// ---------------------------------------------------------------- store // ---------------------------------------------------------------- store
func (s *Store) RackControllers(ctx context.Context) ([]RackController, error) { func (s *Store) RackControllers(ctx context.Context) ([]RackController, error) {
rows, err := s.pool.Query(ctx, `SELECT name, coalesce(url,''), kind, coalesce(version,''), coalesce(arch,''), interfaces, dhcp_interfaces, coalesce(tftp_root,''), last_seen, registered FROM rack_controllers ORDER BY name`) rows, err := s.pool.Query(ctx, `SELECT name, coalesce(url,''), coalesce(tls_url,''), kind, coalesce(version,''), coalesce(arch,''), interfaces, dhcp_interfaces, coalesce(tftp_root,''), last_seen, registered FROM rack_controllers ORDER BY name`)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -99,7 +100,7 @@ func (s *Store) RackControllers(ctx context.Context) ([]RackController, error) {
for rows.Next() { for rows.Next() {
var r RackController var r RackController
var ifs, dif []byte var ifs, dif []byte
if err := rows.Scan(&r.Name, &r.URL, &r.Kind, &r.Version, &r.Arch, &ifs, &dif, &r.TFTPRoot, &r.LastSeen, &r.Registered); err != nil { if err := rows.Scan(&r.Name, &r.URL, &r.TLSURL, &r.Kind, &r.Version, &r.Arch, &ifs, &dif, &r.TFTPRoot, &r.LastSeen, &r.Registered); err != nil {
return nil, err return nil, err
} }
_ = json.Unmarshal(ifs, &r.Interfaces) _ = json.Unmarshal(ifs, &r.Interfaces)
@@ -118,9 +119,9 @@ func (s *Store) PutRackController(ctx context.Context, r RackController) error {
} }
ifs, _ := json.Marshal(r.Interfaces) ifs, _ := json.Marshal(r.Interfaces)
dif, _ := json.Marshal(r.DHCPInterfaces) dif, _ := json.Marshal(r.DHCPInterfaces)
_, err := s.pool.Exec(ctx, `INSERT INTO rack_controllers(name, url, kind, version, interfaces, dhcp_interfaces, tftp_root, last_seen, arch) VALUES ($1,$2,$3,$4,$5,$6,$7,now(),nullif($8,'')) _, err := s.pool.Exec(ctx, `INSERT INTO rack_controllers(name, url, kind, version, interfaces, dhcp_interfaces, tftp_root, last_seen, arch, tls_url) VALUES ($1,$2,$3,$4,$5,$6,$7,now(),nullif($8,''),nullif($9,''))
ON CONFLICT (name) DO UPDATE SET url=$2, kind=$3, version=$4, interfaces=$5, dhcp_interfaces=$6, tftp_root=$7, last_seen=now(), arch=nullif($8,'')`, ON CONFLICT (name) DO UPDATE SET url=$2, kind=$3, version=$4, interfaces=$5, dhcp_interfaces=$6, tftp_root=$7, last_seen=now(), arch=nullif($8,''), tls_url=nullif($9,'')`,
r.Name, r.URL, r.Kind, r.Version, ifs, dif, r.TFTPRoot, r.Arch) r.Name, r.URL, r.Kind, r.Version, ifs, dif, r.TFTPRoot, r.Arch, r.TLSURL)
return err return err
} }
@@ -409,10 +410,14 @@ func (a *App) urlFor(m *Machine) string { return a.rackURL(a.rackOf(m)) }
// appendFor is commission_append with the server URL of the machine's rack. // appendFor is commission_append with the server URL of the machine's rack.
func (a *App) appendFor(base string) string { func (a *App) appendFor(base string) string {
if base == a.cfg.ServerURL { s := a.cfg.CommissionAppend
return a.cfg.CommissionAppend if base != a.cfg.ServerURL {
s = strings.ReplaceAll(s, a.cfg.ServerURL, base)
} }
return strings.ReplaceAll(a.cfg.CommissionAppend, a.cfg.ServerURL, base) if t := a.agentTLSArgs(base); len(t) > 0 && !strings.Contains(s, "rollcall.tls=") {
s += " " + strings.Join(t, " ")
}
return s
} }
// rackAddresses are the rack controllers' own addresses, never handed to machines. // rackAddresses are the rack controllers' own addresses, never handed to machines.
@@ -822,6 +827,7 @@ func (a *App) handleRackRegister(w http.ResponseWriter, r *http.Request, rack st
fail(w, 400, errors.New("url is required")) fail(w, 400, errors.New("url is required"))
return return
} }
rc.TLSURL = validTLSURL(rc) // its own host only
if err := a.store.PutRackController(r.Context(), rc); err != nil { if err := a.store.PutRackController(r.Context(), rc); err != nil {
fail(w, 500, err) fail(w, 500, err)
return return
+133 -11
View File
@@ -58,6 +58,10 @@ type RackConfig struct {
// rack.pem). The files from rack join are root's; this directory is the // rack.pem). The files from rack join are root's; this directory is the
// service user's. Default /var/lib/rollcall/rack-cert. // service user's. Default /var/lib/rollcall/rack-cert.
CertStateDir string `json:"cert_state_dir,omitempty"` CertStateDir string `json:"cert_state_dir,omitempty"`
// TLSListen: HTTPS for the agents (node API, images), with a server
// certificate from the region for the URL's host. Default <url host>:443;
// "off" disables it.
TLSListen string `json:"tls_listen,omitempty"`
} }
func (c RackConfig) renewedPath() string { func (c RackConfig) renewedPath() string {
@@ -65,16 +69,19 @@ func (c RackConfig) renewedPath() string {
} }
type rackd struct { type rackd struct {
cfg RackConfig srvMu sync.Mutex
region *url.URL srvCert *tls.Certificate // server certificate for agents (HTTPS)
hc *http.Client // long polls tlsURL string
fast *http.Client cfg RackConfig
big *http.Client // image downloads, no timeout region *url.URL
gen int64 hc *http.Client // long polls
tlsc *tls.Config fast *http.Client
certMu sync.Mutex big *http.Client // image downloads, no timeout
upd rackUpdater gen int64
cert *tls.Certificate tlsc *tls.Config
certMu sync.Mutex
upd rackUpdater
cert *tls.Certificate
headMu sync.Mutex headMu sync.Mutex
heads map[string]headInfo heads map[string]headInfo
@@ -469,6 +476,12 @@ func runRack(args []string) error {
mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, _ *http.Request) { mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, _ *http.Request) {
writeJSON(w, 200, obj{"rack": cfg.Name, "gen": r.gen, "version": version}) writeJSON(w, 200, obj{"rack": cfg.Name, "gen": r.gen, "version": version})
}) })
mux.HandleFunc("GET /ca.pem", func(w http.ResponseWriter, req *http.Request) { // agents check it by its fingerprint
http.ServeFile(w, req, cfg.CAFile)
})
if r.cert != nil && cfg.TLSListen != "off" {
go r.serveTLS(logging(mux))
}
srv := &http.Server{Addr: cfg.Listen, Handler: logging(mux), ReadHeaderTimeout: 10 * time.Second, ReadTimeout: 2 * time.Minute, IdleTimeout: 2 * time.Minute} srv := &http.Server{Addr: cfg.Listen, Handler: logging(mux), ReadHeaderTimeout: 10 * time.Second, ReadTimeout: 2 * time.Minute, IdleTimeout: 2 * time.Minute}
log.Printf("rack controller %s: region %s, serving %s on %s (tftp %s, cache %s)", cfg.Name, cfg.Region, cfg.URL, cfg.Listen, cfg.TFTPRoot, cfg.CacheDir) log.Printf("rack controller %s: region %s, serving %s on %s (tftp %s, cache %s)", cfg.Name, cfg.Region, cfg.URL, cfg.Listen, cfg.TFTPRoot, cfg.CacheDir)
return srv.ListenAndServe() return srv.ListenAndServe()
@@ -518,7 +531,10 @@ func (r *rackd) call(c *http.Client, method, p string, body, out any) (int, erro
} }
func (r *rackd) register() error { func (r *rackd) register() error {
_, err := r.call(r.fast, "POST", "/api/v1/rack/register", RackController{URL: r.cfg.URL, Version: version, Arch: runtime.GOARCH, r.srvMu.Lock()
tlsURL := r.tlsURL
r.srvMu.Unlock()
_, err := r.call(r.fast, "POST", "/api/v1/rack/register", RackController{URL: r.cfg.URL, TLSURL: tlsURL, Version: version, Arch: runtime.GOARCH,
Interfaces: localInterfaces(), DHCPInterfaces: r.cfg.DHCPInterfaces, TFTPRoot: r.cfg.TFTPRoot}, nil) Interfaces: localInterfaces(), DHCPInterfaces: r.cfg.DHCPInterfaces, TFTPRoot: r.cfg.TFTPRoot}, nil)
return err return err
} }
@@ -751,3 +767,109 @@ func (r *rackd) prefetch(files []string) {
} }
} }
} }
// ---------------------------------------------------------------- HTTPS for agents
func (c RackConfig) serverCertPath() string {
return filepath.Join(orDefault(c.CertStateDir, "/var/lib/rollcall/rack-cert"), "server.pem")
}
// serverCert keeps a server certificate for the URL's host, from the region.
func (r *rackd) serverCert() (*tls.Certificate, error) {
host, err := rackHost(r.cfg.URL)
if err != nil {
return nil, err
}
p := r.cfg.serverCertPath()
if b, err := os.ReadFile(p); err == nil {
if pair, err := tls.X509KeyPair(b, b); err == nil {
leaf, _ := x509.ParseCertificate(pair.Certificate[0])
if leaf != nil && time.Until(leaf.NotAfter) > renewBefore && leaf.VerifyHostname(host) == nil {
pair.Leaf = leaf
return &pair, nil
}
}
}
key, csr, err := newCSR(rackServerCN + r.cfg.Name)
if err != nil {
return nil, err
}
var resp struct {
Cert string `json:"cert"`
}
if _, err := r.call(r.fast, "POST", "/api/v1/rack/server-cert", enrollRequest{CSR: csr}, &resp); err != nil {
return nil, err
}
pair, err := tls.X509KeyPair([]byte(resp.Cert), key)
if err != nil {
return nil, err
}
pair.Leaf, _ = x509.ParseCertificate(pair.Certificate[0])
if err := writeAtomic(p, append([]byte(resp.Cert), key...), 0o600); err != nil {
log.Printf("keep the server certificate: %v (it is used, but asked for again at the next start)", err)
}
return &pair, nil
}
// serveTLS serves the agents' endpoints over HTTPS once the region issued a
// certificate, re-registers with the HTTPS URL and renews it when it ages.
func (r *rackd) serveTLS(h http.Handler) {
host, err := rackHost(r.cfg.URL)
if err != nil {
log.Printf("https for agents: %v", err)
return
}
addr := r.cfg.TLSListen
if addr == "" {
addr = net.JoinHostPort(host, "443")
}
for {
c, err := r.serverCert()
if err == nil {
r.srvMu.Lock()
r.srvCert = c
r.srvMu.Unlock()
break
}
log.Printf("https for agents: no server certificate yet: %v", err)
time.Sleep(30 * time.Second)
}
srv := &http.Server{Addr: addr, Handler: h, ReadHeaderTimeout: 10 * time.Second, ReadTimeout: 2 * time.Minute, IdleTimeout: 2 * time.Minute,
TLSConfig: &tls.Config{MinVersion: tls.VersionTLS12, GetCertificate: func(*tls.ClientHelloInfo) (*tls.Certificate, error) {
r.srvMu.Lock()
defer r.srvMu.Unlock()
return r.srvCert, nil
}}}
ln, err := net.Listen("tcp", addr)
if err != nil {
log.Printf("https for agents on %s: %v", addr, err)
return
}
_, port, _ := net.SplitHostPort(addr)
u := "https://" + hostPort(host, "")
if port != "443" {
u = "https://" + hostPort(host, port)
}
r.srvMu.Lock()
r.tlsURL = u
r.srvMu.Unlock()
if err := r.register(); err != nil {
log.Printf("register with %s: %v", u, err)
}
log.Printf("serving agents over HTTPS on %s (%s)", addr, u)
go func() {
for range time.Tick(12 * time.Hour) {
r.srvMu.Lock()
leaf := r.srvCert.Leaf
r.srvMu.Unlock()
if leaf != nil && time.Until(leaf.NotAfter) < renewBefore {
if c, err := r.serverCert(); err == nil {
r.srvMu.Lock()
r.srvCert = c
r.srvMu.Unlock()
}
}
}
}()
log.Printf("https for agents: %v", srv.ServeTLS(ln, "", ""))
}
+22
View File
@@ -174,3 +174,25 @@ func TestBMCCertificatePin(t *testing.T) {
t.Errorf("another certificate: %v (credentials sent %d times)", err, seen) t.Errorf("another certificate: %v (credentials sent %d times)", err, seen)
} }
} }
// A rack's HTTPS URL is its own host; the region's addresses get no rack certificate.
func TestAgentTLSURLs(t *testing.T) {
ok := RackController{URL: "http://10.213.78.1", TLSURL: "https://10.213.78.1"}
if validTLSURL(ok) != "https://10.213.78.1" {
t.Error("own host refused")
}
for _, bad := range []string{"https://10.0.0.234", "http://10.213.78.1", "https://10.213.78.1/x", "https://evil.example"} {
if validTLSURL(RackController{URL: "http://10.213.78.1", TLSURL: bad}) != "" {
t.Errorf("%s accepted", bad)
}
}
a := &App{cfg: &Config{ServerURL: "http://10.213.77.1", TLSNames: []string{"region.example"}}}
for _, h := range []string{"10.213.77.1", "region.example", "127.0.0.1", "localhost"} {
if !a.isRegionName(h) {
t.Errorf("%s is the region's", h)
}
}
if a.isRegionName("10.213.78.1") {
t.Error("a rack's address taken for the region's")
}
}
+1
View File
@@ -267,6 +267,7 @@ func (a *App) routes() http.Handler {
mux.HandleFunc("POST /api/v1/controllers/join", a.adminAuth(a.handleJoinCode)) mux.HandleFunc("POST /api/v1/controllers/join", a.adminAuth(a.handleJoinCode))
mux.HandleFunc("POST /api/v1/rack/enroll", a.handleRackEnroll) // join-code secret inside, TLS only mux.HandleFunc("POST /api/v1/rack/enroll", a.handleRackEnroll) // join-code secret inside, TLS only
mux.HandleFunc("POST /api/v1/rack/renew", a.rackAuth(a.handleRackRenew)) mux.HandleFunc("POST /api/v1/rack/renew", a.rackAuth(a.handleRackRenew))
mux.HandleFunc("POST /api/v1/rack/server-cert", a.rackAuth(a.handleRackServerCert))
mux.HandleFunc("POST /api/v1/rack/register", a.rackAuth(a.handleRackRegister)) mux.HandleFunc("POST /api/v1/rack/register", a.rackAuth(a.handleRackRegister))
mux.HandleFunc("GET /api/v1/rack/state", a.rackAuth(a.handleRackState)) mux.HandleFunc("GET /api/v1/rack/state", a.rackAuth(a.handleRackState))
mux.HandleFunc("GET /api/v1/rack/work", a.rackAuth(a.handleRackWork)) mux.HandleFunc("GET /api/v1/rack/work", a.rackAuth(a.handleRackWork))
+1
View File
@@ -654,6 +654,7 @@ func (a *App) statelessAppend(m *Machine, p *Profile, token string) string {
"hostname=" + deref(m.NodeName), "hostname=" + deref(m.NodeName),
"live-config.nocomponents=user-setup,sudo", "live-config.nocomponents=user-setup,sudo",
"rollcall.server=" + base, "rollcall.node=" + token} "rollcall.server=" + base, "rollcall.node=" + token}
parts = append(parts, a.agentTLSArgs(base)...)
if p.Append != "" { if p.Append != "" {
parts = append(parts, p.Append) parts = append(parts, p.Append)
} }
+1
View File
@@ -165,6 +165,7 @@ CREATE TABLE IF NOT EXISTS rack_controllers (
registered timestamptz NOT NULL DEFAULT now() registered timestamptz NOT NULL DEFAULT now()
); );
ALTER TABLE rack_controllers ADD COLUMN IF NOT EXISTS cert_serial text; ALTER TABLE rack_controllers ADD COLUMN IF NOT EXISTS cert_serial text;
ALTER TABLE rack_controllers ADD COLUMN IF NOT EXISTS tls_url text;
ALTER TABLE rack_controllers ADD COLUMN IF NOT EXISTS prev_cert_serial text; ALTER TABLE rack_controllers ADD COLUMN IF NOT EXISTS prev_cert_serial text;
ALTER TABLE rack_controllers ADD COLUMN IF NOT EXISTS prev_cert_until timestamptz; ALTER TABLE rack_controllers ADD COLUMN IF NOT EXISTS prev_cert_until timestamptz;
ALTER TABLE rack_controllers ADD COLUMN IF NOT EXISTS cert_expires timestamptz; ALTER TABLE rack_controllers ADD COLUMN IF NOT EXISTS cert_expires timestamptz;