package httpapi_test import ( "bytes" "context" "encoding/json" "io" "net/http" "net/http/cookiejar" "net/http/httptest" "net/url" "os" "path/filepath" "strconv" "strings" "testing" "rmm-openwrt/server/internal/httpapi" "rmm-openwrt/server/internal/model" "rmm-openwrt/server/internal/store" ) func TestAgentOperatorSmokeFlow(t *testing.T) { st, err := store.OpenSQLite(context.Background(), filepath.Join(t.TempDir(), "test.db")) if err != nil { t.Fatal(err) } defer st.Close() srv := httptest.NewServer(httpapi.NewHandler(st, httpapi.Config{ EnrollmentToken: "enroll-test", OperatorToken: "operator-test", })) defer srv.Close() var enrolled struct { DeviceID string `json:"device_id"` DeviceToken string `json:"device_token"` } requestJSON(t, http.MethodPost, srv.URL+"/api/agent/enroll", "", map[string]any{ "enrollment_token": "enroll-test", "hostname": "test-openwrt", "openwrt_version": "OpenWrt test", }, http.StatusCreated, &enrolled) if enrolled.DeviceID == "" || enrolled.DeviceToken == "" { t.Fatalf("expected enrollment credentials, got %#v", enrolled) } var heartbeat struct { Commands []any `json:"commands"` } requestJSON(t, http.MethodPost, srv.URL+"/api/agent/heartbeat", enrolled.DeviceToken, map[string]any{ "device_id": enrolled.DeviceID, "inventory": map[string]any{ "hostname": "test-openwrt", }, "metrics": map[string]any{ "loadavg": "0.00 0.01 0.02", }, }, http.StatusOK, &heartbeat) if len(heartbeat.Commands) != 0 { t.Fatalf("expected no commands, got %d", len(heartbeat.Commands)) } var history struct { Samples []struct { DeviceID string `json:"device_id"` } `json:"samples"` } requestJSON(t, http.MethodGet, srv.URL+"/api/devices/"+enrolled.DeviceID+"/metrics-history", "operator-test", nil, http.StatusOK, &history) if len(history.Samples) != 1 || history.Samples[0].DeviceID != enrolled.DeviceID { t.Fatalf("unexpected metrics history: %#v", history) } var alerts struct { Alerts []struct { ID string `json:"id"` Type string `json:"type"` Severity string `json:"severity"` Status string `json:"status"` } `json:"alerts"` } requestJSON(t, http.MethodGet, srv.URL+"/api/devices/"+enrolled.DeviceID+"/alerts", "operator-test", nil, http.StatusOK, &alerts) if len(alerts.Alerts) != 0 { t.Fatalf("expected no active alerts, got %#v", alerts.Alerts) } var fleetDevice struct { ID string `json:"id"` Group string `json:"group"` Tags []string `json:"tags"` } requestJSON(t, http.MethodPatch, srv.URL+"/api/devices/"+enrolled.DeviceID+"/fleet", "operator-test", map[string]any{ "group": "lab", "tags": []string{"edge", "vpn", "edge"}, }, http.StatusOK, &fleetDevice) if fleetDevice.ID != enrolled.DeviceID || fleetDevice.Group != "lab" || len(fleetDevice.Tags) != 2 { t.Fatalf("unexpected fleet metadata: %#v", fleetDevice) } requestJSON(t, http.MethodGet, srv.URL+"/api/devices", "", nil, http.StatusUnauthorized, nil) var created struct { ID string `json:"id"` Type string `json:"type"` Args json.RawMessage `json:"args"` Status string `json:"status"` } requestJSON(t, http.MethodPost, srv.URL+"/api/devices/"+enrolled.DeviceID+"/commands", "operator-test", map[string]any{ "type": "ping", "args": map[string]any{"target": "1.1.1.1"}, }, http.StatusCreated, &created) if created.ID == "" || created.Type != "ping" || created.Status != "queued" { t.Fatalf("unexpected created command: %#v", created) } if string(created.Args) != `{"target":"1.1.1.1"}` { t.Fatalf("expected compact args, got %s", created.Args) } nextReq := map[string]any{"device_id": enrolled.DeviceID} nextLine := requestText(t, http.MethodPost, srv.URL+"/api/agent/commands/next", enrolled.DeviceToken, nextReq, http.StatusOK) expectedPrefix := created.ID + "\tping\t" if !strings.HasPrefix(nextLine, expectedPrefix) { t.Fatalf("expected next command prefix %q, got %q", expectedPrefix, nextLine) } if !strings.Contains(nextLine, `{"target":"1.1.1.1"}`) { t.Fatalf("expected compact next command args, got %q", nextLine) } if err := st.ExpireClaimedCommands(context.Background(), 0); err != nil { t.Fatal(err) } nextLine = requestText(t, http.MethodPost, srv.URL+"/api/agent/commands/next", enrolled.DeviceToken, nextReq, http.StatusOK) if !strings.HasPrefix(nextLine, expectedPrefix) { t.Fatalf("expected retried command prefix %q, got %q", expectedPrefix, nextLine) } var result struct { Status string `json:"status"` } requestJSON(t, http.MethodPost, srv.URL+"/api/agent/commands/"+created.ID+"/result", enrolled.DeviceToken, map[string]any{ "device_id": enrolled.DeviceID, "status": "completed", "exit_code": 0, "output": "network.wg0.private_key='super-secret'\nnetwork.lan.ipaddr='10.0.0.1'", "result": map[string]any{}, }, http.StatusOK, &result) if result.Status != "ok" { t.Fatalf("unexpected result status: %q", result.Status) } var commandDetail struct { ID string `json:"id"` Status string `json:"status"` Output string `json:"output"` AttemptCount int `json:"attempt_count"` } requestJSON(t, http.MethodGet, srv.URL+"/api/devices/"+enrolled.DeviceID+"/commands/"+created.ID, "operator-test", nil, http.StatusOK, &commandDetail) if commandDetail.ID != created.ID || commandDetail.Status != "completed" || commandDetail.AttemptCount != 2 { t.Fatalf("unexpected command detail: %#v", commandDetail) } if !strings.Contains(commandDetail.Output, "private_key='[redacted]'") || strings.Contains(commandDetail.Output, "super-secret") { t.Fatalf("expected redacted command output, got %q", commandDetail.Output) } var expiring struct { ID string `json:"id"` Status string `json:"status"` } requestJSON(t, http.MethodPost, srv.URL+"/api/devices/"+enrolled.DeviceID+"/commands", "operator-test", map[string]any{ "type": "opkg_list_installed", "args": map[string]any{}, }, http.StatusCreated, &expiring) for i := 0; i < 3; i++ { nextLine = requestText(t, http.MethodPost, srv.URL+"/api/agent/commands/next", enrolled.DeviceToken, nextReq, http.StatusOK) if !strings.HasPrefix(nextLine, expiring.ID+"\topkg_list_installed\t") { t.Fatalf("expected expiring command attempt %d, got %q", i+1, nextLine) } if err := st.ExpireClaimedCommands(context.Background(), 0); err != nil { t.Fatal(err) } } requestText(t, http.MethodPost, srv.URL+"/api/agent/commands/next", enrolled.DeviceToken, nextReq, http.StatusNoContent) requestJSON(t, http.MethodGet, srv.URL+"/api/devices/"+enrolled.DeviceID+"/commands/"+expiring.ID, "operator-test", nil, http.StatusOK, &expiring) if expiring.Status != "expired" { t.Fatalf("expected expired command, got %#v", expiring) } requestJSON(t, http.MethodGet, srv.URL+"/api/devices/"+enrolled.DeviceID+"/alerts", "operator-test", nil, http.StatusOK, &alerts) foundCommandAlert := false commandAlertID := "" for _, alert := range alerts.Alerts { if alert.Type == "command_attention" && alert.Severity == "warning" { foundCommandAlert = true commandAlertID = alert.ID } } if !foundCommandAlert { t.Fatalf("expected command attention alert, got %#v", alerts.Alerts) } var acknowledgedAlert struct { ID string `json:"id"` Status string `json:"status"` } requestJSON(t, http.MethodPost, srv.URL+"/api/devices/"+enrolled.DeviceID+"/alerts/"+commandAlertID+"/acknowledge", "operator-test", nil, http.StatusOK, &acknowledgedAlert) if acknowledgedAlert.ID != commandAlertID || acknowledgedAlert.Status != "acknowledged" { t.Fatalf("unexpected acknowledged alert: %#v", acknowledgedAlert) } var commandHistory struct { Commands []struct { ID string `json:"id"` Status string `json:"status"` } `json:"commands"` } requestJSON(t, http.MethodGet, srv.URL+"/api/devices/"+enrolled.DeviceID+"/commands", "operator-test", nil, http.StatusOK, &commandHistory) foundCreated := false for _, c := range commandHistory.Commands { if c.ID == created.ID && c.Status == "completed" { foundCreated = true } } if !foundCreated { t.Fatalf("unexpected command history: %#v", commandHistory) } var cancellable struct { ID string `json:"id"` Status string `json:"status"` } requestJSON(t, http.MethodPost, srv.URL+"/api/devices/"+enrolled.DeviceID+"/commands", "operator-test", map[string]any{ "type": "traceroute", "args": map[string]any{"target": "1.1.1.1"}, }, http.StatusCreated, &cancellable) requestJSON(t, http.MethodPost, srv.URL+"/api/devices/"+enrolled.DeviceID+"/commands/"+cancellable.ID+"/cancel", "operator-test", nil, http.StatusOK, &cancellable) if cancellable.Status != "cancelled" { t.Fatalf("expected cancelled command, got %#v", cancellable) } var packageCommand struct { ID string `json:"id"` Type string `json:"type"` Status string `json:"status"` } requestJSON(t, http.MethodPost, srv.URL+"/api/devices/"+enrolled.DeviceID+"/commands", "operator-test", map[string]any{ "type": "pkg_list_upgradable", "args": map[string]any{}, }, http.StatusCreated, &packageCommand) if packageCommand.Type != "pkg_list_upgradable" || packageCommand.Status != "queued" { t.Fatalf("unexpected package command: %#v", packageCommand) } var bulk struct { Commands []struct { ID string `json:"id"` Type string `json:"type"` Status string `json:"status"` } `json:"commands"` } requestJSON(t, http.MethodPost, srv.URL+"/api/devices/bulk-commands", "operator-test", map[string]any{ "device_ids": []string{enrolled.DeviceID, enrolled.DeviceID}, "type": "ping", "args": map[string]any{"target": "1.1.1.1"}, }, http.StatusCreated, &bulk) if len(bulk.Commands) != 1 || bulk.Commands[0].Type != "ping" || bulk.Commands[0].Status != "queued" { t.Fatalf("unexpected bulk commands: %#v", bulk.Commands) } var uciCommand struct { ID string `json:"id"` Type string `json:"type"` Status string `json:"status"` Args json.RawMessage `json:"args"` } requestJSON(t, http.MethodPost, srv.URL+"/api/devices/"+enrolled.DeviceID+"/commands", "operator-test", map[string]any{ "type": "uci_show", "args": map[string]any{"config": "network"}, }, http.StatusCreated, &uciCommand) if uciCommand.Type != "uci_show" || uciCommand.Status != "queued" { t.Fatalf("unexpected uci command: %#v", uciCommand) } requestJSON(t, http.MethodPost, srv.URL+"/api/devices/"+enrolled.DeviceID+"/commands", "operator-test", map[string]any{ "type": "uci_backup", "args": map[string]any{"config": "network"}, }, http.StatusCreated, &uciCommand) if uciCommand.Type != "uci_backup" || uciCommand.Status != "queued" { t.Fatalf("unexpected uci backup command: %#v", uciCommand) } requestJSON(t, http.MethodPost, srv.URL+"/api/devices/"+enrolled.DeviceID+"/commands", "operator-test", map[string]any{ "type": "uci_preview", "args": map[string]any{"config": "network", "section": "lan", "option": "ipaddr", "value": "10.10.10.1/24"}, }, http.StatusCreated, &uciCommand) if uciCommand.Type != "uci_preview" || uciCommand.Status != "queued" { t.Fatalf("unexpected uci preview command: %#v", uciCommand) } for _, commandType := range []string{"uci_commit_confirmed", "uci_restore"} { requestJSON(t, http.MethodPost, srv.URL+"/api/devices/"+enrolled.DeviceID+"/commands", "operator-test", map[string]any{ "type": commandType, "args": map[string]any{"config": "network"}, }, http.StatusCreated, &uciCommand) if uciCommand.Type != commandType || uciCommand.Status != "queued" { t.Fatalf("unexpected %s command: %#v", commandType, uciCommand) } } var remoteSession struct { ID string `json:"id"` Status string `json:"status"` CommandID string `json:"command_id"` ServerHost string `json:"server_host"` ServerPort int `json:"server_port"` RemotePort int `json:"remote_port"` LuCIPort int `json:"luci_port"` LocalPort int `json:"local_port"` } requestJSON(t, http.MethodPost, srv.URL+"/api/devices/"+enrolled.DeviceID+"/remote-sessions", "operator-test", map[string]any{ "target": "ssh", "server_host": "10.10.10.2", "server_port": 22, "remote_port": 22022, "local_port": 22, "luci_scheme": "https", "duration_seconds": 900, }, http.StatusCreated, &remoteSession) if remoteSession.ID == "" || remoteSession.Status != "queued" || remoteSession.CommandID == "" || remoteSession.RemotePort != 22022 || remoteSession.LuCIPort < 22100 || remoteSession.LuCIPort > 22199 { t.Fatalf("unexpected remote session: %#v", remoteSession) } var remoteSessions struct { Sessions []struct { ID string `json:"id"` CommandID string `json:"command_id"` Status string `json:"status"` } `json:"remote_sessions"` } requestJSON(t, http.MethodGet, srv.URL+"/api/devices/"+enrolled.DeviceID+"/remote-sessions", "operator-test", nil, http.StatusOK, &remoteSessions) if len(remoteSessions.Sessions) != 1 || remoteSessions.Sessions[0].ID != remoteSession.ID || remoteSessions.Sessions[0].CommandID == "" { t.Fatalf("unexpected remote sessions: %#v", remoteSessions.Sessions) } requestJSON(t, http.MethodGet, srv.URL+"/api/devices/"+enrolled.DeviceID+"/commands/"+remoteSession.CommandID, "operator-test", nil, http.StatusOK, &uciCommand) if uciCommand.Type != "remote_ssh_reverse" || uciCommand.Status != "queued" { t.Fatalf("unexpected remote command: %#v", uciCommand) } if !strings.Contains(string(uciCommand.Args), `"luci_local_port":"443"`) || !strings.Contains(string(uciCommand.Args), `"luci_port":"`) { t.Fatalf("remote command does not include LuCI HTTPS forward: %s", uciCommand.Args) } requestJSON(t, http.MethodPost, srv.URL+"/api/agent/commands/"+remoteSession.CommandID+"/result", enrolled.DeviceToken, map[string]any{ "device_id": enrolled.DeviceID, "status": "completed", "exit_code": 0, "output": "remote ssh reverse started", "result": map[string]any{}, }, http.StatusOK, &result) requestJSON(t, http.MethodGet, srv.URL+"/api/devices/"+enrolled.DeviceID+"/remote-sessions", "operator-test", nil, http.StatusOK, &remoteSessions) if len(remoteSessions.Sessions) != 1 || remoteSessions.Sessions[0].Status != "active" { t.Fatalf("expected active remote session, got %#v", remoteSessions.Sessions) } requestJSON(t, http.MethodPost, srv.URL+"/api/devices/"+enrolled.DeviceID+"/remote-sessions/"+remoteSession.ID+"/close", "operator-test", nil, http.StatusOK, &remoteSession) if remoteSession.Status != "closed" { t.Fatalf("expected closed remote session, got %#v", remoteSession) } var commandsAfterClose struct { Commands []struct { Type string `json:"type"` Args json.RawMessage `json:"args"` } `json:"commands"` } requestJSON(t, http.MethodGet, srv.URL+"/api/devices/"+enrolled.DeviceID+"/commands", "operator-test", nil, http.StatusOK, &commandsAfterClose) foundRemoteClose := false for _, command := range commandsAfterClose.Commands { if command.Type == "remote_ssh_close" && strings.Contains(string(command.Args), remoteSession.ID) { foundRemoteClose = true } } if !foundRemoteClose { t.Fatalf("expected remote_ssh_close command, got %#v", commandsAfterClose.Commands) } var devices struct { Devices []struct { ID string `json:"id"` Online bool `json:"online"` ActiveAlerts int `json:"active_alerts"` } `json:"devices"` } requestJSON(t, http.MethodGet, srv.URL+"/api/devices", "operator-test", nil, http.StatusOK, &devices) if len(devices.Devices) != 1 { t.Fatalf("expected one device, got %d", len(devices.Devices)) } if devices.Devices[0].ID != enrolled.DeviceID || !devices.Devices[0].Online { t.Fatalf("unexpected device list: %#v", devices.Devices) } if devices.Devices[0].ActiveAlerts < 1 { t.Fatalf("expected open alert count, got %#v", devices.Devices) } var audit struct { Events []struct { Action string `json:"action"` DeviceID string `json:"device_id"` CommandID string `json:"command_id"` } `json:"audit_events"` } requestJSON(t, http.MethodGet, srv.URL+"/api/audit-events?device_id="+enrolled.DeviceID, "operator-test", nil, http.StatusOK, &audit) if len(audit.Events) < 2 { t.Fatalf("expected audit events, got %#v", audit.Events) } } func TestLuCIProxyRequiresActiveSessionAndRewritesPaths(t *testing.T) { upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if strings.Contains(r.Header.Get("Cookie"), "rmm_operator_session=") { t.Fatal("operator session cookie leaked to LuCI upstream") } w.Header().Set("Content-Type", "text/html") _, _ = io.WriteString(w, `LuCI`) })) defer upstream.Close() upstreamURL, err := url.Parse(upstream.URL) if err != nil { t.Fatal(err) } _, portText, _ := strings.Cut(upstreamURL.Host, ":") port, err := strconv.Atoi(portText) if err != nil { t.Fatal(err) } st, err := store.OpenSQLite(context.Background(), filepath.Join(t.TempDir(), "luci.db")) if err != nil { t.Fatal(err) } defer st.Close() enrolled, err := st.EnrollDevice(context.Background(), "router", "OpenWrt") if err != nil { t.Fatal(err) } session, _, err := st.CreateRemoteSession(context.Background(), model.RemoteSession{ DeviceID: enrolled.DeviceID, Target: "ssh", Status: "active", ServerHost: "tunnel", ServerPort: 2222, RemotePort: 22000, LuCIPort: port, }) if err != nil { t.Fatal(err) } srv := httptest.NewServer(httpapi.NewHandler(st, httpapi.Config{ OperatorToken: "operator-test", TunnelHTTPHost: "127.0.0.1", })) defer srv.Close() body := requestText(t, http.MethodGet, srv.URL+"/luci/"+enrolled.DeviceID+"/"+session.ID+"/", "operator-test", nil, http.StatusOK) prefix := "/luci/" + enrolled.DeviceID + "/" + session.ID if !strings.Contains(body, `href="`+prefix+`/cgi-bin/luci/admin"`) || !strings.Contains(body, `href="`+prefix+`/luci-static/test.css"`) { t.Fatalf("LuCI paths were not rewritten: %s", body) } } func TestServesStaticWebUI(t *testing.T) { dir := t.TempDir() if err := os.WriteFile(filepath.Join(dir, "index.html"), []byte("