BMC hints: the password stays on the region
CI / agent (push) Successful in 3s
CI / site (push) Successful in 2s
CI / go (push) Successful in 41s

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>
This commit is contained in:
Ilker ManapandClaude Opus 5.5 committed 2026-10-29 21:28:00 +01:00
1 parent a495c33b66
commit e474fbcf16
4 files changed
+102 -18

No files matched your search

+6 -1
View File
@@ -416,7 +416,12 @@ def bmc_hint(server, macs):
if not h or not h.get("address"): if not h or not h.get("address"):
return None return None
res = {"source": "hint", "address": h["address"], "port": h.get("port", 623), res = {"source": "hint", "address": h["address"], "port": h.get("port", 623),
"user": h.get("user"), "password": h.get("password"), "errors": []} "user": h.get("user"), "errors": []}
if not h.get("password"):
# the region keeps the credentials and checks the BMC itself (rollcall >= 0.3)
res["configured"] = True
return res
res["password"] = h["password"]
base = ["-I", "lanplus", "-H", h["address"], "-p", str(h.get("port", 623)), "-U", h.get("user", ""), base = ["-I", "lanplus", "-H", h["address"], "-p", str(h.get("port", 623)), "-U", h.get("user", ""),
"-P", h.get("password", "")] "-P", h.get("password", "")]
rc, mc = ipmi(base + ["mc", "info"], timeout=20) rc, mc = ipmi(base + ["mc", "info"], timeout=20)
+21
View File
@@ -114,6 +114,27 @@ func (s *Store) SetBMC(ctx context.Context, id string, bmc []byte, secret []byte
// probeBMC: after commissioning, a BMC still on plain IPMI is asked whether it // 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. // 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. // 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) { func (a *App) probeBMC(m *Machine) {
if m.BMC == nil || m.bmcSecret == nil { if m.BMC == nil || m.bmcSecret == nil {
return return
+17
View File
@@ -6,9 +6,11 @@ import (
"crypto/rand" "crypto/rand"
"crypto/tls" "crypto/tls"
"crypto/x509" "crypto/x509"
"encoding/json"
"net/http/httptest" "net/http/httptest"
"os" "os"
"path/filepath" "path/filepath"
"strings"
"testing" "testing"
"time" "time"
) )
@@ -107,3 +109,18 @@ func TestIdentitiesOnlyFromTheRoot(t *testing.T) {
t.Error("an admin certificate passed as the region's") t.Error("an admin certificate passed as the region's")
} }
} }
// The hint API names the BMC but never hands out its password.
func TestBMCHintHasNoPassword(t *testing.T) {
a := &App{cfg: &Config{BMCHints: []BMCHint{{MAC: "52:54:00:AA:BB:01", Address: "10.0.0.11", User: "admin", Password: "s3cret"}}}}
rec := httptest.NewRecorder()
a.handleHint(rec, httptest.NewRequest("GET", "/api/v1/bmc-hint?macs=02:00:00:00:00:09,52-54-00-aa-bb-01", nil))
var h map[string]any
_ = json.Unmarshal(rec.Body.Bytes(), &h)
if h["address"] != "10.0.0.11" || h["user"] != "admin" || h["password"] != nil || strings.Contains(rec.Body.String(), "s3cret") {
t.Errorf("hint %s", rec.Body.String())
}
if a.hintFor([]string{"../x", ""}) != nil {
t.Error("garbage matched a hint")
}
}
+58 -17
View File
@@ -554,8 +554,28 @@ func (a *App) handleCommission(w http.ResponseWriter, r *http.Request) {
// Pull the BMC password out of the payload before anything is persisted. // Pull the BMC password out of the payload before anything is persisted.
var secret []byte var secret []byte
detail := "bmc=none" detail := "bmc=none"
if bmc, _ := inv["bmc"].(obj); bmc == nil || str(bmc["source"]) == "hint" || str(bmc["address"]) == "" {
// a configured out-of-band BMC: the region's credentials, whatever the agent sent
if h := a.hintFor(macs); h != nil {
port := h.Port
if port == 0 {
port = 623
}
nb := obj{"source": "hint", "address": h.Address, "port": port, "user": h.User, "configured": true, "password": "<from bmc_hints>"}
if bmc != nil {
nb["agent"] = obj{"source": bmc["source"], "errors": bmc["errors"]}
}
inv["bmc"] = nb
if h.Password != "" {
if secret, err = a.sealer.Seal(h.Password, m.ID); err != nil {
fail(w, 500, err)
return
}
}
}
}
if bmc, ok := inv["bmc"].(obj); ok { if bmc, ok := inv["bmc"].(obj); ok {
if pw := str(bmc["password"]); pw != "" { if pw := str(bmc["password"]); pw != "" && str(bmc["source"]) != "hint" {
secret, err = a.sealer.Seal(pw, m.ID) secret, err = a.sealer.Seal(pw, m.ID)
if err != nil { if err != nil {
fail(w, 500, err) fail(w, 500, err)
@@ -603,9 +623,15 @@ func (a *App) handleCommission(w http.ResponseWriter, r *http.Request) {
if isNew || oldErr != nil || old.State == "recommission" || old.RackController == "" { if isNew || oldErr != nil || old.State == "recommission" || old.RackController == "" {
_ = a.store.SetRackController(r.Context(), m.ID, a.fromRack(r)) // a ready machine is not moved by whoever reports it _ = a.store.SetRackController(r.Context(), m.ID, a.fromRack(r)) // a ready machine is not moved by whoever reports it
} }
if full, err := a.store.Get(r.Context(), m.ID); err == nil { go func(id string) { // check a hinted BMC from where it is reachable, then look for Redfish
go a.probeBMC(full) ctx := context.Background()
} if full, err := a.store.Get(ctx, id); err == nil {
a.verifyHintedBMC(full)
}
if full, err := a.store.Get(ctx, id); err == nil {
a.probeBMC(full)
}
}(m.ID)
if full, err := a.store.Get(r.Context(), m.ID); err == nil { 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 m = full // carries profile / node token when this is a re-commission of a stateless node
} }
@@ -623,22 +649,37 @@ func recommissionable(state string) bool {
return state == "new" || state == "ready" || state == "recommission" || strings.HasSuffix(state, "-failed") return state == "new" || state == "ready" || state == "recommission" || strings.HasSuffix(state, "-failed")
} }
func (a *App) handleHint(w http.ResponseWriter, r *http.Request) { // hintFor is the configured out-of-band BMC for one of these MACs.
func (a *App) hintFor(macs []string) *BMCHint {
want := map[string]bool{} want := map[string]bool{}
for _, m := range strings.Split(r.URL.Query().Get("macs"), ",") { for _, m := range macs {
want[normMAC(m)] = true if n := normMAC(m); n != "" {
} want[n] = 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{}) for i, h := range a.cfg.BMCHints {
if want[normMAC(h.MAC)] {
return &a.cfg.BMCHints[i]
}
}
return nil
}
// handleHint tells a commissioning agent that a BMC is configured for it: the
// address, never the password (the agent token is public, MACs are visible on
// the wire). The region attaches the credentials itself when the machine
// reports in (handleCommission) and checks them from the rack controller.
func (a *App) handleHint(w http.ResponseWriter, r *http.Request) {
h := a.hintFor(strings.Split(r.URL.Query().Get("macs"), ","))
if h == nil {
writeJSON(w, 200, obj{})
return
}
port := h.Port
if port == 0 {
port = 623
}
writeJSON(w, 200, obj{"address": h.Address, "port": port, "user": h.User, "credentials": "on the region"})
} }
func (a *App) handleList(w http.ResponseWriter, r *http.Request) { func (a *App) handleList(w http.ResponseWriter, r *http.Request) {