diff --git a/audit/internal/webui/ipmi_fru.go b/audit/internal/webui/ipmi_fru.go new file mode 100644 index 0000000..5e4ebbd --- /dev/null +++ b/audit/internal/webui/ipmi_fru.go @@ -0,0 +1,293 @@ +package webui + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "os" + "os/exec" + "path/filepath" + "strings" + "time" + "unicode" +) + +type fruField struct { + Name string `json:"name"` + Value string `json:"value"` + Editable bool `json:"editable"` + Area string `json:"area,omitempty"` + Index int `json:"index,omitempty"` +} + +type fruChange struct { + Area string `json:"area"` + Index int `json:"index"` + Name string `json:"name"` + Value string `json:"value"` +} + +// fruEditableFields maps display name → area + index for ipmitool fru edit. +var fruEditableFields = map[string]struct { + Area string + Index int +}{ + "Chassis Part Number": {"c", 0}, + "Chassis Serial Number": {"c", 1}, + "Chassis Extra": {"c", 2}, + "Board Manufacturer": {"b", 0}, + "Board Product Name": {"b", 1}, + "Board Serial Number": {"b", 2}, + "Board Part Number": {"b", 3}, + "Product Manufacturer": {"p", 0}, + "Product Name": {"p", 1}, + "Product Part Number": {"p", 2}, + "Product Version": {"p", 3}, + "Product Serial Number": {"p", 4}, +} + +func parseFRUOutput(output string) []fruField { + var fields []fruField + for _, line := range strings.Split(output, "\n") { + // Lines look like: " Field Name : value" + trimmed := strings.TrimLeft(line, " \t") + if trimmed == "" { + continue + } + colon := strings.Index(trimmed, " : ") + if colon < 0 { + // try ": " with no leading space before colon + colon = strings.Index(trimmed, ": ") + if colon < 0 { + continue + } + name := strings.TrimSpace(trimmed[:colon]) + value := strings.TrimSpace(trimmed[colon+2:]) + if name == "" { + continue + } + editable, area, idx := fruFieldMeta(name) + fields = append(fields, fruField{Name: name, Value: value, Editable: editable, Area: area, Index: idx}) + continue + } + name := strings.TrimSpace(trimmed[:colon]) + value := strings.TrimSpace(trimmed[colon+3:]) + if name == "" { + continue + } + editable, area, idx := fruFieldMeta(name) + fields = append(fields, fruField{Name: name, Value: value, Editable: editable, Area: area, Index: idx}) + } + return fields +} + +func fruFieldMeta(name string) (editable bool, area string, index int) { + if e, ok := fruEditableFields[name]; ok { + return true, e.Area, e.Index + } + return false, "", 0 +} + +func (h *handler) handleAPIIPMIFRURead(w http.ResponseWriter, r *http.Request) { + ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second) + defer cancel() + + out, err := exec.CommandContext(ctx, "ipmitool", "fru", "print", "0").CombinedOutput() + if err != nil { + msg := strings.TrimSpace(string(out)) + if msg == "" { + msg = err.Error() + } + writeError(w, http.StatusInternalServerError, "ipmitool fru print: "+msg) + return + } + + fields := parseFRUOutput(string(out)) + writeJSON(w, fields) +} + +func (h *handler) handleAPIIPMIFRUWrite(w http.ResponseWriter, r *http.Request) { + var req struct { + Changes []fruChange `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 + } + validAreas := map[string]bool{"c": true, "b": true, "p": true} + for _, c := range req.Changes { + if !validAreas[c.Area] { + writeError(w, http.StatusUnprocessableEntity, "invalid area: "+c.Area) + return + } + if c.Index < 0 || c.Index > 9 { + writeError(w, http.StatusUnprocessableEntity, fmt.Sprintf("invalid index %d", c.Index)) + return + } + if len(c.Value) > 64 { + writeError(w, http.StatusUnprocessableEntity, "value too long (max 64 chars)") + return + } + for _, ch := range c.Value { + if ch > unicode.MaxASCII || (ch < 0x20 && ch != 0) { + writeError(w, http.StatusUnprocessableEntity, "value contains non-printable characters") + return + } + } + } + + t := &Task{ + ID: newJobID("ipmi-fru-write"), + Name: fmt.Sprintf("IPMI FRU Write (%d field(s))", len(req.Changes)), + Target: "ipmi-fru-write", + Priority: defaultTaskPriority("ipmi-fru-write", taskParams{}), + Status: TaskPending, + CreatedAt: time.Now(), + params: taskParams{FRUChanges: req.Changes}, + } + globalQueue.enqueue(t) + writeJSON(w, map[string]string{"task_id": t.ID}) +} + +func runIPMIFRUWriteTask(ctx context.Context, j *jobState, exportDir string, p taskParams) error { + // Backup current FRU state + backupDir := filepath.Join(exportDir, "fru-backups") + if err := os.MkdirAll(backupDir, 0755); err != nil { + return fmt.Errorf("mkdir fru-backups: %w", err) + } + stamp := time.Now().Format("20060102150405") + backupPath := filepath.Join(backupDir, "fru-"+stamp+".txt") + + backupOut, err := exec.CommandContext(ctx, "ipmitool", "fru", "print", "0").CombinedOutput() + if err != nil { + return fmt.Errorf("backup fru print: %w", err) + } + if err := os.WriteFile(backupPath, backupOut, 0644); err != nil { + return fmt.Errorf("write backup: %w", err) + } + j.append("Backup saved to " + backupPath) + + // Apply changes + for _, c := range p.FRUChanges { + j.append(fmt.Sprintf("Setting %s (%s %d) = %q", c.Name, c.Area, c.Index, c.Value)) + cmd := exec.CommandContext(ctx, "ipmitool", "fru", "edit", "0", "field", c.Area, fmt.Sprintf("%d", c.Index), c.Value) + if err := streamCmdJob(j, cmd); err != nil { + return fmt.Errorf("fru edit %s %d: %w", c.Area, c.Index, err) + } + } + return nil +} + +func renderIPMIFRUCard() string { + return `
Reads and edits FRU fields via ipmitool (In-Band, device 0). Works on any server with IPMI support.
+ + + +