Add XigmaNAS log parser and tests

This commit is contained in:
2026-02-04 22:14:14 +03:00
parent f9230e12f3
commit b64a8d8709
3 changed files with 532 additions and 0 deletions

View File

@@ -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

View File

@@ -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
}

View File

@@ -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")
}
}