package webui import ( "context" "encoding/json" "fmt" "net/http" "os" "os/exec" "path/filepath" "regexp" "strings" "time" ) type dmiField struct { Name string `json:"name"` Shn string `json:"shn"` Value string `json:"value"` } type saaChange struct { Shn string `json:"shn"` Value string `json:"value"` } var ( shnRE = regexp.MustCompile(`^[A-Za-z0-9_]{1,16}$`) dmiSectionRE = regexp.MustCompile(`^\[(.+?)\]$`) // Item Name {SHN} = value // comment dmiItemRE = regexp.MustCompile(`^(.+?)\s+\{([A-Za-z0-9]{1,16})\}\s*=\s*(.*)$`) dmiVersionRE = regexp.MustCompile(`(?i)^version\s*=`) ) // parseDMIFile parses the DMI.txt produced by "saa GetDmiInfo". // Real format (from SAA User Guide 4.8.1): // // [System] // Version {SYVS} = "A Version" // string value // Serial Number {SYSN} = $DEFAULT$ // string value // UUID {SYUU} = 00112233-... // hex value func parseDMIFile(content string) []dmiField { var fields []dmiField currentSection := "" for _, line := range strings.Split(content, "\n") { line = strings.TrimSpace(line) if line == "" || strings.HasPrefix(line, "//") || strings.HasPrefix(line, "#") { continue } if dmiVersionRE.MatchString(line) { continue } if m := dmiSectionRE.FindStringSubmatch(line); m != nil { currentSection = strings.TrimSpace(m[1]) continue } m := dmiItemRE.FindStringSubmatch(line) if m == nil { continue } itemName := strings.TrimSpace(m[1]) shn := m[2] rawValue := strings.TrimSpace(m[3]) // strip trailing comment (space + //) if idx := strings.LastIndex(rawValue, " //"); idx >= 0 { rawValue = strings.TrimSpace(rawValue[:idx]) } // strip surrounding double quotes from string values if len(rawValue) >= 2 && rawValue[0] == '"' && rawValue[len(rawValue)-1] == '"' { rawValue = rawValue[1 : len(rawValue)-1] } displayName := itemName if currentSection != "" { displayName = currentSection + " / " + itemName } fields = append(fields, dmiField{Name: displayName, Shn: shn, Value: rawValue}) } return fields } func (h *handler) handleAPISAADMIRead(w http.ResponseWriter, r *http.Request) { ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second) defer cancel() tmpDir, err := os.MkdirTemp("", "bee-saa-*") if err != nil { writeError(w, http.StatusInternalServerError, "create temp dir: "+err.Error()) return } defer os.RemoveAll(tmpDir) dmiFile := filepath.Join(tmpDir, "DMI.txt") out, err := exec.CommandContext(ctx, "saa", "-c", "GetDmiInfo", "--file", dmiFile, "--overwrite").CombinedOutput() if err != nil { msg := strings.TrimSpace(string(out)) if msg == "" { msg = err.Error() } writeError(w, http.StatusInternalServerError, "saa GetDmiInfo: "+msg) return } raw, err := os.ReadFile(dmiFile) if err != nil { writeError(w, http.StatusInternalServerError, "read DMI file: "+err.Error()) return } fields := parseDMIFile(string(raw)) if len(fields) == 0 { writeError(w, http.StatusInternalServerError, "no DMI fields found (file may be empty — reboot the server and try again)") return } writeJSON(w, fields) } func (h *handler) handleAPISAADMIWrite(w http.ResponseWriter, r *http.Request) { var req struct { Changes []saaChange `json:"changes"` } if err := json.NewDecoder(r.Body).Decode(&req); err != nil { writeError(w, http.StatusBadRequest, "invalid request body") return } if len(req.Changes) == 0 { writeError(w, http.StatusUnprocessableEntity, "no changes provided") return } for _, c := range req.Changes { if !shnRE.MatchString(c.Shn) { writeError(w, http.StatusUnprocessableEntity, "invalid shn: "+c.Shn) return } if len(c.Value) == 0 || len(c.Value) > 64 { writeError(w, http.StatusUnprocessableEntity, "value length out of range for shn: "+c.Shn) return } for _, ch := range c.Value { if ch < 0x20 || ch > 0x7E { writeError(w, http.StatusUnprocessableEntity, "value contains non-printable character for shn: "+c.Shn) return } } } t := &Task{ ID: newJobID("saa-dmi-write"), Name: fmt.Sprintf("SAA DMI Write (%d field(s))", len(req.Changes)), Target: "saa-dmi-write", Priority: defaultTaskPriority("saa-dmi-write", taskParams{}), Status: TaskPending, CreatedAt: time.Now(), params: taskParams{ SAADmiChanges: req.Changes, }, } globalQueue.enqueue(t) writeJSON(w, map[string]string{"task_id": t.ID}) } func runSAADMIWriteTask(ctx context.Context, j *jobState, exportDir string, p taskParams) error { tmpDir, err := os.MkdirTemp("", "bee-saa-*") if err != nil { return fmt.Errorf("create temp dir: %w", err) } defer os.RemoveAll(tmpDir) dmiFile := filepath.Join(tmpDir, "DMI.txt") j.append("Reading current DMI configuration...") if err := streamCmdJob(j, exec.CommandContext(ctx, "saa", "-c", "GetDmiInfo", "--file", dmiFile, "--overwrite")); err != nil { return fmt.Errorf("GetDmiInfo: %w", err) } backupDir := filepath.Join(exportDir, "dmi-backups") if err := os.MkdirAll(backupDir, 0o755); err != nil { return fmt.Errorf("create backup dir: %w", err) } backupName := "dmi-" + time.Now().UTC().Format("20060102-150405") + ".txt" backupPath := filepath.Join(backupDir, backupName) raw, err := os.ReadFile(dmiFile) if err != nil { return fmt.Errorf("read DMI file: %w", err) } if err := os.WriteFile(backupPath, raw, 0o644); err != nil { return fmt.Errorf("write backup: %w", err) } j.append("Backup saved: dmi-backups/" + backupName) for _, c := range p.SAADmiChanges { j.append("Setting " + c.Shn + " = " + c.Value) cmd := exec.CommandContext(ctx, "saa", "-c", "EditDmiInfo", "--file", dmiFile, "--shn", c.Shn, "--value", c.Value) if err := streamCmdJob(j, cmd); err != nil { return fmt.Errorf("EditDmiInfo %s: %w", c.Shn, err) } } j.append("Applying changes to hardware...") if err := streamCmdJob(j, exec.CommandContext(ctx, "saa", "-c", "ChangeDmiInfo", "--file", dmiFile)); err != nil { return fmt.Errorf("ChangeDmiInfo: %w", err) } j.append("Done. Reboot the server for changes to take effect.") return nil } func renderSAADMICard() string { return `
Supermicro — DMI

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

` }