diff --git a/agent/go/cmd/rmm-agent/main.go b/agent/go/cmd/rmm-agent/main.go index 6813953..73782a9 100644 --- a/agent/go/cmd/rmm-agent/main.go +++ b/agent/go/cmd/rmm-agent/main.go @@ -18,6 +18,7 @@ import ( "os/exec" "os/signal" "path/filepath" + "regexp" "strconv" "strings" "sync" @@ -25,7 +26,9 @@ import ( "time" ) -const agentVersion = "0.6.8" +const agentVersion = "0.6.9" + +var stableVersionPattern = regexp.MustCompile(`^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$`) type agentRuntimeHealth struct { StartedAt time.Time @@ -339,6 +342,10 @@ func buildInventory(cfg config) map[string]any { "openwrt_version": openwrtVersion(), "agent_version": agentVersion, "agent_runtime": "go", + "agent_package": "rmm-agent-go-production", + "package_manager": packageManager(), + "openwrt_release": openwrtRelease(), + "target": openwrtTarget(), "board": jsonObjectOrEmpty(commandOutput("ubus", "call", "system", "board")), "interfaces": interfaces(), "default_route": firstLine(commandOutput("ip", "route", "show", "default")), @@ -350,6 +357,23 @@ func buildInventory(cfg config) map[string]any { } } +func openwrtRelease() string { + values := parseShellConfig(readFileString("/etc/openwrt_release")) + return strings.TrimSpace(values["DISTRIB_RELEASE"]) +} + +func openwrtTarget() string { + var board struct { + Release struct { + Target string `json:"target"` + } `json:"release"` + } + if json.Unmarshal([]byte(commandOutput("ubus", "call", "system", "board")), &board) != nil { + return "" + } + return strings.ReplaceAll(strings.TrimSpace(board.Release.Target), "/", "-") +} + func (cfg config) displayHostname() string { if strings.TrimSpace(cfg.HostnameOverride) != "" { return strings.TrimSpace(cfg.HostnameOverride) @@ -423,6 +447,10 @@ func serverCheckTarget(serverURL string) string { func processCommand(ctx context.Context, client *http.Client, cfg config, cmd command) { output, exitCode := runCommand(ctx, cfg, cmd) + resultDetails := commandMetadata() + if cmd.Type == "agent_update" || cmd.Type == "agent_rollback" { + output, exitCode, resultDetails = agentPackageOperation(ctx, cmd.Type, cmd.Args) + } status := "completed" if exitCode != 0 { status = "failed" @@ -433,7 +461,7 @@ func processCommand(ctx context.Context, client *http.Client, cfg config, cmd co "status": status, "exit_code": exitCode, "output": output, - "result": map[string]any{"agent_version": agentVersion, "agent_runtime": "go"}, + "result": resultDetails, } if err := sendCommandResult(ctx, client, cfg, cmd.ID, result); err != nil { logf("failed to send result for %s: %v", cmd.ID, err) @@ -441,6 +469,10 @@ func processCommand(ctx context.Context, client *http.Client, cfg config, cmd co logf("failed to spool result for %s: %v", cmd.ID, err) } } + if (cmd.Type == "agent_update" || cmd.Type == "agent_rollback") && exitCode == 0 { + // The result is durable before replacing this running binary with the newly installed package. + _, _ = execCommand(context.Background(), 30*time.Second, "/etc/init.d/rmm-agent", "restart") + } } func runCommand(ctx context.Context, cfg config, cmd command) (string, int) { @@ -454,13 +486,15 @@ func runCommand(ctx context.Context, cfg config, cmd command) (string, int) { if !safeHostName(target) { return "target is invalid\n", 2 } - return execCommand(ctx, 20*time.Second, "ping", "-c", "4", target) + output, code := execCommand(ctx, 20*time.Second, "ping", "-c", "4", target) + return output, code 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) + output, code := execCommand(ctx, 45*time.Second, "traceroute", target) + return output, code case "route_show": return execCommand(ctx, 10*time.Second, "ip", "route", "show") case "interfaces_show": @@ -491,6 +525,8 @@ func runCommand(ctx context.Context, cfg config, cmd command) (string, int) { return "package name is invalid\n", 2 } return runPackageCommand(ctx, "remove", packageName) + case "agent_update", "agent_rollback": + return "agent update is handled separately\n", 2 case "uci_show": config, ok := uciConfigArg(args) if !ok { @@ -549,6 +585,96 @@ func runCommand(ctx context.Context, cfg config, cmd command) (string, int) { } } +func commandMetadata() map[string]any { + return map[string]any{"agent_version": agentVersion, "agent_runtime": "go"} +} + +func agentPackageOperation(ctx context.Context, operation string, raw json.RawMessage) (string, int, map[string]any) { + result := commandMetadata() + result["operation"] = operation + args := map[string]string{} + if (operation != "agent_update" && operation != "agent_rollback") || json.Unmarshal(raw, &args) != nil || !validAgentPackageOperationArgs(args) || args["package"] != "rmm-agent-go-production" || !stableVersionPattern.MatchString(args["target_version"]) || !stableVersionPattern.MatchString(args["package_version"]) || !safeAgentFeedURL(args["feed_url"]) || (args["package_manager"] != "opkg" && args["package_manager"] != "apk") { + result["reason"] = "invalid package operation arguments" + return "agent package operation arguments are invalid\n", 2, result + } + if packageManager() != args["package_manager"] { + result["reason"] = "package manager does not match inventory" + return "agent package operation package manager does not match this router\n", 2, result + } + availableKB, ok := rootAvailableKB() + result["available_kb"] = availableKB + if !ok || availableKB < 8192 { + result["reason"] = "insufficient disk space" + return "agent package operation requires at least 8192 KiB free on /\n", 1, result + } + result["feed_url"] = args["feed_url"] + result["package"] = args["package"] + result["previous_version"] = agentVersion + result["target_version"] = args["target_version"] + result["package_version"] = args["package_version"] + if args["package_manager"] == "apk" { + apkArgs := []string{"add"} + if operation == "agent_rollback" { + apkArgs = append(apkArgs, "--allow-downgrade") + } else { + apkArgs = append(apkArgs, "--upgrade") + } + apkArgs = append(apkArgs, "--repository", args["feed_url"], args["package"]+"="+args["package_version"]) + output, code := execCommand(ctx, 2*time.Minute, "apk", apkArgs...) + return output, code, result + } + if err := configureManagedOpkgFeed(args["feed_url"]); err != nil { + result["reason"] = "configure managed feed" + return err.Error() + "\n", 1, result + } + updateOutput, updateCode := execCommand(ctx, 90*time.Second, "opkg", "update") + if updateCode != 0 { + return updateOutput, updateCode, result + } + installOutput, installCode := execCommand(ctx, 2*time.Minute, "opkg", "install", args["package"]+"="+args["package_version"]) + return updateOutput + installOutput, installCode, result +} + +func validAgentPackageOperationArgs(args map[string]string) bool { + for key := range args { + switch key { + case "package", "target_version", "package_version", "feed_url", "package_manager", "rollout_id", "channel": + default: + return false + } + } + return true +} + +func safeAgentFeedURL(raw string) bool { + u, err := url.Parse(strings.TrimSpace(raw)) + return err == nil && u.Scheme == "https" && u.Host != "" && u.RawQuery == "" && u.Fragment == "" && !strings.Contains(u.Path, "..") +} + +func rootAvailableKB() (int64, bool) { + lines := strings.Split(strings.TrimSpace(commandOutput("df", "-k", "/")), "\n") + if len(lines) < 2 { + return 0, false + } + fields := strings.Fields(lines[len(lines)-1]) + if len(fields) < 4 { + return 0, false + } + available, err := strconv.ParseInt(fields[3], 10, 64) + return available, err == nil +} + +func configureManagedOpkgFeed(feedURL string) error { + const path = "/etc/opkg/rmm-agent-managed.conf" + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + return err + } + if err := os.WriteFile(path+".tmp", []byte("src/gz rmm-agent-managed "+feedURL+"\n"), 0o644); err != nil { + return err + } + return os.Rename(path+".tmp", path) +} + func commandTarget(args map[string]string, fallback string) string { target := strings.TrimSpace(args["target"]) if target == "" { diff --git a/agent/go/cmd/rmm-agent/main_test.go b/agent/go/cmd/rmm-agent/main_test.go index e7e4c13..eecace5 100644 --- a/agent/go/cmd/rmm-agent/main_test.go +++ b/agent/go/cmd/rmm-agent/main_test.go @@ -7,16 +7,47 @@ import ( "os" "path/filepath" "strconv" + "strings" "testing" "time" ) func TestAgentVersionIsStable(t *testing.T) { - if agentVersion != "0.6.8" { + if agentVersion != "0.6.9" { t.Fatalf("unexpected agent version %q", agentVersion) } } +func TestSafeAgentFeedURL(t *testing.T) { + for _, value := range []string{"https://packages.example.test/stable/24.10.7/x86-64", "https://packages.example.test/feed"} { + if !safeAgentFeedURL(value) { + t.Fatalf("expected %q to be accepted", value) + } + } + for _, value := range []string{"http://packages.example.test/feed", "https://packages.example.test/feed?next=x", "https://packages.example.test/a/../feed", ""} { + if safeAgentFeedURL(value) { + t.Fatalf("expected %q to be rejected", value) + } + } +} + +func TestValidAgentPackageOperationArgs(t *testing.T) { + valid := map[string]string{"package": "rmm-agent-go-production", "target_version": "0.6.8", "package_version": "0.6.8-1", "feed_url": "https://packages.example.test/feed", "package_manager": "apk"} + if !validAgentPackageOperationArgs(valid) { + t.Fatal("expected exact package operation arguments to be accepted") + } + valid["unexpected"] = "value" + if validAgentPackageOperationArgs(valid) { + t.Fatal("unexpected package operation field was accepted") + } +} + +func TestOpenWrtTargetManifestFormat(t *testing.T) { + if strings.ReplaceAll("x86/64", "/", "-") != "x86-64" { + t.Fatal("OpenWrt target was not normalized") + } +} + func TestParseLocalInterfaceIPv4CandidatesPrefersLAN(t *testing.T) { output := `1: lo inet 127.0.0.1/8 scope host lo 2: eth0 inet 203.0.113.10/24 brd 203.0.113.255 scope global eth0 diff --git a/agent/package/rmm-agent-go-production/Makefile b/agent/package/rmm-agent-go-production/Makefile index fe63a98..64979e3 100644 --- a/agent/package/rmm-agent-go-production/Makefile +++ b/agent/package/rmm-agent-go-production/Makefile @@ -2,7 +2,7 @@ include $(TOPDIR)/rules.mk PKG_NAME:=rmm-agent-go-production -PKG_VERSION:=0.6.8 +PKG_VERSION:=0.6.9 PKG_RELEASE:=1 PKG_MAINTAINER:=RMM OpenWrt