diff --git a/CHANGELOG.md b/CHANGELOG.md index 3ebabee..c0af845 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,16 @@ publish a tag that has no section here. the PowerDNS API (ownership by RRset comment) or RFC 2136 dynamic update with TSIG (ownership by a `_rollcall.` TXT marker and update prerequisites, so foreign records are never overwritten; drift is detected by querying). `rollcall dns`, `rollcall dns sync [-dry]`, a doctor check. +- Redfish vendor profiles: one standard driver plus per-vendor behaviour from public documentation, + for Dell iDRAC, HPE iLO, Lenovo XCC, Supermicro, xFusion/Huawei iBMC, KAYTUS/Inspur, Fujitsu iRMC, + Cisco CIMC, GIGABYTE, ASRock Rack, OpenBMC and AMI MegaRAC. + - Boot mode kept, omitted or tried per vendor. + - If-Match with 412/428 handling; future-state (`/SD`) and `@Redfish.Settings` targets. + - Busy retries and task following. + - Chassis reset fallback and an emulated power cycle. + - Session login (`auth=session`). + - Probe results (reset types, boot targets, boot mode, profile) are kept on the BMC and shown in + the UI, and the UI's driver list comes from the server. ### Security A review of the whole code base; the fixes below. Upgrading: rerun `deploy/install.sh` on the diff --git a/README.md b/README.md index a628a40..c275285 100644 --- a/README.md +++ b/README.md @@ -498,13 +498,64 @@ that reaches the BMC network. | Driver | For | |---|---| | `ipmi` | IPMI 2.0 over LAN (ipmitool lanplus), any vendor; settings `cipher` (17 on newer BMCs), `privilege`, `interface` | -| `redfish` | DMTF Redfish, any vendor: system discovered, ResetType chosen from what the BMC allows, boot override once (with If-Match where the BMC wants it) | -| `idrac` | Dell iDRAC 8/9 (Redfish, `System.Embedded.1`) | -| `ilo` | HPE iLO 4 (2.30+), 5, 6 (Redfish; no PowerCycle, so ForceRestart; PushPowerButton for soft off on older firmware) | -| `supermicro`, `xcc`, `openbmc` | Supermicro, Lenovo XClarity, OpenBMC (Redfish) | +| `redfish` and the vendor drivers below | DMTF Redfish (see below) | | `proxmox` | Proxmox VE VMs: API token (user = token id, password = secret), settings `node`, `vmid` | | `manual` | no power control; actions say so | +### Redfish across vendors + +There is one Redfish implementation of the DMTF standard. These rules hold for +every vendor: + +- **Reset types and boot targets.** Read from what the BMC advertises + (`@Redfish.AllowableValues` or the action's ActionInfo), never assumed. +- **Boot override.** Target and enable go in one PATCH. +- **If-Match.** Sent with the resource's ETag (header or `@odata.etag`). On + 412 it is retried with the strong form, then without; on 428 it is fetched + first. +- **Pending settings.** A BMC that keeps them elsewhere (`.../SD`, + `@Redfish.Settings`) gets the same PATCH there. +- **Busy answers.** Retried for up to a minute: the server is in POST, 409 + right after a power change, 503, "Retry in N seconds". +- **202 answers.** Followed to their task monitor. +- **Reset on the chassis.** Used when the system has no reset of its own. +- **Missing power cycle.** ForceOff, wait for Off, then On. +- **Paths from the BMC.** Only followed if they stay on the BMC and under + `/redfish/`. + +What a vendor documents differently is a profile in +`cmd/rollcall/redfish.go`, with its sources. The profile decides the driver +name, how the BMC is recognised, its usual system path, the boot mode rule, how +to clear an override and the login method. + +| Driver | Vendor | Boot mode in an override | Notes | +|---|---|---|---| +| `idrac` | Dell iDRAC 8/9/10 | not sent: a change starts a BIOS job | `System.Embedded.1`; iDRAC 10 takes boot settings at `.../Settings` | +| `ilo` | HPE iLO 4 (2.30+), 5, 6 | current one, without it if refused | no PowerCycle (ForceRestart); no boot changes during POST (retried) | +| `xcc` | Lenovo XCC, XCC2 | current one, without it if refused | override is Once only | +| `supermicro` | Supermicro X11+, H12+ | always the current one | left out, some boards switch to Legacy | +| `xfusion` | xFusion / Huawei iBMC | current one, without it if refused | session login; If-Match on every PATCH; ForcePowerCycle | +| `kaytus` | KAYTUS / Inspur | current one, without it if refused | no public Redfish guide: reset may be on the chassis, a PATCH that worked can answer 400 | +| `irmc` | Fujitsu / Fsas iRMC S5, S6 | not sent | `Systems/0`; ETag from the body; clearing is Target None (never Enabled Disabled); no GracefulShutdown (power button) | +| `cimc` | Cisco UCS C-series CIMC | current one, without it if refused | `Systems/`, found through the collection | +| `gigabyte` | GIGABYTE (MegaRAC SP-X) | always the current one | `Systems/Self`; If-Match required; short reset list | +| `asrockrack` | ASRock Rack (MegaRAC) | always the current one | boot settings in `Systems/Self/SD` | +| `openbmc` | OpenBMC (bmcweb) | current one, without it if refused | `Systems/system` | +| `ami` | AMI MegaRAC SP-X boards | always the current one | `Systems/Self`; If-Match required | + +Settings a machine can override: + +| Setting | Effect | +|---|---| +| `system=` | the system path | +| `boot_mode=UEFI\|Legacy` | always send this mode | +| `auth=session\|basic` | login method | +| `verify_tls=true` | check the BMC's certificate | + +Every profile is tested against a fake service that behaves as its sources +describe (`redfish_test.go`). The profiles have not yet run against the real +machines. + After commissioning, a BMC still on IPMI is asked whether it speaks Redfish with the same account; if it does, the driver for its vendor takes over (event `bmc driver`). An operator's choice is never overridden. diff --git a/cmd/rollcall/power.go b/cmd/rollcall/power.go index e329b13..7817b9b 100644 --- a/cmd/rollcall/power.go +++ b/cmd/rollcall/power.go @@ -34,17 +34,10 @@ type powerDriver struct { DefaultPort int `json:"default_port"` } -var powerDrivers = []powerDriver{ - {"ipmi", "IPMI 2.0 over LAN (ipmitool lanplus); any vendor", "ipmi", 623}, - {"redfish", "DMTF Redfish, any vendor", "redfish", 443}, - {"idrac", "Dell iDRAC 8/9 (Redfish)", "redfish", 443}, - {"ilo", "HPE iLO 4 (2.30+), 5, 6 (Redfish)", "redfish", 443}, - {"supermicro", "Supermicro X11 and later (Redfish)", "redfish", 443}, - {"xcc", "Lenovo XClarity Controller (Redfish)", "redfish", 443}, - {"openbmc", "OpenBMC (Redfish)", "redfish", 443}, - {"proxmox", "Proxmox VE virtual machine (API token: user = token id, password = secret; extra node, vmid)", "api", 8006}, - {"manual", "no power control: someone presses the button", "none", 0}, -} +var powerDrivers = append(append([]powerDriver{{"ipmi", "IPMI 2.0 over LAN (ipmitool lanplus); any vendor", "ipmi", 623}}, + redfishDrivers()...), + powerDriver{"proxmox", "Proxmox VE virtual machine (API token: user = token id, password = secret; extra node, vmid)", "api", 8006}, + powerDriver{"manual", "no power control: someone presses the button", "none", 0}) func driverByName(n string) (powerDriver, bool) { if n == "" { @@ -136,13 +129,7 @@ func ipmiExec(ctx context.Context, tool string, c *bmcCreds, args ...string) (st return strings.TrimSpace(out.String()), err } -// ---------------------------------------------------------------- Redfish - -type rfClient struct { - base, user, pass string - hc *http.Client - etag string -} +// ---------------------------------------------------------------- BMC HTTP (Redfish, Proxmox) func bmcHTTPClient(c *bmcCreds) *http.Client { // BMCs ship self-signed certificates; checking them is opt-in (extra verify_tls=true) @@ -175,278 +162,6 @@ func bmcBase(c *bmcCreds, scheme string, defPort int) string { return fmt.Sprintf("%s://%s:%d", scheme, host, port) } -func newRF(c *bmcCreds) *rfClient { - return &rfClient{base: bmcBase(c, "https", 443), user: c.User, pass: c.Password, hc: bmcHTTPClient(c)} -} - -// resolve turns a path the BMC handed us (@odata.id, an action target, a task -// monitor) into one on this BMC. Vendors return relative paths, full URLs and -// trailing slashes; anything that would leave the BMC or the Redfish tree (and -// take the credentials with it) is refused. -func (r *rfClient) resolve(p string) (string, error) { - if strings.Contains(p, "://") { - u, err := url.Parse(p) - b, _ := url.Parse(r.base) - if err != nil || b == nil || !strings.EqualFold(u.Host, b.Host) || u.User != nil { - return "", fmt.Errorf("the BMC pointed at %q, which is not this BMC", p) - } - p = u.RequestURI() - } - if !strings.HasPrefix(p, "/redfish/") || strings.Contains(p, "/../") || strings.HasSuffix(p, "/..") { - return "", fmt.Errorf("the BMC pointed at %q, outside /redfish/", p) - } - return p, nil -} - -func (r *rfClient) do(ctx context.Context, method, path string, body, out any) (int, error) { - var rd io.Reader - if body != nil { - b, _ := json.Marshal(body) - rd = bytes.NewReader(b) - } - path, err := r.resolve(path) - if err != nil { - return 0, err - } - req, err := http.NewRequestWithContext(ctx, method, r.base+path, rd) - if err != nil { - return 0, err - } - if r.user != "" { - req.SetBasicAuth(r.user, r.pass) - } - req.Header.Set("Accept", "application/json") - if body != nil { - req.Header.Set("Content-Type", "application/json") - } - if method == "PATCH" && r.etag != "" { - req.Header.Set("If-Match", r.etag) // some services (Supermicro, newer iDRAC) insist - } - resp, err := r.hc.Do(req) - if err != nil { - return 0, err - } - defer resp.Body.Close() - data, _ := io.ReadAll(io.LimitReader(resp.Body, 4<<20)) - if method == "GET" { - if et := resp.Header.Get("ETag"); et != "" { - r.etag = et - } - } - if resp.StatusCode >= 300 { - return resp.StatusCode, fmt.Errorf("%s %s: %s %s", method, path, resp.Status, redfishMessage(data)) - } - if out != nil && len(data) > 0 { - if err := json.Unmarshal(data, out); err != nil { - return resp.StatusCode, fmt.Errorf("%s %s: %w", method, path, err) - } - } - return resp.StatusCode, nil -} - -// redfishMessage pulls the human message out of a Redfish error body. -func redfishMessage(data []byte) string { - var e struct { - Error struct { - Message string `json:"message"` - Ext []struct { - Message string `json:"Message"` - } `json:"@Message.ExtendedInfo"` - } `json:"error"` - } - if json.Unmarshal(data, &e) == nil { - if len(e.Error.Ext) > 0 && e.Error.Ext[0].Message != "" { - return e.Error.Ext[0].Message - } - if e.Error.Message != "" { - return e.Error.Message - } - } - return oneLine(string(data)) -} - -// redfishSystemPath: extra system=…, else the vendor's usual one if it exists, else the first member. -func (r *rfClient) system(ctx context.Context, c *bmcCreds) (string, obj, error) { - var cands []string - if s := c.Extra["system"]; s != "" { - cands = append(cands, s) - } - switch c.Driver { - case "idrac": - cands = append(cands, "/redfish/v1/Systems/System.Embedded.1") - case "ilo", "xcc", "supermicro": - cands = append(cands, "/redfish/v1/Systems/1") - } - for _, p := range cands { - var sys obj - if _, err := r.do(ctx, "GET", p, nil, &sys); err == nil { - return p, sys, nil - } else if c.Extra["system"] != "" { - return "", nil, err - } - } - var coll struct { - Members []struct { - ID string `json:"@odata.id"` - } `json:"Members"` - } - if _, err := r.do(ctx, "GET", "/redfish/v1/Systems", nil, &coll); err != nil { - return "", nil, err - } - if len(coll.Members) == 0 { - return "", nil, errors.New("the BMC lists no systems") - } - p := coll.Members[0].ID - var sys obj - _, err := r.do(ctx, "GET", p, nil, &sys) - return p, sys, err -} - -// resetTypes are the ResetType values the system accepts. -func (r *rfClient) resetTypes(ctx context.Context, sys obj) []string { - act, _ := dig(sys, "Actions", "#ComputerSystem.Reset").(obj) - var out []string - for _, v := range toList(act["ResetType@Redfish.AllowableValues"]) { - out = append(out, str(v)) - } - if len(out) == 0 { - if info := str(act["@Redfish.ActionInfo"]); info != "" { - var ai struct { - Parameters []struct { - Name string `json:"Name"` - AllowableValues []string `json:"AllowableValues"` - } `json:"Parameters"` - } - if _, err := r.do(ctx, "GET", info, nil, &ai); err == nil { - for _, p := range ai.Parameters { - if p.Name == "ResetType" { - out = p.AllowableValues - } - } - } - } - } - return out -} - -func pickReset(allowed []string, prefs ...string) string { - if len(allowed) == 0 { - return prefs[0] - } - for _, p := range prefs { - if hasTag(allowed, p) { - return p - } - } - return "" -} - -var redfishBootTargets = map[string]string{"pxe": "Pxe", "disk": "Hdd", "bios": "BiosSetup", "cdrom": "Cd", "none": "None", "safe": "None"} - -func redfishOp(ctx context.Context, c *bmcCreds, op, arg string) (string, error) { - r := newRF(c) - path, sys, err := r.system(ctx, c) - if err != nil { - return "", err - } - state := strings.ToLower(str(sys["PowerState"])) // On | Off | PoweringOn | PoweringOff - on := state == "on" || state == "poweringon" - reset := func(prefs ...string) (string, error) { - t := pickReset(r.resetTypes(ctx, sys), prefs...) - if t == "" { - return "", fmt.Errorf("the BMC accepts none of %v", prefs) - } - target := str(dig(sys, "Actions", "#ComputerSystem.Reset", "target")) - if target == "" { - target = path + "/Actions/ComputerSystem.Reset" - } - if _, err := r.do(ctx, "POST", target, obj{"ResetType": t}, nil); err != nil { - return "", err - } - return "Chassis Power Control: " + t + " (redfish)", nil - } - switch op { - case "status": - if on { - return "Chassis Power is on", nil - } - return "Chassis Power is off", nil - case "on": - if on { - return "Chassis Power is on already", nil - } - return reset("On", "PushPowerButton") - case "off": - if !on { - return "Chassis Power is off already", nil - } - return reset("ForceOff", "PushPowerButton") - case "soft": - if !on { - return "Chassis Power is off already", nil - } - return reset("GracefulShutdown", "PushPowerButton") - case "cycle": - if !on { - return reset("On", "PushPowerButton") - } - return reset("PowerCycle", "ForceRestart", "GracefulRestart") - case "reset": - if !on { - return reset("On", "PushPowerButton") - } - return reset("ForceRestart", "PowerCycle", "GracefulRestart") - case "bootdev": - boot := obj{"BootSourceOverrideTarget": redfishBootTargets[arg], "BootSourceOverrideEnabled": "Once"} - if arg == "none" || arg == "safe" { - boot = obj{"BootSourceOverrideEnabled": "Disabled"} - } - if _, err := r.do(ctx, "PATCH", path, obj{"Boot": boot}, nil); err != nil { - return "", err - } - return "Set Boot Device to " + arg + " (redfish " + str(boot["BootSourceOverrideTarget"]) + ", once)", nil - } - return "", fmt.Errorf("redfish: %s is not supported", op) -} - -// redfishProbe asks whether the BMC speaks Redfish with these credentials and -// suggests the driver for its vendor. The answer is JSON. -func redfishProbe(ctx context.Context, c *bmcCreds) (string, error) { - r := newRF(c) - var root obj - if _, err := r.do(ctx, "GET", "/redfish/v1/", nil, &root); err != nil { - return "", fmt.Errorf("no Redfish service: %w", err) - } - res := obj{"redfish": str(root["RedfishVersion"]), "vendor": str(root["Vendor"])} - if res["vendor"] == "" { - for k := range asObj(root["Oem"]) { - res["vendor"] = k - } - } - path, sys, err := r.system(ctx, &bmcCreds{Driver: "redfish", Extra: c.Extra, User: c.User, Password: c.Password, Address: c.Address, Port: c.Port}) - if err != nil { - return "", fmt.Errorf("Redfish %s answers, but not to these credentials: %w", res["redfish"], err) - } - res["system"], res["manufacturer"], res["model"], res["power"] = path, str(sys["Manufacturer"]), str(sys["Model"]), str(sys["PowerState"]) - v := strings.ToLower(str(res["vendor"]) + " " + str(res["manufacturer"])) - switch { - case strings.Contains(v, "dell"): - res["driver"] = "idrac" - case strings.Contains(v, "hpe"), strings.Contains(v, "hewlett"), strings.Contains(v, "hp "): - res["driver"] = "ilo" - case strings.Contains(v, "supermicro"): - res["driver"] = "supermicro" - case strings.Contains(v, "lenovo"): - res["driver"] = "xcc" - case strings.Contains(v, "openbmc"): - res["driver"] = "openbmc" - default: - res["driver"] = "redfish" - } - b, _ := json.Marshal(res) - return string(b), nil -} - func asObj(v any) obj { o, _ := v.(obj) return o diff --git a/cmd/rollcall/redfish.go b/cmd/rollcall/redfish.go new file mode 100644 index 0000000..704b8b7 --- /dev/null +++ b/cmd/rollcall/redfish.go @@ -0,0 +1,812 @@ +package main + +// Redfish power driver: one implementation of the DMTF standard, and what each +// vendor documents differently in redfishProfiles (with the sources). +// +// For every vendor: +// - ResetType values and boot targets come from @Redfish.AllowableValues +// (or the action's ActionInfo); nothing is assumed. +// - A one-time boot override sends target and enable in one PATCH, the boot +// mode as the profile says (keep / omit / try), and only writable fields. +// - PATCH carries If-Match with the resource's ETag (header or +// @odata.etag). On 412 the strong form and then none are tried; on 428 +// the ETag is fetched first. +// - A service that keeps pending settings elsewhere (AMI's .../SD, +// @Redfish.Settings) gets the same PATCH there. +// - "Busy" answers are retried for up to a minute: the server is in POST, +// 409 right after a power change, 503, "Retry in N seconds". +// - 202 answers are followed to their task monitor. +// - A power cycle the system does not offer is ForceOff, wait for Off, On. +// +// Field notes behind these rules: OpenStack Ironic/sushy, MAAS and bmclib. + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "regexp" + "strconv" + "strings" + "time" +) + +type redfishProfile struct { + Driver string // the power driver name + Title string // for people + Vendors []string // lower-case substrings of ServiceRoot Vendor or the system's Manufacturer + OemKeys []string // ServiceRoot Oem keys that identify it + Systems []string // its usual ComputerSystem, tried before the collection + // BootMode: BootSourceOverrideMode in a boot override. + // keep: always send the current mode (left out, the BMC may switch UEFI to Legacy) + // omit: never send it unless the operator sets boot_mode (a change starts a BIOS job) + // try: send the current mode; again without it if the BMC refuses the body + BootMode string + // Clear: bodies that clear a boot override, tried in order (default: Enabled Disabled) + Clear []obj + // Auth: "session" logs in through the SessionService (X-Auth-Token); default basic. + Auth string + Sources []string +} + +// redfishProfiles, most specific first: the AMI-based ones (Gigabyte, ASRock +// Rack) also carry Oem.Ami, so they come before the generic AMI entry. +var redfishProfiles = []redfishProfile{ + {Driver: "idrac", Title: "Dell iDRAC 8/9/10", Vendors: []string{"dell"}, OemKeys: []string{"Dell"}, + Systems: []string{"/redfish/v1/Systems/System.Embedded.1"}, BootMode: "omit", + Sources: []string{ + "https://dl.dell.com/topicspdf/idrac9-lifecycle-controller-v33-series_api-guide2_en-us.pdf", + "https://github.com/dell/iDRAC-Redfish-Scripting/blob/master/Redfish%20Python/SetNextOneTimeBootDeviceREDFISH.py", + }}, + {Driver: "ilo", Title: "HPE iLO 4 (2.30+), 5, 6", Vendors: []string{"hpe", "hewlett"}, OemKeys: []string{"Hpe", "Hp"}, + Systems: []string{"/redfish/v1/Systems/1"}, BootMode: "try", + Sources: []string{ + "https://servermanagementportal.ext.hpe.com/docs/redfishservices/ilos/ilo5/ilo5_307/ilo5_computersystem_resourcedefns307", + "https://servermanagementportal.ext.hpe.com/docs/references_and_material/blogposts/etc/masterserverstates/masterserverstates.md", + "https://servermanagementportal.ext.hpe.com/docs/concepts/errorresponses", + }}, + {Driver: "xcc", Title: "Lenovo XClarity Controller (XCC, XCC2)", Vendors: []string{"lenovo"}, + Systems: []string{"/redfish/v1/Systems/1"}, BootMode: "try", + Sources: []string{ + "https://pubs.lenovo.com/xcc-restapi/update_next_onetime_bootconfig_patch", + "https://pubs.lenovo.com/xcc-restapi/server_reset_operations_post", + }}, + {Driver: "supermicro", Title: "Supermicro X11 and later, H12 and later", Vendors: []string{"supermicro"}, OemKeys: []string{"Supermicro"}, + Systems: []string{"/redfish/v1/Systems/1"}, BootMode: "keep", + Sources: []string{ + "https://www.supermicro.com/manuals/other/RedfishUserGuide.pdf", + "https://storyboard.openstack.org/#!/story/2008252", + }}, + {Driver: "xfusion", Title: "xFusion / Huawei FusionServer iBMC", Vendors: []string{"xfusion", "huawei"}, OemKeys: []string{"xFusion", "Huawei"}, + Systems: []string{"/redfish/v1/Systems/1"}, BootMode: "try", Auth: "session", + Sources: []string{ + "https://www.xfusion.com/wp-content/uploads/2025/08/FusionServer-iBMC-User-Guide.pdf", + "https://github.com/Open-xFusion/Server_Plugin_Ansible/blob/HEAD/ibmc_ansible/ibmc_redfish_api/api_manage_boot_device.py", + "https://github.com/Huawei/Huawei-iBMC-Cmdlets/blob/HEAD/code/common/Types.ps1", + }}, + {Driver: "kaytus", Title: "KAYTUS / Inspur (no public Redfish guide: check on hardware)", Vendors: []string{"kaytus", "inspur"}, OemKeys: []string{"Public"}, + Systems: []string{"/redfish/v1/Systems/1"}, BootMode: "try", + Sources: []string{ + "https://github.com/ilkermanap/pyredfish/blob/HEAD/pyredfish/vendors/kaytus.py", + "https://github.com/erelbi/kaytus-bmc-sdk", + "https://www.commoncriteriaportal.org/files/epfiles/Kaytus%20ST%20v27a.pdf", + }}, + {Driver: "irmc", Title: "Fujitsu / Fsas iRMC S5, S6", Vendors: []string{"fujitsu", "ts_fujitsu", "fsas"}, OemKeys: []string{"ts_fujitsu"}, + Systems: []string{"/redfish/v1/Systems/0"}, BootMode: "omit", + // Enabled "Disabled" is refused on every firmware; 1.20 wants Continuous with None + Clear: []obj{{"BootSourceOverrideTarget": "None"}, {"BootSourceOverrideTarget": "None", "BootSourceOverrideEnabled": "Continuous"}}, + Sources: []string{ + "https://github.com/fujitsu/ansible-irmc-integration/blob/HEAD/plugins/modules/irmc_setnextboot.py", + "https://github.com/fujitsu/ansible-irmc-integration/blob/HEAD/plugins/modules/irmc_powerstate.py", + }}, + {Driver: "cimc", Title: "Cisco UCS C-series CIMC (standalone)", Vendors: []string{"cisco"}, OemKeys: []string{"Cisco"}, + BootMode: "try", // the system is /Systems/: found through the collection + Sources: []string{"https://www.cisco.com/c/en/us/td/docs/unified_computing/ucs/c/sw/api/4_3/b-cisco-ucs-c-series-servers-rest-api-programmer-s-guide-release-4-3.pdf"}}, + {Driver: "gigabyte", Title: "GIGABYTE servers (MegaRAC SP-X BMC)", Vendors: []string{"giga-byte", "gigabyte"}, + Systems: []string{"/redfish/v1/Systems/Self"}, BootMode: "keep", + Sources: []string{"https://download.gigabyte.com/FileList/Manual/server_manual_redfish_v1.11.0.pdf"}}, + {Driver: "asrockrack", Title: "ASRock Rack (MegaRAC BMC)", Vendors: []string{"asrockrack", "asrock rack", "asrock"}, + Systems: []string{"/redfish/v1/Systems/Self"}, BootMode: "keep", + Sources: []string{"https://bugs.launchpad.net/ironic/+bug/2073518", "https://opendev.org/openstack/ironic/commit/54977a14"}}, + {Driver: "openbmc", Title: "OpenBMC (bmcweb)", Vendors: []string{"openbmc"}, + Systems: []string{"/redfish/v1/Systems/system"}, BootMode: "try", + Sources: []string{"https://github.com/openbmc/bmcweb/blob/master/redfish-core/lib/systems.hpp"}}, + {Driver: "ami", Title: "AMI MegaRAC SP-X (many ODM boards)", Vendors: []string{"american megatrends"}, OemKeys: []string{"Ami"}, + Systems: []string{"/redfish/v1/Systems/Self"}, BootMode: "keep", + Sources: []string{"https://download-2.msi.com/archive/mnu_exe/server/MSI-Redfish-API-v1.0-UG-250801.pdf"}}, + {Driver: "redfish", Title: "DMTF Redfish, any vendor", BootMode: "try", + Sources: []string{"https://www.dmtf.org/standards/redfish"}}, +} + +func redfishProfileFor(driver string) redfishProfile { + for _, p := range redfishProfiles { + if p.Driver == driver { + return p + } + } + return redfishProfiles[len(redfishProfiles)-1] +} + +// matchRedfishProfile picks the profile for what a service says about itself. +func matchRedfishProfile(vendor, manufacturer string, oemKeys []string) redfishProfile { + v := strings.ToLower(vendor + " " + manufacturer) + for _, p := range redfishProfiles { + for _, s := range p.Vendors { + if strings.Contains(v, s) { + return p + } + } + for _, k := range p.OemKeys { + if hasTag(oemKeys, k) { + return p + } + } + } + return redfishProfiles[len(redfishProfiles)-1] +} + +func redfishDrivers() []powerDriver { + var out []powerDriver + for _, p := range redfishProfiles { + if p.Driver == "redfish" { // the generic one first + out = append([]powerDriver{{p.Driver, p.Title, "redfish", 443}}, out...) + continue + } + out = append(out, powerDriver{p.Driver, p.Title + " (Redfish)", "redfish", 443}) + } + return out +} + +// ---------------------------------------------------------------- client + +type rfClient struct { + base, user, pass string + hc *http.Client + etags map[string]string // resource -> ETag from its last GET + token, session string // X-Auth-Token and the session to delete, when logged in +} + +// login opens a session (profile or extra auth=session); logout closes it. +func (r *rfClient) login(ctx context.Context) error { + resp, err := r.call(ctx, "POST", "/redfish/v1/SessionService/Sessions", obj{"UserName": r.user, "Password": r.pass}, "") + if err != nil { + return fmt.Errorf("session login: %w", err) + } + if r.token = resp.hdr.Get("X-Auth-Token"); r.token == "" { + return errors.New("session login: no X-Auth-Token in the answer") + } + r.session = resp.hdr.Get("Location") + return nil +} + +func (r *rfClient) logout() { + if r.session == "" { + return + } + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + _, _ = r.call(ctx, "DELETE", r.session, nil, "") // sessions are few: never leave one behind + r.token, r.session = "", "" +} + +// open logs in when the profile or the operator wants sessions. +func (r *rfClient) open(ctx context.Context, c *bmcCreds, prof redfishProfile) error { + if a := c.Extra["auth"]; a == "session" || (a == "" && prof.Auth == "session") { + return r.login(ctx) + } + return nil +} + +// rfSleep waits between retries; tests make it instant. +var rfSleep = func(ctx context.Context, d time.Duration) error { + select { + case <-ctx.Done(): + return ctx.Err() + case <-time.After(d): + return nil + } +} + +func newRF(c *bmcCreds) *rfClient { + return &rfClient{base: bmcBase(c, "https", 443), user: c.User, pass: c.Password, hc: bmcHTTPClient(c), etags: map[string]string{}} +} + +// resolve turns a path the BMC handed us (@odata.id, an action target, a task +// monitor) into one on this BMC. Vendors return relative paths, full URLs and +// trailing slashes; anything that would leave the BMC or the Redfish tree (and +// take the credentials with it) is refused. +func (r *rfClient) resolve(p string) (string, error) { + if strings.Contains(p, "://") { + u, err := url.Parse(p) + b, _ := url.Parse(r.base) + if err != nil || b == nil || !strings.EqualFold(u.Host, b.Host) || u.User != nil { + return "", fmt.Errorf("the BMC pointed at %q, which is not this BMC", p) + } + p = u.RequestURI() + } + if !strings.HasPrefix(p, "/redfish/") || strings.Contains(p, "/../") || strings.HasSuffix(p, "/..") { + return "", fmt.Errorf("the BMC pointed at %q, outside /redfish/", p) + } + return p, nil +} + +type rfResp struct { + code int + hdr http.Header + body []byte +} + +// rfError is a refused request, with the registry message IDs it carried. +type rfError struct { + method, path string + code int + ids []string + msg string +} + +func (e *rfError) Error() string { + return fmt.Sprintf("%s %s: %d %s", e.method, e.path, e.code, e.msg) +} + +// has: one of the message IDs ends in key ("Ami.1.0.OperationSupportedInFutureStateURI"). +func (e *rfError) has(key string) bool { + for _, id := range e.ids { + if strings.HasSuffix(id, "."+key) || id == key { + return true + } + } + return strings.Contains(e.msg, key) +} + +// succeeded: the error body says the request worked (only Success messages). +func (e *rfError) succeeded() bool { + if len(e.ids) == 0 { + return false + } + for _, id := range e.ids { + if !strings.HasSuffix(id, ".Success") && !strings.HasSuffix(id, "GeneralError") { + return false + } + } + return e.has("Success") || strings.Contains(strings.ToLower(e.msg), "completed successfully") +} + +var retryInRe = regexp.MustCompile(`(?i)retry in (\d+) seconds?`) + +// busy: the service will take the same request a little later. +func (e *rfError) busy() (time.Duration, bool) { + if m := retryInRe.FindStringSubmatch(e.msg); m != nil { + n, _ := strconv.Atoi(m[1]) + return time.Duration(min(max(n, 1), 30)) * time.Second, true + } + switch { + case e.has("UnableToModifyDuringSystemPOST"): // HPE: boot settings only when off or past POST + return 10 * time.Second, true + case e.code == http.StatusConflict, e.code == http.StatusServiceUnavailable: // right after a power change + return 5 * time.Second, true + } + return 0, false +} + +func asRFError(err error) *rfError { + var e *rfError + if errors.As(err, &e) { + return e + } + return nil +} + +// call sends one request; ifMatch "" sends none. +func (r *rfClient) call(ctx context.Context, method, path string, body any, ifMatch string) (*rfResp, error) { + var rd io.Reader + if body != nil { + b, _ := json.Marshal(body) + rd = bytes.NewReader(b) + } + path, err := r.resolve(path) + if err != nil { + return nil, err + } + req, err := http.NewRequestWithContext(ctx, method, r.base+path, rd) + if err != nil { + return nil, err + } + if r.token != "" { + req.Header.Set("X-Auth-Token", r.token) + } else if r.user != "" && !(method == "POST" && strings.HasSuffix(path, "/SessionService/Sessions")) { + req.SetBasicAuth(r.user, r.pass) + } + req.Header.Set("Accept", "application/json") + if body != nil { + req.Header.Set("Content-Type", "application/json") + } + if ifMatch != "" { + req.Header.Set("If-Match", ifMatch) + } + resp, err := r.hc.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + data, _ := io.ReadAll(io.LimitReader(resp.Body, 4<<20)) + out := &rfResp{code: resp.StatusCode, hdr: resp.Header, body: data} + if resp.StatusCode >= 300 { + ids, msg := redfishMessage(data) + e := &rfError{method: method, path: path, code: resp.StatusCode, ids: ids, msg: resp.Status + " " + msg} + if resp.StatusCode == http.StatusBadRequest && method != "GET" && e.succeeded() { // KAYTUS: 400 with "completed successfully" + return out, nil + } + return out, e + } + if method == "GET" { + et := resp.Header.Get("ETag") + if et == "" { + var o struct { + ETag json.RawMessage `json:"@odata.etag"` // iRMC: a bare number + } + _ = json.Unmarshal(data, &o) + et = strings.Trim(string(o.ETag), `"`) + if len(o.ETag) > 0 && o.ETag[0] == '"' { + var s string + _ = json.Unmarshal(o.ETag, &s) + et = s + } + } + if et != "" { + r.etags[path] = et + } + } + return out, nil +} + +// do is call plus decoding; GETs record the resource's ETag. +func (r *rfClient) do(ctx context.Context, method, path string, body, out any) (int, error) { + resp, err := r.call(ctx, method, path, body, "") + if err != nil { + if resp != nil { + return resp.code, err + } + return 0, err + } + if out != nil && len(resp.body) > 0 { + if err := json.Unmarshal(resp.body, out); err != nil { + return resp.code, fmt.Errorf("%s %s: %w", method, path, err) + } + } + return resp.code, nil +} + +// redfishMessage pulls the message IDs and the human message out of an error body. +func redfishMessage(data []byte) ([]string, string) { + var e struct { + Error struct { + Code string `json:"code"` + Message string `json:"message"` + Ext []struct { + ID string `json:"MessageId"` + Message string `json:"Message"` + } `json:"@Message.ExtendedInfo"` + } `json:"error"` + } + if json.Unmarshal(data, &e) != nil { + return nil, oneLine(string(data)) // some BMCs' web servers answer errors in HTML + } + var ids, msgs []string + for _, x := range e.Error.Ext { + if x.ID != "" { + ids = append(ids, x.ID) + } + if x.Message != "" { + msgs = append(msgs, x.Message) + } else if x.ID != "" { // HPE often sends only the ID + msgs = append(msgs, x.ID) + } + } + if e.Error.Code != "" { + ids = append(ids, e.Error.Code) + } + if len(msgs) == 0 && e.Error.Message != "" { + msgs = append(msgs, e.Error.Message) + } + return ids, strings.Join(msgs, "; ") +} + +// patch sends a PATCH the way picky services want it (If-Match, 412, 428). +func (r *rfClient) patch(ctx context.Context, path string, body obj) (*rfResp, error) { + et := r.etags[path] + resp, err := r.call(ctx, "PATCH", path, body, et) + e := asRFError(err) + switch { + case e == nil: + case e.code == http.StatusPreconditionRequired: // 428: wants If-Match (AMI) + if _, gerr := r.call(ctx, "GET", path, nil, ""); gerr == nil && r.etags[path] != "" { + resp, err = r.call(ctx, "PATCH", path, body, r.etags[path]) + } + case e.code == http.StatusPreconditionFailed && et != "": // 412: weak ETag refused, or stale + if strong := strings.TrimPrefix(et, "W/"); strong != et { + resp, err = r.call(ctx, "PATCH", path, body, strong) + } + if e2 := asRFError(err); e2 != nil && e2.code == http.StatusPreconditionFailed { + resp, err = r.call(ctx, "PATCH", path, body, "") + } + } + if err == nil { + delete(r.etags, path) // changed: never reuse it + } + return resp, err +} + +// retry runs f again while the service says it is busy, for up to a minute. +func retry(ctx context.Context, f func() error) error { + deadline := time.Now().Add(time.Minute) + for { + err := f() + e := asRFError(err) + if e == nil { + return err + } + d, ok := e.busy() + if !ok || time.Now().Add(d).After(deadline) { + return err + } + if serr := rfSleep(ctx, d); serr != nil { + return err + } + } +} + +// follow waits for a 202's task (or Dell job) to finish. +func (r *rfClient) follow(ctx context.Context, resp *rfResp) error { + if resp == nil || resp.code != http.StatusAccepted { + return nil + } + loc := resp.hdr.Get("Location") + if loc == "" { + var t struct { + ID string `json:"@odata.id"` + } + _ = json.Unmarshal(resp.body, &t) + loc = t.ID + } + if loc == "" { + return nil + } + deadline := time.Now().Add(2 * time.Minute) + for time.Now().Before(deadline) { + res, err := r.call(ctx, "GET", loc, nil, "") + if err != nil { + return err + } + var t obj + _ = json.Unmarshal(res.body, &t) + st := str(t["TaskState"]) + str(t["JobState"]) + switch st { + case "Completed": + return nil + case "Exception", "Killed", "Cancelled", "Failed", "CompletedWithErrors": + msg := "" + if ms := toList(t["Messages"]); len(ms) > 0 { + msg = str(asObj(ms[0])["Message"]) + } + return fmt.Errorf("task %s: %s %s", loc, st, msg) + } + if res.code == http.StatusOK && st == "" { // the monitor answered with the result itself + return nil + } + if err := rfSleep(ctx, 3*time.Second); err != nil { + return err + } + } + return fmt.Errorf("task %s still running after 2 minutes", loc) +} + +// system finds the ComputerSystem: extra system=..., else the vendor's usual +// one if it exists, else the first member of the collection. +func (r *rfClient) system(ctx context.Context, c *bmcCreds, prof redfishProfile) (string, obj, error) { + cands := append([]string(nil), prof.Systems...) + if s := c.Extra["system"]; s != "" { + cands = []string{s} + } + for _, p := range cands { + var sys obj + if _, err := r.do(ctx, "GET", p, nil, &sys); err == nil { + return p, sys, nil + } else if c.Extra["system"] != "" { + return "", nil, err + } + } + var coll struct { + Members []struct { + ID string `json:"@odata.id"` + } `json:"Members"` + } + if _, err := r.do(ctx, "GET", "/redfish/v1/Systems", nil, &coll); err != nil { + return "", nil, err + } + if len(coll.Members) == 0 { + return "", nil, errors.New("the BMC lists no systems") + } + p := strings.TrimRight(coll.Members[0].ID, "/") // iLO lists members with a trailing slash + var sys obj + _, err := r.do(ctx, "GET", p, nil, &sys) + return p, sys, err +} + +// resetTypes are the ResetType values the system accepts. +func (r *rfClient) resetTypes(ctx context.Context, sys obj) []string { + act := asObj(dig(sys, "Actions", "#ComputerSystem.Reset")) + var out []string + for _, v := range toList(act["ResetType@Redfish.AllowableValues"]) { + out = append(out, str(v)) + } + if len(out) == 0 { + if info := str(act["@Redfish.ActionInfo"]); info != "" { + var ai struct { + Parameters []struct { + Name string `json:"Name"` + AllowableValues []string `json:"AllowableValues"` + } `json:"Parameters"` + } + if _, err := r.do(ctx, "GET", info, nil, &ai); err == nil { + for _, p := range ai.Parameters { + if p.Name == "ResetType" { + out = p.AllowableValues + } + } + } + } + } + return out +} + +func pickReset(allowed []string, prefs ...string) string { + if len(allowed) == 0 { + return prefs[0] + } + for _, p := range prefs { + if hasTag(allowed, p) { + return p + } + } + return "" +} + +func strList(v any) []string { + var out []string + for _, x := range toList(v) { + out = append(out, str(x)) + } + return out +} + +var redfishBootTargets = map[string]string{"pxe": "Pxe", "disk": "Hdd", "bios": "BiosSetup", "cdrom": "Cd", "none": "", "safe": ""} + +// bootOverride sets the next boot (target "" clears the override). +func (r *rfClient) bootOverride(ctx context.Context, c *bmcCreds, prof redfishProfile, path string, sys obj, target string) error { + boot := asObj(sys["Boot"]) + if allowed := strList(boot["BootSourceOverrideTarget@Redfish.AllowableValues"]); target != "" && len(allowed) > 0 && !hasTag(allowed, target) { + return fmt.Errorf("the BMC offers boot targets %s, not %s", strings.Join(allowed, ", "), target) + } + if target == "" { + clears := prof.Clear + if len(clears) == 0 { + clears = []obj{{"BootSourceOverrideEnabled": "Disabled"}} + } + var err error + for _, b := range clears { + if err = retry(ctx, func() error { _, err := r.patch(ctx, path, obj{"Boot": b}); return err }); err == nil { + return nil + } + if _, gerr := r.call(ctx, "GET", path, nil, ""); gerr != nil { // fresh ETag for the next try + return err + } + } + return err + } + body := obj{"BootSourceOverrideTarget": target, "BootSourceOverrideEnabled": "Once"} + mode, asked := str(boot["BootSourceOverrideMode"]), c.Extra["boot_mode"] // UEFI | Legacy + if asked != "" { + mode = asked + } + withMode := mode != "" && (prof.BootMode != "omit" || asked != "") + full := obj{} + for k, v := range body { + full[k] = v + } + if withMode { + full["BootSourceOverrideMode"] = mode + } + send := func(p string, b obj) error { + return retry(ctx, func() error { + resp, err := r.patch(ctx, p, obj{"Boot": b}) + if err == nil { + err = r.follow(ctx, resp) + } + return err + }) + } + err := send(path, full) + e := asRFError(err) + if e != nil && e.has("OperationSupportedInFutureStateURI") { // AMI: pending settings live in .../SD + sd := path + "/SD" + if _, gerr := r.call(ctx, "GET", sd, nil, ""); gerr == nil { + err = send(sd, full) + } + e = asRFError(err) + } + if settings := str(dig(sys, "@Redfish.Settings", "SettingsObject", "@odata.id")); e != nil && settings != "" && + (e.code == http.StatusBadRequest || e.code == http.StatusMethodNotAllowed) { // e.g. iDRAC 10: .../Settings + if _, gerr := r.call(ctx, "GET", settings, nil, ""); gerr == nil { + err = send(settings, full) + } + e = asRFError(err) + } + if e != nil && withMode && prof.BootMode == "try" && asked == "" && e.code == http.StatusBadRequest { + err = send(path, body) // some refuse the mode in the body (HPE DL160 Gen10) + } + return err +} + +func redfishOp(ctx context.Context, c *bmcCreds, op, arg string) (string, error) { + prof := redfishProfileFor(c.Driver) + r := newRF(c) + if err := r.open(ctx, c, prof); err != nil { + return "", err + } + defer r.logout() + path, sys, err := r.system(ctx, c, prof) + if err != nil { + return "", err + } + powerState := func(sys obj) string { return strings.ToLower(str(sys["PowerState"])) } // On | Off | PoweringOn | PoweringOff | Reset (iLO, briefly) + on := powerState(sys) == "on" || powerState(sys) == "poweringon" + allowed := r.resetTypes(ctx, sys) + target := str(dig(sys, "Actions", "#ComputerSystem.Reset", "target")) + if target == "" && dig(sys, "Actions", "#ComputerSystem.Reset") == nil { + // no reset on the system (seen on KAYTUS): the chassis' one, if it has + if ch := toList(dig(sys, "Links", "Chassis")); len(ch) > 0 { + var chassis obj + if _, err := r.do(ctx, "GET", str(asObj(ch[0])["@odata.id"]), nil, &chassis); err == nil { + if t := str(dig(chassis, "Actions", "#Chassis.Reset", "target")); t != "" { + target = t + allowed = strList(dig(chassis, "Actions", "#Chassis.Reset", "ResetType@Redfish.AllowableValues")) + } + } + } + } + if target == "" { + target = path + "/Actions/ComputerSystem.Reset" + } + post := func(t string) error { + return retry(ctx, func() error { + resp, err := r.call(ctx, "POST", target, obj{"ResetType": t}, "") + if err == nil { + err = r.follow(ctx, resp) + } + return err + }) + } + reset := func(prefs ...string) (string, error) { + t := pickReset(allowed, prefs...) + if t == "" { + return "", fmt.Errorf("the BMC accepts none of %v (it offers %v)", prefs, allowed) + } + if err := post(t); err != nil { + return "", err + } + return "Chassis Power Control: " + t + " (redfish)", nil + } + // waitOff: ForceOff is not instant; On before the system is off is refused + waitOff := func() error { + for i := 0; i < 30; i++ { + var s obj + if _, err := r.do(ctx, "GET", path, nil, &s); err == nil && powerState(s) == "off" { + return nil + } + if err := rfSleep(ctx, 2*time.Second); err != nil { + return err + } + } + return errors.New("the system did not power off within a minute") + } + switch op { + case "status": + if on { + return "Chassis Power is on", nil + } + return "Chassis Power is off", nil + case "on": + if on { + return "Chassis Power is on already", nil + } + return reset("On", "ForceOn", "PushPowerButton") + case "off": + if !on { + return "Chassis Power is off already", nil + } + return reset("ForceOff", "PushPowerButton") + case "soft": + if !on { + return "Chassis Power is off already", nil + } + return reset("GracefulShutdown", "PushPowerButton") + case "cycle", "reset": + if !on { + return reset("On", "ForceOn", "PushPowerButton") + } + prefs := []string{"PowerCycle", "ForcePowerCycle", "ForceRestart", "GracefulRestart"} // ForcePowerCycle: iBMC + if op == "reset" { + prefs = []string{"ForceRestart", "PowerCycle", "ForcePowerCycle", "GracefulRestart"} + } + if pickReset(allowed, prefs...) == "" && hasTag(allowed, "ForceOff") && hasTag(allowed, "On") { + if err := post("ForceOff"); err != nil { // offered no restart at all (some AMI builds) + return "", err + } + if err := waitOff(); err != nil { + return "", err + } + if err := post("On"); err != nil { + return "", err + } + return "Chassis Power Control: ForceOff, On (redfish)", nil + } + return reset(prefs...) + case "bootdev": + t := redfishBootTargets[arg] + if err := r.bootOverride(ctx, c, prof, path, sys, t); err != nil { + return "", err + } + if t == "" { + return "Boot override cleared (redfish)", nil + } + return "Set Boot Device to " + arg + " (redfish " + t + ", once)", nil + } + return "", fmt.Errorf("redfish: %s is not supported", op) +} + +// redfishProbe asks whether the BMC speaks Redfish with these credentials, +// which vendor profile fits, and what the system offers. The answer is JSON +// and is kept on the BMC record (shown in the UI). +func redfishProbe(ctx context.Context, c *bmcCreds) (string, error) { + r := newRF(c) + var root obj + if _, err := r.do(ctx, "GET", "/redfish/v1/", nil, &root); err != nil { // the service root needs no login + return "", fmt.Errorf("no Redfish service: %w", err) + } + var oemKeys []string + for k := range asObj(root["Oem"]) { + oemKeys = append(oemKeys, k) + } + vendor := str(root["Vendor"]) + if vendor == "" && len(oemKeys) > 0 { + vendor = oemKeys[0] + } + res := obj{"redfish": str(root["RedfishVersion"]), "vendor": vendor} + // the system is looked up with the profile the root suggests; the + // manufacturer then settles it + guess := matchRedfishProfile(vendor, "", oemKeys) + if err := r.open(ctx, c, guess); err != nil { + return "", fmt.Errorf("Redfish %s answers, but not to these credentials: %w", res["redfish"], err) + } + defer r.logout() + path, sys, err := r.system(ctx, &bmcCreds{Extra: c.Extra}, guess) + if err != nil { + return "", fmt.Errorf("Redfish %s answers, but not to these credentials: %w", res["redfish"], err) + } + prof := matchRedfishProfile(vendor, str(sys["Manufacturer"]), oemKeys) + boot := asObj(sys["Boot"]) + res["system"], res["manufacturer"], res["model"], res["power"] = path, str(sys["Manufacturer"]), str(sys["Model"]), str(sys["PowerState"]) + res["driver"], res["profile"] = prof.Driver, prof.Title + if prof.Auth == "session" { + res["auth"] = "session" + } + res["reset_types"] = r.resetTypes(ctx, sys) + res["boot_targets"] = strList(boot["BootSourceOverrideTarget@Redfish.AllowableValues"]) + res["boot_mode"] = str(boot["BootSourceOverrideMode"]) + b, _ := json.Marshal(res) + return string(b), nil +} diff --git a/cmd/rollcall/redfish_test.go b/cmd/rollcall/redfish_test.go new file mode 100644 index 0000000..073a095 --- /dev/null +++ b/cmd/rollcall/redfish_test.go @@ -0,0 +1,406 @@ +package main + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "sync" + "testing" + "time" +) + +// vendorBMC behaves the way one vendor's documentation (or the field notes in +// Ironic, MAAS, bmclib) says it does; each flag is one such behaviour. +type vendorBMC struct { + mu sync.Mutex + vendor, manufacturer string + oem []string + system string + trailingSlash bool // collection members end in "/" (iLO) + allowed, targets []string + mode, power, etag string + needIfMatch bool // 428 without If-Match (AMI) + strongOnly bool // 412 for a weak If-Match + futureSD bool // PATCH on the system: 400 OperationSupportedInFutureStateURI (ASRock Rack) + refuseMode bool // 400 when the body carries BootSourceOverrideMode (HPE DL160) + inPOST int // the first N PATCHes: 400 UnableToModifyDuringSystemPOST (HPE) + flipsWithoutMode bool // a PATCH without the mode switches to Legacy (Supermicro) + async bool // reset answers 202 and a task monitor + sessionOnly bool // basic auth refused: log in through the SessionService (iBMC clients) + actionInfo bool // reset values only through @Redfish.ActionInfo (iBMC) + numericETag bool // ETag only as a bare number in @odata.etag (iRMC) + chassisReset bool // no ComputerSystem.Reset; Chassis/1 has the reset (KAYTUS SDK) + successAs400 bool // a working PATCH answers 400 "completed successfully" (KAYTUS SDK) + refuseDisabled bool // Enabled "Disabled" is 400; clearing is Target None (iRMC) + sessions int // open sessions + + patchPaths, ifMatch, resets []string + patches []obj + taskPolls int +} + +func (f *vendorBMC) fail(w http.ResponseWriter, code int, id, msg string) { + w.WriteHeader(code) + _ = json.NewEncoder(w).Encode(obj{"error": obj{"code": "Base.1.0.GeneralError", "message": "see ExtendedInfo", + "@Message.ExtendedInfo": []obj{{"MessageId": id, "Message": msg}}}}) +} + +func (f *vendorBMC) handler() http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + f.mu.Lock() + defer f.mu.Unlock() + if r.URL.Path == "/redfish/v1/SessionService/Sessions" && r.Method == "POST" { + var c struct{ UserName, Password string } + _ = json.NewDecoder(r.Body).Decode(&c) + if c.UserName != "admin" || c.Password != "secret" { + f.fail(w, 401, "Base.1.0.NoValidSession", "bad credentials") + return + } + f.sessions++ + w.Header().Set("X-Auth-Token", "tok") + w.Header().Set("Location", "/redfish/v1/SessionService/Sessions/1") + w.WriteHeader(201) + return + } + authed := r.Header.Get("X-Auth-Token") == "tok" + if u, p, ok := r.BasicAuth(); !f.sessionOnly && ok && u == "admin" && p == "secret" { + authed = true + } + if r.URL.Path != "/redfish/v1/" && !authed { + f.fail(w, 401, "Base.1.0.InsufficientPrivilege", "bad credentials") + return + } + oem := obj{} + for _, k := range f.oem { + oem[k] = obj{} + } + sys := strings.TrimSuffix(r.URL.Path, "/") + switch { + case r.URL.Path == "/redfish/v1/": + _ = json.NewEncoder(w).Encode(obj{"RedfishVersion": "1.15.0", "Vendor": f.vendor, "Oem": oem}) + case r.URL.Path == "/redfish/v1/SessionService/Sessions/1" && r.Method == "DELETE": + f.sessions-- + w.WriteHeader(204) + case r.URL.Path == "/redfish/v1/Systems/1/ResetActionInfo": + _ = json.NewEncoder(w).Encode(obj{"Parameters": []obj{{"Name": "ResetType", "AllowableValues": f.allowed}}}) + case r.URL.Path == "/redfish/v1/Chassis/1" && r.Method == "GET": + _ = json.NewEncoder(w).Encode(obj{"Actions": obj{"#Chassis.Reset": obj{"target": "/redfish/v1/Chassis/1/Actions/Chassis.Reset", "ResetType@Redfish.AllowableValues": f.allowed}}}) + case r.URL.Path == "/redfish/v1/Systems": + m := f.system + if f.trailingSlash { + m += "/" + } + _ = json.NewEncoder(w).Encode(obj{"Members": []obj{{"@odata.id": m}}}) + case (sys == f.system || sys == f.system+"/SD") && r.Method == "GET": + if f.etag != "" && !f.numericETag { + w.Header().Set("ETag", f.etag) + } + boot := obj{"BootSourceOverrideTarget@Redfish.AllowableValues": f.targets, "BootSourceOverrideEnabled": "Disabled"} + if f.mode != "" { + boot["BootSourceOverrideMode"] = f.mode + } + reset := obj{"target": f.system + "/Actions/ComputerSystem.Reset", "ResetType@Redfish.AllowableValues": f.allowed} + if f.actionInfo { + reset = obj{"target": f.system + "/Actions/ComputerSystem.Reset", "@Redfish.ActionInfo": "/redfish/v1/Systems/1/ResetActionInfo"} + } + res := obj{"PowerState": f.power, "Manufacturer": f.manufacturer, "Model": "Test", "Boot": boot, + "Actions": obj{"#ComputerSystem.Reset": reset}, "Links": obj{"Chassis": []obj{{"@odata.id": "/redfish/v1/Chassis/1"}}}} + if f.chassisReset { + res["Actions"] = obj{} + } + if f.numericETag { + res["@odata.etag"] = json.Number(f.etag) + } + _ = json.NewEncoder(w).Encode(res) + case (sys == f.system || sys == f.system+"/SD") && r.Method == "PATCH": + im := r.Header.Get("If-Match") + f.patchPaths, f.ifMatch = append(f.patchPaths, sys), append(f.ifMatch, im) + var body obj + _ = json.NewDecoder(r.Body).Decode(&body) + boot := asObj(body["Boot"]) + switch { + case f.needIfMatch && im == "": + f.fail(w, 428, "Ami.1.0.PreconditionHeaderMissing", "If-Match header is required") + case f.strongOnly && strings.HasPrefix(im, "W/"): + f.fail(w, 412, "HttpStatus.1.0.PreconditionFailed", "ETag does not match") + case f.futureSD && sys == f.system: + f.fail(w, 400, "Ami.1.0.OperationSupportedInFutureStateURI", "moved to FutureState URI(/redfish/v1/Systems/Self/SD)") + case f.inPOST > 0: + f.inPOST-- + f.fail(w, 400, "iLO.2.14.UnableToModifyDuringSystemPOST", "") + case f.refuseDisabled && boot["BootSourceOverrideEnabled"] == "Disabled": + f.fail(w, 400, "Base.1.0.PropertyValueNotInList", "Disabled") + case f.refuseMode && boot["BootSourceOverrideMode"] != nil: + f.fail(w, 400, "Base.1.0.PropertyNotWritable", "BootSourceOverrideMode") + default: + if boot["BootSourceOverrideTarget"] != nil && !hasTag(f.targets, str(boot["BootSourceOverrideTarget"])) { + f.fail(w, 400, "Base.1.0.PropertyValueNotInList", "target") + return + } + if m := str(boot["BootSourceOverrideMode"]); m != "" { + f.mode = m + } else if f.flipsWithoutMode { + f.mode = "Legacy" + } + f.patches = append(f.patches, boot) + if f.successAs400 { + f.fail(w, 400, "Base.1.0.Success", "The request completed successfully.") + return + } + w.WriteHeader(204) + } + case r.URL.Path == f.system+"/Actions/ComputerSystem.Reset" || (f.chassisReset && r.URL.Path == "/redfish/v1/Chassis/1/Actions/Chassis.Reset"): + var body struct{ ResetType string } + _ = json.NewDecoder(r.Body).Decode(&body) + if !hasTag(f.allowed, body.ResetType) { + f.fail(w, 400, "Base.1.0.ActionParameterNotSupported", "ResetType") + return + } + f.resets = append(f.resets, body.ResetType) + switch body.ResetType { + case "On", "ForceOn": + f.power = "On" + case "ForceOff", "GracefulShutdown": + f.power = "Off" + } + if f.async { + w.Header().Set("Location", "/redfish/v1/TaskService/Tasks/7") + w.WriteHeader(202) + return + } + w.WriteHeader(204) + case r.URL.Path == "/redfish/v1/TaskService/Tasks/7": + f.taskPolls++ + st := "Running" + if f.taskPolls >= 2 { + st = "Completed" + } + _ = json.NewEncoder(w).Encode(obj{"TaskState": st}) + default: + http.NotFound(w, r) + } + }) +} + +func runBMC(t *testing.T, f *vendorBMC, driver string) (*bmcCreds, func()) { + t.Helper() + old := rfSleep + rfSleep = func(context.Context, time.Duration) error { return nil } + srv := httptest.NewTLSServer(f.handler()) + return creds(srv.URL, driver), func() { srv.Close(); rfSleep = old } +} + +func mustRun(t *testing.T, c *bmcCreds, op, arg string) string { + t.Helper() + out, err := powerRun(context.Background(), "", c, op, arg) + if err != nil { + t.Fatalf("%s %s: %v", op, arg, err) + } + return out +} + +var stdTargets = []string{"None", "Pxe", "Hdd", "Cd", "BiosSetup", "UefiHttp"} + +func TestMatchRedfishProfile(t *testing.T) { + for _, c := range []struct { + vendor, manufacturer string + oem []string + want string + }{ + {"Dell", "Dell Inc.", []string{"Dell"}, "idrac"}, + {"HPE", "HPE", []string{"Hpe"}, "ilo"}, + {"Lenovo", "Lenovo", nil, "xcc"}, + {"Supermicro", "Supermicro", []string{"Supermicro"}, "supermicro"}, + {"", "GIGA-BYTE TECHNOLOGY CO., LTD", []string{"Ami"}, "gigabyte"}, + {"AsrockRack", "", []string{"Ami"}, "asrockrack"}, + {"", "To Be Filled By O.E.M.", []string{"Ami"}, "ami"}, + {"OpenBMC", "", nil, "openbmc"}, + {"Contoso", "Contoso", nil, "redfish"}, + } { + if got := matchRedfishProfile(c.vendor, c.manufacturer, c.oem).Driver; got != c.want { + t.Errorf("%q/%q/%v: %s, want %s", c.vendor, c.manufacturer, c.oem, got, c.want) + } + } +} + +// Supermicro: a boot override without the mode can switch the board to Legacy. +func TestRedfishSupermicroKeepsBootMode(t *testing.T) { + f := &vendorBMC{vendor: "Supermicro", manufacturer: "Supermicro", system: "/redfish/v1/Systems/1", mode: "UEFI", power: "On", + targets: stdTargets, flipsWithoutMode: true, etag: `"1"`, + allowed: []string{"On", "ForceOff", "GracefulShutdown", "GracefulRestart", "ForceRestart", "Nmi", "ForceOn", "PowerCycle"}} + c, done := runBMC(t, f, "supermicro") + defer done() + mustRun(t, c, "bootdev", "pxe") + if f.mode != "UEFI" || f.patches[0]["BootSourceOverrideMode"] != "UEFI" || f.patches[0]["BootSourceOverrideEnabled"] != "Once" || f.ifMatch[0] != `"1"` { + t.Errorf("mode now %s, patch %v, if-match %v", f.mode, f.patches, f.ifMatch) + } + mustRun(t, c, "cycle", "") + if f.resets[0] != "PowerCycle" { + t.Errorf("resets %v", f.resets) + } +} + +// Dell: changing the mode starts a BIOS job; it is not sent. +func TestRedfishIDRACOmitsBootMode(t *testing.T) { + f := &vendorBMC{vendor: "Dell", manufacturer: "Dell Inc.", system: "/redfish/v1/Systems/System.Embedded.1", mode: "UEFI", power: "Off", + targets: stdTargets, allowed: []string{"On", "ForceOff", "ForceRestart", "GracefulShutdown", "PushPowerButton", "Nmi"}} + c, done := runBMC(t, f, "idrac") + defer done() + mustRun(t, c, "bootdev", "pxe") + if _, sent := f.patches[0]["BootSourceOverrideMode"]; sent { + t.Errorf("idrac got the mode: %v", f.patches[0]) + } + c.Extra["boot_mode"] = "Legacy" // the operator asks: then it goes + mustRun(t, c, "bootdev", "pxe") + if f.patches[1]["BootSourceOverrideMode"] != "Legacy" { + t.Errorf("asked mode not sent: %v", f.patches[1]) + } + mustRun(t, c, "on", "") + mustRun(t, c, "cycle", "") // no PowerCycle on iDRAC9 3.x: ForceRestart + if strings.Join(f.resets, ",") != "On,ForceRestart" { + t.Errorf("resets %v", f.resets) + } +} + +// HPE: no boot changes during POST; some refuse the mode; members end in "/". +func TestRedfishILOPostStateAndMode(t *testing.T) { + f := &vendorBMC{vendor: "HPE", manufacturer: "HPE", oem: []string{"Hpe"}, system: "/redfish/v1/Systems/1", trailingSlash: true, + mode: "UEFI", power: "On", targets: stdTargets, inPOST: 2, refuseMode: true, etag: `W/"9"`, + allowed: []string{"On", "ForceOff", "GracefulShutdown", "ForceRestart", "Nmi", "PushPowerButton", "GracefulRestart"}} + c, done := runBMC(t, f, "redfish") // generic driver, found through the collection + defer done() + out := mustRun(t, c, "bootdev", "pxe") + if len(f.patches) != 1 || f.patches[0]["BootSourceOverrideTarget"] != "Pxe" || f.patches[0]["BootSourceOverrideMode"] != nil { + t.Errorf("%s: patches %v", out, f.patches) + } + var p obj + _ = json.Unmarshal([]byte(mustRun(t, c, "probe", "")), &p) + if p["driver"] != "ilo" || p["system"] != "/redfish/v1/Systems/1" || p["boot_mode"] != "UEFI" { + t.Errorf("probe %v", p) + } +} + +// AMI (Gigabyte): Systems/Self, If-Match required, a short ResetType list. +func TestRedfishAMIIfMatchAndCycle(t *testing.T) { + f := &vendorBMC{manufacturer: "GIGA-BYTE TECHNOLOGY CO., LTD", oem: []string{"Ami"}, system: "/redfish/v1/Systems/Self", + mode: "UEFI", power: "On", targets: stdTargets, needIfMatch: true, strongOnly: true, etag: `W/"abc"`, + allowed: []string{"On", "ForceOff", "GracefulShutdown", "ForceRestart"}} + c, done := runBMC(t, f, "gigabyte") + defer done() + mustRun(t, c, "bootdev", "pxe") + if f.ifMatch[len(f.ifMatch)-1] != `"abc"` || f.patches[0]["BootSourceOverrideMode"] != "UEFI" { + t.Errorf("if-match tries %v, patch %v", f.ifMatch, f.patches) + } + mustRun(t, c, "cycle", "") + if strings.Join(f.resets, ",") != "ForceRestart" { + t.Errorf("resets %v", f.resets) + } + // a build that offers neither PowerCycle nor a restart: off, then on + f.allowed = []string{"On", "ForceOff"} + f.resets = nil + mustRun(t, c, "cycle", "") + if strings.Join(f.resets, ",") != "ForceOff,On" { + t.Errorf("emulated cycle %v", f.resets) + } +} + +// ASRock Rack: boot settings live in the future-state resource .../SD. +func TestRedfishASRockFutureState(t *testing.T) { + f := &vendorBMC{vendor: "AsrockRack", oem: []string{"Ami"}, system: "/redfish/v1/Systems/Self", mode: "UEFI", power: "Off", + targets: stdTargets, futureSD: true, etag: `"5"`, allowed: []string{"On", "ForceOff", "GracefulShutdown", "ForceRestart"}} + c, done := runBMC(t, f, "asrockrack") + defer done() + mustRun(t, c, "bootdev", "pxe") + if strings.Join(f.patchPaths, ",") != "/redfish/v1/Systems/Self,/redfish/v1/Systems/Self/SD" || len(f.patches) != 1 { + t.Errorf("paths %v patches %v", f.patchPaths, f.patches) + } +} + +func TestRedfishTaskAndTargets(t *testing.T) { + f := &vendorBMC{vendor: "Contoso", system: "/redfish/v1/Systems/1", power: "Off", targets: []string{"None", "Pxe", "Hdd"}, + async: true, allowed: []string{"On", "ForceOff"}} + c, done := runBMC(t, f, "redfish") + defer done() + mustRun(t, c, "on", "") + if f.taskPolls < 2 { + t.Errorf("task polled %d times", f.taskPolls) + } + if _, err := powerRun(context.Background(), "", c, "bootdev", "bios"); err == nil || !strings.Contains(err.Error(), "offers boot targets") { + t.Errorf("an unoffered target: %v", err) + } + mustRun(t, c, "bootdev", "none") + if f.patches[0]["BootSourceOverrideEnabled"] != "Disabled" { + t.Errorf("clear %v", f.patches) + } +} + +// iBMC: sessions, ActionInfo, ForcePowerCycle, If-Match on PATCH. +func TestRedfishXFusionSession(t *testing.T) { + f := &vendorBMC{oem: []string{"xFusion"}, manufacturer: "xFusion", system: "/redfish/v1/Systems/1", power: "On", mode: "UEFI", + targets: stdTargets, sessionOnly: true, actionInfo: true, needIfMatch: true, etag: `W/"77"`, + allowed: []string{"On", "ForceOff", "GracefulShutdown", "ForceRestart", "Nmi", "ForcePowerCycle"}} + c, done := runBMC(t, f, "xfusion") + defer done() + mustRun(t, c, "cycle", "") + mustRun(t, c, "bootdev", "pxe") + if strings.Join(f.resets, ",") != "ForcePowerCycle" || len(f.patches) != 1 || f.ifMatch[0] != `W/"77"` || f.sessions != 0 { + t.Errorf("resets %v patches %v if-match %v open sessions %d", f.resets, f.patches, f.ifMatch, f.sessions) + } + var p obj + _ = json.Unmarshal([]byte(mustRun(t, c, "probe", "")), &p) + if p["driver"] != "xfusion" || p["auth"] != "session" || f.sessions != 0 { + t.Errorf("probe %v, sessions %d", p, f.sessions) + } +} + +// iRMC: the ETag only in the body as a number; never Enabled Disabled. +func TestRedfishIRMC(t *testing.T) { + f := &vendorBMC{vendor: "ts_fujitsu", manufacturer: "FUJITSU", system: "/redfish/v1/Systems/0", power: "On", mode: "UEFI", + targets: stdTargets, numericETag: true, etag: "1714", needIfMatch: true, refuseDisabled: true, + allowed: []string{"ForceOff", "ForceRestart", "Nmi", "PushPowerButton"}} + c, done := runBMC(t, f, "irmc") + defer done() + mustRun(t, c, "bootdev", "pxe") + if f.ifMatch[0] != "1714" || f.patches[0]["BootSourceOverrideMode"] != nil { + t.Errorf("if-match %v patch %v", f.ifMatch, f.patches) + } + mustRun(t, c, "bootdev", "none") + if f.patches[1]["BootSourceOverrideTarget"] != "None" || f.patches[1]["BootSourceOverrideEnabled"] != nil { + t.Errorf("clear %v", f.patches[1]) + } + mustRun(t, c, "soft", "") // no GracefulShutdown: the power button + if strings.Join(f.resets, ",") != "PushPowerButton" { + t.Errorf("resets %v", f.resets) + } +} + +// KAYTUS (third-party SDK): reset on the chassis; a PATCH that worked answers 400. +func TestRedfishKaytus(t *testing.T) { + f := &vendorBMC{oem: []string{"Public"}, manufacturer: "KAYTUS", system: "/redfish/v1/Systems/1", power: "Off", mode: "UEFI", + targets: stdTargets, chassisReset: true, successAs400: true, + allowed: []string{"On", "ForceOff", "GracefulShutdown", "ForceRestart", "PowerCycle"}} + c, done := runBMC(t, f, "redfish") + defer done() + mustRun(t, c, "on", "") + mustRun(t, c, "bootdev", "pxe") + if strings.Join(f.resets, ",") != "On" || len(f.patches) != 1 { + t.Errorf("resets %v patches %v", f.resets, f.patches) + } + if p := matchRedfishProfile("", "KAYTUS", []string{"Public"}); p.Driver != "kaytus" { + t.Errorf("profile %s", p.Driver) + } +} + +// CIMC: /Systems/, found through the collection. +func TestRedfishCIMCSerialPath(t *testing.T) { + f := &vendorBMC{manufacturer: "Cisco Systems Inc", oem: []string{"Cisco"}, system: "/redfish/v1/Systems/WZP21330G5B", power: "On", + targets: stdTargets, allowed: []string{"On", "ForceOff", "GracefulShutdown", "GracefulRestart", "ForceRestart", "Nmi", "PowerCycle"}} + c, done := runBMC(t, f, "cimc") + defer done() + mustRun(t, c, "cycle", "") + if strings.Join(f.resets, ",") != "PowerCycle" { + t.Errorf("resets %v", f.resets) + } +} diff --git a/cmd/rollcall/web/app.js b/cmd/rollcall/web/app.js index f989d4b..12e2b54 100644 --- a/cmd/rollcall/web/app.js +++ b/cmd/rollcall/web/app.js @@ -496,6 +496,7 @@ function pageMachine(d, tab) { } else if (tab === 'bmc') { body = `
Commands run on rack controller ${esc(s.rack_controller || '')}
${card('BMC', kv([['Driver', `${esc(b.driver || 'ipmi')}${b.driver_set ? ' (set by operator)' : ''}${b.redfish ? ` · Redfish ${esc(b.redfish.redfish)} ${esc(b.redfish.manufacturer || '')} ${esc(b.redfish.model || '')}` : ''}`], + ['Redfish offers', b.redfish && (b.redfish.reset_types || b.redfish.boot_targets) ? `${esc((b.redfish.reset_types || []).join(' '))}
boot ${esc((b.redfish.boot_targets || []).join(' '))}${b.redfish.boot_mode ? ` · mode ${esc(b.redfish.boot_mode)}` : ''}${b.redfish.profile ? `
profile ${esc(b.redfish.profile)}` : ''}` : ''], ['Settings', b.extra && Object.keys(b.extra).length ? `${esc(Object.entries(b.extra).map(([k, v]) => k + '=' + v).join(' '))}` : ''], ['Source', esc(b.source)], ['Address', `${esc(b.address)}${b.port && b.port !== 623 ? ':' + esc(b.port) : ''}`], ['MAC', `${esc(b.mac)}`], ['User', esc(b.user)], ['Password', d.has_bmc_secret ? 'stored encrypted on the server' : 'none'], ['Configured', b.configured ? 'yes' : 'no'], ['Verified out-of-band', b.verified_oob === true ? 'yes' : b.verified_oob === false ? 'no' : ''], ['Channel', esc(b.channel)], ['LAN', b.lan ? `${esc(JSON.stringify(b.lan))}` : ''], ['Firmware', esc((b.mc_info || {})['Firmware Revision'])], ['Manufacturer', esc((b.mc_info || {})['Manufacturer Name'])], ['Errors', (b.errors || []).length ? `${esc(b.errors.join('; '))}` : '']]))} ${(b.users_before || []).length ? card('User table before commissioning', table(['ID', 'Name', 'Priv', 'IPMI'], b.users_before.map(u => [esc(u.id), esc(u.name), esc(u.priv), u.ipmi_msg ? 'yes' : 'no']))) : ''} @@ -777,15 +778,16 @@ function wireNetwork() { } // ---------------------------------------------------------------- BMC driver settings -const powerDriverNames = [['ipmi', 'IPMI 2.0 (ipmitool)'], ['redfish', 'Redfish, any vendor'], ['idrac', 'Dell iDRAC 8/9'], ['ilo', 'HPE iLO 4/5/6'], ['supermicro', 'Supermicro (Redfish)'], ['xcc', 'Lenovo XClarity'], ['openbmc', 'OpenBMC'], ['proxmox', 'Proxmox VE VM'], ['manual', 'manual (no power control)']]; async function editBMC(id, b) { + // the server's list: one entry per vendor profile it knows + const drivers = await api('GET', '/api/v1/power-drivers').then(ds => ds.map(d => [d.name, d.description]), () => [['ipmi', 'IPMI 2.0'], ['redfish', 'Redfish']]); const v = await dialog('BMC settings', [ - { name: 'driver', label: 'Driver', type: 'select', value: b.driver || 'ipmi', options: powerDriverNames }, + { name: 'driver', label: 'Driver', type: 'select', value: b.driver || 'ipmi', options: drivers }, { name: 'address', label: 'Address', value: b.address || '', placeholder: '10.0.0.5, bmc.example or https://host:8443' }, { name: 'port', label: 'Port', value: b.port ? String(b.port) : '', placeholder: "driver's default" }, { name: 'user', label: 'User', value: b.user || '', help: 'proxmox: token id, e.g. root@pam!rollcall' }, { name: 'password', label: 'Password', placeholder: 'leave empty to keep the stored one', help: 'proxmox: the token secret' }, - { name: 'extra', label: 'Settings', type: 'textarea', rows: 3, value: Object.entries(b.extra || {}).map(([k, x]) => `${k}=${x}`).join('\n'), placeholder: 'system=/redfish/v1/Systems/1\nverify_tls=true\ncipher=17\nnode=pve1\nvmid=211', help: 'one KEY=VALUE per line; a line KEY= removes it' }, + { name: 'extra', label: 'Settings', type: 'textarea', rows: 3, value: Object.entries(b.extra || {}).map(([k, x]) => `${k}=${x}`).join('\n'), placeholder: 'system=/redfish/v1/Systems/1\nboot_mode=UEFI\nverify_tls=true\ncipher=17\nnode=pve1\nvmid=211', help: 'one KEY=VALUE per line; a line KEY= removes it' }, { name: 'verify', label: 'Check with a status call before saving', type: 'checkbox', value: true }, ], 'The password is sealed on the region and only travels to the rack controller over mutual TLS.', 'Save'); if (!v) return;