Files
rollcall/cmd/rollcall/ipmi.go
T
Ilker ManapandClaude Opus 5.5 e474fbcf16
CI / agent (push) Successful in 3s
CI / site (push) Successful in 2s
CI / go (push) Successful in 41s
BMC hints: the password stays on the region
GET /api/v1/bmc-hint answered with the BMC password to anyone with the
agent token (public on the PXE command line) and a MAC (visible on the
wire). It now returns the address and user only. When a machine reports
in, the region matches its MACs against bmc_hints itself, seals the
configured password into the record, and checks the BMC with a status
call from the rack controller (verified_oob). The agent skips its own
check when no password comes; old agents keep working.

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

291 lines
8.9 KiB
Go

package main
// A machine's BMC: the record commissioning (or an operator) leaves in
// machines.bmc, the sealed password, and the API that sets and probes it.
import (
"context"
"encoding/json"
"errors"
"fmt"
"log"
"net/http"
"strings"
"time"
)
// bmcCreds is what a power driver needs.
type bmcCreds struct {
Driver string `json:"driver"`
Address string `json:"address"`
Port int `json:"port"`
User string `json:"user"`
Password string `json:"password"`
Extra map[string]string `json:"extra"`
}
var bootDevices = map[string]bool{"pxe": true, "disk": true, "cdrom": true, "bios": true, "none": true, "safe": true}
type bmcRecord struct {
Driver string `json:"driver"`
Address string `json:"address"`
Port int `json:"port"`
User string `json:"user"`
Extra map[string]string `json:"extra"`
}
func (a *App) credsFor(m *Machine) (*bmcCreds, error) {
if m.BMC == nil || m.bmcSecret == nil {
return nil, fmt.Errorf("no BMC credentials stored")
}
var b bmcRecord
if err := json.Unmarshal(m.BMC, &b); err != nil {
return nil, err
}
if b.Driver == "manual" {
return &bmcCreds{Driver: "manual"}, nil
}
if b.Address == "" {
return nil, fmt.Errorf("BMC record has no address")
}
pw, err := a.sealer.Open(m.bmcSecret, m.ID)
if err != nil {
return nil, fmt.Errorf("decrypt BMC secret: %w", err)
}
if b.Driver == "" {
b.Driver = "ipmi"
}
if b.Extra == nil {
b.Extra = map[string]string{}
}
return &bmcCreds{Driver: b.Driver, Address: b.Address, Port: b.Port, User: b.User, Password: pw, Extra: b.Extra}, nil
}
var errNoBMC = errors.New("no BMC credentials stored")
// power runs a power operation for a machine on the rack controller that can reach its BMC.
func (a *App) power(ctx context.Context, m *Machine, op, arg string) (string, error) {
c, err := a.credsFor(m)
if err != nil {
return "", fmt.Errorf("%w: %v", errNoBMC, err)
}
return a.powerVia(ctx, a.rackOf(m), c, op, arg)
}
func (a *App) powerVia(ctx context.Context, rack string, c *bmcCreds, op, arg string) (string, error) {
if rack == a.cfg.RackName || rack == "" {
return powerRun(ctx, a.cfg.IPMITool, c, op, arg)
}
r, ok := a.racksSnapshot()[rack]
if !ok || !rackOnline(r, a.cfg.RackName) {
return "", fmt.Errorf("rack controller %s is offline", rack)
}
return a.hub.dispatch(ctx, rack, &rackJob{Kind: "power", Driver: c.Driver, Address: c.Address, Port: c.Port, User: c.User,
Password: c.Password, Extra: c.Extra, Op: op, Arg: arg})
}
// firmwareVia runs a firmware operation (fw-*) where the BMC is reachable.
func (a *App) firmwareVia(ctx context.Context, m *Machine, op, arg string) (string, error) {
c, err := a.credsFor(m)
if err != nil {
return "", fmt.Errorf("%w: %v", errNoBMC, err)
}
rack := a.rackOf(m)
if rack == a.cfg.RackName || rack == "" {
return powerRun(ctx, a.cfg.IPMITool, c, op, arg)
}
r, ok := a.racksSnapshot()[rack]
if !ok || !rackOnline(r, a.cfg.RackName) {
return "", fmt.Errorf("rack controller %s is offline", rack)
}
return a.hub.dispatch(ctx, rack, &rackJob{Kind: "power", Driver: c.Driver, Address: c.Address, Port: c.Port, User: c.User,
Password: c.Password, Extra: c.Extra, Op: op, Arg: arg, Timeout: int((fwTimeouts[op] + time.Minute).Seconds())})
}
func (s *Store) SetBMC(ctx context.Context, id string, bmc []byte, secret []byte) error {
if secret != nil {
_, err := s.pool.Exec(ctx, `UPDATE machines SET bmc=$2, bmc_secret=$3 WHERE id=$1`, id, bmc, secret)
return err
}
_, err := s.pool.Exec(ctx, `UPDATE machines SET bmc=$2 WHERE id=$1`, id, bmc)
return err
}
// probeBMC: after commissioning, a BMC still on plain IPMI is asked whether it
// speaks Redfish with the same account; if it does, the vendor's driver takes over.
// An operator's explicit choice (driver_set) is left alone.
// verifyHintedBMC runs a status call against a BMC from bmc_hints (the agent no
// longer gets its password) and records whether it answered.
func (a *App) verifyHintedBMC(m *Machine) {
var b obj
if m.BMC == nil || json.Unmarshal(m.BMC, &b) != nil || str(b["source"]) != "hint" || b["driver_set"] == true {
return
}
ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second)
defer cancel()
out, err := a.power(ctx, m, "status", "")
b["verified_oob"] = err == nil
if err != nil {
b["errors"] = []string{"hinted BMC did not answer: " + oneLine(err.Error())}
} else {
delete(b, "errors")
}
nb, _ := json.Marshal(b)
_ = a.store.SetBMC(ctx, m.ID, nb, nil)
_ = a.store.AddEvent(ctx, m.ID, "bmc hint", fmt.Sprintf("%s: %s", str(b["address"]), oneLine(orDefault(out, fmt.Sprint(err)))))
}
func (a *App) probeBMC(m *Machine) {
if m.BMC == nil || m.bmcSecret == nil {
return
}
var b obj
if json.Unmarshal(m.BMC, &b) != nil || b["driver_set"] == true || str(b["address"]) == "" {
return
}
if d := str(b["driver"]); d != "" && d != "ipmi" {
return
}
c, err := a.credsFor(m)
if err != nil {
return
}
probe := *c
probe.Driver, probe.Port = "redfish", 0
ctx, cancel := context.WithTimeout(context.Background(), 90e9)
defer cancel()
out, err := a.powerVia(ctx, a.rackOf(m), &probe, "probe", "")
if err != nil {
_ = a.store.AddEvent(ctx, m.ID, "bmc probe", "staying on ipmi: "+oneLine(err.Error()))
return
}
var res obj
if json.Unmarshal([]byte(out), &res) != nil || str(res["driver"]) == "" {
return
}
extra, _ := b["extra"].(map[string]any)
if extra == nil {
extra = map[string]any{}
}
extra["system"] = str(res["system"])
b["driver"], b["extra"], b["redfish"] = str(res["driver"]), extra, res
nb, _ := json.Marshal(b)
if err := a.store.SetBMC(ctx, m.ID, nb, nil); err != nil {
log.Printf("probe %s: %v", m.ID, err)
return
}
_ = a.store.AddEvent(ctx, m.ID, "bmc driver", fmt.Sprintf("%s (Redfish %s, %s %s)", str(res["driver"]), str(res["redfish"]), str(res["manufacturer"]), str(res["model"])))
log.Printf("%s: BMC speaks Redfish %s (%s) -> driver %s", m.ID, str(res["redfish"]), str(res["vendor"]), str(res["driver"]))
}
// handleBMC: GET shows the record (no secret); POST sets driver, address,
// account and extra settings, checked with a status call first when verify is set.
func (a *App) handleBMC(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 b obj
_ = json.Unmarshal(m.BMC, &b)
if b == nil {
b = obj{}
}
if r.Method == http.MethodGet {
writeJSON(w, 200, obj{"id": m.ID, "bmc": b, "has_secret": m.bmcSecret != nil, "drivers": powerDrivers})
return
}
var req struct {
Driver *string `json:"driver"`
Address *string `json:"address"`
Port *int `json:"port"`
User *string `json:"user"`
Password *string `json:"password"`
Extra map[string]string `json:"extra"`
Verify bool `json:"verify"`
Probe bool `json:"probe"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
fail(w, 400, err)
return
}
if req.Probe { // re-run Redfish detection
b["driver_set"] = false
b["driver"] = "ipmi"
nb, _ := json.Marshal(b)
_ = a.store.SetBMC(r.Context(), m.ID, nb, nil)
if m, err = a.store.Get(r.Context(), m.ID); err != nil {
fail(w, 500, err)
return
}
a.probeBMC(m)
if m, err = a.store.Get(r.Context(), m.ID); err != nil {
fail(w, 500, err)
return
}
_ = json.Unmarshal(m.BMC, &b)
writeJSON(w, 200, obj{"id": m.ID, "bmc": b})
return
}
if req.Driver != nil {
if _, ok := driverByName(*req.Driver); !ok {
fail(w, 400, fmt.Errorf("unknown driver %q", *req.Driver))
return
}
b["driver"] = *req.Driver
}
if req.Address != nil {
b["address"] = strings.TrimSpace(*req.Address)
}
if req.Port != nil {
b["port"] = *req.Port
}
if req.User != nil {
b["user"] = *req.User
}
if req.Extra != nil {
ex, _ := b["extra"].(map[string]any)
if ex == nil {
ex = map[string]any{}
}
for k, v := range req.Extra {
if v == "" {
delete(ex, k)
} else {
ex[k] = v
}
}
b["extra"] = ex
}
b["driver_set"] = true
if str(b["source"]) == "" {
b["source"] = "operator"
}
secret := m.bmcSecret
if req.Password != nil {
if secret, err = a.sealer.Seal(*req.Password, m.ID); err != nil {
fail(w, 500, err)
return
}
}
nb, _ := json.Marshal(b)
trial := *m
trial.BMC, trial.bmcSecret = nb, secret
out := ""
if req.Verify {
out, err = a.power(r.Context(), &trial, "status", "")
if err != nil {
fail(w, 409, fmt.Errorf("not saved, the BMC did not answer: %v %s", err, out))
return
}
b["configured"], b["verified_oob"] = true, true
nb, _ = json.Marshal(b)
}
if err := a.store.SetBMC(r.Context(), m.ID, nb, secret); err != nil {
fail(w, 500, err)
return
}
_ = a.store.AddEvent(r.Context(), m.ID, "bmc set", fmt.Sprintf("driver=%s address=%s user=%s", str(b["driver"]), str(b["address"]), str(b["user"])))
writeJSON(w, 200, obj{"id": m.ID, "bmc": b, "output": out})
}