Watch
1
0
Fork
You've already forked RedFlag
0
RedFlag/server/internal/api/handlers/processes.go
Fimeg 2da1e92fe9 feat: setup accepts operator-supplied signing keypair + validation
Extract serverSetupRequest type and resolveSetupSigningKeys(): when no keys
are provided the server generates a fresh Ed25519 pair (existing behaviour);
when a private key is provided it is validated and the public key derived from
it (public key may be omitted or supplied for cross-check). Mismatched pairs
are rejected 400. Remove configure-secrets route from welcome-mode router
(was only usable with Docker socket mounted, unreachable in that mode).
Add inferPublicURL() helper to fill publicURL from X-Forwarded-* headers when
the operator omits it. pq.QuoteLiteral() used for password in ALTER USER.
Tests: generate-when-missing, use-provided-pair, reject-mismatched-pair.
2026-06-11 02:01:43 -04:00

374 lines
11 KiB
Go

package handlers
import (
"encoding/json"
"fmt"
"log"
"net/http"
"time"
"github.com/Fimeg/RedFlag/server/internal/database/queries"
"github.com/Fimeg/RedFlag/server/internal/models"
"github.com/Fimeg/RedFlag/server/internal/services"
"github.com/gin-gonic/gin"
"github.com/gofrs/uuid/v5"
)
// ProcessHandler handles process scan endpoints.
type ProcessHandler struct {
processQueries *queries.ProcessQueries
agentQueries *queries.AgentQueries
commandQueries *queries.CommandQueries
signingService *services.SigningService
}
// NewProcessHandler creates a new process handler.
func NewProcessHandler(
pq *queries.ProcessQueries,
aq *queries.AgentQueries,
cq *queries.CommandQueries,
ss *services.SigningService,
) *ProcessHandler {
return &ProcessHandler{
processQueries: pq,
agentQueries: aq,
commandQueries: cq,
signingService: ss,
}
}
// ReportProcessScan handles POST /api/v1/agents/:id/process-scan
// Agent reports a full process scan result.
func (h *ProcessHandler) ReportProcessScan(c *gin.Context) {
agentID := c.MustGet("agent_id").(uuid.UUID)
var req models.ProcessScanRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body"})
return
}
if req.AgentID != agentID {
c.JSON(http.StatusBadRequest, gin.H{"error": "Agent ID mismatch"})
return
}
ctx := c.Request.Context()
now := time.Now().UTC()
// Insert snapshot header
snapID := uuid.Must(uuid.NewV4())
snap := models.ProcessSnapshot{
ID: snapID,
AgentID: agentID,
CommandID: req.CommandID,
ProcessCount: req.Snapshot.ProcessCount,
ScannedAt: req.Snapshot.ScannedAt,
ScanDurationMs: int(req.Snapshot.DurationMs),
CreatedAt: now,
}
if err := h.processQueries.InsertSnapshot(ctx, snap); err != nil {
log.Printf("[ERROR] [server] [processes] insert_snapshot agent=%s error=%v", agentID, err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to save snapshot"})
return
}
// Convert and insert processes
procs := make([]models.Process, 0, len(req.Snapshot.Processes))
for _, p := range req.Snapshot.Processes {
procID := uuid.Must(uuid.NewV4())
procs = append(procs, models.Process{
ID: procID,
SnapshotID: snapID,
AgentID: agentID,
PID: p.PID,
Name: p.Name,
Path: p.Path,
Cmdline: p.Cmdline,
Cwd: p.Cwd,
State: p.State,
UID: int(p.UID),
GID: int(p.GID),
EUID: int(p.EUID),
EGID: int(p.EGID),
User: p.User,
Group: p.Group,
TTY: p.TTY,
TTYName: p.TTYName,
CPUSecondsUser: p.CPUSecondsUser,
CPUSecondsSystem: p.CPUSecondsSystem,
CPUPercent: p.CPUPercent,
RSSBytes: int64(p.RSSBytes),
VMSBytes: int64(p.VMSBytes),
MemPercent: p.MemPercent,
Threads: p.Threads,
Nice: p.Nice,
StartTimeSeconds: int64(p.StartTimeSeconds),
ParentPID: p.ParentPID,
ProcessGroupID: p.ProcessGroupID,
ElevationStatus: p.ElevationStatus,
OnDisk: p.OnDisk,
DiskBytesRead: int64(p.DiskBytesRead),
DiskBytesWritten: int64(p.DiskBytesWritten),
CreatedAt: now,
})
}
if err := h.processQueries.InsertProcesses(ctx, procs); err != nil {
log.Printf("[ERROR] [server] [processes] insert_processes agent=%s error=%v", agentID, err)
// Clean up the snapshot header so the UI doesn't show an empty snapshot as latest.
_ = h.processQueries.DeleteSnapshot(ctx, snapID)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to save processes"})
return
}
// Insert related data for each process
var relatedEntries []models.ProcessRelated
for i, p := range req.Snapshot.Processes {
if i >= len(procs) {
break
}
procID := procs[i].ID
now := time.Now().UTC()
for _, relType := range []struct {
name string
data interface{}
}{
{"open_file", p.OpenFiles},
{"socket", p.OpenSockets},
{"pipe", p.OpenPipes},
{"environment", p.EnvironmentVars},
{"memory_map", p.MemoryMap},
{"namespace", p.Namespaces},
{"listening_port", p.ListeningPorts},
} {
if relType.data == nil {
continue
}
// Marshal once, check for empty/null, then store as JSONB.
dataJSON, err := json.Marshal(relType.data)
if err != nil {
continue
}
if len(dataJSON) <= 2 || string(dataJSON) == "null" {
continue // "[]", "{}", or "null"
}
// Unmarshal into interface{} to get a generic Go value (slice or map),
// which JSONB (map[string]interface{}) can hold as a value.
var jb models.JSONB
if err := json.Unmarshal(dataJSON, &jb); err != nil {
// Slice data won't unmarshal into a map — wrap it.
jb = models.JSONB{"_items": json.RawMessage(dataJSON)}
}
relatedEntries = append(relatedEntries, models.ProcessRelated{
ID: uuid.Must(uuid.NewV4()),
ProcessID: procID,
RelationType: relType.name,
Data: jb,
CreatedAt: now,
})
}
}
if len(relatedEntries) > 0 {
if err := h.processQueries.InsertRelatedData(ctx, relatedEntries); err != nil {
log.Printf("[ERROR] [server] [processes] insert_related agent=%s error=%v", agentID, err)
// Non-fatal: process data is saved, related data failed
}
}
// Cleanup old snapshots (keep last 10)
deleted, err := h.processQueries.CleanupOldSnapshots(ctx, agentID, 10)
if err != nil {
log.Printf("[WARN] [server] [processes] cleanup_old agent=%s error=%v", agentID, err)
} else if deleted > 0 {
log.Printf("[INFO] [server] [processes] cleanup_old agent=%s deleted=%d", agentID, deleted)
}
log.Printf("[INFO] [server] [processes] scan_stored agent=%s snapshot=%s processes=%d duration=%dms",
agentID, snapID, len(procs), snap.ScanDurationMs)
c.JSON(http.StatusOK, gin.H{
"status": "success",
"snapshot_id": snapID,
"processes": len(procs),
})
}
// GetLatestProcessSnapshot handles GET /api/v1/agents/:id/processes
// Dashboard reads the latest snapshot.
func (h *ProcessHandler) GetLatestProcessSnapshot(c *gin.Context) {
agentIDStr := c.Param("id")
agentID, err := uuid.FromString(agentIDStr)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid agent ID"})
return
}
ctx := c.Request.Context()
snap, err := h.processQueries.GetLatestSnapshot(ctx, agentID)
if err != nil {
log.Printf("[ERROR] [server] [processes] get_snapshot agent=%s error=%v", agentID, err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to retrieve snapshot"})
return
}
if snap == nil {
c.JSON(http.StatusOK, gin.H{"snapshot": nil, "processes": []interface{}{}})
return
}
// Parse query params for filtering
filter := queries.ProcessFilter{
Name: c.Query("name"),
User: c.Query("user"),
State: c.Query("state"),
SortBy: c.DefaultQuery("sort_by", "cpu"),
SortDir: c.DefaultQuery("sort_dir", "desc"),
Limit: 500, // cap at 500 for UI
}
if v := c.Query("limit"); v != "" {
fmt.Sscanf(v, "%d", &filter.Limit)
}
if v := c.Query("offset"); v != "" {
fmt.Sscanf(v, "%d", &filter.Offset)
}
procs, total, err := h.processQueries.GetProcessesBySnapshot(ctx, snap.ID, filter)
if err != nil {
log.Printf("[ERROR] [server] [processes] get_processes agent=%s error=%v", agentID, err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to retrieve processes"})
return
}
c.JSON(http.StatusOK, gin.H{
"snapshot": snap,
"processes": procs,
"total": total,
})
}
// GetProcessDetail handles GET /api/v1/agents/:id/processes/:processId
// Dashboard reads a single process with its related data.
func (h *ProcessHandler) GetProcessDetail(c *gin.Context) {
processIDStr := c.Param("processId")
processID, err := uuid.FromString(processIDStr)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid process ID"})
return
}
ctx := c.Request.Context()
proc, err := h.processQueries.GetProcessByID(ctx, processID)
if err != nil {
log.Printf("[ERROR] [server] [processes] get_detail error=%v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to retrieve process"})
return
}
if proc == nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Process not found"})
return
}
// Get all related data
related, err := h.processQueries.GetProcessRelated(ctx, processID, "")
if err != nil {
log.Printf("[WARN] [server] [processes] get_related error=%v", err)
}
// Group by type
resp := models.ProcessDetailResponse{Process: *proc}
for _, r := range related {
switch r.RelationType {
case "open_file":
resp.OpenFiles = append(resp.OpenFiles, r)
case "socket":
resp.OpenSockets = append(resp.OpenSockets, r)
case "pipe":
resp.OpenPipes = append(resp.OpenPipes, r)
case "environment":
resp.Environment = append(resp.Environment, r)
case "memory_map":
resp.MemoryMap = append(resp.MemoryMap, r)
case "namespace":
resp.Namespaces = append(resp.Namespaces, r)
case "listening_port":
resp.ListeningPorts = append(resp.ListeningPorts, r)
}
}
c.JSON(http.StatusOK, resp)
}
// TriggerProcessScan handles POST /api/v1/agents/:id/processes/scan
// Dashboard triggers an on-demand process scan.
func (h *ProcessHandler) TriggerProcessScan(c *gin.Context) {
agentIDStr := c.Param("id")
agentID, err := uuid.FromString(agentIDStr)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid agent ID"})
return
}
// Verify agent exists
agent, err := h.agentQueries.GetAgentByID(agentID)
if err != nil || agent == nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Agent not found"})
return
}
// Check for existing pending scan_processes command (dedup)
// Use GetPendingCommands (small bounded set) instead of GetCommandsByAgentID (all history).
pendingCmds, err := h.commandQueries.GetPendingCommands(agentID)
if err == nil {
for _, cmd := range pendingCmds {
if cmd.CommandType == "scan_processes" {
c.JSON(http.StatusOK, gin.H{
"message": "Scan already in progress",
"command_id": cmd.ID.String(),
})
return
}
}
}
// Create signed command
cmdID := uuid.Must(uuid.NewV4())
cmd := &models.AgentCommand{
ID: cmdID,
AgentID: agentID,
CommandType: "scan_processes",
Status: "pending",
CreatedAt: time.Now().UTC(),
UpdatedAt: time.Now().UTC(),
}
if h.signingService == nil || !h.signingService.IsEnabled() {
log.Printf("[ERROR] [server] [processes] signing_unavailable agent=%s", agentID)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Signing service unavailable"})
return
}
signature, err := h.signingService.SignCommand(cmd)
if err != nil {
log.Printf("[ERROR] [server] [processes] sign_failed agent=%s error=%v", agentID, err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to sign command"})
return
}
cmd.Signature = signature
if err := h.commandQueries.CreateCommand(cmd); err != nil {
log.Printf("[ERROR] [server] [processes] create_command agent=%s error=%v", agentID, err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create command"})
return
}
log.Printf("[INFO] [server] [processes] scan_triggered agent=%s command=%s", agentID, cmdID)
c.JSON(http.StatusOK, gin.H{
"message": "Process scan triggered",
"command_id": cmdID,
})
}