diff --git a/CHECKLIST.md b/CHECKLIST.md index 9f33652..ddf4ac5 100644 --- a/CHECKLIST.md +++ b/CHECKLIST.md @@ -81,7 +81,12 @@ - [x] enrollment/heartbeat; - [x] inventory/metrics MVP; - [x] command polling/result/spool; - - [ ] перенос allowlist команд из shell-agent; + - [~] перенос allowlist команд из shell-agent; + - [x] `ping`; + - [x] `traceroute`; + - [x] `route_show`; + - [x] `interfaces_show`; + - [x] `pkg_list_installed` / `opkg_list_installed`; - [ ] OpenWrt cross-build/package integration. ## Этап 2: MVP Server API diff --git a/agent/README.md b/agent/README.md index 9a2244f..d0c9d22 100644 --- a/agent/README.md +++ b/agent/README.md @@ -50,7 +50,15 @@ agent/go/cmd/rmm-agent It is protocol-compatible with the server for enrollment, heartbeat, inventory, metrics, command polling, command result reporting, lock handling, backoff, graceful shutdown, and result spooling. -The Go agent is not the production command runner yet. It intentionally reports queued commands as failed with a clear message until the shell allowlist is migrated command by command. +The Go agent is not the production command runner yet. It currently supports the first read-only command set: + +- `ping` +- `traceroute` +- `route_show` +- `interfaces_show` +- `pkg_list_installed` / `opkg_list_installed` + +Other queued commands are reported as failed with a clear message until the shell allowlist is migrated command by command. Build locally: diff --git a/agent/go/cmd/rmm-agent/main.go b/agent/go/cmd/rmm-agent/main.go index 71fa688..9eed6b8 100644 --- a/agent/go/cmd/rmm-agent/main.go +++ b/agent/go/cmd/rmm-agent/main.go @@ -272,11 +272,16 @@ func buildMetrics(targets []string) map[string]any { } func processCommand(ctx context.Context, client *http.Client, cfg config, cmd command) { - output := fmt.Sprintf("go agent preview does not implement command %q yet; shell agent remains the production command runner", cmd.Type) + output, exitCode := runCommand(ctx, cmd) + status := "completed" + if exitCode != 0 { + status = "failed" + } + output = redactSensitiveOutput(output) result := map[string]any{ "device_id": cfg.DeviceID, - "status": "failed", - "exit_code": 2, + "status": status, + "exit_code": exitCode, "output": output, "result": map[string]any{"agent_version": agentVersion, "agent_runtime": "go"}, } @@ -288,6 +293,127 @@ func processCommand(ctx context.Context, client *http.Client, cfg config, cmd co } } +func runCommand(ctx context.Context, cmd command) (string, int) { + args := map[string]string{} + if len(cmd.Args) > 0 { + _ = json.Unmarshal(cmd.Args, &args) + } + switch cmd.Type { + case "ping": + target := commandTarget(args, "1.1.1.1") + if !safeHostName(target) { + return "target is invalid\n", 2 + } + return execCommand(ctx, 20*time.Second, "ping", "-c", "4", target) + case "traceroute": + target := commandTarget(args, "1.1.1.1") + if !safeHostName(target) { + return "target is invalid\n", 2 + } + return execCommand(ctx, 45*time.Second, "traceroute", target) + case "route_show": + return execCommand(ctx, 10*time.Second, "ip", "route", "show") + case "interfaces_show": + return execCommand(ctx, 10*time.Second, "ip", "-o", "addr", "show") + case "pkg_list_installed", "opkg_list_installed": + return runPackageCommand(ctx, "list_installed") + default: + return fmt.Sprintf("go agent preview does not implement command %q yet; shell agent remains the production command runner\n", cmd.Type), 2 + } +} + +func commandTarget(args map[string]string, fallback string) string { + target := strings.TrimSpace(args["target"]) + if target == "" { + return fallback + } + return target +} + +func safeHostName(value string) bool { + if value == "" || len(value) > 255 { + return false + } + for _, r := range value { + if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') { + continue + } + switch r { + case '.', '-', '_', ':', '[', ']': + continue + default: + return false + } + } + return true +} + +func runPackageCommand(ctx context.Context, action string) (string, int) { + pm := packageManager() + switch pm + ":" + action { + case "apk:list_installed": + return execCommand(ctx, 30*time.Second, "apk", "list", "-I") + case "opkg:list_installed": + return execCommand(ctx, 30*time.Second, "opkg", "list-installed") + default: + return "no supported package manager found\n", 2 + } +} + +func packageManager() string { + if _, err := exec.LookPath("apk"); err == nil { + return "apk" + } + if _, err := exec.LookPath("opkg"); err == nil { + return "opkg" + } + return "none" +} + +func execCommand(ctx context.Context, timeout time.Duration, name string, args ...string) (string, int) { + cmdCtx, cancel := context.WithTimeout(ctx, timeout) + defer cancel() + cmd := exec.CommandContext(cmdCtx, name, args...) + data, err := cmd.CombinedOutput() + output := string(data) + if cmdCtx.Err() == context.DeadlineExceeded { + return output + "\ncommand timed out\n", 124 + } + if err == nil { + return output, 0 + } + var exitErr *exec.ExitError + if errors.As(err, &exitErr) { + return output, exitErr.ExitCode() + } + if output != "" { + return output, 1 + } + return err.Error() + "\n", 1 +} + +func redactSensitiveOutput(output string) string { + words := []string{"private_key", "password", "passwd", "secret", "psk", "token"} + lines := strings.Split(output, "\n") + for i, line := range lines { + lower := strings.ToLower(line) + for _, word := range words { + if strings.Contains(lower, word) { + lines[i] = redactLine(line) + break + } + } + } + return strings.Join(lines, "\n") +} + +func redactLine(line string) string { + if idx := strings.Index(line, "="); idx >= 0 { + return line[:idx+1] + "[redacted]" + } + return "[redacted]" +} + func sendCommandResult(ctx context.Context, client *http.Client, cfg config, commandID string, body any) error { status, err := postJSONStatus(ctx, client, cfg.ServerURL+"/api/agent/commands/"+commandID+"/result", cfg.DeviceToken, body) if err != nil {