diff --git a/.claude/commands/add-native-task.md b/.claude/commands/add-native-task.md new file mode 100644 index 0000000..c4ebb08 --- /dev/null +++ b/.claude/commands/add-native-task.md @@ -0,0 +1,77 @@ +Add a new native task handler to this project. Arguments: $ARGUMENTS + +The first token of the arguments is the task slug (e.g. `collect-logs`). Everything after it is the human-readable description. If no arguments are provided, ask the user for the slug and description before proceeding. + +## Pattern Reference + +Native task handlers live in `internal/service/task/native/`. Each handler is one file in that package. Adding a new file is the **only** change required — no other files need to be modified. + +**Key files to understand the pattern:** +- `internal/service/task/native/registry.go` — defines `Handler` and `PostRunner` interfaces +- `internal/service/task/native/sync_inventory.go` — cleanest minimal example +- `internal/service/task/native/sync_software.go` — example that calls existing services + +**Interfaces (defined in `registry.go`):** +```go +// Required — every handler must implement both methods +type Handler interface { + Slugs() []string + Run(ctx context.Context, cfg *config.Config, task taskstore.Task) (string, error) +} + +// Optional — implement this to auto-run other handlers after success +type PostRunner interface { + PostRun() []string // return slugs to run after Run() succeeds +} +``` + +**Self-registration:** `func init() { Register(&myHandler{}) }` — runs automatically at startup. No central list to update. + +## Steps + +1. **Read** `internal/service/task/native/sync_inventory.go` to see the exact file structure. + +2. **Derive the filename** from the slug: replace every `-` with `_`, append `.go`. + - `collect-logs` → `collect_logs.go` + - `sync-software` → `sync_software.go` + +3. **Derive the struct name** from the slug: convert to UpperCamelCase and append `Handler`. + - `collect-logs` → `CollectLogsHandler` (but keep it unexported: `collectLogsHandler`) + +4. **Create** `internal/service/task/native/` with this structure: +```go +package native + +import ( + "context" + "fmt" + + "sentinelgo/internal/config" + "sentinelgo/internal/taskstore" +) + +type Handler struct{} + +func init() { Register(&Handler{}) } + +func (h *Handler) Slugs() []string { + return []string{""} +} + +// +func (h *Handler) Run(ctx context.Context, cfg *config.Config, _ taskstore.Task) (string, error) { + // TODO: implement + return "", fmt.Errorf(": not implemented") +} +``` + +5. **Ask the user:** "Should this handler automatically trigger any post-run tasks after it succeeds (e.g. `sync-inventory`)?" If yes, add: +```go +func (h *Handler) PostRun() []string { + return []string{""} +} +``` + +6. **Run** `go build ./internal/service/task/native/...` to confirm the new file compiles without errors. + +7. **Tell the user** the scaffold is ready and point them to implement the `Run` body. Reference `internal/service/task/native/sync_software.go` as an example of a handler that opens a local store and calls an RPC service, or `internal/service/task/native/sync_inventory.go` for one that collects system info. Both show the correct pattern for using `cfg *config.Config` to access Supabase credentials and config paths. diff --git a/LICENSE b/LICENSE index 261eeb9..08cb09c 100644 --- a/LICENSE +++ b/LICENSE @@ -186,7 +186,7 @@ same "printed page" as the copyright notice for easier identification within third-party archives. - Copyright [yyyy] [name of copyright owner] + Copyright [2026] [Brain Station 23 PLC.] Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. diff --git a/Makefile b/Makefile index 275ffef..ec4fe83 100644 --- a/Makefile +++ b/Makefile @@ -37,7 +37,7 @@ endif export CGO_ENABLED=0 # Targets -.PHONY: build clean clean-all all windows linux macos release sign version test-version deps test coverage coverage-html pre-release quality-check format-check setup packages check-no-cgo verify-cross +.PHONY: build clean clean-all all windows linux macos release sign version test-version deps test coverage coverage-html pre-release quality-check format format-check setup packages check-no-cgo verify-cross all: windows linux macos @@ -92,7 +92,7 @@ clean: rm -rf build/ # Development build (current platform only) -build: +build: format go build $(LDFLAGS) -o $(DEV_BIN) ./cmd/sentinelgo @echo "Built $(DEV_BIN) (version $(VERSION))" @@ -166,6 +166,10 @@ quality-check: @command -v golangci-lint >/dev/null 2>&1 || (echo "📦 Installing golangci-lint..." && go install github.com/golangci/golangci-lint/cmd/golangci-lint@latest) @export PATH=$$PATH:$$(go env GOPATH)/bin && golangci-lint run +# Auto-format all Go source files +format: + gofmt -s -w . + # Code format check format-check: @echo "📝 Checking code formatting..." diff --git a/README.md b/README.md index 3227d1c..e10ef77 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,16 @@ A single, dependency-free binary that turns every Windows, macOS, and Linux devi [![Platforms](https://img.shields.io/badge/platforms-Windows%20%7C%20macOS%20%7C%20Linux-blue)](#runs-everywhere-your-fleet-does) [![Build](https://img.shields.io/badge/build-CGO__free%20static%20binary-success)](#why-teams-choose-sentinelgo) [![Go](https://img.shields.io/badge/Go-1.25%2B-00ADD8)](#build-from-source) -[![License](https://img.shields.io/badge/license-MIT-green)](#license) +[![License](https://img.shields.io/badge/license-Apache%202.0-green)](#license) + +[![Quality Gate Status](https://sonarcloud.io/api/project_badges/measure?project=BrainStation-23_SentinelGo&metric=alert_status)](https://sonarcloud.io/summary/new_code?id=BrainStation-23_SentinelGo) +[![Coverage](https://sonarcloud.io/api/project_badges/measure?project=BrainStation-23_SentinelGo&metric=coverage)](https://sonarcloud.io/summary/new_code?id=BrainStation-23_SentinelGo) +[![Bugs](https://sonarcloud.io/api/project_badges/measure?project=BrainStation-23_SentinelGo&metric=bugs)](https://sonarcloud.io/summary/new_code?id=BrainStation-23_SentinelGo) +[![Vulnerabilities](https://sonarcloud.io/api/project_badges/measure?project=BrainStation-23_SentinelGo&metric=vulnerabilities)](https://sonarcloud.io/summary/new_code?id=BrainStation-23_SentinelGo) +[![Code Smells](https://sonarcloud.io/api/project_badges/measure?project=BrainStation-23_SentinelGo&metric=code_smells)](https://sonarcloud.io/summary/new_code?id=BrainStation-23_SentinelGo) +[![Security Rating](https://sonarcloud.io/api/project_badges/measure?project=BrainStation-23_SentinelGo&metric=security_rating)](https://sonarcloud.io/summary/new_code?id=BrainStation-23_SentinelGo) +[![Reliability Rating](https://sonarcloud.io/api/project_badges/measure?project=BrainStation-23_SentinelGo&metric=reliability_rating)](https://sonarcloud.io/summary/new_code?id=BrainStation-23_SentinelGo) +[![Maintainability Rating](https://sonarcloud.io/api/project_badges/measure?project=BrainStation-23_SentinelGo&metric=sqale_rating)](https://sonarcloud.io/summary/new_code?id=BrainStation-23_SentinelGo) @@ -193,4 +202,4 @@ All builds are `CGO_ENABLED=0` static binaries cross-compiled from a single host ## License -MIT +Apache 2.0 diff --git a/internal/osinfo/display/display_darwin.go b/internal/osinfo/display/display_darwin.go index 9c1057a..8c1c569 100644 --- a/internal/osinfo/display/display_darwin.go +++ b/internal/osinfo/display/display_darwin.go @@ -38,22 +38,29 @@ func getDisplays() []shared.Display { gpuVendorRaw, _ := gm["spdisplays_vendor"].(string) gpuVendor := cleanMacVendorName(gpuVendorRaw) - items, hasItems := gm["_items"].([]any) + // macOS 13+: display items are nested under "spdisplays_ndrvs". + // Older format used "_items". + items, hasItems := gm["spdisplays_ndrvs"].([]any) if !hasItems { - // Legacy format: entry itself is the display when it has resolution data. - if _, hasRes := gm["spdisplays_resolution"]; hasRes { - if d, ok := buildMacDisplay(gm, gpuVendor); ok { + items, hasItems = gm["_items"].([]any) + } + if hasItems { + for _, item := range items { + dm, ok := item.(map[string]any) + if !ok { + continue + } + if d, ok := buildMacDisplay(dm, gpuVendor); ok { displays = append(displays, d) } } continue } - for _, item := range items { - dm, ok := item.(map[string]any) - if !ok { - continue - } - if d, ok := buildMacDisplay(dm, gpuVendor); ok { + // Legacy format: entry itself is the display when it has resolution data. + _, hasNewRes := gm["_spdisplays_resolution"] + _, hasOldRes := gm["spdisplays_resolution"] + if hasNewRes || hasOldRes { + if d, ok := buildMacDisplay(gm, gpuVendor); ok { displays = append(displays, d) } } @@ -79,10 +86,14 @@ func buildMacDisplay(dm map[string]any, gpuVendor string) (shared.Display, bool) d.Manufacturer = gpuVendor } - if v, ok := dm["spdisplays_serial_number"].(string); ok && v != "" { + // Serial number: modern key is "_spdisplays_display-serial-number" + if v, ok := dm["_spdisplays_display-serial-number"].(string); ok && v != "" { + d.SerialNumber = v + } else if v, ok := dm["spdisplays_serial_number"].(string); ok && v != "" { d.SerialNumber = v } + // Physical size in inches if v, ok := dm["spdisplays_inches"].(string); ok && v != "" { if sz, err := strconv.ParseFloat(strings.TrimSpace(v), 64); err == nil && sz > 0 { d.Size = sz @@ -94,9 +105,26 @@ func buildMacDisplay(dm map[string]any, gpuVendor string) (shared.Display, bool) } } - if v, ok := dm["spdisplays_resolution"].(string); ok && v != "" { + // Native (physical) pixel resolution, e.g. "_spdisplays_pixels": "3024 x 1964" + if v, ok := dm["_spdisplays_pixels"].(string); ok && v != "" { + if res, _ := parseResolutionAndRefreshRate(v); res != "" { + d.Resolution = res + } + } + + // Refresh rate from "_spdisplays_resolution"; also provides logical resolution + // as a fallback when native pixels were not available. + if v, ok := dm["_spdisplays_resolution"].(string); ok && v != "" { + logicalRes, hz := parseResolutionAndRefreshRate(v) + d.RefreshRate = hz + if d.Resolution == "" { + d.Resolution = logicalRes + } + } else if v, ok := dm["spdisplays_resolution"].(string); ok && v != "" { res, hz := parseResolutionAndRefreshRate(v) - d.Resolution = res + if d.Resolution == "" { + d.Resolution = res + } d.RefreshRate = hz } @@ -104,12 +132,28 @@ func buildMacDisplay(dm map[string]any, gpuVendor string) (shared.Display, bool) d.ConnectionType = macDisplayConnectionType(v) } + // Year: present for external displays; 0 for built-in panels (skip those) + if v, ok := dm["_spdisplays_display-year"].(string); ok && v != "" { + if yr, err := strconv.Atoi(v); err == nil && yr > 1990 { + d.Year = yr + } + } + + // Monitor type tags derived from spdisplays_display_type + if v, ok := dm["spdisplays_display_type"].(string); ok && v != "" { + d.MonitorType = parseMacDisplayType(v) + } + return d, true } -// cleanMacVendorName strips the PCI-ID suffix from strings like "Apple (0x106b)" -// and returns the bare vendor name. Returns the input unchanged if no " (" is found. +// cleanMacVendorName normalises vendor strings from system_profiler: +// - "sppci_vendor_Apple" → "Apple" +// - "Apple (0x106b)" → "Apple" func cleanMacVendorName(s string) string { + if strings.HasPrefix(s, "sppci_vendor_") { + return s[len("sppci_vendor_"):] + } if idx := strings.Index(s, " ("); idx > 0 { name := strings.TrimSpace(s[:idx]) if strings.EqualFold(name, "unknown") { @@ -120,6 +164,40 @@ func cleanMacVendorName(s string) string { return s } +// parseMacDisplayType converts a spdisplays_display_type token like +// "spdisplays_built-in-liquid-retina-xdr" into human-readable tags. +func parseMacDisplayType(s string) []string { + s = strings.TrimPrefix(strings.ToLower(s), "spdisplays_") + if s == "" { + return nil + } + var tags []string + if strings.Contains(s, "built-in") { + tags = append(tags, "Built-In") + } else if strings.Contains(s, "external") { + tags = append(tags, "External") + } + switch { + case strings.Contains(s, "liquid-retina-xdr"): + tags = append(tags, "Liquid Retina XDR") + case strings.Contains(s, "retina-xdr"): + tags = append(tags, "Retina XDR") + case strings.Contains(s, "retina"): + tags = append(tags, "Retina") + } + if len(tags) == 0 { + parts := strings.Split(s, "-") + var words []string + for _, p := range parts { + if p != "" { + words = append(words, strings.ToUpper(p[:1])+p[1:]) + } + } + return []string{strings.Join(words, " ")} + } + return tags +} + // parseResolutionAndRefreshRate parses strings like "2560 x 1440 @ 60.00Hz" or // "3456 x 2160 Retina" into a compact "WxH" resolution and Hz value. func parseResolutionAndRefreshRate(s string) (resolution string, refreshRate float64) { diff --git a/internal/osinfo/display/display_darwin_test.go b/internal/osinfo/display/display_darwin_test.go index 3dac5a3..7b4419e 100644 --- a/internal/osinfo/display/display_darwin_test.go +++ b/internal/osinfo/display/display_darwin_test.go @@ -16,6 +16,10 @@ func TestCleanMacVendorName(t *testing.T) { {"Unknown (0x1234)", ""}, {"", ""}, {"Dell Inc.", "Dell Inc."}, + // sppci_vendor_ prefix (modern format) + {"sppci_vendor_Apple", "Apple"}, + {"sppci_vendor_AMD", "AMD"}, + {"sppci_vendor_Intel", "Intel"}, } for _, tc := range tests { t.Run(tc.input, func(t *testing.T) { @@ -81,6 +85,71 @@ func TestMacDisplayConnectionType(t *testing.T) { } } +func TestParseMacDisplayType(t *testing.T) { + tests := []struct { + input string + want []string + }{ + {"spdisplays_built-in-liquid-retina-xdr", []string{"Built-In", "Liquid Retina XDR"}}, + {"spdisplays_built-in-retina-xdr", []string{"Built-In", "Retina XDR"}}, + {"spdisplays_built-in-retina", []string{"Built-In", "Retina"}}, + {"spdisplays_built-in", []string{"Built-In"}}, + {"spdisplays_external", []string{"External"}}, + {"", nil}, + } + for _, tc := range tests { + t.Run(tc.input, func(t *testing.T) { + got := parseMacDisplayType(tc.input) + if len(got) != len(tc.want) { + t.Fatalf("parseMacDisplayType(%q) = %v, want %v", tc.input, got, tc.want) + } + for i := range got { + if got[i] != tc.want[i] { + t.Errorf("parseMacDisplayType(%q)[%d] = %q, want %q", tc.input, i, got[i], tc.want[i]) + } + } + }) + } +} + +func TestBuildMacDisplay_ModernFormat(t *testing.T) { + // Mirrors the real system_profiler output structure for an Apple M2 Pro built-in display + dm := map[string]any{ + "_name": "Color LCD", + "_spdisplays_display-serial-number": "fd626d62", + "_spdisplays_display-year": "0", + "_spdisplays_pixels": "3024 x 1964", + "_spdisplays_resolution": "1512 x 982 @ 120.00Hz", + "spdisplays_connection_type": "spdisplays_internal", + "spdisplays_display_type": "spdisplays_built-in-liquid-retina-xdr", + } + d, ok := buildMacDisplay(dm, "Apple") + if !ok { + t.Fatal("buildMacDisplay returned ok=false") + } + if d.Resolution != "3024x1964" { + t.Errorf("Resolution = %q, want %q", d.Resolution, "3024x1964") + } + if d.RefreshRate != 120.0 { + t.Errorf("RefreshRate = %v, want 120.0", d.RefreshRate) + } + if d.SerialNumber != "fd626d62" { + t.Errorf("SerialNumber = %q, want %q", d.SerialNumber, "fd626d62") + } + if d.ConnectionType != "Internal" { + t.Errorf("ConnectionType = %q, want %q", d.ConnectionType, "Internal") + } + if d.Manufacturer != "Apple" { + t.Errorf("Manufacturer = %q, want %q", d.Manufacturer, "Apple") + } + if len(d.MonitorType) == 0 { + t.Error("MonitorType is empty, want at least one tag") + } + if d.Year != 0 { + t.Errorf("Year = %d, want 0 (built-in has year=0, should be skipped)", d.Year) + } +} + func TestGetDisplays_Integration(t *testing.T) { if testing.Short() { t.Skip("skipping integration test in -short mode") @@ -94,9 +163,9 @@ func TestGetDisplays_Integration(t *testing.T) { if d.Model == "" { t.Errorf("display[%d] %q has empty Model", i, d.Description) } - t.Logf("display[%d]: desc=%q mfr=%q model=%q serial=%q size=%.1f\" refresh=%.2fHz conn=%q res=%q", + t.Logf("display[%d]: desc=%q mfr=%q model=%q serial=%q size=%.1f\" refresh=%.2fHz conn=%q res=%q year=%d type=%v", i, d.Description, d.Manufacturer, d.Model, d.SerialNumber, - d.Size, d.RefreshRate, d.ConnectionType, d.Resolution) + d.Size, d.RefreshRate, d.ConnectionType, d.Resolution, d.Year, d.MonitorType) } t.Logf("total displays: %d", len(displays)) } diff --git a/internal/osinfo/peripherals/peripherals_darwin.go b/internal/osinfo/peripherals/peripherals_darwin.go index c22a3bd..dd1fcfd 100644 --- a/internal/osinfo/peripherals/peripherals_darwin.go +++ b/internal/osinfo/peripherals/peripherals_darwin.go @@ -86,11 +86,15 @@ func parseUSBItems(items []any, peripherals *[]shared.PeripheralDevice, connecti productID, _ := m["product_id"].(string) locationID, _ := m["location_id"].(string) - devType := determineDeviceType(name, strings.ToLower(name)) - if devType == "" { - devType = "USB Device" - } - if name != "" { + // Skip internal host bus controllers — they have a "host_controller" field + // but no vendor_id or manufacturer (e.g. "USB31Bus"). + _, isHostController := m["host_controller"] + + if name != "" && !isHostController { + devType := determineDeviceType(name, strings.ToLower(name)) + if devType == "" { + devType = "USB Device" + } *peripherals = append(*peripherals, shared.PeripheralDevice{ Type: devType, Description: name, @@ -111,6 +115,8 @@ func parseUSBItems(items []any, peripherals *[]shared.PeripheralDevice, connecti } } +// parseBluetoothItems iterates the top-level SPBluetoothDataType array and +// dispatches each entry's device list to parseBTDeviceList. func parseBluetoothItems(items []any, peripherals *[]shared.PeripheralDevice) { for _, item := range items { m, ok := item.(map[string]any) @@ -118,24 +124,68 @@ func parseBluetoothItems(items []any, peripherals *[]shared.PeripheralDevice) { continue } if devices, ok := m["device_title"].([]any); ok { - for _, device := range devices { - if devMap, ok := device.(map[string]any); ok { - name, _ := devMap["device_name"].(string) - address, _ := devMap["device_address"].(string) - majorType, _ := devMap["device_majorType"].(string) - minorType, _ := devMap["device_minorType"].(string) - if name != "" { - *peripherals = append(*peripherals, shared.PeripheralDevice{ - Type: determineBluetoothDeviceType(majorType, minorType), - Description: name, - SerialNumber: address, - ConnectionType: "Bluetooth", - Status: "Connected", - }) - } - } - } + parseBTDeviceList(devices, peripherals) + } + } +} + +// parseBTDeviceList processes an array of Bluetooth device entries. +// It handles both the flat list format (older macOS) and the section-header +// format where device_title contains group objects that nest actual devices +// under "_items" (some macOS versions). +func parseBTDeviceList(devices []any, peripherals *[]shared.PeripheralDevice) { + for _, device := range devices { + devMap, ok := device.(map[string]any) + if !ok { + continue + } + // Section-header nesting: some macOS versions group devices under a + // header entry with _items containing the actual device records. + if items, ok := devMap["_items"].([]any); ok { + parseBTDeviceList(items, peripherals) + continue + } + + // Device name: macOS 13+ uses "_name"; older versions used "device_name". + name, _ := devMap["_name"].(string) + if name == "" { + name, _ = devMap["device_name"].(string) } + if name == "" { + continue + } + + address, _ := devMap["device_address"].(string) + + // Major/minor type: macOS 13+ uses *ClassOfDevice_string; older used *Type. + majorType, _ := devMap["device_majorClassOfDevice_string"].(string) + if majorType == "" { + majorType, _ = devMap["device_majorType"].(string) + } + minorType, _ := devMap["device_minorClassOfDevice_string"].(string) + if minorType == "" { + minorType, _ = devMap["device_minorType"].(string) + } + + vendorID, _ := devMap["device_vendorID"].(string) + productID, _ := devMap["device_productID"].(string) + + // Connection status: "attrib_yes" = currently connected; "attrib_no" = paired + // but not connected. Absent in older formats — treat as Connected in that case. + status := "Connected" + if connected, _ := devMap["device_connected"].(string); connected == "attrib_no" { + status = "Paired" + } + + *peripherals = append(*peripherals, shared.PeripheralDevice{ + Type: determineBluetoothDeviceType(majorType, minorType), + Description: name, + SerialNumber: address, + VendorID: vendorID, + ProductID: productID, + ConnectionType: "Bluetooth", + Status: status, + }) } } @@ -146,14 +196,25 @@ func parseThunderboltItems(items []any, peripherals *[]shared.PeripheralDevice) continue } name, _ := m["_name"].(string) - vendorName, _ := m["vendor_name"].(string) + // "vendor_name_key" is the correct field in modern system_profiler output; + // "vendor_name" was used in older versions. + vendorName, _ := m["vendor_name_key"].(string) + if vendorName == "" { + vendorName, _ = m["vendor_name"].(string) + } serialNum, _ := m["serial_number"].(string) - if name != "" { - manufacturer := vendorName + + // Skip host bus controller entries — they represent the MacBook's own + // Thunderbolt ports, not attached external devices. Bus entries carry a + // "domain_uuid_key" (port-level UUID) and route_string_key == "0" + // (host node). External devices appear as _items under these entries. + _, isBusController := m["domain_uuid_key"] + + if name != "" && !isBusController { *peripherals = append(*peripherals, shared.PeripheralDevice{ Type: "Thunderbolt Device", Description: name, - Manufacturer: manufacturer, + Manufacturer: vendorName, SerialNumber: serialNum, ConnectionType: "Thunderbolt", Status: "Connected", @@ -174,11 +235,10 @@ func parseFireWireItems(items []any, peripherals *[]shared.PeripheralDevice) { name, _ := m["_name"].(string) vendorName, _ := m["vendor_name"].(string) if name != "" { - manufacturer := vendorName *peripherals = append(*peripherals, shared.PeripheralDevice{ Type: "FireWire Device", Description: name, - Manufacturer: manufacturer, + Manufacturer: vendorName, ConnectionType: "FireWire", Status: "Connected", }) @@ -275,31 +335,38 @@ func addHIDDeviceFromMap(deviceMap map[string]string, peripherals *[]shared.Peri } } +// determineBluetoothDeviceType classifies a Bluetooth device from its major/minor +// type strings. It uses Contains rather than exact equality to handle both the +// older format ("Audio", "HID") and the modern macOS 13+ format +// ("Audio/Video", "Peripheral"). func determineBluetoothDeviceType(majorType, minorType string) string { - switch majorType { - case "Audio": - switch { - case strings.Contains(minorType, "Microphone"): - return "Microphone" - case strings.Contains(minorType, "Speaker"): - return "Speaker" - case strings.Contains(minorType, "Headphones"): - return "Headphones" - } + minorLower := strings.ToLower(minorType) + // Minor type is more specific — check it first. + switch { + case strings.Contains(minorLower, "headphone"): + return "Headphones" + case strings.Contains(minorLower, "microphone"): + return "Microphone" + case strings.Contains(minorLower, "speaker"): + return "Speaker" + case strings.Contains(minorLower, "keyboard"): + return "Keyboard" + case strings.Contains(minorLower, "mouse") || strings.Contains(minorLower, "pointing"): + return "Mouse or other pointing device" + case strings.Contains(minorLower, "gamepad"): + return "Game Controller" + } + // Fall back to major type. + majorLower := strings.ToLower(majorType) + switch { + case strings.Contains(majorLower, "audio"): return "Audio Device" - case "HID": - switch { - case strings.Contains(minorType, "Keyboard"): - return "Keyboard" - case strings.Contains(minorType, "Mouse"): - return "Mouse or other pointing device" - case strings.Contains(minorType, "Gamepad"): - return "Game Controller" - } + case majorLower == "hid" || strings.HasPrefix(majorLower, "hid "): return "HID Device" - case "Peripheral": + case strings.Contains(majorLower, "peripheral"): return "Peripheral Device" - default: - return "Bluetooth Device" + case strings.Contains(majorLower, "hid"): + return "HID Device" } + return "Bluetooth Device" } diff --git a/internal/osinfo/peripherals/peripherals_darwin_test.go b/internal/osinfo/peripherals/peripherals_darwin_test.go index 812ddf2..717f819 100644 --- a/internal/osinfo/peripherals/peripherals_darwin_test.go +++ b/internal/osinfo/peripherals/peripherals_darwin_test.go @@ -13,6 +13,7 @@ func TestDetermineBluetoothDeviceType(t *testing.T) { minorType string want string }{ + // Older macOS format (exact type strings) {"audio microphone", "Audio", "Microphone", "Microphone"}, {"audio speaker", "Audio", "Speaker", "Speaker"}, {"audio headphones", "Audio", "Headphones", "Headphones"}, @@ -24,6 +25,12 @@ func TestDetermineBluetoothDeviceType(t *testing.T) { {"peripheral", "Peripheral", "", "Peripheral Device"}, {"unknown major type", "Phone", "Smartphone", "Bluetooth Device"}, {"empty major type", "", "", "Bluetooth Device"}, + // macOS 13+ format ("Audio/Video" major, full minor strings) + {"modern audio/video headphones", "Audio/Video", "Headphones", "Headphones"}, + {"modern audio/video microphone", "Audio/Video", "Microphone", "Microphone"}, + {"modern audio/video speaker", "Audio/Video", "Loudspeaker", "Speaker"}, + {"modern audio/video other", "Audio/Video", "VCR", "Audio Device"}, + {"modern peripheral keyboard", "Peripheral", "Keyboard", "Keyboard"}, } for _, tc := range tests { @@ -37,6 +44,189 @@ func TestDetermineBluetoothDeviceType(t *testing.T) { } } +func TestParseBTDeviceList_ModernFormat(t *testing.T) { + // Mirrors the real macOS 13+ SPBluetoothDataType device entry structure. + devices := []any{ + map[string]any{ + "_name": "AirPods Pro", + "device_address": "11-22-33-44-55-66", + "device_majorClassOfDevice_string": "Audio/Video", + "device_minorClassOfDevice_string": "Headphones", + "device_connected": "attrib_yes", + "device_vendorID": "0x004C", + "device_productID": "0x200F", + }, + map[string]any{ + "_name": "Magic Mouse", + "device_address": "AA-BB-CC-DD-EE-FF", + "device_majorClassOfDevice_string": "Peripheral", + "device_minorClassOfDevice_string": "Mouse", + "device_connected": "attrib_no", // paired but not connected + }, + } + var peripherals []shared.PeripheralDevice + parseBTDeviceList(devices, &peripherals) + + if len(peripherals) != 2 { + t.Fatalf("expected 2 BT devices, got %d", len(peripherals)) + } + + ap := peripherals[0] + if ap.Description != "AirPods Pro" { + t.Errorf("AirPods desc = %q", ap.Description) + } + if ap.Type != "Headphones" { + t.Errorf("AirPods type = %q, want Headphones", ap.Type) + } + if ap.Status != "Connected" { + t.Errorf("AirPods status = %q, want Connected", ap.Status) + } + if ap.SerialNumber != "11-22-33-44-55-66" { + t.Errorf("AirPods serial = %q", ap.SerialNumber) + } + if ap.ConnectionType != "Bluetooth" { + t.Errorf("AirPods connectionType = %q", ap.ConnectionType) + } + + mouse := peripherals[1] + if mouse.Type != "Mouse or other pointing device" { + t.Errorf("Magic Mouse type = %q", mouse.Type) + } + if mouse.Status != "Paired" { + t.Errorf("Magic Mouse status = %q, want Paired (device_connected=attrib_no)", mouse.Status) + } +} + +func TestParseBTDeviceList_OldFormat(t *testing.T) { + // Older macOS format used "device_name" and "device_majorType"/"device_minorType". + devices := []any{ + map[string]any{ + "device_name": "Keyboard", + "device_address": "11-22-33-44-55-66", + "device_majorType": "HID", + "device_minorType": "Keyboard", + }, + } + var peripherals []shared.PeripheralDevice + parseBTDeviceList(devices, &peripherals) + + if len(peripherals) != 1 { + t.Fatalf("expected 1 BT device, got %d", len(peripherals)) + } + if peripherals[0].Type != "Keyboard" { + t.Errorf("type = %q, want Keyboard", peripherals[0].Type) + } + if peripherals[0].Description != "Keyboard" { + t.Errorf("desc = %q", peripherals[0].Description) + } +} + +func TestParseBTDeviceList_SectionHeaderNesting(t *testing.T) { + // Some macOS versions nest devices under a section header entry with _items. + devices := []any{ + map[string]any{ + "_name": "Input Devices", + "_items": []any{ + map[string]any{ + "_name": "Magic Keyboard", + "device_address": "AA-BB-CC-DD-EE-FF", + "device_majorClassOfDevice_string": "Peripheral", + "device_minorClassOfDevice_string": "Keyboard", + "device_connected": "attrib_yes", + }, + }, + }, + } + var peripherals []shared.PeripheralDevice + parseBTDeviceList(devices, &peripherals) + + if len(peripherals) != 1 { + t.Fatalf("expected 1 BT device (nested in section header), got %d", len(peripherals)) + } + if peripherals[0].Description != "Magic Keyboard" { + t.Errorf("desc = %q, want Magic Keyboard", peripherals[0].Description) + } + if peripherals[0].Type != "Keyboard" { + t.Errorf("type = %q, want Keyboard", peripherals[0].Type) + } +} + +func TestParseUSBItems_SkipsHostControllers(t *testing.T) { + // USB31Bus entries have a "host_controller" field — they are internal bus + // controllers and must not appear in the peripherals list. + items := []any{ + map[string]any{ + "_name": "USB31Bus", + "host_controller": "AppleT8112USBXHCI", + }, + map[string]any{ + "_name": "USB31Bus", + "host_controller": "AppleT8112USBXHCI", + // with a real device nested underneath + "_items": []any{ + map[string]any{ + "_name": "Logitech USB Receiver", + "vendor_id": "0x046d", + "product_id": "0xc52b", + "manufacturer": "Logitech", + }, + }, + }, + } + var peripherals []shared.PeripheralDevice + parseUSBItems(items, &peripherals, "USB") + + // Only the nested Logitech device should appear, not the two USB31Bus controllers. + if len(peripherals) != 1 { + t.Fatalf("expected 1 peripheral (child of bus), got %d: %+v", len(peripherals), peripherals) + } + if peripherals[0].Description != "Logitech USB Receiver" { + t.Errorf("desc = %q, want Logitech USB Receiver", peripherals[0].Description) + } +} + +func TestParseThunderboltItems_SkipsBusControllers(t *testing.T) { + // thunderboltusb4_bus_* entries are host port controllers — they carry a + // "domain_uuid_key" and should not be listed as peripheral devices. + items := []any{ + map[string]any{ + "_name": "thunderboltusb4_bus_0", + "device_name_key": "MacBook Pro", + "domain_uuid_key": "9C7FA856-A18E-4A29-94F2-5BE2543CE06A", + "vendor_name_key": "Apple Inc.", + "route_string_key": "0", + }, + map[string]any{ + "_name": "thunderboltusb4_bus_0", + "device_name_key": "MacBook Pro", + "domain_uuid_key": "9C7FA856-A18E-4A29-94F2-5BE2543CE06A", + "vendor_name_key": "Apple Inc.", + "route_string_key": "0", + // with a real external Thunderbolt device connected + "_items": []any{ + map[string]any{ + "_name": "Thunderbolt Display", + "vendor_name_key": "Apple Inc.", + "serial_number": "C02K1234F8J2", + }, + }, + }, + } + var peripherals []shared.PeripheralDevice + parseThunderboltItems(items, &peripherals) + + // Only the nested display should appear, not the bus controllers. + if len(peripherals) != 1 { + t.Fatalf("expected 1 Thunderbolt peripheral, got %d: %+v", len(peripherals), peripherals) + } + if peripherals[0].Description != "Thunderbolt Display" { + t.Errorf("desc = %q, want Thunderbolt Display", peripherals[0].Description) + } + if peripherals[0].Manufacturer != "Apple Inc." { + t.Errorf("mfr = %q, want Apple Inc.", peripherals[0].Manufacturer) + } +} + func TestParseUSBItems_IncludesUnknownTypes(t *testing.T) { // USB devices whose names don't match any known category should // now be included with Type="USB Device" rather than silently dropped. diff --git a/internal/service/task/executor.go b/internal/service/task/executor.go index b1fc6ed..70b93a7 100644 --- a/internal/service/task/executor.go +++ b/internal/service/task/executor.go @@ -14,27 +14,28 @@ import ( "sentinelgo/internal/config" "sentinelgo/internal/httpx" - "sentinelgo/internal/sanitize" "sentinelgo/internal/taskstore" - "sentinelgo/internal/updater" ) // TaskExecutorService handles the execution of tasks assigned to the agent. type TaskExecutorService struct { - cfg *config.Config - pollingSvc *TaskPollingService - client *http.Client - runningTasks map[string]bool + cfg *config.Config + pollingSvc *TaskPollingService + client *http.Client + runningTasks map[string]bool + nativeHandlers map[string]NativeTaskHandler } // NewTaskExecutorService creates a new task execution service. func NewTaskExecutorService(cfg *config.Config, pollingSvc *TaskPollingService) *TaskExecutorService { - return &TaskExecutorService{ + s := &TaskExecutorService{ cfg: cfg, pollingSvc: pollingSvc, client: httpx.NewClient(2 * time.Minute), runningTasks: make(map[string]bool), } + s.registerNativeHandlers() + return s } // RunExecutionLoop starts a loop that checks for and executes assigned tasks. @@ -106,8 +107,8 @@ func (s *TaskExecutorService) executeTask(ctx context.Context, task taskstore.Ta } func (s *TaskExecutorService) runTask(ctx context.Context, task taskstore.Task) (string, error) { - if task.Slug == "agent-update" || task.Slug == "update-agent" { - return s.executeAgentUpdate(ctx, task) + if handler, ok := s.nativeHandlers[task.Slug]; ok { + return handler(ctx, task) } timeoutCtx, cancel := context.WithTimeout(ctx, 10*time.Minute) @@ -222,40 +223,3 @@ func (s *TaskExecutorService) downloadScript(ctx context.Context, remotePath, lo _, err = io.Copy(out, io.LimitReader(resp.Body, maxScriptBytes)) return err } - -// executeAgentUpdate handles the agent-update task by directly invoking the updater. -func (s *TaskExecutorService) executeAgentUpdate(ctx context.Context, task taskstore.Task) (string, error) { - log.Printf("Executor: Executing agent-update task %s", task.ID) - - if runtime.GOOS == "linux" && os.Getuid() != 0 { - log.Printf("Executor: Agent-update requires root privileges") - return "", fmt.Errorf("agent-update requires root privileges. Please restart the agent with sudo") - } - - if !updater.CheckInternetConnectivity() { - log.Printf("Executor: Warning - TCP connectivity check failed, attempting update anyway") - } - - if !updater.CheckInternetWithHTTP() { - log.Printf("Executor: Warning - HTTP connectivity check failed, attempting update anyway") - } - - currentVersion := s.cfg.CurrentVersion - if currentVersion == "" { - currentVersion = config.Version - } - - sanitizedCurrentVersion := sanitize.ForLog(currentVersion) - log.Printf("Executor: Current version: %s, checking for updates...", sanitizedCurrentVersion) - - if err := updater.CheckAndApplyWithRetry(ctx, s.cfg, ""); err != nil { - log.Printf("Executor: Agent-update failed: %v", err) - return fmt.Sprintf("Update failed: %v (Current version: %s)", err, sanitizedCurrentVersion), err - } - - newVersion := s.cfg.CurrentVersion - sanitizedNewVersion := sanitize.ForLog(newVersion) - log.Printf("Executor: Agent-update successful, updated from %s to %s", sanitizedCurrentVersion, sanitizedNewVersion) - - return fmt.Sprintf("Successfully updated from %s to %s. Agent is restarting.", sanitizedCurrentVersion, sanitizedNewVersion), nil -} diff --git a/internal/service/task/native/agent_update.go b/internal/service/task/native/agent_update.go new file mode 100644 index 0000000..60e7814 --- /dev/null +++ b/internal/service/task/native/agent_update.go @@ -0,0 +1,62 @@ +package native + +import ( + "context" + "fmt" + "log" + "os" + "runtime" + + "sentinelgo/internal/config" + "sentinelgo/internal/sanitize" + "sentinelgo/internal/taskstore" + "sentinelgo/internal/updater" +) + +type agentUpdateHandler struct{} + +func init() { Register(&agentUpdateHandler{}) } + +func (h *agentUpdateHandler) Slugs() []string { + return []string{"agent-update"} +} + +func (h *agentUpdateHandler) PostRun() []string { + return []string{"sync-inventory"} +} + +func (h *agentUpdateHandler) Run(ctx context.Context, cfg *config.Config, task taskstore.Task) (string, error) { + log.Printf("Executor: Executing agent-update task %s", task.ID) + + if runtime.GOOS == "linux" && os.Getuid() != 0 { + log.Printf("Executor: Agent-update requires root privileges") + return "", fmt.Errorf("agent-update requires root privileges. Please restart the agent with sudo") + } + + if !updater.CheckInternetConnectivity() { + log.Printf("Executor: Warning - TCP connectivity check failed, attempting update anyway") + } + + if !updater.CheckInternetWithHTTP() { + log.Printf("Executor: Warning - HTTP connectivity check failed, attempting update anyway") + } + + currentVersion := cfg.CurrentVersion + if currentVersion == "" { + currentVersion = config.Version + } + + sanitizedCurrentVersion := sanitize.ForLog(currentVersion) + log.Printf("Executor: Current version: %s, checking for updates...", sanitizedCurrentVersion) + + if err := updater.CheckAndApplyWithRetry(ctx, cfg, ""); err != nil { + log.Printf("Executor: Agent-update failed: %v", err) + return fmt.Sprintf("Update failed: %v (Current version: %s)", err, sanitizedCurrentVersion), err + } + + newVersion := cfg.CurrentVersion + sanitizedNewVersion := sanitize.ForLog(newVersion) + log.Printf("Executor: Agent-update successful, updated from %s to %s", sanitizedCurrentVersion, sanitizedNewVersion) + + return fmt.Sprintf("Successfully updated from %s to %s. Agent is restarting.", sanitizedCurrentVersion, sanitizedNewVersion), nil +} diff --git a/internal/service/task/native/registry.go b/internal/service/task/native/registry.go new file mode 100644 index 0000000..5d533b3 --- /dev/null +++ b/internal/service/task/native/registry.go @@ -0,0 +1,50 @@ +package native + +import ( + "context" + "slices" + + "sentinelgo/internal/config" + "sentinelgo/internal/taskstore" +) + +// Handler is the interface every native task handler must implement. +// Slugs returns the task slug strings this handler claims. +// Run executes the task and returns a human-readable note and any error. +type Handler interface { + Slugs() []string + Run(ctx context.Context, cfg *config.Config, task taskstore.Task) (string, error) +} + +// PostRunner is an optional interface a Handler may implement to declare slugs +// that should run automatically after the handler completes successfully. +// Post-run failures are logged but do not affect the primary task's status. +type PostRunner interface { + PostRun() []string +} + +var registry []Handler + +// Register adds h to the global registry. Call from init() in each handler file. +// All Register calls come from init() functions, which run sequentially before +// any goroutine can access the registry, so no mutex is needed. +func Register(h Handler) { + registry = append(registry, h) +} + +// Registry returns a snapshot of all registered handlers. +func Registry() []Handler { + out := make([]Handler, len(registry)) + copy(out, registry) + return out +} + +// Find returns the first registered handler that claims slug, or nil. +func Find(slug string) Handler { + for _, h := range registry { + if slices.Contains(h.Slugs(), slug) { + return h + } + } + return nil +} diff --git a/internal/service/task/native/sync_inventory.go b/internal/service/task/native/sync_inventory.go new file mode 100644 index 0000000..80ef5c2 --- /dev/null +++ b/internal/service/task/native/sync_inventory.go @@ -0,0 +1,50 @@ +package native + +import ( + "context" + "fmt" + "time" + + "sentinelgo/internal/config" + "sentinelgo/internal/osinfo" + "sentinelgo/internal/osinfo/shared" + agentsvc "sentinelgo/internal/service/agent" + "sentinelgo/internal/taskstore" +) + +const syncInventoryTimeout = 90 * time.Second + +type syncInventoryHandler struct{} + +func init() { Register(&syncInventoryHandler{}) } + +func (h *syncInventoryHandler) Slugs() []string { + return []string{"sync-inventory"} +} + +func (h *syncInventoryHandler) Run(ctx context.Context, cfg *config.Config, _ taskstore.Task) (string, error) { + tctx, cancel := context.WithTimeout(ctx, syncInventoryTimeout) + defer cancel() + + type collectResult struct{ info *shared.SystemInfo } + ch := make(chan collectResult, 1) + go func() { ch <- collectResult{osinfo.Collect()} }() + + var sysInfo *shared.SystemInfo + select { + case <-tctx.Done(): + return "", fmt.Errorf("sync-inventory: osinfo.Collect timed out after %v", syncInventoryTimeout) + case r := <-ch: + sysInfo = r.info + } + + if sysInfo == nil { + return "", fmt.Errorf("sync-inventory: system info collection returned no data") + } + + agentSvc := agentsvc.NewAgentService() + if err := agentSvc.UpdateAgentInfo(tctx, cfg, sysInfo); err != nil { + return "", fmt.Errorf("sync-inventory: %w", err) + } + return "inventory synced successfully", nil +} diff --git a/internal/service/task/native/sync_software.go b/internal/service/task/native/sync_software.go new file mode 100644 index 0000000..86fbbc7 --- /dev/null +++ b/internal/service/task/native/sync_software.go @@ -0,0 +1,70 @@ +package native + +import ( + "context" + "fmt" + "log" + "path/filepath" + "time" + + "sentinelgo/internal/config" + "sentinelgo/internal/sanitize" + swsvc "sentinelgo/internal/service/software" + "sentinelgo/internal/store" + "sentinelgo/internal/taskstore" +) + +type syncSoftwareHandler struct{} + +func init() { Register(&syncSoftwareHandler{}) } + +func (h *syncSoftwareHandler) Slugs() []string { + return []string{"sync-software"} +} + +func (h *syncSoftwareHandler) Run(ctx context.Context, cfg *config.Config, _ taskstore.Task) (string, error) { + configDir := filepath.Dir(cfg.Path) + swStore, err := store.NewSoftwareStore(filepath.Join(configDir, "software.sqlite")) + if err != nil { + return "", fmt.Errorf("sync-software: open software store: %w", err) + } + defer func() { + if err := swStore.Close(); err != nil { + log.Printf("sync-software: close software store: %v", err) + } + }() + + svc := swsvc.NewSoftwareService() + svc.SetSupabaseURL(cfg.SupabaseURL) + svc.SetEdgeFunctionConfig(cfg.SupabaseURL+"/functions/v1/sync-software", cfg.AccessToken) + + freshList := svc.GetSoftwareList() + + if err := swStore.SyncBatch(freshList, time.Now()); err != nil { + log.Printf("sync-software: store sync error: %v", err) + } + if err := swStore.QueueSync(); err != nil { + log.Printf("sync-software: queue sync error: %v", err) + } + + catalog, err := swStore.GetAll() + if err != nil { + return "", fmt.Errorf("sync-software: read catalog: %w", err) + } + if len(catalog) == 0 { + _ = swStore.ClearSync() + return "software catalog is empty, nothing to upload", nil + } + + if err := svc.SendByRPC(ctx, cfg.DeviceID, catalog, cfg); err != nil { + return "", fmt.Errorf("sync-software: send data: %w", err) + } + + if err := swStore.ClearSync(); err != nil { + log.Printf("sync-software: clear sync error: %v", err) + } + + return fmt.Sprintf("software synced successfully: %s catalog entries (%s fresh)", + sanitize.ForLog(fmt.Sprintf("%d", len(catalog))), + sanitize.ForLog(fmt.Sprintf("%d", len(freshList)))), nil +} diff --git a/internal/service/task/native_handlers.go b/internal/service/task/native_handlers.go new file mode 100644 index 0000000..7fe0f3d --- /dev/null +++ b/internal/service/task/native_handlers.go @@ -0,0 +1,55 @@ +package task + +import ( + "context" + "log" + + "sentinelgo/internal/service/task/native" + "sentinelgo/internal/taskstore" +) + +// NativeTaskHandler handles a task entirely in Go — no script download required. +type NativeTaskHandler func(ctx context.Context, task taskstore.Task) (string, error) + +// registerNativeHandlers builds the slug→handler map from the native sub-package registry. +// To add a new native task: create a file in native/, implement Handler, call Register() +// from init(). No changes here or anywhere else are required. +// Handlers may also implement native.PostRunner to declare slugs that run automatically +// after a successful execution. +func (s *TaskExecutorService) registerNativeHandlers() { + s.nativeHandlers = make(map[string]NativeTaskHandler) + for _, h := range native.Registry() { + h := h + for _, slug := range h.Slugs() { + slug := slug + s.nativeHandlers[slug] = func(ctx context.Context, task taskstore.Task) (string, error) { + return s.runWithPostHooks(ctx, h, slug, task) + } + } + } +} + +func (s *TaskExecutorService) runWithPostHooks(ctx context.Context, h native.Handler, slug string, task taskstore.Task) (string, error) { + note, err := h.Run(ctx, s.cfg, task) + if err != nil { + return note, err + } + pr, ok := h.(native.PostRunner) + if !ok { + return note, nil + } + for _, postSlug := range pr.PostRun() { + ph := native.Find(postSlug) + if ph == nil { + log.Printf("post-run: no handler registered for slug %q (requested by %q)", postSlug, slug) + continue + } + postNote, postErr := ph.Run(ctx, s.cfg, task) + if postErr != nil { + log.Printf("post-run %q (after %q) failed: %v", postSlug, slug, postErr) + } else if postNote != "" { + note += "; " + postNote + } + } + return note, nil +}