1393 lines
41 KiB
Go
1393 lines
41 KiB
Go
package httpapi
|
|
|
|
import (
|
|
"context"
|
|
"crypto/rand"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"log"
|
|
"net/http"
|
|
"os"
|
|
"path/filepath"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"rmm-openwrt/server/internal/model"
|
|
"rmm-openwrt/server/internal/store"
|
|
)
|
|
|
|
type Store interface {
|
|
EnrollDevice(ctx context.Context, hostname, openwrtVersion string) (store.EnrolledDevice, error)
|
|
AuthorizeDevice(ctx context.Context, deviceID, token string) (bool, error)
|
|
SaveHeartbeat(ctx context.Context, deviceID string, inventory, metrics json.RawMessage) ([]model.Command, error)
|
|
ClaimNextCommand(ctx context.Context, deviceID string) (model.Command, bool, error)
|
|
SaveCommandResult(ctx context.Context, commandID, deviceID, status string, exitCode int, output string, result json.RawMessage) (bool, error)
|
|
ListDevices(ctx context.Context) ([]model.Device, error)
|
|
GetDevice(ctx context.Context, deviceID string) (model.Device, bool, error)
|
|
UpdateDeviceFleet(ctx context.Context, deviceID, group string, tags []string) (model.Device, bool, error)
|
|
ListMetricSamples(ctx context.Context, deviceID string, opts store.MetricHistoryOptions) ([]model.MetricSample, bool, error)
|
|
SyncDeviceAlerts(ctx context.Context, deviceID string, active []model.Alert) ([]model.Alert, bool, error)
|
|
ListAlerts(ctx context.Context, opts store.AlertListOptions) ([]model.Alert, error)
|
|
AcknowledgeAlert(ctx context.Context, deviceID, alertID, actor string) (model.Alert, bool, error)
|
|
CreateCommand(ctx context.Context, deviceID, commandType string, args json.RawMessage) (model.Command, bool, error)
|
|
ListCommands(ctx context.Context, deviceID string, opts store.CommandListOptions) ([]model.Command, bool, error)
|
|
GetCommand(ctx context.Context, deviceID, commandID string) (model.Command, bool, error)
|
|
CancelCommand(ctx context.Context, deviceID, commandID string) (model.Command, bool, error)
|
|
CreateRemoteSession(ctx context.Context, session model.RemoteSession) (model.RemoteSession, bool, error)
|
|
ListRemoteSessions(ctx context.Context, deviceID string, opts store.RemoteSessionListOptions) ([]model.RemoteSession, bool, error)
|
|
GetRemoteSession(ctx context.Context, deviceID, sessionID string) (model.RemoteSession, bool, error)
|
|
AttachRemoteSessionCommand(ctx context.Context, deviceID, sessionID, commandID string) (model.RemoteSession, bool, error)
|
|
CloseRemoteSession(ctx context.Context, deviceID, sessionID string) (model.RemoteSession, bool, error)
|
|
ExpireRemoteSessions(ctx context.Context) error
|
|
AddAuditEvent(ctx context.Context, actor, action, deviceID, commandID string, details json.RawMessage) (model.AuditEvent, error)
|
|
ListAuditEvents(ctx context.Context, opts store.AuditListOptions) ([]model.AuditEvent, error)
|
|
}
|
|
|
|
type Config struct {
|
|
EnrollmentToken string
|
|
OperatorToken string
|
|
OperatorUsername string
|
|
OperatorPassword string
|
|
SessionSecret string
|
|
CookieSecure bool
|
|
StaticDir string
|
|
}
|
|
|
|
type App struct {
|
|
store Store
|
|
enrollmentToken string
|
|
operatorToken string
|
|
operatorUsername string
|
|
operatorPassword string
|
|
sessionSecret []byte
|
|
cookieSecure bool
|
|
}
|
|
|
|
type contextKey string
|
|
|
|
const requestIDContextKey contextKey = "request_id"
|
|
|
|
type enrollRequest struct {
|
|
EnrollmentToken string `json:"enrollment_token"`
|
|
Hostname string `json:"hostname"`
|
|
OpenWrtVersion string `json:"openwrt_version"`
|
|
}
|
|
|
|
type enrollResponse struct {
|
|
DeviceID string `json:"device_id"`
|
|
DeviceToken string `json:"device_token"`
|
|
}
|
|
|
|
type heartbeatRequest struct {
|
|
DeviceID string `json:"device_id"`
|
|
Inventory json.RawMessage `json:"inventory"`
|
|
Metrics json.RawMessage `json:"metrics"`
|
|
}
|
|
|
|
type heartbeatResponse struct {
|
|
Commands []model.Command `json:"commands"`
|
|
}
|
|
|
|
type commandRequest struct {
|
|
Type string `json:"type"`
|
|
Args json.RawMessage `json:"args"`
|
|
}
|
|
|
|
type remoteSessionRequest struct {
|
|
Target string `json:"target"`
|
|
DurationSeconds int `json:"duration_seconds"`
|
|
ServerHost string `json:"server_host"`
|
|
ServerPort int `json:"server_port"`
|
|
RemotePort int `json:"remote_port"`
|
|
LocalPort int `json:"local_port"`
|
|
}
|
|
|
|
type fleetRequest struct {
|
|
Group string `json:"group"`
|
|
Tags []string `json:"tags"`
|
|
}
|
|
|
|
type bulkCommandRequest struct {
|
|
DeviceIDs []string `json:"device_ids"`
|
|
Type string `json:"type"`
|
|
Args json.RawMessage `json:"args"`
|
|
}
|
|
|
|
type nextCommandRequest struct {
|
|
DeviceID string `json:"device_id"`
|
|
}
|
|
|
|
type commandResultRequest struct {
|
|
DeviceID string `json:"device_id"`
|
|
Status string `json:"status"`
|
|
ExitCode int `json:"exit_code"`
|
|
Output string `json:"output"`
|
|
Result json.RawMessage `json:"result"`
|
|
}
|
|
|
|
type loginRequest struct {
|
|
Username string `json:"username"`
|
|
Password string `json:"password"`
|
|
}
|
|
|
|
func NewHandler(s Store, cfg Config) http.Handler {
|
|
if strings.TrimSpace(cfg.OperatorUsername) == "" {
|
|
cfg.OperatorUsername = "admin"
|
|
}
|
|
if cfg.OperatorPassword == "" {
|
|
cfg.OperatorPassword = cfg.OperatorToken
|
|
}
|
|
if cfg.SessionSecret == "" {
|
|
cfg.SessionSecret = cfg.OperatorToken
|
|
}
|
|
a := &App{
|
|
store: s,
|
|
enrollmentToken: cfg.EnrollmentToken,
|
|
operatorToken: cfg.OperatorToken,
|
|
operatorUsername: cfg.OperatorUsername,
|
|
operatorPassword: cfg.OperatorPassword,
|
|
sessionSecret: []byte(cfg.SessionSecret),
|
|
cookieSecure: cfg.CookieSecure,
|
|
}
|
|
|
|
mux := http.NewServeMux()
|
|
mux.HandleFunc("POST /api/auth/login", a.handleLogin)
|
|
mux.HandleFunc("POST /api/auth/logout", a.handleLogout)
|
|
mux.Handle("GET /api/auth/me", a.operatorAuth(http.HandlerFunc(a.handleAuthMe)))
|
|
mux.HandleFunc("POST /api/agent/enroll", a.handleEnroll)
|
|
mux.HandleFunc("POST /api/agent/heartbeat", a.handleHeartbeat)
|
|
mux.HandleFunc("POST /api/agent/commands/next", a.handleNextCommand)
|
|
mux.HandleFunc("POST /api/agent/commands/", a.handleCommandResult)
|
|
mux.Handle("GET /api/devices", a.operatorAuth(http.HandlerFunc(a.handleListDevices)))
|
|
mux.Handle("POST /api/devices/bulk-commands", a.operatorAuth(http.HandlerFunc(a.handleCreateBulkCommand)))
|
|
mux.Handle("GET /api/devices/", a.operatorAuth(http.HandlerFunc(a.handleDeviceSubtree)))
|
|
mux.Handle("POST /api/devices/", a.operatorAuth(http.HandlerFunc(a.handleDeviceSubtree)))
|
|
mux.Handle("PATCH /api/devices/", a.operatorAuth(http.HandlerFunc(a.handleDeviceSubtree)))
|
|
mux.Handle("GET /api/audit-events", a.operatorAuth(http.HandlerFunc(a.handleListAuditEvents)))
|
|
mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, r *http.Request) {
|
|
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
|
|
})
|
|
if cfg.StaticDir != "" {
|
|
mux.Handle("GET /", staticHandler(cfg.StaticDir))
|
|
}
|
|
|
|
return withRequestLogging(mux)
|
|
}
|
|
|
|
func (a *App) handleEnroll(w http.ResponseWriter, r *http.Request) {
|
|
var req enrollRequest
|
|
if !decodeJSON(w, r, &req) {
|
|
return
|
|
}
|
|
if req.EnrollmentToken != a.enrollmentToken {
|
|
writeError(w, http.StatusUnauthorized, "invalid enrollment token")
|
|
return
|
|
}
|
|
|
|
enrolled, err := a.store.EnrollDevice(r.Context(), req.Hostname, req.OpenWrtVersion)
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, "failed to enroll device")
|
|
return
|
|
}
|
|
_, _ = a.store.AddAuditEvent(r.Context(), "agent", "agent.enroll", enrolled.DeviceID, "", mustJSON(map[string]string{
|
|
"hostname": req.Hostname,
|
|
"openwrt_version": req.OpenWrtVersion,
|
|
"request_id": requestID(r.Context()),
|
|
}))
|
|
|
|
writeJSON(w, http.StatusCreated, enrollResponse{DeviceID: enrolled.DeviceID, DeviceToken: enrolled.DeviceToken})
|
|
}
|
|
|
|
func (a *App) handleHeartbeat(w http.ResponseWriter, r *http.Request) {
|
|
var req heartbeatRequest
|
|
if !decodeJSON(w, r, &req) {
|
|
return
|
|
}
|
|
if err := a.authorizeDevice(r, req.DeviceID); err != nil {
|
|
writeError(w, http.StatusUnauthorized, err.Error())
|
|
return
|
|
}
|
|
|
|
commands, err := a.store.SaveHeartbeat(r.Context(), req.DeviceID, req.Inventory, req.Metrics)
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, "failed to save heartbeat")
|
|
return
|
|
}
|
|
if _, _, err := a.refreshDeviceAlerts(r.Context(), req.DeviceID); err != nil {
|
|
log.Printf("refresh alerts for %s: %v", req.DeviceID, err)
|
|
}
|
|
|
|
writeJSON(w, http.StatusOK, heartbeatResponse{Commands: commands})
|
|
}
|
|
|
|
func (a *App) handleNextCommand(w http.ResponseWriter, r *http.Request) {
|
|
var req nextCommandRequest
|
|
if !decodeJSON(w, r, &req) {
|
|
return
|
|
}
|
|
if err := a.authorizeDevice(r, req.DeviceID); err != nil {
|
|
writeError(w, http.StatusUnauthorized, err.Error())
|
|
return
|
|
}
|
|
|
|
c, ok, err := a.store.ClaimNextCommand(r.Context(), req.DeviceID)
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, "failed to load command")
|
|
return
|
|
}
|
|
if !ok {
|
|
w.WriteHeader(http.StatusNoContent)
|
|
return
|
|
}
|
|
_, _ = a.store.AddAuditEvent(r.Context(), "agent", "command.claim", req.DeviceID, c.ID, mustJSON(map[string]string{
|
|
"type": c.Type,
|
|
"request_id": requestID(r.Context()),
|
|
}))
|
|
|
|
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
|
|
_, _ = w.Write([]byte(c.ID + "\t" + c.Type + "\t" + string(c.Args) + "\n"))
|
|
}
|
|
|
|
func (a *App) handleCommandResult(w http.ResponseWriter, r *http.Request) {
|
|
id, ok := commandIDFromResultPath(r.URL.Path)
|
|
if !ok {
|
|
writeError(w, http.StatusNotFound, "not found")
|
|
return
|
|
}
|
|
|
|
var req commandResultRequest
|
|
if !decodeJSON(w, r, &req) {
|
|
return
|
|
}
|
|
if err := a.authorizeDevice(r, req.DeviceID); err != nil {
|
|
writeError(w, http.StatusUnauthorized, err.Error())
|
|
return
|
|
}
|
|
|
|
status := req.Status
|
|
if status == "" {
|
|
status = "completed"
|
|
}
|
|
if status != "completed" && status != "failed" {
|
|
writeError(w, http.StatusBadRequest, "invalid command status")
|
|
return
|
|
}
|
|
|
|
found, err := a.store.SaveCommandResult(r.Context(), id, req.DeviceID, status, req.ExitCode, req.Output, req.Result)
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, "failed to save command result")
|
|
return
|
|
}
|
|
if !found {
|
|
_, _ = a.store.AddAuditEvent(r.Context(), "agent", "command.result_rejected", req.DeviceID, id, mustJSON(map[string]string{
|
|
"status": status,
|
|
"request_id": requestID(r.Context()),
|
|
}))
|
|
writeError(w, http.StatusNotFound, "command not found")
|
|
return
|
|
}
|
|
_, _ = a.store.AddAuditEvent(r.Context(), "agent", "command.result", req.DeviceID, id, mustJSON(map[string]string{
|
|
"status": status,
|
|
"request_id": requestID(r.Context()),
|
|
}))
|
|
|
|
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
|
|
}
|
|
|
|
func (a *App) handleListDevices(w http.ResponseWriter, r *http.Request) {
|
|
devices, err := a.store.ListDevices(r.Context())
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, "failed to load devices")
|
|
return
|
|
}
|
|
for _, device := range devices {
|
|
if _, _, err := a.refreshDeviceAlerts(r.Context(), device.ID); err != nil {
|
|
writeError(w, http.StatusInternalServerError, "failed to refresh device alerts")
|
|
return
|
|
}
|
|
}
|
|
devices, err = a.store.ListDevices(r.Context())
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, "failed to load devices")
|
|
return
|
|
}
|
|
|
|
writeJSON(w, http.StatusOK, map[string]any{"devices": devices})
|
|
}
|
|
|
|
func (a *App) handleGetDevice(w http.ResponseWriter, r *http.Request) {
|
|
id, ok := deviceIDFromPath(r.URL.Path)
|
|
if !ok {
|
|
writeError(w, http.StatusNotFound, "not found")
|
|
return
|
|
}
|
|
|
|
d, found, err := a.store.GetDevice(r.Context(), id)
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, "failed to load device")
|
|
return
|
|
}
|
|
if !found {
|
|
writeError(w, http.StatusNotFound, "device not found")
|
|
return
|
|
}
|
|
|
|
writeJSON(w, http.StatusOK, d)
|
|
}
|
|
|
|
func (a *App) handleDeviceSubtree(w http.ResponseWriter, r *http.Request) {
|
|
parts := strings.Split(strings.Trim(r.URL.Path, "/"), "/")
|
|
if len(parts) == 3 && r.Method == http.MethodGet {
|
|
a.handleGetDevice(w, r)
|
|
return
|
|
}
|
|
if len(parts) == 4 && parts[3] == "fleet" && r.Method == http.MethodPatch {
|
|
a.handleUpdateDeviceFleet(w, r)
|
|
return
|
|
}
|
|
if len(parts) == 4 && parts[3] == "metrics-history" && r.Method == http.MethodGet {
|
|
a.handleListMetricSamples(w, r)
|
|
return
|
|
}
|
|
if len(parts) == 4 && parts[3] == "alerts" && r.Method == http.MethodGet {
|
|
a.handleListAlerts(w, r)
|
|
return
|
|
}
|
|
if len(parts) == 6 && parts[3] == "alerts" && parts[5] == "acknowledge" && r.Method == http.MethodPost {
|
|
a.handleAcknowledgeAlert(w, r)
|
|
return
|
|
}
|
|
if len(parts) == 4 && parts[3] == "commands" && r.Method == http.MethodGet {
|
|
a.handleListCommands(w, r)
|
|
return
|
|
}
|
|
if len(parts) == 4 && parts[3] == "commands" && r.Method == http.MethodPost {
|
|
a.handleCreateCommand(w, r)
|
|
return
|
|
}
|
|
if len(parts) == 5 && parts[3] == "commands" && r.Method == http.MethodGet {
|
|
a.handleGetCommand(w, r)
|
|
return
|
|
}
|
|
if len(parts) == 6 && parts[3] == "commands" && parts[5] == "cancel" && r.Method == http.MethodPost {
|
|
a.handleCancelCommand(w, r)
|
|
return
|
|
}
|
|
if len(parts) == 4 && parts[3] == "remote-sessions" && r.Method == http.MethodGet {
|
|
a.handleListRemoteSessions(w, r)
|
|
return
|
|
}
|
|
if len(parts) == 4 && parts[3] == "remote-sessions" && r.Method == http.MethodPost {
|
|
a.handleCreateRemoteSession(w, r)
|
|
return
|
|
}
|
|
if len(parts) == 6 && parts[3] == "remote-sessions" && parts[5] == "close" && r.Method == http.MethodPost {
|
|
a.handleCloseRemoteSession(w, r)
|
|
return
|
|
}
|
|
writeError(w, http.StatusNotFound, "not found")
|
|
}
|
|
|
|
func (a *App) handleUpdateDeviceFleet(w http.ResponseWriter, r *http.Request) {
|
|
parts := strings.Split(strings.Trim(r.URL.Path, "/"), "/")
|
|
if len(parts) != 4 || parts[0] != "api" || parts[1] != "devices" || parts[3] != "fleet" {
|
|
writeError(w, http.StatusNotFound, "not found")
|
|
return
|
|
}
|
|
deviceID := parts[2]
|
|
var req fleetRequest
|
|
if !decodeJSON(w, r, &req) {
|
|
return
|
|
}
|
|
d, found, err := a.store.UpdateDeviceFleet(r.Context(), deviceID, req.Group, req.Tags)
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, "failed to update device fleet metadata")
|
|
return
|
|
}
|
|
if !found {
|
|
writeError(w, http.StatusNotFound, "device not found")
|
|
return
|
|
}
|
|
_, _ = a.store.AddAuditEvent(r.Context(), "operator", "device.fleet_update", deviceID, "", mustJSON(map[string]any{
|
|
"group": req.Group,
|
|
"tags": req.Tags,
|
|
"request_id": requestID(r.Context()),
|
|
}))
|
|
writeJSON(w, http.StatusOK, d)
|
|
}
|
|
|
|
func (a *App) handleListMetricSamples(w http.ResponseWriter, r *http.Request) {
|
|
parts := strings.Split(strings.Trim(r.URL.Path, "/"), "/")
|
|
if len(parts) != 4 || parts[0] != "api" || parts[1] != "devices" || parts[3] != "metrics-history" {
|
|
writeError(w, http.StatusNotFound, "not found")
|
|
return
|
|
}
|
|
limit, _ := strconv.Atoi(r.URL.Query().Get("limit"))
|
|
samples, found, err := a.store.ListMetricSamples(r.Context(), parts[2], store.MetricHistoryOptions{Limit: limit})
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, "failed to load metrics history")
|
|
return
|
|
}
|
|
if !found {
|
|
writeError(w, http.StatusNotFound, "device not found")
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]any{"samples": samples})
|
|
}
|
|
|
|
func (a *App) handleListAlerts(w http.ResponseWriter, r *http.Request) {
|
|
parts := strings.Split(strings.Trim(r.URL.Path, "/"), "/")
|
|
if len(parts) != 4 || parts[0] != "api" || parts[1] != "devices" || parts[3] != "alerts" {
|
|
writeError(w, http.StatusNotFound, "not found")
|
|
return
|
|
}
|
|
alerts, found, err := a.refreshDeviceAlerts(r.Context(), parts[2])
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, "failed to load alerts")
|
|
return
|
|
}
|
|
if !found {
|
|
writeError(w, http.StatusNotFound, "device not found")
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]any{"alerts": alerts})
|
|
}
|
|
|
|
func (a *App) handleAcknowledgeAlert(w http.ResponseWriter, r *http.Request) {
|
|
deviceID, alertID, ok := alertAcknowledgeIDsFromPath(r.URL.Path)
|
|
if !ok {
|
|
writeError(w, http.StatusNotFound, "not found")
|
|
return
|
|
}
|
|
alert, changed, err := a.store.AcknowledgeAlert(r.Context(), deviceID, alertID, "operator")
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, "failed to acknowledge alert")
|
|
return
|
|
}
|
|
if !changed {
|
|
writeError(w, http.StatusNotFound, "active alert not found")
|
|
return
|
|
}
|
|
_, _ = a.store.AddAuditEvent(r.Context(), "operator", "alert.acknowledge", deviceID, "", mustJSON(map[string]string{
|
|
"alert_id": alertID,
|
|
"type": alert.Type,
|
|
"request_id": requestID(r.Context()),
|
|
}))
|
|
writeJSON(w, http.StatusOK, alert)
|
|
}
|
|
|
|
func (a *App) refreshDeviceAlerts(ctx context.Context, deviceID string) ([]model.Alert, bool, error) {
|
|
active, found, err := a.computeDeviceAlerts(ctx, deviceID)
|
|
if err != nil || !found {
|
|
return nil, found, err
|
|
}
|
|
return a.store.SyncDeviceAlerts(ctx, deviceID, active)
|
|
}
|
|
|
|
func (a *App) computeDeviceAlerts(ctx context.Context, deviceID string) ([]model.Alert, bool, error) {
|
|
device, found, err := a.store.GetDevice(ctx, deviceID)
|
|
if err != nil || !found {
|
|
return nil, found, err
|
|
}
|
|
samples, samplesFound, err := a.store.ListMetricSamples(ctx, deviceID, store.MetricHistoryOptions{Limit: 3})
|
|
if err != nil {
|
|
return nil, true, err
|
|
}
|
|
if !samplesFound {
|
|
samples = nil
|
|
}
|
|
commands, commandsFound, err := a.store.ListCommands(ctx, deviceID, store.CommandListOptions{Limit: 50})
|
|
if err != nil {
|
|
return nil, true, err
|
|
}
|
|
if !commandsFound {
|
|
commands = nil
|
|
}
|
|
|
|
now := time.Now().UTC()
|
|
alerts := make([]model.Alert, 0, 4)
|
|
if !device.Online {
|
|
details := map[string]any{"last_seen_at": device.LastSeenAt}
|
|
alerts = append(alerts, newAlert(deviceID, "offline", "critical", "Device is offline", details, now))
|
|
}
|
|
if ratio, ok := jsonRatio(device.Metrics, "memory", "used_kb", "total_kb"); ok && ratio >= 0.85 {
|
|
alerts = append(alerts, newUsageAlert(deviceID, "memory_high", "Memory usage is high", ratio, now))
|
|
}
|
|
if ratio, ok := diskUsageRatio(device.Metrics); ok && ratio >= 0.85 {
|
|
alerts = append(alerts, newUsageAlert(deviceID, "disk_high", "Disk usage is high", ratio, now))
|
|
}
|
|
alerts = append(alerts, connectivityAlerts(deviceID, device.Metrics, now)...)
|
|
if len(samples) >= 2 {
|
|
latestWAN := jsonString(samples[0].Inventory, "wan_ip")
|
|
previousWAN := jsonString(samples[1].Inventory, "wan_ip")
|
|
if latestWAN != "" && previousWAN != "" && latestWAN != previousWAN {
|
|
alerts = append(alerts, newAlert(deviceID, "wan_ip_changed", "warning", "WAN IP changed", map[string]string{
|
|
"current": latestWAN,
|
|
"previous": previousWAN,
|
|
}, now))
|
|
}
|
|
}
|
|
if failedCount, latest := commandAttention(commands); failedCount > 0 {
|
|
alerts = append(alerts, newAlert(deviceID, "command_attention", "warning", "Recent commands need attention", map[string]any{
|
|
"count": failedCount,
|
|
"latest_at": latest,
|
|
}, now))
|
|
}
|
|
return alerts, true, nil
|
|
}
|
|
|
|
func connectivityAlerts(deviceID string, raw json.RawMessage, now time.Time) []model.Alert {
|
|
checks := jsonObjectArray(raw, "connectivity_checks")
|
|
if len(checks) == 0 {
|
|
return nil
|
|
}
|
|
|
|
reachableCount := 0
|
|
worstLoss := 0.0
|
|
worstLatency := 0.0
|
|
worstLossTarget := ""
|
|
worstLatencyTarget := ""
|
|
for _, check := range checks {
|
|
target, _ := check["target"].(string)
|
|
if reachable, ok := check["reachable"].(bool); ok && reachable {
|
|
reachableCount++
|
|
}
|
|
if loss, ok := numberValue(check["packet_loss_percent"]); ok && loss > worstLoss {
|
|
worstLoss = loss
|
|
worstLossTarget = target
|
|
}
|
|
if latency, ok := numberValue(check["latency_ms"]); ok && latency > worstLatency {
|
|
worstLatency = latency
|
|
worstLatencyTarget = target
|
|
}
|
|
}
|
|
|
|
alerts := make([]model.Alert, 0, 3)
|
|
if reachableCount == 0 {
|
|
alerts = append(alerts, newAlert(deviceID, "wan_down", "critical", "WAN connectivity checks are failing", map[string]any{
|
|
"targets": len(checks),
|
|
}, now))
|
|
}
|
|
if worstLoss >= 20 {
|
|
severity := "warning"
|
|
if worstLoss >= 80 {
|
|
severity = "critical"
|
|
}
|
|
alerts = append(alerts, newAlert(deviceID, "packet_loss_high", severity, "Packet loss is high", map[string]any{
|
|
"target": worstLossTarget,
|
|
"packet_loss_percent": int(worstLoss),
|
|
"reachable_target_cnt": reachableCount,
|
|
}, now))
|
|
}
|
|
if worstLatency >= 200 {
|
|
severity := "warning"
|
|
if worstLatency >= 500 {
|
|
severity = "critical"
|
|
}
|
|
alerts = append(alerts, newAlert(deviceID, "latency_high", severity, "Latency is high", map[string]any{
|
|
"target": worstLatencyTarget,
|
|
"latency_ms": int(worstLatency),
|
|
}, now))
|
|
}
|
|
return alerts
|
|
}
|
|
|
|
func newUsageAlert(deviceID, alertType, message string, ratio float64, now time.Time) model.Alert {
|
|
severity := "warning"
|
|
if ratio >= 0.95 {
|
|
severity = "critical"
|
|
}
|
|
return newAlert(deviceID, alertType, severity, message, map[string]any{
|
|
"used_percent": int(ratio * 100),
|
|
}, now)
|
|
}
|
|
|
|
func newAlert(deviceID, alertType, severity, message string, details any, createdAt time.Time) model.Alert {
|
|
return model.Alert{
|
|
ID: alertType + ":" + deviceID,
|
|
DeviceID: deviceID,
|
|
Type: alertType,
|
|
Severity: severity,
|
|
Status: "active",
|
|
Message: message,
|
|
Details: mustJSON(details),
|
|
FirstSeenAt: createdAt,
|
|
LastSeenAt: createdAt,
|
|
CreatedAt: createdAt,
|
|
}
|
|
}
|
|
|
|
func commandAttention(commands []model.Command) (int, *time.Time) {
|
|
cutoff := time.Now().UTC().Add(-24 * time.Hour)
|
|
count := 0
|
|
var latest *time.Time
|
|
for _, command := range commands {
|
|
if command.Status != "failed" && command.Status != "expired" {
|
|
continue
|
|
}
|
|
at := command.CompletedAt
|
|
if at == nil {
|
|
at = command.ExpiredAt
|
|
}
|
|
if at == nil {
|
|
at = &command.CreatedAt
|
|
}
|
|
if at.Before(cutoff) {
|
|
continue
|
|
}
|
|
count++
|
|
if latest == nil || at.After(*latest) {
|
|
latestAt := *at
|
|
latest = &latestAt
|
|
}
|
|
}
|
|
return count, latest
|
|
}
|
|
|
|
func jsonRatio(raw json.RawMessage, objectKey, usedKey, totalKey string) (float64, bool) {
|
|
var root map[string]any
|
|
if len(raw) == 0 || json.Unmarshal(raw, &root) != nil {
|
|
return 0, false
|
|
}
|
|
obj, ok := root[objectKey].(map[string]any)
|
|
if !ok {
|
|
return 0, false
|
|
}
|
|
used, usedOK := numberValue(obj[usedKey])
|
|
total, totalOK := numberValue(obj[totalKey])
|
|
if !usedOK || !totalOK || total <= 0 {
|
|
return 0, false
|
|
}
|
|
return used / total, true
|
|
}
|
|
|
|
func diskUsageRatio(raw json.RawMessage) (float64, bool) {
|
|
var root map[string]any
|
|
if len(raw) == 0 || json.Unmarshal(raw, &root) != nil {
|
|
return 0, false
|
|
}
|
|
disk, ok := root["disk"].(map[string]any)
|
|
if !ok {
|
|
return 0, false
|
|
}
|
|
if value, ok := numberValue(disk["used_percent"]); ok {
|
|
if value > 1 {
|
|
return value / 100, true
|
|
}
|
|
return value, true
|
|
}
|
|
used, usedOK := numberValue(disk["used_kb"])
|
|
total, totalOK := numberValue(disk["total_kb"])
|
|
if !usedOK || !totalOK || total <= 0 {
|
|
return 0, false
|
|
}
|
|
return used / total, true
|
|
}
|
|
|
|
func jsonString(raw json.RawMessage, key string) string {
|
|
var root map[string]any
|
|
if len(raw) == 0 || json.Unmarshal(raw, &root) != nil {
|
|
return ""
|
|
}
|
|
value, ok := root[key].(string)
|
|
if !ok {
|
|
return ""
|
|
}
|
|
return strings.TrimSpace(value)
|
|
}
|
|
|
|
func jsonObjectArray(raw json.RawMessage, key string) []map[string]any {
|
|
var root map[string]any
|
|
if len(raw) == 0 || json.Unmarshal(raw, &root) != nil {
|
|
return nil
|
|
}
|
|
items, ok := root[key].([]any)
|
|
if !ok {
|
|
return nil
|
|
}
|
|
out := make([]map[string]any, 0, len(items))
|
|
for _, item := range items {
|
|
obj, ok := item.(map[string]any)
|
|
if ok {
|
|
out = append(out, obj)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
func numberValue(value any) (float64, bool) {
|
|
switch v := value.(type) {
|
|
case float64:
|
|
return v, true
|
|
case int:
|
|
return float64(v), true
|
|
case string:
|
|
trimmed := strings.TrimSuffix(strings.TrimSpace(v), "%")
|
|
parsed, err := strconv.ParseFloat(trimmed, 64)
|
|
return parsed, err == nil
|
|
default:
|
|
return 0, false
|
|
}
|
|
}
|
|
|
|
func (a *App) handleCreateCommand(w http.ResponseWriter, r *http.Request) {
|
|
deviceID, ok := commandDeviceIDFromPath(r.URL.Path)
|
|
if !ok {
|
|
writeError(w, http.StatusNotFound, "not found")
|
|
return
|
|
}
|
|
|
|
var req commandRequest
|
|
if !decodeJSON(w, r, &req) {
|
|
return
|
|
}
|
|
req.Type = strings.TrimSpace(req.Type)
|
|
if req.Type == "" {
|
|
writeError(w, http.StatusBadRequest, "command type is required")
|
|
return
|
|
}
|
|
if !AllowedCommandType(req.Type) {
|
|
writeError(w, http.StatusBadRequest, "command type is not allowed")
|
|
return
|
|
}
|
|
|
|
c, found, err := a.store.CreateCommand(r.Context(), deviceID, req.Type, req.Args)
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, "failed to create command")
|
|
return
|
|
}
|
|
if !found {
|
|
writeError(w, http.StatusNotFound, "device not found")
|
|
return
|
|
}
|
|
|
|
_, _ = a.store.AddAuditEvent(r.Context(), "operator", "command.create", deviceID, c.ID, mustJSON(map[string]string{
|
|
"type": req.Type,
|
|
"request_id": requestID(r.Context()),
|
|
}))
|
|
writeJSON(w, http.StatusCreated, c)
|
|
}
|
|
|
|
func (a *App) handleCreateBulkCommand(w http.ResponseWriter, r *http.Request) {
|
|
var req bulkCommandRequest
|
|
if !decodeJSON(w, r, &req) {
|
|
return
|
|
}
|
|
req.Type = strings.TrimSpace(req.Type)
|
|
if req.Type == "" {
|
|
writeError(w, http.StatusBadRequest, "command type is required")
|
|
return
|
|
}
|
|
if !AllowedCommandType(req.Type) {
|
|
writeError(w, http.StatusBadRequest, "command type is not allowed")
|
|
return
|
|
}
|
|
if len(req.DeviceIDs) == 0 {
|
|
writeError(w, http.StatusBadRequest, "device_ids are required")
|
|
return
|
|
}
|
|
if len(req.DeviceIDs) > 100 {
|
|
writeError(w, http.StatusBadRequest, "too many devices")
|
|
return
|
|
}
|
|
|
|
commands := make([]model.Command, 0, len(req.DeviceIDs))
|
|
for _, deviceID := range uniqueStrings(req.DeviceIDs) {
|
|
c, found, err := a.store.CreateCommand(r.Context(), deviceID, req.Type, req.Args)
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, "failed to create command")
|
|
return
|
|
}
|
|
if !found {
|
|
writeError(w, http.StatusNotFound, "device not found: "+deviceID)
|
|
return
|
|
}
|
|
commands = append(commands, c)
|
|
_, _ = a.store.AddAuditEvent(r.Context(), "operator", "command.bulk_create", deviceID, c.ID, mustJSON(map[string]string{
|
|
"type": req.Type,
|
|
"request_id": requestID(r.Context()),
|
|
}))
|
|
}
|
|
writeJSON(w, http.StatusCreated, map[string]any{"commands": commands})
|
|
}
|
|
|
|
func (a *App) handleListCommands(w http.ResponseWriter, r *http.Request) {
|
|
deviceID, ok := commandListDeviceIDFromPath(r.URL.Path)
|
|
if !ok {
|
|
writeError(w, http.StatusNotFound, "not found")
|
|
return
|
|
}
|
|
|
|
limit, _ := strconv.Atoi(r.URL.Query().Get("limit"))
|
|
commands, found, err := a.store.ListCommands(r.Context(), deviceID, store.CommandListOptions{Limit: limit})
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, "failed to load commands")
|
|
return
|
|
}
|
|
if !found {
|
|
writeError(w, http.StatusNotFound, "device not found")
|
|
return
|
|
}
|
|
|
|
writeJSON(w, http.StatusOK, map[string]any{"commands": commands})
|
|
}
|
|
|
|
func (a *App) handleGetCommand(w http.ResponseWriter, r *http.Request) {
|
|
deviceID, commandID, ok := commandDetailIDsFromPath(r.URL.Path)
|
|
if !ok {
|
|
writeError(w, http.StatusNotFound, "not found")
|
|
return
|
|
}
|
|
|
|
c, found, err := a.store.GetCommand(r.Context(), deviceID, commandID)
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, "failed to load command")
|
|
return
|
|
}
|
|
if !found {
|
|
writeError(w, http.StatusNotFound, "command not found")
|
|
return
|
|
}
|
|
|
|
writeJSON(w, http.StatusOK, c)
|
|
}
|
|
|
|
func (a *App) handleCancelCommand(w http.ResponseWriter, r *http.Request) {
|
|
deviceID, commandID, ok := commandCancelIDsFromPath(r.URL.Path)
|
|
if !ok {
|
|
writeError(w, http.StatusNotFound, "not found")
|
|
return
|
|
}
|
|
|
|
c, found, err := a.store.CancelCommand(r.Context(), deviceID, commandID)
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, "failed to cancel command")
|
|
return
|
|
}
|
|
if !found {
|
|
writeError(w, http.StatusNotFound, "command not found")
|
|
return
|
|
}
|
|
|
|
_, _ = a.store.AddAuditEvent(r.Context(), "operator", "command.cancel", deviceID, commandID, mustJSON(map[string]string{
|
|
"request_id": requestID(r.Context()),
|
|
}))
|
|
writeJSON(w, http.StatusOK, c)
|
|
}
|
|
|
|
func (a *App) handleListRemoteSessions(w http.ResponseWriter, r *http.Request) {
|
|
deviceID, ok := remoteSessionListDeviceIDFromPath(r.URL.Path)
|
|
if !ok {
|
|
writeError(w, http.StatusNotFound, "not found")
|
|
return
|
|
}
|
|
limit, _ := strconv.Atoi(r.URL.Query().Get("limit"))
|
|
sessions, found, err := a.store.ListRemoteSessions(r.Context(), deviceID, store.RemoteSessionListOptions{Limit: limit})
|
|
if err != nil {
|
|
logStructured(map[string]any{
|
|
"event": "remote_sessions.list_failed",
|
|
"request_id": requestID(r.Context()),
|
|
"device_id": deviceID,
|
|
"error": err.Error(),
|
|
})
|
|
writeError(w, http.StatusInternalServerError, "failed to load remote sessions")
|
|
return
|
|
}
|
|
if !found {
|
|
writeError(w, http.StatusNotFound, "device not found")
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]any{"remote_sessions": sessions})
|
|
}
|
|
|
|
func (a *App) handleCreateRemoteSession(w http.ResponseWriter, r *http.Request) {
|
|
deviceID, ok := remoteSessionListDeviceIDFromPath(r.URL.Path)
|
|
if !ok {
|
|
writeError(w, http.StatusNotFound, "not found")
|
|
return
|
|
}
|
|
var req remoteSessionRequest
|
|
if !decodeJSON(w, r, &req) {
|
|
return
|
|
}
|
|
target := strings.TrimSpace(req.Target)
|
|
if target == "" {
|
|
target = "ssh"
|
|
}
|
|
if target != "ssh" {
|
|
writeError(w, http.StatusBadRequest, "only ssh remote sessions are supported")
|
|
return
|
|
}
|
|
duration := req.DurationSeconds
|
|
if duration <= 0 {
|
|
duration = 15 * 60
|
|
}
|
|
if duration < 60 || duration > 2*60*60 {
|
|
writeError(w, http.StatusBadRequest, "duration_seconds must be between 60 and 7200")
|
|
return
|
|
}
|
|
serverHost := strings.TrimSpace(req.ServerHost)
|
|
if serverHost == "" {
|
|
serverHost = hostWithoutPort(r.Host)
|
|
}
|
|
if serverHost == "" || !safeEndpointHost(serverHost) {
|
|
writeError(w, http.StatusBadRequest, "server_host is invalid")
|
|
return
|
|
}
|
|
serverPort := req.ServerPort
|
|
if serverPort <= 0 {
|
|
serverPort = 22
|
|
}
|
|
localPort := req.LocalPort
|
|
if localPort <= 0 {
|
|
localPort = 22
|
|
}
|
|
remotePort := req.RemotePort
|
|
if remotePort <= 0 {
|
|
remotePort = randomRemotePort()
|
|
}
|
|
if !validTCPPort(serverPort) || !validTCPPort(localPort) || !validTCPPort(remotePort) {
|
|
writeError(w, http.StatusBadRequest, "ports must be between 1 and 65535")
|
|
return
|
|
}
|
|
|
|
expiresAt := time.Now().UTC().Add(time.Duration(duration) * time.Second)
|
|
session, found, err := a.store.CreateRemoteSession(r.Context(), model.RemoteSession{
|
|
DeviceID: deviceID,
|
|
Target: target,
|
|
Status: "requested",
|
|
ServerHost: serverHost,
|
|
ServerPort: serverPort,
|
|
RemotePort: remotePort,
|
|
LocalHost: "127.0.0.1",
|
|
LocalPort: localPort,
|
|
ExpiresAt: expiresAt,
|
|
})
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, "failed to create remote session")
|
|
return
|
|
}
|
|
if !found {
|
|
writeError(w, http.StatusNotFound, "device not found")
|
|
return
|
|
}
|
|
|
|
args := mustJSON(map[string]any{
|
|
"session_id": session.ID,
|
|
"server_host": session.ServerHost,
|
|
"server_port": strconv.Itoa(session.ServerPort),
|
|
"remote_port": strconv.Itoa(session.RemotePort),
|
|
"local_host": session.LocalHost,
|
|
"local_port": strconv.Itoa(session.LocalPort),
|
|
"server_user": "rmm-tunnel",
|
|
"duration_seconds": strconv.Itoa(duration),
|
|
"expires_at": session.ExpiresAt.Format(time.RFC3339Nano),
|
|
})
|
|
command, commandFound, err := a.store.CreateCommand(r.Context(), deviceID, "remote_ssh_reverse", args)
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, "failed to queue remote tunnel command")
|
|
return
|
|
}
|
|
if !commandFound {
|
|
writeError(w, http.StatusNotFound, "device not found")
|
|
return
|
|
}
|
|
session, _, err = a.store.AttachRemoteSessionCommand(r.Context(), deviceID, session.ID, command.ID)
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, "failed to attach remote session command")
|
|
return
|
|
}
|
|
|
|
_, _ = a.store.AddAuditEvent(r.Context(), "operator", "remote_session.create", deviceID, command.ID, mustJSON(map[string]any{
|
|
"session_id": session.ID,
|
|
"target": session.Target,
|
|
"server_host": session.ServerHost,
|
|
"server_port": session.ServerPort,
|
|
"remote_port": session.RemotePort,
|
|
"local_port": session.LocalPort,
|
|
"expires_at": session.ExpiresAt,
|
|
"request_id": requestID(r.Context()),
|
|
}))
|
|
writeJSON(w, http.StatusCreated, session)
|
|
}
|
|
|
|
func (a *App) handleCloseRemoteSession(w http.ResponseWriter, r *http.Request) {
|
|
deviceID, sessionID, ok := remoteSessionCloseIDsFromPath(r.URL.Path)
|
|
if !ok {
|
|
writeError(w, http.StatusNotFound, "not found")
|
|
return
|
|
}
|
|
session, found, err := a.store.GetRemoteSession(r.Context(), deviceID, sessionID)
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, "failed to close remote session")
|
|
return
|
|
}
|
|
if !found {
|
|
writeError(w, http.StatusNotFound, "remote session not found")
|
|
return
|
|
}
|
|
closeCommand, commandFound, err := a.store.CreateCommand(r.Context(), deviceID, "remote_ssh_close", mustJSON(map[string]string{
|
|
"session_id": session.ID,
|
|
"remote_port": strconv.Itoa(session.RemotePort),
|
|
}))
|
|
if err != nil || !commandFound {
|
|
writeError(w, http.StatusInternalServerError, "failed to queue remote session close")
|
|
return
|
|
}
|
|
session, _, err = a.store.CloseRemoteSession(r.Context(), deviceID, sessionID)
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, "failed to close remote session")
|
|
return
|
|
}
|
|
_, _ = a.store.AddAuditEvent(r.Context(), "operator", "remote_session.close", deviceID, closeCommand.ID, mustJSON(map[string]string{
|
|
"session_id": sessionID,
|
|
"open_command_id": session.CommandID,
|
|
"close_command_id": closeCommand.ID,
|
|
"request_id": requestID(r.Context()),
|
|
}))
|
|
writeJSON(w, http.StatusOK, session)
|
|
}
|
|
|
|
func (a *App) handleListAuditEvents(w http.ResponseWriter, r *http.Request) {
|
|
limit, _ := strconv.Atoi(r.URL.Query().Get("limit"))
|
|
events, err := a.store.ListAuditEvents(r.Context(), store.AuditListOptions{
|
|
DeviceID: r.URL.Query().Get("device_id"),
|
|
Limit: limit,
|
|
})
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, "failed to load audit events")
|
|
return
|
|
}
|
|
|
|
writeJSON(w, http.StatusOK, map[string]any{"audit_events": events})
|
|
}
|
|
|
|
func (a *App) authorizeDevice(r *http.Request, deviceID string) error {
|
|
if strings.TrimSpace(deviceID) == "" {
|
|
return errors.New("device_id is required")
|
|
}
|
|
token, ok := bearerToken(r)
|
|
if !ok {
|
|
return errors.New("missing bearer token")
|
|
}
|
|
|
|
allowed, err := a.store.AuthorizeDevice(r.Context(), deviceID, token)
|
|
if err != nil {
|
|
return errors.New("failed to authorize device")
|
|
}
|
|
if !allowed {
|
|
return errors.New("invalid device credentials")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (a *App) operatorAuth(next http.Handler) http.Handler {
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if !a.operatorAuthorized(r) {
|
|
writeError(w, http.StatusUnauthorized, "invalid operator credentials")
|
|
return
|
|
}
|
|
next.ServeHTTP(w, r)
|
|
})
|
|
}
|
|
|
|
func bearerToken(r *http.Request) (string, bool) {
|
|
auth := r.Header.Get("Authorization")
|
|
token, ok := strings.CutPrefix(auth, "Bearer ")
|
|
if !ok || strings.TrimSpace(token) == "" {
|
|
return "", false
|
|
}
|
|
return token, true
|
|
}
|
|
|
|
func AllowedCommandType(t string) bool {
|
|
switch t {
|
|
case "ping", "traceroute", "route_show", "interfaces_show", "reboot", "service_restart", "pkg_list_installed", "pkg_update", "pkg_list_upgradable", "pkg_install", "pkg_remove", "opkg_list_installed", "opkg_update", "opkg_list_upgradable", "opkg_install", "opkg_remove", "uci_show", "uci_backup", "uci_preview", "uci_set", "uci_commit", "uci_commit_confirmed", "uci_revert", "uci_restore", "remote_ssh_reverse", "remote_ssh_close":
|
|
return true
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
func commandIDFromResultPath(path string) (string, bool) {
|
|
parts := strings.Split(strings.Trim(path, "/"), "/")
|
|
if len(parts) != 5 {
|
|
return "", false
|
|
}
|
|
if parts[0] != "api" || parts[1] != "agent" || parts[2] != "commands" || parts[4] != "result" {
|
|
return "", false
|
|
}
|
|
return parts[3], parts[3] != ""
|
|
}
|
|
|
|
func deviceIDFromPath(path string) (string, bool) {
|
|
parts := strings.Split(strings.Trim(path, "/"), "/")
|
|
if len(parts) != 3 {
|
|
return "", false
|
|
}
|
|
if parts[0] != "api" || parts[1] != "devices" {
|
|
return "", false
|
|
}
|
|
return parts[2], parts[2] != ""
|
|
}
|
|
|
|
func commandDeviceIDFromPath(path string) (string, bool) {
|
|
parts := strings.Split(strings.Trim(path, "/"), "/")
|
|
if len(parts) != 4 {
|
|
return "", false
|
|
}
|
|
if parts[0] != "api" || parts[1] != "devices" || parts[3] != "commands" {
|
|
return "", false
|
|
}
|
|
return parts[2], parts[2] != ""
|
|
}
|
|
|
|
func commandListDeviceIDFromPath(path string) (string, bool) {
|
|
parts := strings.Split(strings.Trim(path, "/"), "/")
|
|
if len(parts) != 4 {
|
|
return "", false
|
|
}
|
|
if parts[0] != "api" || parts[1] != "devices" || parts[3] != "commands" {
|
|
return "", false
|
|
}
|
|
return parts[2], parts[2] != ""
|
|
}
|
|
|
|
func commandDetailIDsFromPath(path string) (string, string, bool) {
|
|
parts := strings.Split(strings.Trim(path, "/"), "/")
|
|
if len(parts) != 5 {
|
|
return "", "", false
|
|
}
|
|
if parts[0] != "api" || parts[1] != "devices" || parts[3] != "commands" {
|
|
return "", "", false
|
|
}
|
|
return parts[2], parts[4], parts[2] != "" && parts[4] != ""
|
|
}
|
|
|
|
func commandCancelIDsFromPath(path string) (string, string, bool) {
|
|
parts := strings.Split(strings.Trim(path, "/"), "/")
|
|
if len(parts) != 6 {
|
|
return "", "", false
|
|
}
|
|
if parts[0] != "api" || parts[1] != "devices" || parts[3] != "commands" || parts[5] != "cancel" {
|
|
return "", "", false
|
|
}
|
|
return parts[2], parts[4], parts[2] != "" && parts[4] != ""
|
|
}
|
|
|
|
func remoteSessionListDeviceIDFromPath(path string) (string, bool) {
|
|
parts := strings.Split(strings.Trim(path, "/"), "/")
|
|
if len(parts) != 4 {
|
|
return "", false
|
|
}
|
|
if parts[0] != "api" || parts[1] != "devices" || parts[3] != "remote-sessions" {
|
|
return "", false
|
|
}
|
|
return parts[2], parts[2] != ""
|
|
}
|
|
|
|
func remoteSessionCloseIDsFromPath(path string) (string, string, bool) {
|
|
parts := strings.Split(strings.Trim(path, "/"), "/")
|
|
if len(parts) != 6 {
|
|
return "", "", false
|
|
}
|
|
if parts[0] != "api" || parts[1] != "devices" || parts[3] != "remote-sessions" || parts[5] != "close" {
|
|
return "", "", false
|
|
}
|
|
return parts[2], parts[4], parts[2] != "" && parts[4] != ""
|
|
}
|
|
|
|
func alertAcknowledgeIDsFromPath(path string) (string, string, bool) {
|
|
parts := strings.Split(strings.Trim(path, "/"), "/")
|
|
if len(parts) != 6 {
|
|
return "", "", false
|
|
}
|
|
if parts[0] != "api" || parts[1] != "devices" || parts[3] != "alerts" || parts[5] != "acknowledge" {
|
|
return "", "", false
|
|
}
|
|
return parts[2], parts[4], parts[2] != "" && parts[4] != ""
|
|
}
|
|
|
|
func uniqueStrings(values []string) []string {
|
|
seen := map[string]bool{}
|
|
unique := make([]string, 0, len(values))
|
|
for _, value := range values {
|
|
value = strings.TrimSpace(value)
|
|
if value == "" || seen[value] {
|
|
continue
|
|
}
|
|
seen[value] = true
|
|
unique = append(unique, value)
|
|
}
|
|
return unique
|
|
}
|
|
|
|
func hostWithoutPort(host string) string {
|
|
host = strings.TrimSpace(host)
|
|
if host == "" {
|
|
return ""
|
|
}
|
|
if strings.HasPrefix(host, "[") {
|
|
end := strings.Index(host, "]")
|
|
if end > 0 {
|
|
return host[1:end]
|
|
}
|
|
}
|
|
if value, _, ok := strings.Cut(host, ":"); ok {
|
|
return value
|
|
}
|
|
return host
|
|
}
|
|
|
|
func safeEndpointHost(host string) bool {
|
|
host = strings.TrimSpace(host)
|
|
if host == "" || len(host) > 255 {
|
|
return false
|
|
}
|
|
clean := strings.Trim(host, "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789.-_:[]")
|
|
return clean == ""
|
|
}
|
|
|
|
func validTCPPort(port int) bool {
|
|
return port > 0 && port <= 65535
|
|
}
|
|
|
|
func randomRemotePort() int {
|
|
var b [2]byte
|
|
if _, err := rand.Read(b[:]); err != nil {
|
|
return 22022
|
|
}
|
|
value := int(b[0])<<8 | int(b[1])
|
|
return 22000 + (value % 100)
|
|
}
|
|
|
|
func decodeJSON(w http.ResponseWriter, r *http.Request, v any) bool {
|
|
defer r.Body.Close()
|
|
dec := json.NewDecoder(r.Body)
|
|
dec.DisallowUnknownFields()
|
|
if err := dec.Decode(v); err != nil {
|
|
var syntaxErr *json.SyntaxError
|
|
if errors.As(err, &syntaxErr) {
|
|
writeError(w, http.StatusBadRequest, fmt.Sprintf("invalid json at byte %d: %v", syntaxErr.Offset, err))
|
|
return false
|
|
}
|
|
writeError(w, http.StatusBadRequest, fmt.Sprintf("invalid json: %v", err))
|
|
return false
|
|
}
|
|
return true
|
|
}
|
|
|
|
func writeJSON(w http.ResponseWriter, status int, v any) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(status)
|
|
if err := json.NewEncoder(w).Encode(v); err != nil {
|
|
log.Printf("write json: %v", err)
|
|
}
|
|
}
|
|
|
|
func writeError(w http.ResponseWriter, status int, message string) {
|
|
writeJSON(w, status, map[string]string{"error": message})
|
|
}
|
|
|
|
func mustJSON(v any) json.RawMessage {
|
|
data, err := json.Marshal(v)
|
|
if err != nil {
|
|
return json.RawMessage(`{}`)
|
|
}
|
|
return data
|
|
}
|
|
|
|
type statusRecorder struct {
|
|
http.ResponseWriter
|
|
status int
|
|
}
|
|
|
|
func (r *statusRecorder) WriteHeader(status int) {
|
|
r.status = status
|
|
r.ResponseWriter.WriteHeader(status)
|
|
}
|
|
|
|
func (r *statusRecorder) Write(data []byte) (int, error) {
|
|
if r.status == 0 {
|
|
r.status = http.StatusOK
|
|
}
|
|
return r.ResponseWriter.Write(data)
|
|
}
|
|
|
|
func withRequestLogging(next http.Handler) http.Handler {
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
start := time.Now()
|
|
reqID := r.Header.Get("X-Request-ID")
|
|
if strings.TrimSpace(reqID) == "" {
|
|
reqID = newRequestID()
|
|
}
|
|
w.Header().Set("X-Request-ID", reqID)
|
|
|
|
rec := &statusRecorder{ResponseWriter: w}
|
|
ctx := context.WithValue(r.Context(), requestIDContextKey, reqID)
|
|
next.ServeHTTP(rec, r.WithContext(ctx))
|
|
if rec.status == 0 {
|
|
rec.status = http.StatusOK
|
|
}
|
|
|
|
logStructured(map[string]any{
|
|
"event": "http.request",
|
|
"request_id": reqID,
|
|
"method": r.Method,
|
|
"path": r.URL.Path,
|
|
"status": rec.status,
|
|
"duration_ms": time.Since(start).Milliseconds(),
|
|
"remote": r.RemoteAddr,
|
|
})
|
|
})
|
|
}
|
|
|
|
func requestID(ctx context.Context) string {
|
|
v, ok := ctx.Value(requestIDContextKey).(string)
|
|
if !ok {
|
|
return ""
|
|
}
|
|
return v
|
|
}
|
|
|
|
func newRequestID() string {
|
|
var b [12]byte
|
|
if _, err := rand.Read(b[:]); err != nil {
|
|
return fmt.Sprintf("req_%d", time.Now().UnixNano())
|
|
}
|
|
return "req_" + hex.EncodeToString(b[:])
|
|
}
|
|
|
|
func logStructured(fields map[string]any) {
|
|
data, err := json.Marshal(fields)
|
|
if err != nil {
|
|
log.Printf("log encode failed: %v", err)
|
|
return
|
|
}
|
|
log.Print(string(data))
|
|
}
|
|
|
|
func staticHandler(dir string) http.Handler {
|
|
fs := http.FileServer(http.Dir(dir))
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
path := filepath.Clean(strings.TrimPrefix(r.URL.Path, "/"))
|
|
if path == "." {
|
|
path = "index.html"
|
|
}
|
|
fullPath := filepath.Join(dir, path)
|
|
if info, err := os.Stat(fullPath); err == nil && !info.IsDir() {
|
|
fs.ServeHTTP(w, r)
|
|
return
|
|
}
|
|
indexPath := filepath.Join(dir, "index.html")
|
|
if _, err := os.Stat(indexPath); err != nil {
|
|
writeError(w, http.StatusNotFound, "not found")
|
|
return
|
|
}
|
|
http.ServeFile(w, r, indexPath)
|
|
})
|
|
}
|