diff --git a/audit/internal/webui/huawei_elabel.go b/audit/internal/webui/huawei_elabel.go new file mode 100644 index 0000000..a1e1519 --- /dev/null +++ b/audit/internal/webui/huawei_elabel.go @@ -0,0 +1,280 @@ +package webui + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "os/exec" + "strconv" + "strings" + "time" +) + +type huaweiField struct { + Name string `json:"name"` + Key string `json:"key"` + Value string `json:"value"` + ReadOnly bool `json:"read_only,omitempty"` +} + +type huaweiChange struct { + Key string `json:"key"` + Value string `json:"value"` +} + +type huaweiFieldDef struct { + Name string + Key string + FruID byte + TypeID byte + FieldID byte + Special string // "chassis-type" | "guid" +} + +var huaweiElabelDefs = []huaweiFieldDef{ + {"Device Name", "DeviceName", 0x00, 0x06, 0x01, ""}, + {"Device Serial Number", "DeviceSerialNumber", 0x00, 0x06, 0x03, ""}, + {"Product Name", "ProductName", 0x00, 0x03, 0x01, ""}, + {"Product Serial Number", "ProductSerialNumber", 0x00, 0x03, 0x04, ""}, + {"Product Asset Tag", "ProductAssetTag", 0x00, 0x03, 0x05, ""}, + {"Product Manufacturer", "ProductManufacturer", 0x00, 0x03, 0x00, ""}, + {"Mainboard Manufacturer", "MainboardManufacturer", 0x00, 0x02, 0x01, ""}, + {"Board Product Name", "BoardProductName", 0x00, 0x02, 0x02, ""}, + {"Chassis Part Number", "ChassisPartnumber", 0x00, 0x01, 0x01, ""}, + {"Chassis Type", "ChassisType", 0x00, 0x01, 0x00, "chassis-type"}, + {"IO Chassis Serial", "IOChassisSerialNumber", 0x01, 0x03, 0x04, ""}, + {"IO Chassis Asset Tag", "IOChassisAssetTag", 0x01, 0x03, 0x05, ""}, + {"GUID", "GUID", 0x00, 0x00, 0x00, "guid"}, +} + +// huaweiGetRaw reads a string elabel field via OEM IPMI raw command. +// Protocol: ipmitool raw 0x30 0x90 0x05 0x00 0x30 +// Response: ... (null-terminated) +func huaweiGetRaw(ctx context.Context, def huaweiFieldDef) (string, error) { + if def.Special == "guid" { + return huaweiGetGUID(ctx) + } + args := []string{ + "0x30", "0x90", "0x05", + fmt.Sprintf("0x%02x", def.FruID), + fmt.Sprintf("0x%02x", def.TypeID), + fmt.Sprintf("0x%02x", def.FieldID), + "0x00", "0x30", + } + out, err := exec.CommandContext(ctx, "ipmitool", append([]string{"raw"}, args...)...).CombinedOutput() + if err != nil { + return "", err + } + return huaweiParseStringResponse(strings.TrimSpace(string(out)), def.Special), nil +} + +// huaweiParseStringResponse decodes the OEM IPMI response bytes to a string. +// Format: ... +func huaweiParseStringResponse(hexOut, special string) string { + parts := strings.Fields(hexOut) + if len(parts) < 2 { + return "" + } + if special == "chassis-type" { + // Response: + if len(parts) >= 2 { + n, err := strconv.ParseUint(parts[1], 16, 8) + if err == nil { + return fmt.Sprintf("0x%02x", n) + } + } + return "" + } + var sb strings.Builder + for _, p := range parts[1:] { + b, err := strconv.ParseUint(p, 16, 8) + if err != nil || b == 0 { + break + } + sb.WriteByte(byte(b)) + } + return strings.TrimRight(sb.String(), "\x00") +} + +// huaweiGetGUID reads the system GUID via standard IPMI Get System GUID (0x06 0x08). +func huaweiGetGUID(ctx context.Context) (string, error) { + out, err := exec.CommandContext(ctx, "ipmitool", "raw", "0x06", "0x08").CombinedOutput() + if err != nil { + return "", err + } + parts := strings.Fields(strings.TrimSpace(string(out))) + if len(parts) != 16 { + return "", nil + } + // Format as UUID: 4-2-2-2-6 byte groups + // iBMC returns bytes in reversed order; re-reverse to get canonical UUID. + var bytes [16]string + for i, p := range parts { + bytes[15-i] = p + } + return fmt.Sprintf("%s%s%s%s-%s%s-%s%s-%s%s-%s%s%s%s%s%s", + bytes[0], bytes[1], bytes[2], bytes[3], + bytes[4], bytes[5], + bytes[6], bytes[7], + bytes[8], bytes[9], + bytes[10], bytes[11], bytes[12], bytes[13], bytes[14], bytes[15], + ), nil +} + +// huaweiChunks splits a value into 19-byte chunks for the OEM IPMI SET protocol. +// Key byte: bit7=1 means more chunks follow; bits 0-6 = offset into string. +func huaweiChunks(value string) [][]string { + if len(value) == 0 { + return [][]string{{"0x00", "0x01", "0x00"}} + } + const maxLen = 63 + if len(value) > maxLen { + value = value[:maxLen] + } + const chunkSize = 19 + var chunks [][]string + for offset := 0; offset < len(value); { + end := offset + chunkSize + if end > len(value) { + end = len(value) + } + isLast := end >= len(value) + key := byte(offset) + if !isLast { + key |= 0x80 + } + args := []string{ + fmt.Sprintf("0x%02x", key), + fmt.Sprintf("0x%02x", end-offset), + } + for _, b := range []byte(value[offset:end]) { + args = append(args, fmt.Sprintf("0x%02x", b)) + } + chunks = append(chunks, args) + offset = end + } + return chunks +} + +func (h *handler) handleAPIHuaweiElabelRead(w http.ResponseWriter, r *http.Request) { + ctx, cancel := context.WithTimeout(r.Context(), 60*time.Second) + defer cancel() + + var fields []huaweiField + for _, def := range huaweiElabelDefs { + val, err := huaweiGetRaw(ctx, def) + if err != nil { + // First field failure likely means no Huawei BMC — abort with error. + if len(fields) == 0 { + msg := strings.TrimSpace(err.Error()) + writeError(w, http.StatusInternalServerError, "huawei elabel not available: "+msg) + return + } + val = "" + } + fields = append(fields, huaweiField{ + Name: def.Name, + Key: def.Key, + Value: val, + ReadOnly: def.Special == "guid" || def.Special == "chassis-type", + }) + } + writeJSON(w, fields) +} + +func (h *handler) handleAPIHuaweiElabelWrite(w http.ResponseWriter, r *http.Request) { + var req struct { + Changes []huaweiChange `json:"changes"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, "invalid JSON") + return + } + if len(req.Changes) == 0 { + writeError(w, http.StatusUnprocessableEntity, "no changes provided") + return + } + + defByKey := make(map[string]huaweiFieldDef, len(huaweiElabelDefs)) + for _, d := range huaweiElabelDefs { + defByKey[d.Key] = d + } + + for _, c := range req.Changes { + def, ok := defByKey[c.Key] + if !ok { + writeError(w, http.StatusUnprocessableEntity, "unknown field key: "+c.Key) + return + } + if def.Special == "guid" || def.Special == "chassis-type" { + writeError(w, http.StatusUnprocessableEntity, "field is read-only: "+c.Key) + return + } + if len(c.Value) > 63 { + writeError(w, http.StatusUnprocessableEntity, "value too long (max 63 chars): "+c.Key) + return + } + for _, ch := range c.Value { + if ch < 0x20 || ch > 0x7E { + writeError(w, http.StatusUnprocessableEntity, "non-printable character in value for: "+c.Key) + return + } + } + } + + t := &Task{ + ID: newJobID("huawei-elabel-write"), + Name: fmt.Sprintf("Huawei Elabel Write (%d field(s))", len(req.Changes)), + Target: "huawei-elabel-write", + Priority: defaultTaskPriority("huawei-elabel-write", taskParams{}), + Status: TaskPending, + CreatedAt: time.Now(), + params: taskParams{HuaweiElabelChanges: req.Changes}, + } + globalQueue.enqueue(t) + writeJSON(w, map[string]string{"task_id": t.ID}) +} + +func runHuaweiElabelWriteTask(ctx context.Context, j *jobState, p taskParams) error { + defByKey := make(map[string]huaweiFieldDef, len(huaweiElabelDefs)) + for _, d := range huaweiElabelDefs { + defByKey[d.Key] = d + } + + // Enable device name effective flag before writing. + enableCmd := exec.CommandContext(ctx, "ipmitool", "raw", "0x30", "0x90", "0x21", "0x04", "0x01") + if out, err := enableCmd.CombinedOutput(); err != nil { + j.append("Warning: enable flag: " + strings.TrimSpace(string(out))) + } + + for _, c := range p.HuaweiElabelChanges { + def := defByKey[c.Key] + setPrefix := []string{ + "0x30", "0x90", "0x04", + fmt.Sprintf("0x%02x", def.FruID), + fmt.Sprintf("0x%02x", def.TypeID), + fmt.Sprintf("0x%02x", def.FieldID), + } + + chunks := huaweiChunks(c.Value) + j.append(fmt.Sprintf("Setting %s = %q (%d chunk(s))", c.Key, c.Value, len(chunks))) + + for _, chunk := range chunks { + args := append([]string{"raw"}, setPrefix...) + args = append(args, chunk...) + cmd := exec.CommandContext(ctx, "ipmitool", args...) + if err := streamCmdJob(j, cmd); err != nil { + return fmt.Errorf("set %s: %w", c.Key, err) + } + } + + // Commit after each field. + commitCmd := exec.CommandContext(ctx, "ipmitool", "raw", "0x30", "0x90", "0x06", "0x00", "0xAA") + if out, err := commitCmd.CombinedOutput(); err != nil { + return fmt.Errorf("commit after %s: %w (output: %s)", c.Key, err, strings.TrimSpace(string(out))) + } + j.append("Committed " + c.Key) + } + return nil +} diff --git a/audit/internal/webui/ipmi_fru.go b/audit/internal/webui/ipmi_fru.go index 8ad5c9e..3a8b403 100644 --- a/audit/internal/webui/ipmi_fru.go +++ b/audit/internal/webui/ipmi_fru.go @@ -202,83 +202,3 @@ func runIPMIFRUWriteTask(ctx context.Context, j *jobState, exportDir string, p t return nil } -func renderIPMIFRUCard() string { - return `
IPMI — FRU
-

Reads and edits FRU fields via ipmitool (In-Band, device 0). Works on any server with IPMI support.

-
-
-
-` -} diff --git a/audit/internal/webui/page_export_tools.go b/audit/internal/webui/page_export_tools.go index f43027e..fd91f7d 100644 --- a/audit/internal/webui/page_export_tools.go +++ b/audit/internal/webui/page_export_tools.go @@ -404,13 +404,196 @@ loadNvidiaSelfHeal(); func renderTools() string { return renderNVMeFormatCard() + ` -` + renderSAADMICard() + ` - -` + renderIPMIFRUCard() + ` +` + renderFRUEditorCard() + ` ` + renderRAIDMgmtCard() } +func renderFRUEditorCard() string { + return `
FRU / Elabel
+

Reads and edits hardware identity fields from all available sources. Each field shows its source method.

+
+
+
+ +` +} + func renderExportIndex(exportDir string) (string, error) { entries, err := listExportFiles(exportDir) if err != nil { diff --git a/audit/internal/webui/saa_dmi.go b/audit/internal/webui/saa_dmi.go index 32c33c4..1342ec1 100644 --- a/audit/internal/webui/saa_dmi.go +++ b/audit/internal/webui/saa_dmi.go @@ -212,86 +212,3 @@ func runSAADMIWriteTask(ctx context.Context, j *jobState, exportDir string, p ta return nil } -func renderSAADMICard() string { - return `
Supermicro — DMI
-

Reads and edits DMI fields via SAA (In-Band).

-
-
-
-` -} diff --git a/audit/internal/webui/server.go b/audit/internal/webui/server.go index 5501beb..888477f 100644 --- a/audit/internal/webui/server.go +++ b/audit/internal/webui/server.go @@ -318,6 +318,8 @@ func NewHandler(opts HandlerOptions) http.Handler { mux.HandleFunc("POST /api/tools/saa-dmi/write", h.handleAPISAADMIWrite) mux.HandleFunc("GET /api/tools/ipmi-fru", h.handleAPIIPMIFRURead) mux.HandleFunc("POST /api/tools/ipmi-fru/write", h.handleAPIIPMIFRUWrite) + mux.HandleFunc("GET /api/tools/huawei-elabel", h.handleAPIHuaweiElabelRead) + mux.HandleFunc("POST /api/tools/huawei-elabel/write", h.handleAPIHuaweiElabelWrite) mux.HandleFunc("GET /api/tools/raid/status", h.handleAPIRAIDStatus) mux.HandleFunc("POST /api/tools/raid/foreign", h.handleAPIRAIDForeignAction) mux.HandleFunc("POST /api/tools/raid/create-mirror", h.handleAPIRAIDCreateMirror) diff --git a/audit/internal/webui/task_runner.go b/audit/internal/webui/task_runner.go index 85c5ea5..f2a1848 100644 --- a/audit/internal/webui/task_runner.go +++ b/audit/internal/webui/task_runner.go @@ -394,6 +394,12 @@ func executeTaskWithOptions(opts *HandlerOptions, t *Task, j *jobState, ctx cont break } err = runIPMIFRUWriteTask(ctx, j, opts.ExportDir, t.params) + case "huawei-elabel-write": + if len(t.params.HuaweiElabelChanges) == 0 { + err = fmt.Errorf("no changes provided") + break + } + err = runHuaweiElabelWriteTask(ctx, j, t.params) case "raid-foreign-clear": err = runRAIDForeignClearTask(ctx, j, t.params.RAIDController) case "raid-foreign-import": diff --git a/audit/internal/webui/tasks.go b/audit/internal/webui/tasks.go index 8cd0594..3094e28 100644 --- a/audit/internal/webui/tasks.go +++ b/audit/internal/webui/tasks.go @@ -140,8 +140,9 @@ type taskParams struct { Device string `json:"device,omitempty"` // for install LBAF int `json:"lbaf,omitempty"` PlatformComponents []string `json:"platform_components,omitempty"` - SAADmiChanges []saaChange `json:"saa_dmi_changes,omitempty"` - FRUChanges []fruChange `json:"fru_changes,omitempty"` + SAADmiChanges []saaChange `json:"saa_dmi_changes,omitempty"` + FRUChanges []fruChange `json:"fru_changes,omitempty"` + HuaweiElabelChanges []huaweiChange `json:"huawei_elabel_changes,omitempty"` RAIDController int `json:"raid_controller,omitempty"` RAIDDevices []string `json:"raid_devices,omitempty"` RAIDArrayName string `json:"raid_array_name,omitempty"`