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/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/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 +}