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 — vendor doc names and ipmitool abbreviated names "Chassis Part Number": {"c", 0}, "Chassis Serial Number": {"c", 1}, "Chassis Serial": {"c", 1}, "Chassis Extra": {"c", 2}, // Board — vendor doc names and ipmitool abbreviated names "Board Manufacturer": {"b", 0}, "Board Mfg": {"b", 0}, "Board Product Name": {"b", 1}, "Board Product": {"b", 1}, "Board Serial Number": {"b", 2}, "Board Serial": {"b", 2}, "Board Part Number": {"b", 3}, // Product — vendor doc names and ipmitool abbreviated names "Product Manufacturer": {"p", 0}, "Product Name": {"p", 1}, "Product Part Number": {"p", 2}, "Product Version": {"p", 3}, "Product Serial Number": {"p", 4}, "Product Serial": {"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 } // All fields are shown as editable; server will reject unknown fields. return true, "", 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 i, c := range req.Changes { if c.Area == "" { e, ok := fruEditableFields[c.Name] if !ok { writeError(w, http.StatusUnprocessableEntity, "field not writable via ipmitool: "+c.Name) return } req.Changes[i].Area = e.Area req.Changes[i].Index = e.Index c = req.Changes[i] } 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 }