rollcall admin-cert request NAME makes a key locally and sends a CSR; the region signs admin:NAME (client auth) and records the serial; a verified, recorded, unrevoked certificate is an admin. CLI/console read it from client.json, a .p12 is written for the browser; the web UI no longer insists on a token. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
753 lines
26 KiB
Go
753 lines
26 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"crypto/subtle"
|
|
"crypto/tls"
|
|
"crypto/x509"
|
|
"embed"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"io/fs"
|
|
"log"
|
|
"net"
|
|
"net/http"
|
|
"net/url"
|
|
"regexp"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
)
|
|
|
|
//go:embed web
|
|
var webFS embed.FS
|
|
|
|
type App struct {
|
|
cfg *Config
|
|
store *Store
|
|
sealer *Sealer
|
|
hub *rackHub
|
|
ca *CA
|
|
|
|
netMu sync.RWMutex
|
|
racks map[string]RackController
|
|
subnets []Subnet
|
|
|
|
ipam ipamProvider // nil = built-in IPAM only
|
|
ipamSt *ipamState
|
|
|
|
adminCC adminCertCache
|
|
}
|
|
|
|
type apiError struct {
|
|
Error string `json:"error"`
|
|
}
|
|
|
|
func writeJSON(w http.ResponseWriter, code int, v any) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(code)
|
|
enc := json.NewEncoder(w)
|
|
enc.SetIndent("", " ")
|
|
_ = enc.Encode(v)
|
|
}
|
|
|
|
func fail(w http.ResponseWriter, code int, err error) {
|
|
if errors.Is(err, errNotFound) {
|
|
code = http.StatusNotFound
|
|
}
|
|
writeJSON(w, code, apiError{err.Error()})
|
|
}
|
|
|
|
func tokenEqual(a, b string) bool {
|
|
return b != "" && subtle.ConstantTimeCompare([]byte(a), []byte(b)) == 1
|
|
}
|
|
|
|
func (a *App) isAdmin(r *http.Request) bool {
|
|
h := r.Header.Get("Authorization")
|
|
if strings.HasPrefix(h, "Bearer ") && tokenEqual(strings.TrimPrefix(h, "Bearer "), a.cfg.AdminToken) {
|
|
return true
|
|
}
|
|
return a.peerAdmin(r) != "" // an admin client certificate (admincert.go)
|
|
}
|
|
|
|
// adminOverHTTP applies admin_http to requests that did not come over TLS.
|
|
func (a *App) adminOverHTTP(r *http.Request) error {
|
|
if r.TLS != nil || a.cfg.AdminHTTP == "allow" {
|
|
return nil
|
|
}
|
|
host, _, _ := net.SplitHostPort(r.RemoteAddr)
|
|
if ip := net.ParseIP(host); a.cfg.AdminHTTP == "loopback" && ip != nil && ip.IsLoopback() {
|
|
return nil
|
|
}
|
|
_, port, _ := net.SplitHostPort(a.cfg.TLSListen)
|
|
h := r.Host
|
|
if hh, _, err := net.SplitHostPort(h); err == nil {
|
|
h = hh
|
|
}
|
|
return fmt.Errorf("the admin API is HTTPS-only here: https://%s (trust the CA from /ca.pem)", net.JoinHostPort(strings.Trim(h, "[]"), port))
|
|
}
|
|
|
|
// httpsURL is the region's HTTPS address for the host the client used.
|
|
func (a *App) httpsURL(r *http.Request) string {
|
|
_, port, _ := net.SplitHostPort(a.cfg.TLSListen)
|
|
h := r.Host
|
|
if hh, _, err := net.SplitHostPort(h); err == nil {
|
|
h = hh
|
|
}
|
|
h = strings.Trim(h, "[]")
|
|
if port == "" || port == "443" {
|
|
if strings.Contains(h, ":") {
|
|
return "https://[" + h + "]"
|
|
}
|
|
return "https://" + h
|
|
}
|
|
return "https://" + net.JoinHostPort(h, port)
|
|
}
|
|
|
|
// uiOverHTTPS sends browsers that reach the web UI over plain HTTP to HTTPS when
|
|
// the admin API is not allowed over HTTP anyway (the page could not work).
|
|
// Only the UI is redirected: nodes keep using HTTP for images and the agent.
|
|
func (a *App) uiOverHTTPS(next http.Handler) http.Handler {
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if r.TLS == nil && a.cfg.AdminHTTP != "" && a.cfg.AdminHTTP != "allow" && a.cfg.TLSListen != "" && a.adminOverHTTP(r) != nil {
|
|
http.Redirect(w, r, a.httpsURL(r)+r.URL.RequestURI(), http.StatusPermanentRedirect)
|
|
return
|
|
}
|
|
next.ServeHTTP(w, r)
|
|
})
|
|
}
|
|
|
|
func (a *App) adminAuth(next http.HandlerFunc) http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
if err := a.adminOverHTTP(r); err != nil {
|
|
fail(w, 403, err)
|
|
return
|
|
}
|
|
if !a.isAdmin(r) {
|
|
w.Header().Set("WWW-Authenticate", `Bearer realm="rollcall"`)
|
|
fail(w, http.StatusUnauthorized, errors.New("admin token required"))
|
|
return
|
|
}
|
|
next(w, r)
|
|
}
|
|
}
|
|
|
|
func (a *App) agentAuth(next http.HandlerFunc) http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
if a.cfg.AgentToken != "" && !tokenEqual(r.Header.Get("X-Rollcall-Token"), a.cfg.AgentToken) && !a.isAdmin(r) {
|
|
fail(w, http.StatusUnauthorized, errors.New("agent token required"))
|
|
return
|
|
}
|
|
next(w, r)
|
|
}
|
|
}
|
|
|
|
func logging(next http.Handler) http.Handler {
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
start := time.Now()
|
|
rw := &statusWriter{ResponseWriter: w, code: 200}
|
|
next.ServeHTTP(rw, r)
|
|
if (!strings.HasPrefix(r.URL.Path, "/rollcall/") && !strings.HasPrefix(r.URL.Path, "/images/") && !strings.HasPrefix(r.URL.Path, "/os/")) || rw.code >= 400 {
|
|
log.Printf("%s %s %s %d %s", r.RemoteAddr, r.Method, r.URL.RequestURI(), rw.code, time.Since(start).Round(time.Millisecond))
|
|
}
|
|
})
|
|
}
|
|
|
|
type statusWriter struct {
|
|
http.ResponseWriter
|
|
code int
|
|
}
|
|
|
|
func (s *statusWriter) WriteHeader(c int) { s.code = c; s.ResponseWriter.WriteHeader(c) }
|
|
|
|
func (a *App) routes() http.Handler {
|
|
mux := http.NewServeMux()
|
|
// agent endpoints
|
|
mux.HandleFunc("POST /api/v1/commission", a.agentAuth(a.handleCommission))
|
|
mux.HandleFunc("GET /api/v1/bmc-hint", a.agentAuth(a.handleHint))
|
|
// management endpoints
|
|
mux.HandleFunc("GET /api/v1/machines", a.adminAuth(a.handleList))
|
|
mux.HandleFunc("GET /api/v1/machines/{id}", a.adminAuth(a.handleGet))
|
|
mux.HandleFunc("DELETE /api/v1/machines/{id}", a.adminAuth(a.handleDelete))
|
|
mux.HandleFunc("POST /api/v1/machines/{id}/recommission", a.adminAuth(a.handleRecommission))
|
|
mux.HandleFunc("GET /api/v1/machines/{id}/power", a.adminAuth(a.handlePower))
|
|
mux.HandleFunc("POST /api/v1/machines/{id}/power", a.adminAuth(a.handlePower))
|
|
mux.HandleFunc("POST /api/v1/machines/{id}/bootdev", a.adminAuth(a.handleBootdev))
|
|
mux.HandleFunc("GET /api/v1/machines/{id}/bmc", a.adminAuth(a.handleBMC))
|
|
mux.HandleFunc("POST /api/v1/machines/{id}/bmc", a.adminAuth(a.handleBMC))
|
|
mux.HandleFunc("GET /api/v1/power-drivers", a.adminAuth(func(w http.ResponseWriter, r *http.Request) { writeJSON(w, 200, powerDrivers) }))
|
|
mux.HandleFunc("GET /api/v1/whoami", a.adminAuth(func(w http.ResponseWriter, r *http.Request) {
|
|
writeJSON(w, 200, map[string]any{"ok": true})
|
|
}))
|
|
// stateless provisioning
|
|
mux.HandleFunc("GET /api/v1/profiles", a.adminAuth(a.handleProfiles))
|
|
mux.HandleFunc("POST /api/v1/profiles", a.adminAuth(a.handleProfiles))
|
|
mux.HandleFunc("DELETE /api/v1/profiles/{name}", a.adminAuth(a.handleProfileDelete))
|
|
mux.HandleFunc("GET /api/v1/images", a.adminAuth(a.handleImages))
|
|
mux.HandleFunc("POST /api/v1/machines/{id}/assign", a.adminAuth(a.handleAssign))
|
|
mux.HandleFunc("POST /api/v1/machines/{id}/unassign", a.adminAuth(a.handleUnassign))
|
|
mux.HandleFunc("GET /api/v1/machines/{id}/overlay", a.adminAuth(a.handleAdminOverlay))
|
|
mux.HandleFunc("GET /api/v1/render/{profile}/{path...}", a.adminAuth(a.handleRender))
|
|
mux.HandleFunc("GET /api/v1/nodes/self/overlay", a.handleNodeOverlay) // node token auth inside
|
|
// disk-based deployment
|
|
mux.HandleFunc("GET /api/v1/os", a.adminAuth(a.handleOS))
|
|
mux.HandleFunc("POST /api/v1/machines/{id}/deploy", a.adminAuth(a.handleDeploy))
|
|
mux.HandleFunc("POST /api/v1/machines/{id}/release", a.adminAuth(a.handleRelease))
|
|
mux.HandleFunc("GET /api/v1/machines/{id}/jobs", a.adminAuth(a.handleJobs))
|
|
mux.HandleFunc("POST /api/v1/machines/{id}/jobs/cancel", a.adminAuth(a.handleJobCancel))
|
|
mux.HandleFunc("GET /api/v1/jobs/self", a.handleJobSelf) // job token auth inside
|
|
mux.HandleFunc("POST /api/v1/jobs/self/progress", a.handleJobProgress) // job token auth inside
|
|
mux.HandleFunc("POST /api/v1/phone-home/{token}", a.handlePhoneHome)
|
|
// fleet-wide views for the UI
|
|
mux.HandleFunc("GET /api/v1/events", a.adminAuth(func(w http.ResponseWriter, r *http.Request) {
|
|
evs, err := a.store.RecentEvents(r.Context(), 100)
|
|
if err != nil {
|
|
fail(w, 500, err)
|
|
return
|
|
}
|
|
writeJSON(w, 200, evs)
|
|
}))
|
|
mux.HandleFunc("GET /api/v1/jobs", a.adminAuth(func(w http.ResponseWriter, r *http.Request) {
|
|
jobs, err := a.store.RecentJobs(r.Context(), 100)
|
|
if err != nil {
|
|
fail(w, 500, err)
|
|
return
|
|
}
|
|
writeJSON(w, 200, jobs)
|
|
}))
|
|
// network model and rack controllers
|
|
mux.HandleFunc("GET /api/v1/fabrics", a.adminAuth(a.handleFabrics))
|
|
mux.HandleFunc("POST /api/v1/fabrics", a.adminAuth(a.handleFabrics))
|
|
mux.HandleFunc("DELETE /api/v1/fabrics/{name}", a.adminAuth(a.handleFabricDelete))
|
|
mux.HandleFunc("GET /api/v1/vlans", a.adminAuth(a.handleVLANs))
|
|
mux.HandleFunc("POST /api/v1/vlans", a.adminAuth(a.handleVLANs))
|
|
mux.HandleFunc("DELETE /api/v1/vlans/{fabric}/{vid}", a.adminAuth(a.handleVLANDelete))
|
|
mux.HandleFunc("GET /api/v1/subnets", a.adminAuth(a.handleSubnets))
|
|
mux.HandleFunc("POST /api/v1/subnets", a.adminAuth(a.handleSubnets))
|
|
mux.HandleFunc("DELETE /api/v1/subnets/{cidr...}", a.adminAuth(a.handleSubnetDelete))
|
|
mux.HandleFunc("POST /api/v1/ranges", a.adminAuth(a.handleRanges))
|
|
mux.HandleFunc("DELETE /api/v1/ranges/{id}", a.adminAuth(a.handleRangeDelete))
|
|
mux.HandleFunc("GET /api/v1/ips", a.adminAuth(a.handleIPs))
|
|
mux.HandleFunc("POST /api/v1/ips", a.adminAuth(a.handleIPs))
|
|
mux.HandleFunc("DELETE /api/v1/ips/{ip}", a.adminAuth(a.handleIPDelete))
|
|
mux.HandleFunc("GET /api/v1/ipam", a.adminAuth(a.handleIPAM))
|
|
mux.HandleFunc("GET /api/v1/admin-certs", a.adminAuth(a.handleAdminCerts))
|
|
mux.HandleFunc("POST /api/v1/admin-certs", a.adminAuth(a.handleAdminCerts))
|
|
mux.HandleFunc("DELETE /api/v1/admin-certs/{ref}", a.adminAuth(a.handleAdminCertRevoke))
|
|
mux.HandleFunc("POST /api/v1/ipam/sync", a.adminAuth(a.handleIPAMSync))
|
|
mux.HandleFunc("GET /api/v1/ipam/prefixes", a.adminAuth(a.handleIPAMPrefixes))
|
|
mux.HandleFunc("POST /api/v1/ipam/prefixes", a.adminAuth(a.handleIPAMPrefixes))
|
|
mux.HandleFunc("GET /api/v1/layouts", a.adminAuth(a.handleLayouts))
|
|
mux.HandleFunc("POST /api/v1/layouts", a.adminAuth(a.handleLayouts))
|
|
mux.HandleFunc("DELETE /api/v1/layouts/{name}", a.adminAuth(a.handleLayoutDelete))
|
|
mux.HandleFunc("GET /api/v1/machines/{id}/network", a.adminAuth(a.handleMachineNetwork))
|
|
mux.HandleFunc("POST /api/v1/machines/{id}/network", a.adminAuth(a.handleMachineNetwork))
|
|
mux.HandleFunc("GET /api/v1/controllers", a.adminAuth(a.handleControllers))
|
|
mux.HandleFunc("DELETE /api/v1/controllers/{name}", a.adminAuth(a.handleControllerDelete))
|
|
mux.HandleFunc("GET /api/v1/controllers/{name}/dhcp", a.adminAuth(a.handleRackDHCP))
|
|
mux.HandleFunc("GET /ca.pem", a.handleCAPEM)
|
|
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/renew", a.rackAuth(a.handleRackRenew))
|
|
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/work", a.rackAuth(a.handleRackWork))
|
|
mux.HandleFunc("POST /api/v1/rack/result", a.rackAuth(a.handleRackResult))
|
|
mux.HandleFunc("GET /api/v1/rack/binary", a.rackAuth(a.handleRackBinary))
|
|
// physical location
|
|
mux.HandleFunc("POST /api/v1/machines/{id}/location", a.adminAuth(a.handleLocation))
|
|
mux.HandleFunc("POST /api/v1/locations/import", a.adminAuth(a.handleLocationImport))
|
|
mux.HandleFunc("GET /api/v1/racks", a.adminAuth(a.handleRacks))
|
|
mux.HandleFunc("POST /api/v1/racks", a.adminAuth(a.handleRacks))
|
|
mux.HandleFunc("DELETE /api/v1/racks", a.adminAuth(a.handleRacks))
|
|
// tags
|
|
mux.HandleFunc("GET /api/v1/tags", a.adminAuth(a.handleTags))
|
|
mux.HandleFunc("POST /api/v1/tags", a.adminAuth(a.handleTags))
|
|
mux.HandleFunc("DELETE /api/v1/tags/{name}", a.adminAuth(a.handleTagDelete))
|
|
mux.HandleFunc("POST /api/v1/machines/{id}/tags", a.adminAuth(a.handleMachineTags))
|
|
// image files for the live boot (no auth: the kernel/initrd/squashfs must be fetchable)
|
|
imgDir := a.cfg.ImageDir
|
|
mux.Handle("GET /rollcall/", http.StripPrefix("/rollcall/", http.FileServer(http.Dir(imgDir))))
|
|
mux.Handle("GET /images/", http.StripPrefix("/images/", http.FileServer(http.Dir(a.cfg.ImagesDir))))
|
|
mux.Handle("GET /os/", http.StripPrefix("/os/", http.FileServer(http.Dir(a.cfg.OSDir))))
|
|
// embedded web UI
|
|
sub, _ := fs.Sub(webFS, "web")
|
|
mux.Handle("GET /", a.uiOverHTTPS(http.FileServer(http.FS(sub))))
|
|
return logging(mux)
|
|
}
|
|
|
|
// ---------------------------------------------------------------- helpers over inventory JSON
|
|
|
|
type obj = map[string]any
|
|
|
|
func dig(m any, path ...string) any {
|
|
cur := m
|
|
for _, p := range path {
|
|
mm, ok := cur.(obj)
|
|
if !ok {
|
|
return nil
|
|
}
|
|
cur = mm[p]
|
|
}
|
|
return cur
|
|
}
|
|
|
|
func str(v any) string {
|
|
switch t := v.(type) {
|
|
case nil:
|
|
return ""
|
|
case string:
|
|
return t
|
|
case float64:
|
|
return fmt.Sprintf("%v", t)
|
|
default:
|
|
return fmt.Sprintf("%v", t)
|
|
}
|
|
}
|
|
|
|
func strPtr(v any) *string {
|
|
s := str(v)
|
|
if s == "" {
|
|
return nil
|
|
}
|
|
return &s
|
|
}
|
|
|
|
func listLen(v any) int {
|
|
if l, ok := v.([]any); ok {
|
|
return len(l)
|
|
}
|
|
return 0
|
|
}
|
|
|
|
func normMAC(s string) string { return strings.ToLower(strings.ReplaceAll(s, "-", ":")) }
|
|
|
|
var nonAlnum = regexp.MustCompile(`[^a-z0-9]`)
|
|
|
|
func machineID(uuid, serial string, macs []string) string {
|
|
u := strings.ToLower(uuid)
|
|
if u != "" && u != "00000000-0000-0000-0000-000000000000" && !strings.Contains(u, "not ") {
|
|
u = strings.ReplaceAll(u, "-", "")
|
|
if len(u) > 12 {
|
|
u = u[:12]
|
|
}
|
|
return "m-" + u
|
|
}
|
|
if s := nonAlnum.ReplaceAllString(strings.ToLower(serial), ""); s != "" && !strings.Contains(strings.ToLower(serial), "not ") {
|
|
if len(s) > 16 {
|
|
s = s[:16]
|
|
}
|
|
return "m-" + s
|
|
}
|
|
if len(macs) > 0 {
|
|
return "m-" + strings.ReplaceAll(macs[0], ":", "")
|
|
}
|
|
return "m-" + randomToken(6)
|
|
}
|
|
|
|
// Summary is the list-view projection of a machine (no secrets, no full inventory).
|
|
type Summary struct {
|
|
ID string `json:"id"`
|
|
State string `json:"state"`
|
|
Hostname *string `json:"hostname"`
|
|
Serial *string `json:"serial"`
|
|
UUID *string `json:"uuid"`
|
|
Manufacturer *string `json:"manufacturer"`
|
|
Product *string `json:"product"`
|
|
FirstSeen time.Time `json:"first_seen"`
|
|
LastSeen *time.Time `json:"last_seen"`
|
|
MACs []string `json:"macs"`
|
|
CPU string `json:"cpu"`
|
|
CPUs string `json:"cpus"`
|
|
MemoryBytes float64 `json:"memory_bytes"`
|
|
Disks float64 `json:"disks"`
|
|
StorageBytes float64 `json:"storage_bytes"`
|
|
NICs int `json:"nics"`
|
|
IBHCAs int `json:"ib_hcas"`
|
|
GPUs int `json:"gpus"`
|
|
BMC *obj `json:"bmc"`
|
|
Profile *string `json:"profile"`
|
|
NodeName *string `json:"node_name"`
|
|
MgmtIP *string `json:"mgmt_ip"`
|
|
IBIP *string `json:"ib_ip"`
|
|
BootedAt *time.Time `json:"booted_at"`
|
|
OS *string `json:"os"`
|
|
OSVersion *string `json:"os_version"`
|
|
DeployedAt *time.Time `json:"deployed_at"`
|
|
Job *Job `json:"job,omitempty"` // active deployment job, if any
|
|
Tags []string `json:"tags"` // effective (manual + auto)
|
|
ManualTags []string `json:"manual_tags"`
|
|
AutoTags []string `json:"auto_tags"`
|
|
Location Location `json:"location"`
|
|
Rack string `json:"rack_controller"`
|
|
NetLayout string `json:"net_layout"`
|
|
}
|
|
|
|
func num(v any) float64 {
|
|
f, _ := v.(float64)
|
|
return f
|
|
}
|
|
|
|
func summarize(m *Machine) Summary {
|
|
var inv obj
|
|
_ = json.Unmarshal(m.Inventory, &inv)
|
|
s := Summary{ID: m.ID, State: m.State, Hostname: m.Hostname, Serial: m.Serial, UUID: m.UUID,
|
|
Manufacturer: m.Manufacturer, Product: m.Product, FirstSeen: m.FirstSeen, LastSeen: m.LastSeen, MACs: m.MACs,
|
|
CPU: str(dig(inv, "cpu", "Model name")), CPUs: str(dig(inv, "cpu", "CPU(s)")),
|
|
MemoryBytes: num(dig(inv, "memory", "total_bytes")), Disks: num(dig(inv, "storage", "disk_count")),
|
|
StorageBytes: num(dig(inv, "storage", "total_bytes")), NICs: listLen(inv["network"]),
|
|
IBHCAs: listLen(dig(inv, "infiniband", "hcas")), GPUs: nodeCtx(m).GPUs, // real accelerators only
|
|
Profile: m.Profile, NodeName: m.NodeName, MgmtIP: m.MgmtIP, IBIP: m.IBIP, BootedAt: m.BootedAt,
|
|
OS: m.OS, OSVersion: m.OSVersion, DeployedAt: m.DeployedAt,
|
|
Tags: effectiveTags(m), ManualTags: m.Tags, AutoTags: m.AutoTags, Location: m.Location,
|
|
Rack: m.RackController, NetLayout: m.NetLayout}
|
|
if m.BMC != nil {
|
|
var b obj
|
|
if json.Unmarshal(m.BMC, &b) == nil {
|
|
drv := str(b["driver"])
|
|
if drv == "" {
|
|
drv = "ipmi"
|
|
}
|
|
s.BMC = &obj{"source": b["source"], "address": b["address"], "user": b["user"], "driver": drv,
|
|
"configured": b["configured"], "verified_oob": b["verified_oob"]}
|
|
}
|
|
}
|
|
return s
|
|
}
|
|
|
|
// ---------------------------------------------------------------- pxelinux per-MAC files
|
|
|
|
const commissionedTmpl = `# generated by rollcall %s
|
|
# machine %s %s %s serial=%s
|
|
DEFAULT menu.c32
|
|
PROMPT 0
|
|
TIMEOUT 150
|
|
MENU TITLE %s - commissioned (%s %s, S/N %s)
|
|
|
|
LABEL poweroff
|
|
MENU LABEL Commissioned: power off (default in 15s)
|
|
MENU DEFAULT
|
|
KERNEL poweroff.c32
|
|
|
|
LABEL recommission
|
|
MENU LABEL Re-run commissioning
|
|
KERNEL rollcall/vmlinuz
|
|
INITRD rollcall/initrd.img
|
|
APPEND %s
|
|
|
|
LABEL local
|
|
MENU LABEL Boot from local disk
|
|
LOCALBOOT 0
|
|
`
|
|
|
|
func deref(p *string) string {
|
|
if p == nil {
|
|
return ""
|
|
}
|
|
return *p
|
|
}
|
|
|
|
func (a *App) writePXE(m *Machine) error {
|
|
man, prod, ser := deref(m.Manufacturer), deref(m.Product), deref(m.Serial)
|
|
body := fmt.Sprintf(commissionedTmpl, time.Now().Format(time.RFC3339), m.ID, man, prod, ser,
|
|
m.ID, man, prod, ser, a.appendFor(a.urlFor(m)))
|
|
return a.writePXEBody(m, body)
|
|
}
|
|
|
|
// ---------------------------------------------------------------- handlers
|
|
|
|
func (a *App) handleCommission(w http.ResponseWriter, r *http.Request) {
|
|
body, err := io.ReadAll(http.MaxBytesReader(w, r.Body, 32<<20))
|
|
if err != nil {
|
|
fail(w, 400, err)
|
|
return
|
|
}
|
|
var inv obj
|
|
if err := json.Unmarshal(body, &inv); err != nil {
|
|
fail(w, 400, fmt.Errorf("bad json: %w", err))
|
|
return
|
|
}
|
|
ident, _ := inv["identity"].(obj)
|
|
var macs []string
|
|
if l, ok := ident["macs"].([]any); ok {
|
|
for _, v := range l {
|
|
if s := normMAC(str(v)); s != "" {
|
|
macs = append(macs, s)
|
|
}
|
|
}
|
|
}
|
|
if boot := normMAC(str(ident["boot_mac"])); boot != "" {
|
|
for i, mac := range macs {
|
|
if mac == boot && i != 0 {
|
|
macs = append([]string{boot}, append(macs[:i], macs[i+1:]...)...)
|
|
break
|
|
}
|
|
}
|
|
}
|
|
m := &Machine{
|
|
ID: machineID(str(ident["uuid"]), str(ident["serial"]), macs),
|
|
UUID: strPtr(ident["uuid"]),
|
|
Serial: strPtr(ident["serial"]),
|
|
Hostname: strPtr(inv["hostname"]),
|
|
Manufacturer: strPtr(dig(inv, "system", "system-manufacturer")),
|
|
Product: strPtr(dig(inv, "system", "system-product-name")),
|
|
MACs: macs,
|
|
}
|
|
// Pull the BMC password out of the payload before anything is persisted.
|
|
var secret []byte
|
|
detail := "bmc=none"
|
|
if bmc, ok := inv["bmc"].(obj); ok {
|
|
if pw := str(bmc["password"]); pw != "" {
|
|
secret, err = a.sealer.Seal(pw, m.ID)
|
|
if err != nil {
|
|
fail(w, 500, err)
|
|
return
|
|
}
|
|
bmc["password"] = "<stored encrypted>"
|
|
}
|
|
m.BMC, _ = json.Marshal(bmc)
|
|
detail = fmt.Sprintf("bmc=%s:%s configured=%v", str(bmc["source"]), str(bmc["address"]), bmc["configured"])
|
|
}
|
|
m.Inventory, _ = json.Marshal(inv)
|
|
|
|
// a BMC an operator configured (driver_set) survives commissioning; what the agent
|
|
// found is kept next to it for reference
|
|
if old, err := a.store.Get(r.Context(), m.ID); err == nil && old.BMC != nil {
|
|
var ob obj
|
|
if json.Unmarshal(old.BMC, &ob) == nil && ob["driver_set"] == true {
|
|
var found obj
|
|
_ = json.Unmarshal(m.BMC, &found)
|
|
delete(found, "password")
|
|
ob["commissioning"] = found
|
|
m.BMC, _ = json.Marshal(ob)
|
|
secret = old.bmcSecret
|
|
detail += " (operator BMC settings kept)"
|
|
}
|
|
}
|
|
|
|
isNew, err := a.store.UpsertCommissioned(r.Context(), m, secret)
|
|
if err != nil {
|
|
fail(w, 500, err)
|
|
return
|
|
}
|
|
_ = a.store.AddEvent(r.Context(), m.ID, "commissioned", detail)
|
|
_ = a.store.SetRackController(r.Context(), m.ID, a.fromRack(r))
|
|
if full, err := a.store.Get(r.Context(), m.ID); err == nil {
|
|
go a.probeBMC(full)
|
|
}
|
|
if full, err := a.store.Get(r.Context(), m.ID); err == nil {
|
|
m = full // carries profile / node token when this is a re-commission of a stateless node
|
|
}
|
|
a.retag(r.Context(), []*Machine{m})
|
|
if err := a.writePXEAny(r.Context(), m); err != nil {
|
|
log.Printf("pxe write failed for %s: %v", m.ID, err)
|
|
_ = a.store.AddEvent(r.Context(), m.ID, "pxe-write-failed", err.Error())
|
|
}
|
|
log.Printf("commissioned %s new=%v (%s %s, serial %s, %d macs) %s", m.ID, isNew, deref(m.Manufacturer),
|
|
deref(m.Product), deref(m.Serial), len(macs), detail)
|
|
writeJSON(w, 200, map[string]any{"id": m.ID, "state": "ready", "new": isNew})
|
|
}
|
|
|
|
func (a *App) handleHint(w http.ResponseWriter, r *http.Request) {
|
|
want := map[string]bool{}
|
|
for _, m := range strings.Split(r.URL.Query().Get("macs"), ",") {
|
|
want[normMAC(m)] = true
|
|
}
|
|
for _, h := range a.cfg.BMCHints {
|
|
if want[normMAC(h.MAC)] {
|
|
port := h.Port
|
|
if port == 0 {
|
|
port = 623
|
|
}
|
|
writeJSON(w, 200, obj{"address": h.Address, "port": port, "user": h.User, "password": h.Password})
|
|
return
|
|
}
|
|
}
|
|
writeJSON(w, 200, obj{})
|
|
}
|
|
|
|
func (a *App) handleList(w http.ResponseWriter, r *http.Request) {
|
|
ms, err := a.store.List(r.Context())
|
|
if err != nil {
|
|
fail(w, 500, err)
|
|
return
|
|
}
|
|
active, _ := a.store.ActiveJobs(r.Context())
|
|
wantTag := r.URL.Query().Get("tag")
|
|
out := make([]Summary, 0, len(ms))
|
|
for _, m := range ms {
|
|
if wantTag != "" && !hasTag(effectiveTags(m), wantTag) {
|
|
continue
|
|
}
|
|
if !locationMatch(m, r.URL.Query()) {
|
|
continue
|
|
}
|
|
s := summarize(m)
|
|
s.Job = active[m.ID]
|
|
out = append(out, s)
|
|
}
|
|
writeJSON(w, 200, out)
|
|
}
|
|
|
|
func (a *App) handleGet(w http.ResponseWriter, r *http.Request) {
|
|
m, err := a.store.Get(r.Context(), r.PathValue("id"))
|
|
if err != nil {
|
|
fail(w, 500, err)
|
|
return
|
|
}
|
|
evs, _ := a.store.Events(r.Context(), m.ID)
|
|
jobs, _ := a.store.Jobs(r.Context(), m.ID, 10)
|
|
s := summarize(m)
|
|
if j, err := a.store.ActiveJob(r.Context(), m.ID); err == nil {
|
|
s.Job = j
|
|
}
|
|
var bmc, inv any
|
|
_ = json.Unmarshal(m.BMC, &bmc)
|
|
_ = json.Unmarshal(m.Inventory, &inv)
|
|
writeJSON(w, 200, obj{"summary": s, "bmc": bmc, "has_bmc_secret": m.bmcSecret != nil,
|
|
"inventory": inv, "events": evs, "jobs": jobs})
|
|
}
|
|
|
|
func (a *App) handleDelete(w http.ResponseWriter, r *http.Request) {
|
|
m, err := a.store.Get(r.Context(), r.PathValue("id"))
|
|
if err != nil {
|
|
fail(w, 500, err)
|
|
return
|
|
}
|
|
a.removePXE(m)
|
|
defer a.kickIPAM()
|
|
if err := a.store.Delete(r.Context(), m.ID); err != nil {
|
|
fail(w, 500, err)
|
|
return
|
|
}
|
|
log.Printf("deleted %s", m.ID)
|
|
writeJSON(w, 200, obj{"deleted": m.ID})
|
|
}
|
|
|
|
func (a *App) handleRecommission(w http.ResponseWriter, r *http.Request) {
|
|
m, err := a.store.Get(r.Context(), r.PathValue("id"))
|
|
if err != nil {
|
|
fail(w, 500, err)
|
|
return
|
|
}
|
|
a.removePXE(m)
|
|
_ = a.store.SetState(r.Context(), m.ID, "recommission")
|
|
_ = a.store.AddEvent(r.Context(), m.ID, "recommission requested", "")
|
|
// optional: power-cycle it right away when the caller asks (?now=1)
|
|
out := obj{"id": m.ID, "state": "recommission"}
|
|
if r.URL.Query().Get("now") == "1" {
|
|
out["ipmi"], out["ipmi_ok"] = a.pxeBoot(r.Context(), m)
|
|
}
|
|
writeJSON(w, 200, out)
|
|
}
|
|
|
|
func (a *App) handlePower(w http.ResponseWriter, r *http.Request) {
|
|
m, err := a.store.Get(r.Context(), r.PathValue("id"))
|
|
if err != nil {
|
|
fail(w, 500, err)
|
|
return
|
|
}
|
|
action := "status"
|
|
if r.Method == http.MethodPost {
|
|
var req struct {
|
|
Action string `json:"action"`
|
|
}
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
fail(w, 400, err)
|
|
return
|
|
}
|
|
action = req.Action
|
|
}
|
|
if !powerOps[action] || action == "bootdev" || action == "probe" {
|
|
fail(w, 400, fmt.Errorf("unknown power action %q", action))
|
|
return
|
|
}
|
|
out, err := a.power(r.Context(), m, action, "")
|
|
if errors.Is(err, errNoBMC) {
|
|
fail(w, 409, err)
|
|
return
|
|
}
|
|
if action != "status" {
|
|
_ = a.store.AddEvent(r.Context(), m.ID, "power "+action, out)
|
|
}
|
|
writeJSON(w, 200, obj{"id": m.ID, "action": action, "ok": err == nil, "output": out})
|
|
}
|
|
|
|
func (a *App) handleBootdev(w http.ResponseWriter, r *http.Request) {
|
|
m, err := a.store.Get(r.Context(), r.PathValue("id"))
|
|
if err != nil {
|
|
fail(w, 500, err)
|
|
return
|
|
}
|
|
var req struct {
|
|
Device string `json:"device"`
|
|
}
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil || !bootDevices[req.Device] {
|
|
fail(w, 400, fmt.Errorf("device must be one of pxe, disk, cdrom, bios, none, safe"))
|
|
return
|
|
}
|
|
out, err := a.power(r.Context(), m, "bootdev", req.Device)
|
|
if errors.Is(err, errNoBMC) {
|
|
fail(w, 409, err)
|
|
return
|
|
}
|
|
_ = a.store.AddEvent(r.Context(), m.ID, "bootdev "+req.Device, out)
|
|
writeJSON(w, 200, obj{"id": m.ID, "device": req.Device, "ok": err == nil, "output": out})
|
|
}
|
|
|
|
func serve(cfgPath string) error {
|
|
cfg, err := loadConfig(cfgPath)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
sealer, err := loadOrCreateKey(cfg.MasterKeyFile)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
|
store, err := openStore(ctx, cfg.DatabaseURL)
|
|
cancel()
|
|
if err != nil {
|
|
return fmt.Errorf("database: %w", err)
|
|
}
|
|
app := &App{cfg: cfg, store: store, sealer: sealer, hub: newRackHub()}
|
|
if app.ca, err = loadOrCreateCA(cfg.CADir, "rollcall CA"); err != nil {
|
|
return fmt.Errorf("certificate authority: %w", err)
|
|
}
|
|
ctx2, cancel2 := context.WithTimeout(context.Background(), 30*time.Second)
|
|
err = app.startRacks(ctx2)
|
|
cancel2()
|
|
if err != nil {
|
|
return fmt.Errorf("rack controllers: %w", err)
|
|
}
|
|
if err := app.startIPAM(); err != nil {
|
|
return fmt.Errorf("ipam: %w", err)
|
|
}
|
|
handler := app.routes()
|
|
if cfg.TLSListen != "" {
|
|
names := append([]string{}, cfg.TLSNames...)
|
|
if u, err := url.Parse(cfg.ServerURL); err == nil && u.Hostname() != "" {
|
|
names = append(names, u.Hostname())
|
|
}
|
|
pair, err := regionCert(app.ca, cfg.CADir, names)
|
|
if err != nil {
|
|
return fmt.Errorf("region certificate: %w", err)
|
|
}
|
|
pool := x509.NewCertPool()
|
|
pool.AddCert(app.ca.Cert)
|
|
tsrv := &http.Server{Addr: cfg.TLSListen, Handler: handler, ReadHeaderTimeout: 10 * time.Second,
|
|
TLSConfig: &tls.Config{MinVersion: tls.VersionTLS12, Certificates: []tls.Certificate{pair}, ClientCAs: pool, ClientAuth: tls.VerifyClientCertIfGiven}}
|
|
go func() {
|
|
log.Printf("rollcall HTTPS on %s (CA %s)", cfg.TLSListen, app.ca.Fingerprint())
|
|
if err := tsrv.ListenAndServeTLS("", ""); err != nil {
|
|
log.Printf("HTTPS listener: %v", err)
|
|
}
|
|
}()
|
|
}
|
|
srv := &http.Server{Addr: cfg.Listen, Handler: handler, ReadHeaderTimeout: 10 * time.Second}
|
|
log.Printf("rollcall listening on %s (image=%s tftp=%s agent_token=%v)", cfg.Listen, cfg.ImageDir, cfg.TFTPRoot, cfg.AgentToken != "")
|
|
return srv.ListenAndServe()
|
|
}
|