From b64a8d870999c3f7cfef7c4132e73448c7a7ad6c Mon Sep 17 00:00:00 2001 From: Michael Chus Date: Wed, 4 Feb 2026 22:14:14 +0300 Subject: [PATCH] Add XigmaNAS log parser and tests --- internal/parser/vendors/xigmanas/README.md | 46 ++ internal/parser/vendors/xigmanas/parser.go | 392 ++++++++++++++++++ .../parser/vendors/xigmanas/parser_test.go | 94 +++++ 3 files changed, 532 insertions(+) create mode 100644 internal/parser/vendors/xigmanas/README.md create mode 100644 internal/parser/vendors/xigmanas/parser.go create mode 100644 internal/parser/vendors/xigmanas/parser_test.go diff --git a/internal/parser/vendors/xigmanas/README.md b/internal/parser/vendors/xigmanas/README.md new file mode 100644 index 0000000..e038638 --- /dev/null +++ b/internal/parser/vendors/xigmanas/README.md @@ -0,0 +1,46 @@ +# Xigmanas Parser + +Parser for Xigmanas (FreeBSD-based NAS) system logs. + +## Supported Files + +- `xigmanas` - Main system log file with configuration and status information +- `dmesg` - Kernel messages and hardware initialization information +- SMART data from disk monitoring + +## Features + +This parser extracts the following information from Xigmanas logs: + +### System Information +- Firmware version +- System uptime +- CPU model and specifications +- Memory configuration +- Hardware platform information + +### Storage Information +- Disk models and serial numbers +- Disk capacity and health status +- SMART temperature readings + +### Hardware Configuration +- CPU information +- Memory modules +- Storage devices + +## Detection Logic + +The parser detects Xigmanas format by looking for: +- Files with "xigmanas", "system", or "dmesg" in their names +- Content containing "XigmaNAS" or "FreeBSD" strings +- SMART-related information in log content + +## Example Output + +The parser populates the following fields in AnalysisResult: +- `Hardware.Firmware` - Firmware versions +- `Hardware.CPUs` - CPU information +- `Hardware.Memory` - Memory configuration +- `Hardware.Storage` - Storage devices with SMART data +- `Sensors` - Temperature readings from SMART data \ No newline at end of file diff --git a/internal/parser/vendors/xigmanas/parser.go b/internal/parser/vendors/xigmanas/parser.go new file mode 100644 index 0000000..a8b66c6 --- /dev/null +++ b/internal/parser/vendors/xigmanas/parser.go @@ -0,0 +1,392 @@ +// Package xigmanas provides parser for XigmaNAS diagnostic dumps. +package xigmanas + +import ( + "regexp" + "strconv" + "strings" + "time" + + "git.mchus.pro/mchus/logpile/internal/models" + "git.mchus.pro/mchus/logpile/internal/parser" +) + +// parserVersion - increment when parsing logic changes. +const parserVersion = "2.0.0" + +func init() { + parser.Register(&Parser{}) +} + +// Parser implements VendorParser for XigmaNAS logs. +type Parser struct{} + +func (p *Parser) Name() string { return "XigmaNAS Parser" } +func (p *Parser) Vendor() string { return "xigmanas" } +func (p *Parser) Version() string { + return parserVersion +} + +// Detect checks if files contain typical XigmaNAS markers. +func (p *Parser) Detect(files []parser.ExtractedFile) int { + confidence := 0 + + for _, f := range files { + path := strings.ToLower(f.Path) + content := strings.ToLower(string(f.Content)) + + if strings.Contains(path, "xigmanas") || strings.HasSuffix(path, "dmesg") { + confidence += 20 + } + if strings.Contains(content, `loader_brand="xigmanas"`) { + confidence += 70 + } + if strings.Contains(content, "xigmanas kernel build") { + confidence += 35 + } + if strings.Contains(content, "system uptime:") && strings.Contains(content, "routing tables:") { + confidence += 20 + } + if strings.Contains(content, "s.m.a.r.t. [/dev/") { + confidence += 10 + } + if confidence >= 100 { + return 100 + } + } + + if confidence > 100 { + return 100 + } + return confidence +} + +// Parse parses XigmaNAS logs and returns normalized data. +func (p *Parser) Parse(files []parser.ExtractedFile) (*models.AnalysisResult, error) { + result := &models.AnalysisResult{ + Events: make([]models.Event, 0), + FRU: make([]models.FRUInfo, 0), + Sensors: make([]models.SensorReading, 0), + Hardware: &models.HardwareConfig{ + Firmware: make([]models.FirmwareInfo, 0), + CPUs: make([]models.CPU, 0), + Memory: make([]models.MemoryDIMM, 0), + Storage: make([]models.Storage, 0), + }, + } + + content := joinFileContents(files) + if strings.TrimSpace(content) == "" { + return result, nil + } + + parseSystemInfo(content, result) + parseCPU(content, result) + parseMemory(content, result) + parseUptime(content, result) + parseZFSState(content, result) + parseStorageAndSMART(content, result) + + return result, nil +} + +func joinFileContents(files []parser.ExtractedFile) string { + var b strings.Builder + for _, f := range files { + b.Write(f.Content) + b.WriteString("\n") + } + return b.String() +} + +func parseSystemInfo(content string, result *models.AnalysisResult) { + if m := regexp.MustCompile(`(?m)^Version:\s*\n-+\s*\n([^\n]+)`).FindStringSubmatch(content); len(m) == 2 { + result.Hardware.Firmware = append(result.Hardware.Firmware, models.FirmwareInfo{ + DeviceName: "XigmaNAS", + Version: strings.TrimSpace(m[1]), + }) + } + if m := regexp.MustCompile(`(?m)^smbios\.bios\.version="([^"]+)"`).FindStringSubmatch(content); len(m) == 2 { + result.Hardware.Firmware = append(result.Hardware.Firmware, models.FirmwareInfo{ + DeviceName: "System BIOS", + Version: strings.TrimSpace(m[1]), + }) + } + + board := models.BoardInfo{} + if m := regexp.MustCompile(`(?m)^smbios\.system\.maker="([^"]+)"`).FindStringSubmatch(content); len(m) == 2 { + board.Manufacturer = strings.TrimSpace(m[1]) + } + if m := regexp.MustCompile(`(?m)^smbios\.system\.product="([^"]+)"`).FindStringSubmatch(content); len(m) == 2 { + board.ProductName = strings.TrimSpace(m[1]) + } + if m := regexp.MustCompile(`(?m)^smbios\.system\.serial="([^"]+)"`).FindStringSubmatch(content); len(m) == 2 { + board.SerialNumber = strings.TrimSpace(m[1]) + } + if m := regexp.MustCompile(`(?m)^smbios\.system\.uuid="([^"]+)"`).FindStringSubmatch(content); len(m) == 2 { + board.UUID = strings.TrimSpace(m[1]) + } + result.Hardware.BoardInfo = board +} + +func parseCPU(content string, result *models.AnalysisResult) { + var cores, threads int + if m := regexp.MustCompile(`(?m)^FreeBSD/SMP:\s+\d+\s+package\(s\)\s+x\s+(\d+)\s+core\(s\)`).FindStringSubmatch(content); len(m) == 2 { + cores = parseInt(m[1]) + threads = cores + } + + seen := map[string]struct{}{} + cpuRe := regexp.MustCompile(`(?m)^CPU:\s+(.+?)\s+\(([\d.]+)-MHz`) + for _, m := range cpuRe.FindAllStringSubmatch(content, -1) { + model := strings.TrimSpace(m[1]) + if _, ok := seen[model]; ok { + continue + } + seen[model] = struct{}{} + + result.Hardware.CPUs = append(result.Hardware.CPUs, models.CPU{ + Socket: len(result.Hardware.CPUs), + Model: model, + Cores: cores, + Threads: threads, + FrequencyMHz: int(parseFloat(m[2])), + }) + } +} + +func parseMemory(content string, result *models.AnalysisResult) { + if m := regexp.MustCompile(`(?m)^real memory\s*=\s*\d+\s+\((\d+)\s+MB\)`).FindStringSubmatch(content); len(m) == 2 { + result.Hardware.Memory = append(result.Hardware.Memory, models.MemoryDIMM{ + Slot: "system", + Present: true, + SizeMB: parseInt(m[1]), + Type: "DRAM", + Status: "ok", + }) + return + } + + // Fallback for logs that only have active/inactive breakdown. + if m := regexp.MustCompile(`(?m)^Mem:\s+(.+)$`).FindStringSubmatch(content); len(m) == 2 { + totalMB := 0 + tokenRe := regexp.MustCompile(`(\d+)M`) + for _, t := range tokenRe.FindAllStringSubmatch(m[1], -1) { + totalMB += parseInt(t[1]) + } + if totalMB > 0 { + result.Hardware.Memory = append(result.Hardware.Memory, models.MemoryDIMM{ + Slot: "system", + Present: true, + SizeMB: totalMB, + Type: "DRAM", + Status: "estimated", + }) + } + } +} + +func parseUptime(content string, result *models.AnalysisResult) { + upRe := regexp.MustCompile(`(?m)^(\d+:\d+(?:AM|PM))\s+up\s+(.+?),\s+(\d+)\s+users?,\s+load averages?:\s+([\d.]+),\s+([\d.]+),\s+([\d.]+)$`) + m := upRe.FindStringSubmatch(content) + if len(m) != 7 { + return + } + + result.Events = append(result.Events, models.Event{ + Timestamp: time.Now(), + Source: "System", + EventType: "Uptime", + Severity: models.SeverityInfo, + Description: "System uptime and load averages parsed", + RawData: "time=" + m[1] + "; uptime=" + m[2] + "; users=" + m[3] + "; load=" + m[4] + "," + m[5] + "," + m[6], + }) +} + +func parseZFSState(content string, result *models.AnalysisResult) { + m := regexp.MustCompile(`(?m)^state:\s+([A-Z]+)$`).FindStringSubmatch(content) + if len(m) != 2 { + return + } + + state := m[1] + severity := models.SeverityInfo + if state != "ONLINE" { + severity = models.SeverityWarning + } + result.Events = append(result.Events, models.Event{ + Timestamp: time.Now(), + Source: "ZFS", + EventType: "Pool State", + Severity: severity, + Description: "ZFS pool state: " + state, + RawData: state, + }) +} + +func parseStorageAndSMART(content string, result *models.AnalysisResult) { + type smartInfo struct { + model string + serial string + firmware string + health string + tempC int + capacityB int64 + } + + storageBySlot := make(map[string]*models.Storage) + scsiRe := regexp.MustCompile(`(?m)^<([^>]+)>\s+at\s+scbus\d+\s+target\s+\d+\s+lun\s+\d+\s+\(([^,]+),([^)]+)\)$`) + for _, m := range scsiRe.FindAllStringSubmatch(content, -1) { + slot := strings.TrimSpace(m[3]) + model, fw := splitModelAndFirmware(strings.TrimSpace(m[1])) + entry := &models.Storage{ + Slot: slot, + Type: guessStorageType(slot), + Model: model, + Firmware: fw, + Present: true, + Interface: "SCSI/SATA", + } + storageBySlot[slot] = entry + } + + smartBySlot := make(map[string]smartInfo) + sectionRe := regexp.MustCompile(`(?m)^S\.M\.A\.R\.T\.\s+\[(/dev/[^\]]+)\]:\s*\n-+\n`) + sections := sectionRe.FindAllStringSubmatchIndex(content, -1) + for i, sec := range sections { + // sec indexes: + // [0]=full start, [1]=full end, [2]=capture 1 start, [3]=capture 1 end + if len(sec) < 4 { + continue + } + slot := strings.TrimPrefix(strings.TrimSpace(content[sec[2]:sec[3]]), "/dev/") + bodyStart := sec[1] + bodyEnd := len(content) + if i+1 < len(sections) { + bodyEnd = sections[i+1][0] + } + body := content[bodyStart:bodyEnd] + + info := smartInfo{ + model: findFirst(body, `(?m)^Device Model:\s+(.+)$`), + serial: findFirst(body, `(?m)^Serial Number:\s+(.+)$`), + firmware: findFirst(body, `(?m)^Firmware Version:\s+(.+)$`), + health: findFirst(body, `(?m)^SMART overall-health self-assessment test result:\s+(.+)$`), + } + info.capacityB = parseCapacityBytes(findFirst(body, `(?m)^User Capacity:\s+([\d,]+)\s+bytes`)) + if t := findFirst(body, `(?m)^\s*194\s+Temperature_Celsius.*?-\s+(\d+)(?:\s|\()`); t != "" { + info.tempC = parseInt(t) + } + smartBySlot[slot] = info + + if info.tempC > 0 { + status := "ok" + if info.health != "" && !strings.EqualFold(info.health, "PASSED") { + status = "warning" + } + result.Sensors = append(result.Sensors, models.SensorReading{ + Name: "disk_temp_" + slot, + Type: "temperature", + Value: float64(info.tempC), + Unit: "C", + Status: status, + RawValue: strconv.Itoa(info.tempC), + }) + } + if info.health != "" && !strings.EqualFold(info.health, "PASSED") { + result.Events = append(result.Events, models.Event{ + Timestamp: time.Now(), + Source: "SMART", + EventType: "Disk Health", + Severity: models.SeverityWarning, + Description: "SMART health is not PASSED for " + slot, + RawData: info.health, + }) + } + } + + // Merge SMART data into storage entries and add missing entries. + for slot, info := range smartBySlot { + s := storageBySlot[slot] + if s == nil { + s = &models.Storage{ + Slot: slot, + Type: guessStorageType(slot), + Present: true, + Interface: "SATA", + } + storageBySlot[slot] = s + } + + if s.Model == "" && info.model != "" { + s.Model = info.model + } + if info.serial != "" { + s.SerialNumber = info.serial + } + if s.Firmware == "" && info.firmware != "" { + s.Firmware = info.firmware + } + if info.capacityB > 0 { + s.SizeGB = int(info.capacityB / 1_000_000_000) + } + } + + for _, s := range storageBySlot { + result.Hardware.Storage = append(result.Hardware.Storage, *s) + } +} + +func splitModelAndFirmware(raw string) (string, string) { + fields := strings.Fields(raw) + if len(fields) < 2 { + return raw, "" + } + last := fields[len(fields)-1] + // Firmware token is usually compact (e.g. GKAOAB0A, 1.00). + if regexp.MustCompile(`^[A-Za-z0-9._-]{2,12}$`).MatchString(last) { + return strings.TrimSpace(strings.Join(fields[:len(fields)-1], " ")), last + } + return raw, "" +} + +func guessStorageType(slot string) string { + switch { + case strings.HasPrefix(slot, "cd"): + return "optical" + case strings.HasPrefix(slot, "da"), strings.HasPrefix(slot, "ada"): + return "hdd" + default: + return "unknown" + } +} + +func findFirst(content, expr string) string { + m := regexp.MustCompile(expr).FindStringSubmatch(content) + if len(m) != 2 { + return "" + } + return strings.TrimSpace(m[1]) +} + +func parseCapacityBytes(s string) int64 { + clean := strings.ReplaceAll(strings.TrimSpace(s), ",", "") + if clean == "" { + return 0 + } + v, err := strconv.ParseInt(clean, 10, 64) + if err != nil { + return 0 + } + return v +} + +func parseInt(s string) int { + v, _ := strconv.Atoi(strings.TrimSpace(s)) + return v +} + +func parseFloat(s string) float64 { + v, _ := strconv.ParseFloat(strings.TrimSpace(s), 64) + return v +} diff --git a/internal/parser/vendors/xigmanas/parser_test.go b/internal/parser/vendors/xigmanas/parser_test.go new file mode 100644 index 0000000..146f6bb --- /dev/null +++ b/internal/parser/vendors/xigmanas/parser_test.go @@ -0,0 +1,94 @@ +package xigmanas + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "git.mchus.pro/mchus/logpile/internal/parser" +) + +func TestParserDetect(t *testing.T) { + p := &Parser{} + + files := []parser.ExtractedFile{ + { + Path: "xigmanas", + Content: []byte(`Version: +-------- +14.3.0.5 +loader_brand="XigmaNAS"`), + }, + } + + if got := p.Detect(files); got < 70 { + t.Fatalf("expected high confidence, got %d", got) + } + + files2 := []parser.ExtractedFile{ + { + Path: "random_file.txt", + Content: []byte("Some random content"), + }, + } + + if got := p.Detect(files2); got != 0 { + t.Fatalf("expected zero confidence, got %d", got) + } +} + +func TestParserParseExample(t *testing.T) { + p := &Parser{} + + examplePath := filepath.Join("..", "..", "..", "..", "example", "xigmanas.txt") + raw, err := os.ReadFile(examplePath) + if err != nil { + t.Fatalf("read example file: %v", err) + } + + files := []parser.ExtractedFile{ + {Path: "xigmanas", Content: raw}, + } + + result, err := p.Parse(files) + if err != nil { + t.Fatalf("parse failed: %v", err) + } + if result == nil || result.Hardware == nil { + t.Fatal("expected non-nil result with hardware") + } + + if len(result.Hardware.Firmware) == 0 { + t.Fatal("expected firmware data") + } + foundXigmaVersion := false + for _, fw := range result.Hardware.Firmware { + if fw.DeviceName == "XigmaNAS" && fw.Version == "14.3.0.5" { + foundXigmaVersion = true + } + } + if !foundXigmaVersion { + t.Fatalf("expected XigmaNAS firmware version 14.3.0.5, got %+v", result.Hardware.Firmware) + } + + if result.Hardware.BoardInfo.Manufacturer != "HP" { + t.Fatalf("expected board manufacturer HP, got %q", result.Hardware.BoardInfo.Manufacturer) + } + if len(result.Hardware.CPUs) == 0 { + t.Fatal("expected at least one CPU") + } + if !strings.Contains(strings.ToLower(result.Hardware.CPUs[0].Model), "athlon") { + t.Fatalf("expected CPU model to contain athlon, got %q", result.Hardware.CPUs[0].Model) + } + + if len(result.Hardware.Storage) < 4 { + t.Fatalf("expected at least 4 storage devices, got %d", len(result.Hardware.Storage)) + } + if len(result.Sensors) == 0 { + t.Fatal("expected SMART temperature sensors") + } + if len(result.Events) == 0 { + t.Fatal("expected events from uptime/zfs sections") + } +}