From 4ba9983cf859f883e001931f929f07b7a0173536 Mon Sep 17 00:00:00 2001 From: mrhid6 Date: Thu, 13 Aug 2026 12:04:49 +0000 Subject: [PATCH] feat: Control Windows services and read their event log as workloads --- agent/internal/workloads/control_windows.go | 74 ++++++++++++++++++++ agent/internal/workloads/logs_windows.go | 75 +++++++++++++++++++++ agent/internal/workloads/winparse.go | 58 ++++++++++++++++ agent/internal/workloads/winparse_test.go | 57 +++++++++++++++- 4 files changed, 263 insertions(+), 1 deletion(-) create mode 100644 agent/internal/workloads/control_windows.go create mode 100644 agent/internal/workloads/logs_windows.go diff --git a/agent/internal/workloads/control_windows.go b/agent/internal/workloads/control_windows.go new file mode 100644 index 0000000..6fabf88 --- /dev/null +++ b/agent/internal/workloads/control_windows.go @@ -0,0 +1,74 @@ +package workloads + +import ( + "context" + "fmt" + "os/exec" + "strings" + + "gitea.hostxtra.co.uk/mrhid6/vantage/agent/internal/winexec" +) + +// AgentUnit is the service this agent runs as — the NSSM service name written +// by installer/setup.ps1. Change one, change the other. +const AgentUnit = "VantageAgent" + +// A Windows agent is never itself in a container; the Linux build reads +// /proc/self/cgroup, and there is no equivalent question to ask here. +var ownContainerID = "" + +// Windows service names are case-insensitive, so the comparison must be too. +func isProtectedUnit(id, name string) bool { + return strings.EqualFold(id, AgentUnit) || strings.EqualFold(name, AgentUnit) +} + +func controlPlatform(ctx context.Context, kind, id, action string) error { + switch kind { + case "container": + // Docker behaves identically on Windows, so this path is shared in + // spirit with the Linux one rather than routed through PowerShell. + cmd := exec.CommandContext(ctx, "docker", action, id) + if out, err := cmd.CombinedOutput(); err != nil { + if ctx.Err() == context.DeadlineExceeded { + return fmt.Errorf("%s %s timed out after %s", action, id, controlTimeout) + } + return fmt.Errorf("%s %s: %s", action, id, strings.TrimSpace(string(out))) + } + return nil + + case "unit": + // -Force is required: Stop-Service without it refuses outright when + // another service depends on the target, and that refusal reads to an + // operator as a silent no-op. + // + // sc.exe is avoided because it returns before the operation completes, + // which turns a timeout into a false success. + var verb string + switch action { + case "start": + verb = "Start-Service" + case "stop": + verb = "Stop-Service" + case "restart": + verb = "Restart-Service" + default: + return fmt.Errorf("unknown action %q", action) + } + + script := "$ErrorActionPreference='Stop'\n" + verb + " -Name " + psQuote(id) + if action != "start" { + script += " -Force" + } + + if _, err := winexec.Run(ctx, script); err != nil { + if ctx.Err() == context.DeadlineExceeded { + return fmt.Errorf("%s %s timed out after %s", action, id, controlTimeout) + } + return fmt.Errorf("%s %s: %w", action, id, err) + } + return nil + + default: + return fmt.Errorf("unknown workload kind %q", kind) + } +} diff --git a/agent/internal/workloads/logs_windows.go b/agent/internal/workloads/logs_windows.go new file mode 100644 index 0000000..cb5ff4d --- /dev/null +++ b/agent/internal/workloads/logs_windows.go @@ -0,0 +1,75 @@ +package workloads + +import ( + "context" + "fmt" + "os/exec" + "strconv" + + "gitea.hostxtra.co.uk/mrhid6/vantage/agent/internal/winexec" +) + +func logsPlatform(ctx context.Context, kind, id string, tail int) (string, error) { + switch kind { + case "container": + cmd := exec.CommandContext(ctx, "docker", "logs", + "--tail", strconv.Itoa(tail), "--timestamps", id) + out, err := cmd.CombinedOutput() + if err != nil && len(out) == 0 { + return "", fmt.Errorf("read logs for %s: %s", id, errText(err)) + } + return string(out), nil + + case "unit": + display := serviceDisplayName(ctx, id) + + // Timestamps are formatted PowerShell-side rather than left to + // ConvertTo-Json, whose DateTime rendering differs between PowerShell + // versions — one of them emits /Date(1699...)/. + // + // -ErrorAction SilentlyContinue because Get-WinEvent treats "no events + // matched" as a terminating error, and a quiet service is normal. + names := psQuote(id) + if display != "" && display != id { + names += "," + psQuote(display) + } + names += "," + psQuote(scmProvider) + + script := ` +$ErrorActionPreference = 'SilentlyContinue' +$rows = Get-WinEvent -FilterHashtable @{LogName='System','Application'; ProviderName=@(` + names + `)} ` + + `-MaxEvents ` + strconv.Itoa(tail) + ` | + ForEach-Object { + [pscustomobject]@{ + t = $_.TimeCreated.ToUniversalTime().ToString('o') + l = [string]$_.LevelDisplayName + p = [string]$_.ProviderName + m = [string]$_.Message + } + } +ConvertTo-Json -InputObject @($rows) -Depth 3 -Compress +` + + out, err := winexec.Run(ctx, script) + if err != nil { + return "", fmt.Errorf("read events for %s: %w", id, err) + } + return parseEvents(out, id, display) + + default: + return "", fmt.Errorf("unknown workload kind %q", kind) + } +} + +// serviceDisplayName resolves a service's display name, which is what Service +// Control Manager events name it by. An empty answer is fine — the filter then +// matches on the service name alone. +func serviceDisplayName(ctx context.Context, id string) string { + out, err := winexec.Run(ctx, + "$ErrorActionPreference='SilentlyContinue'\n"+ + "(Get-Service -Name "+psQuote(id)+").DisplayName") + if err != nil { + return "" + } + return trimLine(out) +} diff --git a/agent/internal/workloads/winparse.go b/agent/internal/workloads/winparse.go index fe32377..62b1b12 100644 --- a/agent/internal/workloads/winparse.go +++ b/agent/internal/workloads/winparse.go @@ -142,3 +142,61 @@ func parseServices(jsonText, systemRoot string) ([]Workload, error) { func psQuote(s string) string { return "'" + strings.ReplaceAll(s, "'", "''") + "'" } + +// scmProvider is the provider every service's start and stop is logged under, +// host-wide. +const scmProvider = "Service Control Manager" + +type winEvent struct { + T string `json:"t"` + L string `json:"l"` + P string `json:"p"` + M string `json:"m"` +} + +// parseEvents renders Get-WinEvent output as text in the shape journalctl +// --output=short-iso produces, so the log dialog needs no per-platform +// rendering: " ", oldest first. +func parseEvents(jsonText, serviceName, displayName string) (string, error) { + s := strings.TrimSpace(jsonText) + if s == "" || s == "null" { + return "", nil + } + + var rows []winEvent + if err := json.Unmarshal([]byte(s), &rows); err != nil { + var one winEvent + if err2 := json.Unmarshal([]byte(s), &one); err2 != nil { + return "", err + } + rows = []winEvent{one} + } + + var lines []string + for _, e := range rows { + if strings.EqualFold(e.P, scmProvider) { + if !strings.Contains(e.M, serviceName) && + (displayName == "" || !strings.Contains(e.M, displayName)) { + continue + } + } + msg := strings.TrimSpace(strings.ReplaceAll(e.M, "\r\n", " ")) + lines = append(lines, e.T+" "+e.L+" "+msg) + } + + // Get-WinEvent is newest-first. Reverse it. + for i, j := 0, len(lines)-1; i < j; i, j = i+1, j-1 { + lines[i], lines[j] = lines[j], lines[i] + } + return strings.Join(lines, "\n"), nil +} + +// trimLine reduces single-value PowerShell output to its first non-empty line. +func trimLine(s string) string { + for _, l := range strings.Split(s, "\n") { + if t := strings.TrimSpace(l); t != "" { + return t + } + } + return "" +} diff --git a/agent/internal/workloads/winparse_test.go b/agent/internal/workloads/winparse_test.go index 4817c22..4133097 100644 --- a/agent/internal/workloads/winparse_test.go +++ b/agent/internal/workloads/winparse_test.go @@ -1,6 +1,9 @@ package workloads -import "testing" +import ( + "strings" + "testing" +) func TestServicePath(t *testing.T) { cases := []struct{ in, want string }{ @@ -105,3 +108,55 @@ func TestPSQuote(t *testing.T) { t.Fatalf("psQuote = %s", got) } } + +func TestParseEventsFormatsAndOrders(t *testing.T) { + // Get-WinEvent returns newest first; journalctl --output=short-iso returns + // oldest first, and the log dialog and capLog's front-trim both assume the + // most recent line is at the bottom. + in := `[ + {"t":"2026-08-13T10:22:31.0000000Z","l":"Error","p":"Contoso","m":"broker died"}, + {"t":"2026-08-13T10:22:03.0000000Z","l":"Information","p":"Contoso","m":"broker starting"} + ]` + + got, err := parseEvents(in, "Contoso", "Contoso Broker") + if err != nil { + t.Fatalf("parseEvents: %v", err) + } + + want := "2026-08-13T10:22:03.0000000Z Information broker starting\n" + + "2026-08-13T10:22:31.0000000Z Error broker died" + if got != want { + t.Fatalf("parseEvents =\n%q\nwant\n%q", got, want) + } +} + +// Service Control Manager logs every service on the host under one provider, so +// its rows must be filtered down to the target or the log is somebody else's. +func TestParseEventsFiltersOtherServicesSCM(t *testing.T) { + in := `[ + {"t":"2026-08-13T10:00:00Z","l":"Information","p":"Service Control Manager","m":"The Print Spooler service entered the running state."}, + {"t":"2026-08-13T10:00:01Z","l":"Information","p":"Service Control Manager","m":"The Contoso Broker service entered the running state."} + ]` + + got, err := parseEvents(in, "Contoso", "Contoso Broker") + if err != nil { + t.Fatalf("parseEvents: %v", err) + } + if strings.Contains(got, "Print Spooler") { + t.Errorf("another service's SCM event leaked in:\n%s", got) + } + if !strings.Contains(got, "Contoso Broker") { + t.Errorf("the target's SCM event was dropped:\n%s", got) + } +} + +// A service that has logged nothing is normal. An error there would read as a +// broken feature. +func TestParseEventsEmpty(t *testing.T) { + for _, in := range []string{"", "[]", "null"} { + got, err := parseEvents(in, "Contoso", "Contoso Broker") + if err != nil || got != "" { + t.Fatalf("parseEvents(%q) = %q, err %v", in, got, err) + } + } +}