diff --git a/agent/README.md b/agent/README.md index ea6193f..ce28f71 100644 --- a/agent/README.md +++ b/agent/README.md @@ -48,14 +48,16 @@ The Go agent lives at: 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. +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. Its inventory payload includes system metadata, interfaces, routes, WAN IP, DHCP leases, Wi-Fi clients, memory, disk, interface counters, package manager metadata, and connectivity checks. -The Go agent is not the production command runner yet. It currently supports the first read-only command set: +The Go agent is not packaged as the production OpenWrt agent yet, but it supports the migrated command allowlist: - `ping` - `traceroute` - `route_show` - `interfaces_show` +- `reboot` +- `service_restart` - `pkg_list_installed` / `opkg_list_installed` - `pkg_update` / `opkg_update` - `pkg_list_upgradable` / `opkg_list_upgradable` @@ -72,7 +74,7 @@ The Go agent is not the production command runner yet. It currently supports the - `remote_ssh_reverse` - `remote_ssh_close` -Other queued commands are reported as failed with a clear message until the shell allowlist is migrated command by command. +Other queued commands are reported as failed with a clear message. Build locally: diff --git a/agent/go/cmd/rmm-agent/main.go b/agent/go/cmd/rmm-agent/main.go index 731bbca..bd3fa92 100644 --- a/agent/go/cmd/rmm-agent/main.go +++ b/agent/go/cmd/rmm-agent/main.go @@ -289,7 +289,7 @@ func buildInventory(cfg config) map[string]any { "default_route": firstLine(commandOutput("ip", "route", "show", "default")), "wan_ip": wanIP(), "dhcp_leases": dhcpLeases(), - "wifi_clients": []any{}, + "wifi_clients": wifiClients(), } } @@ -356,6 +356,14 @@ func runCommand(ctx context.Context, cfg config, cmd command) (string, int) { return execCommand(ctx, 10*time.Second, "ip", "route", "show") case "interfaces_show": return execCommand(ctx, 10*time.Second, "ip", "-o", "addr", "show") + case "reboot": + return scheduleReboot() + case "service_restart": + service := strings.TrimSpace(args["service"]) + if !safeServiceName(service) { + return "service is not allowlisted\n", 2 + } + return execCommand(ctx, 30*time.Second, "/etc/init.d/"+service, "restart") case "pkg_list_installed", "opkg_list_installed": return runPackageCommand(ctx, "list_installed") case "pkg_update", "opkg_update": @@ -539,6 +547,15 @@ func safePackageName(value string) bool { return true } +func safeServiceName(value string) bool { + switch value { + case "network", "firewall", "dnsmasq", "dropbear", "uhttpd": + return true + default: + return false + } +} + type uciTarget struct { Config string Section string @@ -1068,6 +1085,17 @@ func execCommandWithInput(ctx context.Context, timeout time.Duration, input, nam return err.Error() + "\n", 1 } +func scheduleReboot() (string, int) { + cmd := exec.Command("sh", "-c", "sleep 2; reboot") + if err := cmd.Start(); err != nil { + return err.Error() + "\n", 1 + } + go func() { + _ = cmd.Wait() + }() + return "reboot scheduled\n", 0 +} + func redactSensitiveOutput(output string) string { words := []string{"private_key", "password", "passwd", "secret", "psk", "token"} lines := strings.Split(output, "\n") @@ -1277,6 +1305,104 @@ func dhcpLeases() []map[string]string { return leases } +func wifiClients() []map[string]string { + if _, err := exec.LookPath("iwinfo"); err != nil { + return []map[string]string{} + } + var clients []map[string]string + for _, iface := range iwinfoInterfaces() { + assoc := commandOutput("iwinfo", iface, "assoclist") + clients = append(clients, parseIwinfoAssocList(iface, assoc)...) + } + if clients == nil { + return []map[string]string{} + } + return clients +} + +func iwinfoInterfaces() []string { + output := commandOutput("iwinfo") + var interfaces []string + seen := map[string]bool{} + for _, line := range strings.Split(output, "\n") { + if strings.TrimSpace(line) == "" { + continue + } + if strings.HasPrefix(line, " ") || strings.HasPrefix(line, "\t") { + continue + } + fields := strings.Fields(line) + if len(fields) == 0 { + continue + } + iface := fields[0] + if !seen[iface] { + interfaces = append(interfaces, iface) + seen[iface] = true + } + } + return interfaces +} + +func parseIwinfoAssocList(iface, output string) []map[string]string { + var clients []map[string]string + var current map[string]string + flush := func() { + if current != nil && current["mac"] != "" { + clients = append(clients, current) + } + } + for _, line := range strings.Split(output, "\n") { + trimmed := strings.TrimSpace(line) + if trimmed == "" { + continue + } + fields := strings.Fields(trimmed) + if len(fields) > 0 && looksLikeMAC(fields[0]) { + flush() + current = map[string]string{"interface": iface, "mac": strings.TrimSuffix(fields[0], ",")} + if len(fields) > 1 { + current["signal_dbm"] = strings.TrimSuffix(fields[1], ",") + } + continue + } + if current == nil { + continue + } + switch { + case strings.HasPrefix(trimmed, "RX:"): + current["rx_rate"] = strings.TrimSpace(strings.TrimPrefix(trimmed, "RX:")) + case strings.HasPrefix(trimmed, "TX:"): + current["tx_rate"] = strings.TrimSpace(strings.TrimPrefix(trimmed, "TX:")) + } + } + flush() + if clients == nil { + return []map[string]string{} + } + return clients +} + +func looksLikeMAC(value string) bool { + value = strings.TrimSuffix(value, ",") + if len(value) != 17 { + return false + } + for i, r := range value { + if i%3 == 2 { + if r != ':' { + return false + } + continue + } + if (r >= '0' && r <= '9') || (r >= 'a' && r <= 'f') || (r >= 'A' && r <= 'F') { + continue + } + return false + } + return true +} + func memoryInfo() map[string]int64 { values := map[string]int64{} for _, line := range strings.Split(readFileString("/proc/meminfo"), "\n") { diff --git a/agent/go/cmd/rmm-agent/main_test.go b/agent/go/cmd/rmm-agent/main_test.go new file mode 100644 index 0000000..c23e5eb --- /dev/null +++ b/agent/go/cmd/rmm-agent/main_test.go @@ -0,0 +1,49 @@ +package main + +import "testing" + +func TestParseIwinfoAssocList(t *testing.T) { + output := `AA:BB:CC:DD:EE:FF -49 dBm / -95 dBm (SNR 46) 210 ms ago + RX: 72.2 MBit/s, MCS 7, 20MHz + TX: 135.0 MBit/s, MCS 6, 40MHz + +11:22:33:44:55:66 -62 dBm / -95 dBm (SNR 33) 30 ms ago + RX: 6.0 MBit/s + TX: 54.0 MBit/s` + + clients := parseIwinfoAssocList("phy0-ap0", output) + if len(clients) != 2 { + t.Fatalf("expected 2 clients, got %d", len(clients)) + } + if clients[0]["interface"] != "phy0-ap0" { + t.Fatalf("unexpected interface: %q", clients[0]["interface"]) + } + if clients[0]["mac"] != "AA:BB:CC:DD:EE:FF" { + t.Fatalf("unexpected mac: %q", clients[0]["mac"]) + } + if clients[0]["signal_dbm"] != "-49" { + t.Fatalf("unexpected signal: %q", clients[0]["signal_dbm"]) + } + if clients[0]["rx_rate"] == "" || clients[0]["tx_rate"] == "" { + t.Fatalf("expected RX/TX rates, got %#v", clients[0]) + } + if clients[1]["mac"] != "11:22:33:44:55:66" { + t.Fatalf("unexpected second mac: %q", clients[1]["mac"]) + } +} + +func TestLooksLikeMAC(t *testing.T) { + valid := []string{"aa:bb:cc:dd:ee:ff", "AA:BB:CC:DD:EE:FF", "11:22:33:44:55:66,"} + for _, value := range valid { + if !looksLikeMAC(value) { + t.Fatalf("expected %q to be a MAC", value) + } + } + + invalid := []string{"", "aa:bb:cc:dd:ee", "aa-bb-cc-dd-ee-ff", "not-a-mac-address"} + for _, value := range invalid { + if looksLikeMAC(value) { + t.Fatalf("expected %q to be invalid", value) + } + } +}