From 42f0cb67cbb3a4ed131ad587bec2d3fd3d7de2fc Mon Sep 17 00:00:00 2001 From: mrhid6 Date: Thu, 13 Aug 2026 11:52:08 +0000 Subject: [PATCH] feat: Collect Windows services as workloads --- agent/internal/workloads/services_windows.go | 42 +++++++ agent/internal/workloads/winparse.go | 117 +++++++++++++++++++ agent/internal/workloads/winparse_test.go | 99 ++++++++++++++++ 3 files changed, 258 insertions(+) create mode 100644 agent/internal/workloads/services_windows.go create mode 100644 agent/internal/workloads/winparse.go create mode 100644 agent/internal/workloads/winparse_test.go diff --git a/agent/internal/workloads/services_windows.go b/agent/internal/workloads/services_windows.go new file mode 100644 index 0000000..c370326 --- /dev/null +++ b/agent/internal/workloads/services_windows.go @@ -0,0 +1,42 @@ +package workloads + +import ( + "context" + "os" + "time" + + "gitea.hostxtra.co.uk/mrhid6/vantage/agent/internal/winexec" +) + +const servicesTimeout = 60 * time.Second + +const servicesScript = ` +$ErrorActionPreference = 'Stop' +$svcs = Get-CimInstance Win32_Service | + Select-Object Name,DisplayName,State,StartMode,PathName,ExitCode +ConvertTo-Json -InputObject @($svcs) -Depth 3 -Compress +` + +// collectUnits enumerates Windows services. The bool and string it returns are +// the same SystemdOK / SystemdError pair the Linux collector fills: the wire +// shape is shared, and the UI words it per platform. +func collectUnits(ctx context.Context) ([]Workload, bool, string) { + ctx, cancel := context.WithTimeout(ctx, servicesTimeout) + defer cancel() + + out, err := winexec.Run(ctx, servicesScript) + if err != nil { + return nil, false, "Win32_Service query failed: " + err.Error() + } + + systemRoot := os.Getenv("SystemRoot") + if systemRoot == "" { + systemRoot = `C:\Windows` + } + + wls, err := parseServices(out, systemRoot) + if err != nil { + return nil, false, "Win32_Service output could not be read: " + err.Error() + } + return wls, true, "" +} diff --git a/agent/internal/workloads/winparse.go b/agent/internal/workloads/winparse.go new file mode 100644 index 0000000..f1ec245 --- /dev/null +++ b/agent/internal/workloads/winparse.go @@ -0,0 +1,117 @@ +package workloads + +import ( + "encoding/json" + "strings" +) + +// winService is one row of Get-CimInstance Win32_Service. +// +// Win32_Service rather than Get-Service: Get-Service exposes neither PathName +// nor StartMode, and the filter below needs both. +type winService struct { + Name string `json:"Name"` + DisplayName string `json:"DisplayName"` + State string `json:"State"` + StartMode string `json:"StartMode"` + PathName string `json:"PathName"` + ExitCode int `json:"ExitCode"` +} + +// exitCodeNeverStarted is ERROR_SERVICE_NEVER_STARTED. A stopped service +// carrying it has not failed — it has not run since boot — and painting that +// red would cry wolf on every host. +const exitCodeNeverStarted = 1077 + +// servicePath extracts the executable from a Win32_Service PathName. +// +// A naive split on whitespace misfiles a substantial share of a real fleet: +// `"C:\Program Files\X\x.exe" -service` is one path and one argument. +func servicePath(pathName string) string { + s := strings.TrimSpace(pathName) + if s == "" { + return "" + } + if s[0] == '"' { + if end := strings.IndexByte(s[1:], '"'); end >= 0 { + return s[1 : 1+end] + } + return strings.TrimPrefix(s, `"`) + } + if i := strings.Index(strings.ToLower(s), ".exe"); i >= 0 { + return s[:i+len(".exe")] + } + if i := strings.IndexAny(s, " \t"); i >= 0 { + return s[:i] + } + return s +} + +// parseServices turns the collector's JSON into workloads. +// +// systemRoot is a parameter rather than an environment read so this is testable +// off Windows. The caller passes %SystemRoot%. +// +// The filter mirrors the systemd collector's intent: show what an operator +// installed, and show what is meant to be up but is not. Services under +// %SystemRoot%\System32 are the platform's own, and a typical host has well +// over a hundred of them. +func parseServices(jsonText, systemRoot string) ([]Workload, error) { + s := strings.TrimSpace(jsonText) + if s == "" || s == "null" { + return nil, nil + } + + var rows []winService + if err := json.Unmarshal([]byte(s), &rows); err != nil { + var one winService + if err2 := json.Unmarshal([]byte(s), &one); err2 != nil { + return nil, err + } + rows = []winService{one} + } + + sys32 := strings.ToLower(strings.TrimRight(systemRoot, `\`) + `\system32\`) + + var wls []Workload + for _, r := range rows { + if p := strings.ToLower(servicePath(r.PathName)); p != "" && strings.HasPrefix(p, sys32) { + continue + } + + running := strings.EqualFold(r.State, "Running") + failed := !running && r.ExitCode != 0 && r.ExitCode != exitCodeNeverStarted + auto := strings.HasPrefix(strings.ToLower(r.StartMode), "auto") + if !running && !failed && !auto { + continue + } + + state := "stopped" + switch { + case running: + state = "running" + case failed: + state = "failed" + } + + name := r.DisplayName + if name == "" { + name = r.Name + } + + wls = append(wls, Workload{ + Kind: "unit", + ID: r.Name, + Name: name, + State: state, + }) + } + return wls, nil +} + +// psQuote renders a Go string as a PowerShell single-quoted literal. Single +// quotes suppress every form of expansion, so the only character needing an +// escape is the quote itself, which is doubled. +func psQuote(s string) string { + return "'" + strings.ReplaceAll(s, "'", "''") + "'" +} diff --git a/agent/internal/workloads/winparse_test.go b/agent/internal/workloads/winparse_test.go new file mode 100644 index 0000000..2ed186c --- /dev/null +++ b/agent/internal/workloads/winparse_test.go @@ -0,0 +1,99 @@ +package workloads + +import "testing" + +func TestServicePath(t *testing.T) { + cases := []struct{ in, want string }{ + {`"C:\Program Files\Contoso\svc.exe" -service`, `C:\Program Files\Contoso\svc.exe`}, + {`C:\WINDOWS\system32\svchost.exe -k netsvcs`, `C:\WINDOWS\system32\svchost.exe`}, + {`C:\Vantage\vantage-agent.exe`, `C:\Vantage\vantage-agent.exe`}, + {`"C:\no\args.exe"`, `C:\no\args.exe`}, + {``, ``}, + } + for _, c := range cases { + if got := servicePath(c.in); got != c.want { + t.Errorf("servicePath(%q) = %q, want %q", c.in, got, c.want) + } + } +} + +func TestParseServicesFilters(t *testing.T) { + in := `[ + {"Name":"Contoso","DisplayName":"Contoso Broker","State":"Running","StartMode":"Auto","PathName":"\"C:\\Program Files\\Contoso\\svc.exe\" -service","ExitCode":0}, + {"Name":"Themes","DisplayName":"Themes","State":"Running","StartMode":"Auto","PathName":"C:\\WINDOWS\\system32\\svchost.exe -k netsvcs","ExitCode":0}, + {"Name":"Fabrikam","DisplayName":"Fabrikam Sync","State":"Stopped","StartMode":"Auto","PathName":"C:\\Fabrikam\\sync.exe","ExitCode":0}, + {"Name":"Northwind","DisplayName":"Northwind Poller","State":"Stopped","StartMode":"Manual","PathName":"C:\\Northwind\\poll.exe","ExitCode":0}, + {"Name":"Crashed","DisplayName":"Crashed Thing","State":"Stopped","StartMode":"Auto","PathName":"C:\\Crashed\\c.exe","ExitCode":1067} + ]` + + got, err := parseServices(in, `C:\WINDOWS`) + if err != nil { + t.Fatalf("parseServices: %v", err) + } + + byID := map[string]Workload{} + for _, w := range got { + byID[w.ID] = w + } + + // The OS's own svchost service is dropped; a manual, stopped, never-failed + // service is nobody's business either. + if _, ok := byID["Themes"]; ok { + t.Error("Themes (under %SystemRoot%) should be filtered out") + } + if _, ok := byID["Northwind"]; ok { + t.Error("stopped Manual service should be filtered out") + } + if len(got) != 3 { + t.Fatalf("got %d workloads, want 3: %+v", len(got), got) + } + + if w := byID["Contoso"]; w.Kind != "unit" || w.Name != "Contoso Broker" || w.State != "running" { + t.Errorf("Contoso = %+v", w) + } + // Enabled but not running is exactly the row worth seeing. + if byID["Fabrikam"].State != "stopped" { + t.Errorf("Fabrikam state = %q, want stopped", byID["Fabrikam"].State) + } + // A non-zero exit code on a stopped service is a crash, not a clean stop. + if byID["Crashed"].State != "failed" { + t.Errorf("Crashed state = %q, want failed", byID["Crashed"].State) + } +} + +// 1077 means "no attempt to start since boot" — a clean stopped service, not a +// failure, and reporting it red would cry wolf on every host. +func TestParseServicesExitCode1077(t *testing.T) { + in := `[{"Name":"Idle","DisplayName":"Idle","State":"Stopped","StartMode":"Auto","PathName":"C:\\Idle\\i.exe","ExitCode":1077}]` + got, err := parseServices(in, `C:\WINDOWS`) + if err != nil { + t.Fatalf("parseServices: %v", err) + } + if len(got) != 1 || got[0].State != "stopped" { + t.Fatalf("got %+v, want one stopped workload", got) + } +} + +func TestParseServicesSingleObjectAndEmpty(t *testing.T) { + one := `{"Name":"Solo","DisplayName":"Solo","State":"Running","StartMode":"Auto","PathName":"C:\\Solo\\s.exe","ExitCode":0}` + got, err := parseServices(one, `C:\WINDOWS`) + if err != nil || len(got) != 1 { + t.Fatalf("single object: got %+v, err %v", got, err) + } + + for _, in := range []string{"", "[]", "null"} { + got, err := parseServices(in, `C:\WINDOWS`) + if err != nil || len(got) != 0 { + t.Fatalf("parseServices(%q) = %+v, err %v", in, got, err) + } + } +} + +func TestPSQuote(t *testing.T) { + if got := psQuote(`it's`); got != `'it''s'` { + t.Fatalf("psQuote = %s", got) + } + if got := psQuote(`plain`); got != `'plain'` { + t.Fatalf("psQuote = %s", got) + } +}