Initial OpenWrt RMM implementation
This commit is contained in:
@@ -0,0 +1,138 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"crypto/hmac"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"crypto/subtle"
|
||||
"encoding/base64"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
operatorSessionCookie = "rmm_operator_session"
|
||||
operatorSessionTTL = 12 * time.Hour
|
||||
)
|
||||
|
||||
func (a *App) handleLogin(w http.ResponseWriter, r *http.Request) {
|
||||
var req loginRequest
|
||||
if !decodeJSON(w, r, &req) {
|
||||
return
|
||||
}
|
||||
if !constantTimeEqual(strings.TrimSpace(req.Username), a.operatorUsername) || !constantTimeEqual(req.Password, a.operatorPassword) {
|
||||
time.Sleep(250 * time.Millisecond)
|
||||
writeError(w, http.StatusUnauthorized, "invalid username or password")
|
||||
return
|
||||
}
|
||||
token, expiresAt, err := a.newOperatorSession(req.Username)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "failed to create session")
|
||||
return
|
||||
}
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: operatorSessionCookie,
|
||||
Value: token,
|
||||
Path: "/",
|
||||
HttpOnly: true,
|
||||
Secure: a.cookieSecure,
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
Expires: expiresAt,
|
||||
MaxAge: int(operatorSessionTTL.Seconds()),
|
||||
})
|
||||
_, _ = a.store.AddAuditEvent(r.Context(), req.Username, "auth.login", "", "", mustJSON(map[string]string{
|
||||
"request_id": requestID(r.Context()),
|
||||
}))
|
||||
writeJSON(w, http.StatusOK, map[string]any{"username": req.Username, "expires_at": expiresAt})
|
||||
}
|
||||
|
||||
func (a *App) handleLogout(w http.ResponseWriter, r *http.Request) {
|
||||
username, _ := a.operatorSessionUsername(r)
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: operatorSessionCookie,
|
||||
Value: "",
|
||||
Path: "/",
|
||||
HttpOnly: true,
|
||||
Secure: a.cookieSecure,
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
MaxAge: -1,
|
||||
Expires: time.Unix(0, 0),
|
||||
})
|
||||
if username != "" {
|
||||
_, _ = a.store.AddAuditEvent(r.Context(), username, "auth.logout", "", "", mustJSON(map[string]string{
|
||||
"request_id": requestID(r.Context()),
|
||||
}))
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
|
||||
}
|
||||
|
||||
func (a *App) handleAuthMe(w http.ResponseWriter, r *http.Request) {
|
||||
username, ok := a.operatorSessionUsername(r)
|
||||
if !ok {
|
||||
username = a.operatorUsername
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]string{"username": username})
|
||||
}
|
||||
|
||||
func (a *App) newOperatorSession(username string) (string, time.Time, error) {
|
||||
var nonce [16]byte
|
||||
if _, err := rand.Read(nonce[:]); err != nil {
|
||||
return "", time.Time{}, err
|
||||
}
|
||||
expiresAt := time.Now().UTC().Add(operatorSessionTTL)
|
||||
payload := strings.Join([]string{
|
||||
username,
|
||||
strconv.FormatInt(expiresAt.Unix(), 10),
|
||||
base64.RawURLEncoding.EncodeToString(nonce[:]),
|
||||
}, "|")
|
||||
encodedPayload := base64.RawURLEncoding.EncodeToString([]byte(payload))
|
||||
signature := a.signSessionPayload(encodedPayload)
|
||||
return encodedPayload + "." + signature, expiresAt, nil
|
||||
}
|
||||
|
||||
func (a *App) operatorSessionUsername(r *http.Request) (string, bool) {
|
||||
cookie, err := r.Cookie(operatorSessionCookie)
|
||||
if err != nil || cookie.Value == "" {
|
||||
return "", false
|
||||
}
|
||||
encodedPayload, signature, ok := strings.Cut(cookie.Value, ".")
|
||||
if !ok || !hmac.Equal([]byte(signature), []byte(a.signSessionPayload(encodedPayload))) {
|
||||
return "", false
|
||||
}
|
||||
payloadBytes, err := base64.RawURLEncoding.DecodeString(encodedPayload)
|
||||
if err != nil {
|
||||
return "", false
|
||||
}
|
||||
parts := strings.Split(string(payloadBytes), "|")
|
||||
if len(parts) != 3 || !constantTimeEqual(parts[0], a.operatorUsername) {
|
||||
return "", false
|
||||
}
|
||||
expiresUnix, err := strconv.ParseInt(parts[1], 10, 64)
|
||||
if err != nil || time.Now().UTC().Unix() >= expiresUnix {
|
||||
return "", false
|
||||
}
|
||||
return parts[0], true
|
||||
}
|
||||
|
||||
func (a *App) signSessionPayload(payload string) string {
|
||||
mac := hmac.New(sha256.New, a.sessionSecret)
|
||||
_, _ = mac.Write([]byte(payload))
|
||||
return base64.RawURLEncoding.EncodeToString(mac.Sum(nil))
|
||||
}
|
||||
|
||||
func constantTimeEqual(left, right string) bool {
|
||||
if len(left) != len(right) {
|
||||
return false
|
||||
}
|
||||
return subtle.ConstantTimeCompare([]byte(left), []byte(right)) == 1
|
||||
}
|
||||
|
||||
func (a *App) operatorAuthorized(r *http.Request) bool {
|
||||
if token, ok := bearerToken(r); ok && constantTimeEqual(token, a.operatorToken) {
|
||||
return true
|
||||
}
|
||||
_, ok := a.operatorSessionUsername(r)
|
||||
return ok
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,571 @@
|
||||
package httpapi_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/cookiejar"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"rmm-openwrt/server/internal/httpapi"
|
||||
"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"`
|
||||
}
|
||||
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"`
|
||||
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,
|
||||
"duration_seconds": 900,
|
||||
}, http.StatusCreated, &remoteSession)
|
||||
if remoteSession.ID == "" || remoteSession.Status != "queued" || remoteSession.CommandID == "" || remoteSession.RemotePort != 22022 {
|
||||
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)
|
||||
}
|
||||
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 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 TestServesStaticWebUI(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(dir, "index.html"), []byte("<!doctype html><title>RMM UI</title>"), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
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",
|
||||
StaticDir: dir,
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
resp, err := http.Get(srv.URL + "/")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
data, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK || !strings.Contains(string(data), "RMM UI") {
|
||||
t.Fatalf("unexpected static response %d: %s", resp.StatusCode, data)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOperatorCookieAuth(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",
|
||||
OperatorUsername: "admin",
|
||||
OperatorPassword: "correct-horse-battery-staple",
|
||||
SessionSecret: "test-session-secret-with-enough-entropy",
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
jar, err := cookiejar.New(nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
client := &http.Client{Jar: jar}
|
||||
|
||||
authRequestJSON(t, client, http.MethodGet, srv.URL+"/api/devices", nil, http.StatusUnauthorized, nil)
|
||||
authRequestJSON(t, client, http.MethodPost, srv.URL+"/api/auth/login", map[string]any{
|
||||
"username": "admin",
|
||||
"password": "wrong",
|
||||
}, http.StatusUnauthorized, nil)
|
||||
var me struct {
|
||||
Username string `json:"username"`
|
||||
}
|
||||
authRequestJSON(t, client, http.MethodPost, srv.URL+"/api/auth/login", map[string]any{
|
||||
"username": "admin",
|
||||
"password": "correct-horse-battery-staple",
|
||||
}, http.StatusOK, &me)
|
||||
if me.Username != "admin" {
|
||||
t.Fatalf("unexpected login response: %#v", me)
|
||||
}
|
||||
authRequestJSON(t, client, http.MethodGet, srv.URL+"/api/auth/me", nil, http.StatusOK, &me)
|
||||
authRequestJSON(t, client, http.MethodGet, srv.URL+"/api/devices", nil, http.StatusOK, nil)
|
||||
authRequestJSON(t, client, http.MethodPost, srv.URL+"/api/auth/logout", nil, http.StatusOK, nil)
|
||||
authRequestJSON(t, client, http.MethodGet, srv.URL+"/api/devices", nil, http.StatusUnauthorized, nil)
|
||||
}
|
||||
|
||||
func authRequestJSON(t *testing.T, client *http.Client, method, url string, body any, wantStatus int, out any) {
|
||||
t.Helper()
|
||||
var reader io.Reader
|
||||
if body != nil {
|
||||
buf, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
reader = bytes.NewReader(buf)
|
||||
}
|
||||
req, err := http.NewRequest(method, url, reader)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if body != nil {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != wantStatus {
|
||||
data, _ := io.ReadAll(resp.Body)
|
||||
t.Fatalf("expected status %d, got %d: %s", wantStatus, resp.StatusCode, data)
|
||||
}
|
||||
if out != nil {
|
||||
if err := json.NewDecoder(resp.Body).Decode(out); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func requestJSON(t *testing.T, method, url, token string, body any, wantStatus int, out any) {
|
||||
t.Helper()
|
||||
|
||||
var reader io.Reader
|
||||
if body != nil {
|
||||
buf, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
reader = bytes.NewReader(buf)
|
||||
}
|
||||
|
||||
req, err := http.NewRequest(method, url, reader)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if body != nil {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
if token != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
}
|
||||
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != wantStatus {
|
||||
data, _ := io.ReadAll(resp.Body)
|
||||
t.Fatalf("expected status %d, got %d: %s", wantStatus, resp.StatusCode, data)
|
||||
}
|
||||
if out == nil {
|
||||
return
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(out); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func requestText(t *testing.T, method, url, token string, body any, wantStatus int) string {
|
||||
t.Helper()
|
||||
|
||||
var reader io.Reader
|
||||
if body != nil {
|
||||
buf, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
reader = bytes.NewReader(buf)
|
||||
}
|
||||
|
||||
req, err := http.NewRequest(method, url, reader)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if body != nil {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
if token != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
}
|
||||
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
data, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if resp.StatusCode != wantStatus {
|
||||
t.Fatalf("expected status %d, got %d: %s", wantStatus, resp.StatusCode, data)
|
||||
}
|
||||
return string(data)
|
||||
}
|
||||
Reference in New Issue
Block a user