Session 4 complete - RedFlag update management platform

🚩 Private development - version retention only

 Complete web dashboard (React + TypeScript + TailwindCSS)
 Production-ready server backend (Go + Gin + PostgreSQL)
 Linux agent with APT + Docker scanning + local CLI tools
 JWT authentication and REST API
 Update discovery and approval workflow

🚧 Status: Alpha software - active development
📦 Purpose: Version retention during development
⚠️  Not for public use or deployment
This commit is contained in:
Fimeg
2025-10-13 16:46:31 -04:00
commit 55b7d03010
57 changed files with 7326 additions and 0 deletions

View File

@@ -0,0 +1,178 @@
package handlers
import (
"net/http"
"time"
"github.com/aggregator-project/aggregator-server/internal/api/middleware"
"github.com/aggregator-project/aggregator-server/internal/database/queries"
"github.com/aggregator-project/aggregator-server/internal/models"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
)
type AgentHandler struct {
agentQueries *queries.AgentQueries
commandQueries *queries.CommandQueries
checkInInterval int
}
func NewAgentHandler(aq *queries.AgentQueries, cq *queries.CommandQueries, checkInInterval int) *AgentHandler {
return &AgentHandler{
agentQueries: aq,
commandQueries: cq,
checkInInterval: checkInInterval,
}
}
// RegisterAgent handles agent registration
func (h *AgentHandler) RegisterAgent(c *gin.Context) {
var req models.AgentRegistrationRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// Create new agent
agent := &models.Agent{
ID: uuid.New(),
Hostname: req.Hostname,
OSType: req.OSType,
OSVersion: req.OSVersion,
OSArchitecture: req.OSArchitecture,
AgentVersion: req.AgentVersion,
LastSeen: time.Now(),
Status: "online",
Metadata: models.JSONB{},
}
// Add metadata if provided
if req.Metadata != nil {
for k, v := range req.Metadata {
agent.Metadata[k] = v
}
}
// Save to database
if err := h.agentQueries.CreateAgent(agent); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to register agent"})
return
}
// Generate JWT token
token, err := middleware.GenerateAgentToken(agent.ID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to generate token"})
return
}
// Return response
response := models.AgentRegistrationResponse{
AgentID: agent.ID,
Token: token,
Config: map[string]interface{}{
"check_in_interval": h.checkInInterval,
"server_url": c.Request.Host,
},
}
c.JSON(http.StatusOK, response)
}
// GetCommands returns pending commands for an agent
func (h *AgentHandler) GetCommands(c *gin.Context) {
agentID := c.MustGet("agent_id").(uuid.UUID)
// Update last_seen
if err := h.agentQueries.UpdateAgentLastSeen(agentID); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update last seen"})
return
}
// Get pending commands
commands, err := h.commandQueries.GetPendingCommands(agentID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to retrieve commands"})
return
}
// Convert to response format
commandItems := make([]models.CommandItem, 0, len(commands))
for _, cmd := range commands {
commandItems = append(commandItems, models.CommandItem{
ID: cmd.ID.String(),
Type: cmd.CommandType,
Params: cmd.Params,
})
// Mark as sent
h.commandQueries.MarkCommandSent(cmd.ID)
}
response := models.CommandsResponse{
Commands: commandItems,
}
c.JSON(http.StatusOK, response)
}
// ListAgents returns all agents
func (h *AgentHandler) ListAgents(c *gin.Context) {
status := c.Query("status")
osType := c.Query("os_type")
agents, err := h.agentQueries.ListAgents(status, osType)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list agents"})
return
}
c.JSON(http.StatusOK, gin.H{
"agents": agents,
"total": len(agents),
})
}
// GetAgent returns a single agent by ID
func (h *AgentHandler) GetAgent(c *gin.Context) {
idStr := c.Param("id")
id, err := uuid.Parse(idStr)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid agent ID"})
return
}
agent, err := h.agentQueries.GetAgentByID(id)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "agent not found"})
return
}
c.JSON(http.StatusOK, agent)
}
// TriggerScan creates a scan command for an agent
func (h *AgentHandler) TriggerScan(c *gin.Context) {
idStr := c.Param("id")
agentID, err := uuid.Parse(idStr)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid agent ID"})
return
}
// Create scan command
cmd := &models.AgentCommand{
ID: uuid.New(),
AgentID: agentID,
CommandType: models.CommandTypeScanUpdates,
Params: models.JSONB{},
Status: models.CommandStatusPending,
}
if err := h.commandQueries.CreateCommand(cmd); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create command"})
return
}
c.JSON(http.StatusOK, gin.H{"message": "scan triggered", "command_id": cmd.ID})
}

View File

@@ -0,0 +1,163 @@
package handlers
import (
"net/http"
"strconv"
"time"
"github.com/aggregator-project/aggregator-server/internal/database/queries"
"github.com/aggregator-project/aggregator-server/internal/models"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
)
type UpdateHandler struct {
updateQueries *queries.UpdateQueries
}
func NewUpdateHandler(uq *queries.UpdateQueries) *UpdateHandler {
return &UpdateHandler{updateQueries: uq}
}
// ReportUpdates handles update reports from agents
func (h *UpdateHandler) ReportUpdates(c *gin.Context) {
agentID := c.MustGet("agent_id").(uuid.UUID)
var req models.UpdateReportRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// Process each update
for _, item := range req.Updates {
update := &models.UpdatePackage{
ID: uuid.New(),
AgentID: agentID,
PackageType: item.PackageType,
PackageName: item.PackageName,
PackageDescription: item.PackageDescription,
CurrentVersion: item.CurrentVersion,
AvailableVersion: item.AvailableVersion,
Severity: item.Severity,
CVEList: models.StringArray(item.CVEList),
KBID: item.KBID,
RepositorySource: item.RepositorySource,
SizeBytes: item.SizeBytes,
Status: "pending",
Metadata: item.Metadata,
}
if err := h.updateQueries.UpsertUpdate(update); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to save update"})
return
}
}
c.JSON(http.StatusOK, gin.H{
"message": "updates recorded",
"count": len(req.Updates),
})
}
// ListUpdates retrieves updates with filtering
func (h *UpdateHandler) ListUpdates(c *gin.Context) {
filters := &models.UpdateFilters{
Status: c.Query("status"),
Severity: c.Query("severity"),
PackageType: c.Query("package_type"),
}
// Parse agent_id if provided
if agentIDStr := c.Query("agent_id"); agentIDStr != "" {
agentID, err := uuid.Parse(agentIDStr)
if err == nil {
filters.AgentID = &agentID
}
}
// Parse pagination
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "50"))
filters.Page = page
filters.PageSize = pageSize
updates, total, err := h.updateQueries.ListUpdates(filters)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list updates"})
return
}
c.JSON(http.StatusOK, gin.H{
"updates": updates,
"total": total,
"page": page,
"page_size": pageSize,
})
}
// GetUpdate retrieves a single update by ID
func (h *UpdateHandler) GetUpdate(c *gin.Context) {
idStr := c.Param("id")
id, err := uuid.Parse(idStr)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid update ID"})
return
}
update, err := h.updateQueries.GetUpdateByID(id)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "update not found"})
return
}
c.JSON(http.StatusOK, update)
}
// ApproveUpdate marks an update as approved
func (h *UpdateHandler) ApproveUpdate(c *gin.Context) {
idStr := c.Param("id")
id, err := uuid.Parse(idStr)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid update ID"})
return
}
// For now, use "admin" as approver. Will integrate with proper auth later
if err := h.updateQueries.ApproveUpdate(id, "admin"); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to approve update"})
return
}
c.JSON(http.StatusOK, gin.H{"message": "update approved"})
}
// ReportLog handles update execution logs from agents
func (h *UpdateHandler) ReportLog(c *gin.Context) {
agentID := c.MustGet("agent_id").(uuid.UUID)
var req models.UpdateLogRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
log := &models.UpdateLog{
ID: uuid.New(),
AgentID: agentID,
Action: req.Action,
Result: req.Result,
Stdout: req.Stdout,
Stderr: req.Stderr,
ExitCode: req.ExitCode,
DurationSeconds: req.DurationSeconds,
ExecutedAt: time.Now(),
}
if err := h.updateQueries.CreateUpdateLog(log); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to save log"})
return
}
c.JSON(http.StatusOK, gin.H{"message": "log recorded"})
}

View File

@@ -0,0 +1,71 @@
package middleware
import (
"net/http"
"strings"
"time"
"github.com/gin-gonic/gin"
"github.com/golang-jwt/jwt/v5"
"github.com/google/uuid"
)
// AgentClaims represents JWT claims for agent authentication
type AgentClaims struct {
AgentID uuid.UUID `json:"agent_id"`
jwt.RegisteredClaims
}
// JWTSecret is set by the server at initialization
var JWTSecret string
// GenerateAgentToken creates a new JWT token for an agent
func GenerateAgentToken(agentID uuid.UUID) (string, error) {
claims := AgentClaims{
AgentID: agentID,
RegisteredClaims: jwt.RegisteredClaims{
ExpiresAt: jwt.NewNumericDate(time.Now().Add(24 * time.Hour)),
IssuedAt: jwt.NewNumericDate(time.Now()),
},
}
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
return token.SignedString([]byte(JWTSecret))
}
// AuthMiddleware validates JWT tokens from agents
func AuthMiddleware() gin.HandlerFunc {
return func(c *gin.Context) {
authHeader := c.GetHeader("Authorization")
if authHeader == "" {
c.JSON(http.StatusUnauthorized, gin.H{"error": "missing authorization header"})
c.Abort()
return
}
tokenString := strings.TrimPrefix(authHeader, "Bearer ")
if tokenString == authHeader {
c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid authorization format"})
c.Abort()
return
}
token, err := jwt.ParseWithClaims(tokenString, &AgentClaims{}, func(token *jwt.Token) (interface{}, error) {
return []byte(JWTSecret), nil
})
if err != nil || !token.Valid {
c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid token"})
c.Abort()
return
}
if claims, ok := token.Claims.(*AgentClaims); ok {
c.Set("agent_id", claims.AgentID)
c.Next()
} else {
c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid token claims"})
c.Abort()
}
}
}

View File

@@ -0,0 +1,41 @@
package config
import (
"os"
"strconv"
"github.com/joho/godotenv"
)
// Config holds the application configuration
type Config struct {
ServerPort string
DatabaseURL string
JWTSecret string
CheckInInterval int
OfflineThreshold int
}
// Load reads configuration from environment variables
func Load() (*Config, error) {
// Load .env file if it exists (for development)
_ = godotenv.Load()
checkInInterval, _ := strconv.Atoi(getEnv("CHECK_IN_INTERVAL", "300"))
offlineThreshold, _ := strconv.Atoi(getEnv("OFFLINE_THRESHOLD", "600"))
return &Config{
ServerPort: getEnv("SERVER_PORT", "8080"),
DatabaseURL: getEnv("DATABASE_URL", "postgres://aggregator:aggregator@localhost:5432/aggregator?sslmode=disable"),
JWTSecret: getEnv("JWT_SECRET", "change-me-in-production"),
CheckInInterval: checkInInterval,
OfflineThreshold: offlineThreshold,
}, nil
}
func getEnv(key, defaultValue string) string {
if value := os.Getenv(key); value != "" {
return value
}
return defaultValue
}

View File

@@ -0,0 +1,76 @@
package database
import (
"fmt"
"os"
"path/filepath"
"sort"
"strings"
"github.com/jmoiron/sqlx"
_ "github.com/lib/pq"
)
// DB wraps the database connection
type DB struct {
*sqlx.DB
}
// Connect establishes a connection to the PostgreSQL database
func Connect(databaseURL string) (*DB, error) {
db, err := sqlx.Connect("postgres", databaseURL)
if err != nil {
return nil, fmt.Errorf("failed to connect to database: %w", err)
}
// Configure connection pool
db.SetMaxOpenConns(25)
db.SetMaxIdleConns(5)
// Test the connection
if err := db.Ping(); err != nil {
return nil, fmt.Errorf("failed to ping database: %w", err)
}
return &DB{db}, nil
}
// Migrate runs database migrations
func (db *DB) Migrate(migrationsPath string) error {
// Read migration files
files, err := os.ReadDir(migrationsPath)
if err != nil {
return fmt.Errorf("failed to read migrations directory: %w", err)
}
// Filter and sort .up.sql files
var migrationFiles []string
for _, file := range files {
if strings.HasSuffix(file.Name(), ".up.sql") {
migrationFiles = append(migrationFiles, file.Name())
}
}
sort.Strings(migrationFiles)
// Execute migrations
for _, filename := range migrationFiles {
path := filepath.Join(migrationsPath, filename)
content, err := os.ReadFile(path)
if err != nil {
return fmt.Errorf("failed to read migration %s: %w", filename, err)
}
if _, err := db.Exec(string(content)); err != nil {
return fmt.Errorf("failed to execute migration %s: %w", filename, err)
}
fmt.Printf("✓ Executed migration: %s\n", filename)
}
return nil
}
// Close closes the database connection
func (db *DB) Close() error {
return db.DB.Close()
}

View File

@@ -0,0 +1,11 @@
-- Drop tables in reverse order (respecting foreign key constraints)
DROP TABLE IF EXISTS agent_commands;
DROP TABLE IF EXISTS users;
DROP TABLE IF EXISTS agent_tags;
DROP TABLE IF EXISTS update_logs;
DROP TABLE IF EXISTS update_packages;
DROP TABLE IF EXISTS agent_specs;
DROP TABLE IF EXISTS agents;
-- Drop extension
DROP EXTENSION IF EXISTS "uuid-ossp";

View File

@@ -0,0 +1,127 @@
-- Enable UUID extension
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
-- Agents table
CREATE TABLE agents (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
hostname VARCHAR(255) NOT NULL,
os_type VARCHAR(50) NOT NULL CHECK (os_type IN ('windows', 'linux', 'macos')),
os_version VARCHAR(100),
os_architecture VARCHAR(20),
agent_version VARCHAR(20) NOT NULL,
last_seen TIMESTAMP NOT NULL DEFAULT NOW(),
status VARCHAR(20) DEFAULT 'online' CHECK (status IN ('online', 'offline', 'error')),
metadata JSONB,
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW()
);
CREATE INDEX idx_agents_status ON agents(status);
CREATE INDEX idx_agents_os_type ON agents(os_type);
CREATE INDEX idx_agents_last_seen ON agents(last_seen);
-- Agent specs
CREATE TABLE agent_specs (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
agent_id UUID REFERENCES agents(id) ON DELETE CASCADE,
cpu_model VARCHAR(255),
cpu_cores INTEGER,
memory_total_mb INTEGER,
disk_total_gb INTEGER,
disk_free_gb INTEGER,
network_interfaces JSONB,
docker_installed BOOLEAN DEFAULT false,
docker_version VARCHAR(50),
package_managers TEXT[],
collected_at TIMESTAMP DEFAULT NOW()
);
CREATE INDEX idx_agent_specs_agent_id ON agent_specs(agent_id);
-- Update packages
CREATE TABLE update_packages (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
agent_id UUID REFERENCES agents(id) ON DELETE CASCADE,
package_type VARCHAR(50) NOT NULL,
package_name VARCHAR(500) NOT NULL,
package_description TEXT,
current_version VARCHAR(100),
available_version VARCHAR(100) NOT NULL,
severity VARCHAR(20) CHECK (severity IN ('critical', 'important', 'moderate', 'low', 'none')),
cve_list TEXT[],
kb_id VARCHAR(50),
repository_source VARCHAR(255),
size_bytes BIGINT,
status VARCHAR(30) DEFAULT 'pending' CHECK (status IN ('pending', 'approved', 'scheduled', 'installing', 'installed', 'failed', 'ignored')),
discovered_at TIMESTAMP DEFAULT NOW(),
approved_by VARCHAR(255),
approved_at TIMESTAMP,
scheduled_for TIMESTAMP,
installed_at TIMESTAMP,
error_message TEXT,
metadata JSONB,
UNIQUE(agent_id, package_type, package_name, available_version)
);
CREATE INDEX idx_updates_status ON update_packages(status);
CREATE INDEX idx_updates_agent ON update_packages(agent_id);
CREATE INDEX idx_updates_severity ON update_packages(severity);
CREATE INDEX idx_updates_package_type ON update_packages(package_type);
CREATE INDEX idx_updates_composite ON update_packages(status, severity, agent_id);
-- Update logs
CREATE TABLE update_logs (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
agent_id UUID REFERENCES agents(id) ON DELETE CASCADE,
update_package_id UUID REFERENCES update_packages(id) ON DELETE SET NULL,
action VARCHAR(50) NOT NULL,
result VARCHAR(20) NOT NULL CHECK (result IN ('success', 'failed', 'partial')),
stdout TEXT,
stderr TEXT,
exit_code INTEGER,
duration_seconds INTEGER,
executed_at TIMESTAMP DEFAULT NOW()
);
CREATE INDEX idx_logs_agent ON update_logs(agent_id);
CREATE INDEX idx_logs_result ON update_logs(result);
CREATE INDEX idx_logs_executed_at ON update_logs(executed_at DESC);
-- Agent tags
CREATE TABLE agent_tags (
agent_id UUID REFERENCES agents(id) ON DELETE CASCADE,
tag VARCHAR(100) NOT NULL,
PRIMARY KEY (agent_id, tag)
);
CREATE INDEX idx_agent_tags_tag ON agent_tags(tag);
-- Users (for authentication)
CREATE TABLE users (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
username VARCHAR(255) UNIQUE NOT NULL,
email VARCHAR(255) UNIQUE NOT NULL,
password_hash VARCHAR(255) NOT NULL,
role VARCHAR(50) DEFAULT 'user' CHECK (role IN ('admin', 'user', 'readonly')),
created_at TIMESTAMP DEFAULT NOW(),
last_login TIMESTAMP
);
CREATE INDEX idx_users_username ON users(username);
CREATE INDEX idx_users_email ON users(email);
-- Commands queue (for agent orchestration)
CREATE TABLE agent_commands (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
agent_id UUID REFERENCES agents(id) ON DELETE CASCADE,
command_type VARCHAR(50) NOT NULL,
params JSONB,
status VARCHAR(20) DEFAULT 'pending' CHECK (status IN ('pending', 'sent', 'completed', 'failed')),
created_at TIMESTAMP DEFAULT NOW(),
sent_at TIMESTAMP,
completed_at TIMESTAMP,
result JSONB
);
CREATE INDEX idx_commands_agent_status ON agent_commands(agent_id, status);
CREATE INDEX idx_commands_created_at ON agent_commands(created_at DESC);

View File

@@ -0,0 +1,83 @@
package queries
import (
"time"
"github.com/aggregator-project/aggregator-server/internal/models"
"github.com/google/uuid"
"github.com/jmoiron/sqlx"
)
type AgentQueries struct {
db *sqlx.DB
}
func NewAgentQueries(db *sqlx.DB) *AgentQueries {
return &AgentQueries{db: db}
}
// CreateAgent inserts a new agent into the database
func (q *AgentQueries) CreateAgent(agent *models.Agent) error {
query := `
INSERT INTO agents (
id, hostname, os_type, os_version, os_architecture,
agent_version, last_seen, status, metadata
) VALUES (
:id, :hostname, :os_type, :os_version, :os_architecture,
:agent_version, :last_seen, :status, :metadata
)
`
_, err := q.db.NamedExec(query, agent)
return err
}
// GetAgentByID retrieves an agent by ID
func (q *AgentQueries) GetAgentByID(id uuid.UUID) (*models.Agent, error) {
var agent models.Agent
query := `SELECT * FROM agents WHERE id = $1`
err := q.db.Get(&agent, query, id)
if err != nil {
return nil, err
}
return &agent, nil
}
// UpdateAgentLastSeen updates the agent's last_seen timestamp
func (q *AgentQueries) UpdateAgentLastSeen(id uuid.UUID) error {
query := `UPDATE agents SET last_seen = $1, status = 'online' WHERE id = $2`
_, err := q.db.Exec(query, time.Now(), id)
return err
}
// ListAgents returns all agents with optional filtering
func (q *AgentQueries) ListAgents(status, osType string) ([]models.Agent, error) {
var agents []models.Agent
query := `SELECT * FROM agents WHERE 1=1`
args := []interface{}{}
argIdx := 1
if status != "" {
query += ` AND status = $` + string(rune(argIdx+'0'))
args = append(args, status)
argIdx++
}
if osType != "" {
query += ` AND os_type = $` + string(rune(argIdx+'0'))
args = append(args, osType)
}
query += ` ORDER BY last_seen DESC`
err := q.db.Select(&agents, query, args...)
return agents, err
}
// MarkOfflineAgents marks agents as offline if they haven't checked in recently
func (q *AgentQueries) MarkOfflineAgents(threshold time.Duration) error {
query := `
UPDATE agents
SET status = 'offline'
WHERE last_seen < $1 AND status = 'online'
`
_, err := q.db.Exec(query, time.Now().Add(-threshold))
return err
}

View File

@@ -0,0 +1,79 @@
package queries
import (
"time"
"github.com/aggregator-project/aggregator-server/internal/models"
"github.com/google/uuid"
"github.com/jmoiron/sqlx"
)
type CommandQueries struct {
db *sqlx.DB
}
func NewCommandQueries(db *sqlx.DB) *CommandQueries {
return &CommandQueries{db: db}
}
// CreateCommand inserts a new command for an agent
func (q *CommandQueries) CreateCommand(cmd *models.AgentCommand) error {
query := `
INSERT INTO agent_commands (
id, agent_id, command_type, params, status
) VALUES (
:id, :agent_id, :command_type, :params, :status
)
`
_, err := q.db.NamedExec(query, cmd)
return err
}
// GetPendingCommands retrieves pending commands for an agent
func (q *CommandQueries) GetPendingCommands(agentID uuid.UUID) ([]models.AgentCommand, error) {
var commands []models.AgentCommand
query := `
SELECT * FROM agent_commands
WHERE agent_id = $1 AND status = 'pending'
ORDER BY created_at ASC
LIMIT 10
`
err := q.db.Select(&commands, query, agentID)
return commands, err
}
// MarkCommandSent updates a command's status to sent
func (q *CommandQueries) MarkCommandSent(id uuid.UUID) error {
now := time.Now()
query := `
UPDATE agent_commands
SET status = 'sent', sent_at = $1
WHERE id = $2
`
_, err := q.db.Exec(query, now, id)
return err
}
// MarkCommandCompleted updates a command's status to completed
func (q *CommandQueries) MarkCommandCompleted(id uuid.UUID, result models.JSONB) error {
now := time.Now()
query := `
UPDATE agent_commands
SET status = 'completed', completed_at = $1, result = $2
WHERE id = $3
`
_, err := q.db.Exec(query, now, result, id)
return err
}
// MarkCommandFailed updates a command's status to failed
func (q *CommandQueries) MarkCommandFailed(id uuid.UUID, result models.JSONB) error {
now := time.Now()
query := `
UPDATE agent_commands
SET status = 'failed', completed_at = $1, result = $2
WHERE id = $3
`
_, err := q.db.Exec(query, now, result, id)
return err
}

View File

@@ -0,0 +1,141 @@
package queries
import (
"fmt"
"strings"
"github.com/aggregator-project/aggregator-server/internal/models"
"github.com/google/uuid"
"github.com/jmoiron/sqlx"
)
type UpdateQueries struct {
db *sqlx.DB
}
func NewUpdateQueries(db *sqlx.DB) *UpdateQueries {
return &UpdateQueries{db: db}
}
// UpsertUpdate inserts or updates an update package
func (q *UpdateQueries) UpsertUpdate(update *models.UpdatePackage) error {
query := `
INSERT INTO update_packages (
id, agent_id, package_type, package_name, package_description,
current_version, available_version, severity, cve_list, kb_id,
repository_source, size_bytes, status, metadata
) VALUES (
:id, :agent_id, :package_type, :package_name, :package_description,
:current_version, :available_version, :severity, :cve_list, :kb_id,
:repository_source, :size_bytes, :status, :metadata
)
ON CONFLICT (agent_id, package_type, package_name, available_version)
DO UPDATE SET
package_description = EXCLUDED.package_description,
current_version = EXCLUDED.current_version,
severity = EXCLUDED.severity,
cve_list = EXCLUDED.cve_list,
kb_id = EXCLUDED.kb_id,
repository_source = EXCLUDED.repository_source,
size_bytes = EXCLUDED.size_bytes,
metadata = EXCLUDED.metadata,
discovered_at = NOW()
`
_, err := q.db.NamedExec(query, update)
return err
}
// ListUpdates retrieves updates with filtering
func (q *UpdateQueries) ListUpdates(filters *models.UpdateFilters) ([]models.UpdatePackage, int, error) {
var updates []models.UpdatePackage
whereClause := []string{"1=1"}
args := []interface{}{}
argIdx := 1
if filters.AgentID != nil {
whereClause = append(whereClause, fmt.Sprintf("agent_id = $%d", argIdx))
args = append(args, *filters.AgentID)
argIdx++
}
if filters.Status != "" {
whereClause = append(whereClause, fmt.Sprintf("status = $%d", argIdx))
args = append(args, filters.Status)
argIdx++
}
if filters.Severity != "" {
whereClause = append(whereClause, fmt.Sprintf("severity = $%d", argIdx))
args = append(args, filters.Severity)
argIdx++
}
if filters.PackageType != "" {
whereClause = append(whereClause, fmt.Sprintf("package_type = $%d", argIdx))
args = append(args, filters.PackageType)
argIdx++
}
// Get total count
countQuery := "SELECT COUNT(*) FROM update_packages WHERE " + strings.Join(whereClause, " AND ")
var total int
err := q.db.Get(&total, countQuery, args...)
if err != nil {
return nil, 0, err
}
// Get paginated results
query := fmt.Sprintf(`
SELECT * FROM update_packages
WHERE %s
ORDER BY discovered_at DESC
LIMIT $%d OFFSET $%d
`, strings.Join(whereClause, " AND "), argIdx, argIdx+1)
limit := filters.PageSize
if limit == 0 {
limit = 50
}
offset := (filters.Page - 1) * limit
if offset < 0 {
offset = 0
}
args = append(args, limit, offset)
err = q.db.Select(&updates, query, args...)
return updates, total, err
}
// GetUpdateByID retrieves a single update by ID
func (q *UpdateQueries) GetUpdateByID(id uuid.UUID) (*models.UpdatePackage, error) {
var update models.UpdatePackage
query := `SELECT * FROM update_packages WHERE id = $1`
err := q.db.Get(&update, query, id)
if err != nil {
return nil, err
}
return &update, nil
}
// ApproveUpdate marks an update as approved
func (q *UpdateQueries) ApproveUpdate(id uuid.UUID, approvedBy string) error {
query := `
UPDATE update_packages
SET status = 'approved', approved_by = $1, approved_at = NOW()
WHERE id = $2 AND status = 'pending'
`
_, err := q.db.Exec(query, approvedBy, id)
return err
}
// CreateUpdateLog inserts an update log entry
func (q *UpdateQueries) CreateUpdateLog(log *models.UpdateLog) error {
query := `
INSERT INTO update_logs (
id, agent_id, update_package_id, action, result,
stdout, stderr, exit_code, duration_seconds
) VALUES (
:id, :agent_id, :update_package_id, :action, :result,
:stdout, :stderr, :exit_code, :duration_seconds
)
`
_, err := q.db.NamedExec(query, log)
return err
}

View File

@@ -0,0 +1,105 @@
package models
import (
"database/sql/driver"
"encoding/json"
"time"
"github.com/google/uuid"
)
// Agent represents a registered update agent
type Agent struct {
ID uuid.UUID `json:"id" db:"id"`
Hostname string `json:"hostname" db:"hostname"`
OSType string `json:"os_type" db:"os_type"`
OSVersion string `json:"os_version" db:"os_version"`
OSArchitecture string `json:"os_architecture" db:"os_architecture"`
AgentVersion string `json:"agent_version" db:"agent_version"`
LastSeen time.Time `json:"last_seen" db:"last_seen"`
Status string `json:"status" db:"status"`
Metadata JSONB `json:"metadata" db:"metadata"`
CreatedAt time.Time `json:"created_at" db:"created_at"`
UpdatedAt time.Time `json:"updated_at" db:"updated_at"`
}
// AgentSpecs represents system specifications for an agent
type AgentSpecs struct {
ID uuid.UUID `json:"id" db:"id"`
AgentID uuid.UUID `json:"agent_id" db:"agent_id"`
CPUModel string `json:"cpu_model" db:"cpu_model"`
CPUCores int `json:"cpu_cores" db:"cpu_cores"`
MemoryTotalMB int `json:"memory_total_mb" db:"memory_total_mb"`
DiskTotalGB int `json:"disk_total_gb" db:"disk_total_gb"`
DiskFreeGB int `json:"disk_free_gb" db:"disk_free_gb"`
NetworkInterfaces JSONB `json:"network_interfaces" db:"network_interfaces"`
DockerInstalled bool `json:"docker_installed" db:"docker_installed"`
DockerVersion string `json:"docker_version" db:"docker_version"`
PackageManagers StringArray `json:"package_managers" db:"package_managers"`
CollectedAt time.Time `json:"collected_at" db:"collected_at"`
}
// AgentRegistrationRequest is the payload for agent registration
type AgentRegistrationRequest struct {
Hostname string `json:"hostname" binding:"required"`
OSType string `json:"os_type" binding:"required"`
OSVersion string `json:"os_version"`
OSArchitecture string `json:"os_architecture"`
AgentVersion string `json:"agent_version" binding:"required"`
Metadata map[string]string `json:"metadata"`
}
// AgentRegistrationResponse is returned after successful registration
type AgentRegistrationResponse struct {
AgentID uuid.UUID `json:"agent_id"`
Token string `json:"token"`
Config map[string]interface{} `json:"config"`
}
// JSONB type for PostgreSQL JSONB columns
type JSONB map[string]interface{}
// Value implements driver.Valuer for database storage
func (j JSONB) Value() (driver.Value, error) {
if j == nil {
return nil, nil
}
return json.Marshal(j)
}
// Scan implements sql.Scanner for database retrieval
func (j *JSONB) Scan(value interface{}) error {
if value == nil {
*j = nil
return nil
}
bytes, ok := value.([]byte)
if !ok {
return nil
}
return json.Unmarshal(bytes, j)
}
// StringArray type for PostgreSQL text[] columns
type StringArray []string
// Value implements driver.Valuer
func (s StringArray) Value() (driver.Value, error) {
if s == nil {
return nil, nil
}
return json.Marshal(s)
}
// Scan implements sql.Scanner
func (s *StringArray) Scan(value interface{}) error {
if value == nil {
*s = nil
return nil
}
bytes, ok := value.([]byte)
if !ok {
return nil
}
return json.Unmarshal(bytes, s)
}

View File

@@ -0,0 +1,49 @@
package models
import (
"time"
"github.com/google/uuid"
)
// AgentCommand represents a command to be executed by an agent
type AgentCommand struct {
ID uuid.UUID `json:"id" db:"id"`
AgentID uuid.UUID `json:"agent_id" db:"agent_id"`
CommandType string `json:"command_type" db:"command_type"`
Params JSONB `json:"params" db:"params"`
Status string `json:"status" db:"status"`
CreatedAt time.Time `json:"created_at" db:"created_at"`
SentAt *time.Time `json:"sent_at,omitempty" db:"sent_at"`
CompletedAt *time.Time `json:"completed_at,omitempty" db:"completed_at"`
Result JSONB `json:"result,omitempty" db:"result"`
}
// CommandsResponse is returned when an agent checks in for commands
type CommandsResponse struct {
Commands []CommandItem `json:"commands"`
}
// CommandItem represents a command in the response
type CommandItem struct {
ID string `json:"id"`
Type string `json:"type"`
Params JSONB `json:"params"`
}
// Command types
const (
CommandTypeScanUpdates = "scan_updates"
CommandTypeCollectSpecs = "collect_specs"
CommandTypeInstallUpdate = "install_updates"
CommandTypeRollback = "rollback_update"
CommandTypeUpdateAgent = "update_agent"
)
// Command statuses
const (
CommandStatusPending = "pending"
CommandStatusSent = "sent"
CommandStatusCompleted = "completed"
CommandStatusFailed = "failed"
)

View File

@@ -0,0 +1,88 @@
package models
import (
"time"
"github.com/google/uuid"
)
// UpdatePackage represents a single update available for installation
type UpdatePackage struct {
ID uuid.UUID `json:"id" db:"id"`
AgentID uuid.UUID `json:"agent_id" db:"agent_id"`
PackageType string `json:"package_type" db:"package_type"`
PackageName string `json:"package_name" db:"package_name"`
PackageDescription string `json:"package_description" db:"package_description"`
CurrentVersion string `json:"current_version" db:"current_version"`
AvailableVersion string `json:"available_version" db:"available_version"`
Severity string `json:"severity" db:"severity"`
CVEList StringArray `json:"cve_list" db:"cve_list"`
KBID string `json:"kb_id" db:"kb_id"`
RepositorySource string `json:"repository_source" db:"repository_source"`
SizeBytes int64 `json:"size_bytes" db:"size_bytes"`
Status string `json:"status" db:"status"`
DiscoveredAt time.Time `json:"discovered_at" db:"discovered_at"`
ApprovedBy string `json:"approved_by,omitempty" db:"approved_by"`
ApprovedAt *time.Time `json:"approved_at,omitempty" db:"approved_at"`
ScheduledFor *time.Time `json:"scheduled_for,omitempty" db:"scheduled_for"`
InstalledAt *time.Time `json:"installed_at,omitempty" db:"installed_at"`
ErrorMessage string `json:"error_message,omitempty" db:"error_message"`
Metadata JSONB `json:"metadata" db:"metadata"`
}
// UpdateReportRequest is sent by agents when reporting discovered updates
type UpdateReportRequest struct {
CommandID string `json:"command_id"`
Timestamp time.Time `json:"timestamp"`
Updates []UpdateReportItem `json:"updates"`
}
// UpdateReportItem represents a single update discovered by an agent
type UpdateReportItem struct {
PackageType string `json:"package_type" binding:"required"`
PackageName string `json:"package_name" binding:"required"`
PackageDescription string `json:"package_description"`
CurrentVersion string `json:"current_version"`
AvailableVersion string `json:"available_version" binding:"required"`
Severity string `json:"severity"`
CVEList []string `json:"cve_list"`
KBID string `json:"kb_id"`
RepositorySource string `json:"repository_source"`
SizeBytes int64 `json:"size_bytes"`
Metadata JSONB `json:"metadata"`
}
// UpdateLog represents an execution log entry
type UpdateLog struct {
ID uuid.UUID `json:"id" db:"id"`
AgentID uuid.UUID `json:"agent_id" db:"agent_id"`
UpdatePackageID *uuid.UUID `json:"update_package_id,omitempty" db:"update_package_id"`
Action string `json:"action" db:"action"`
Result string `json:"result" db:"result"`
Stdout string `json:"stdout" db:"stdout"`
Stderr string `json:"stderr" db:"stderr"`
ExitCode int `json:"exit_code" db:"exit_code"`
DurationSeconds int `json:"duration_seconds" db:"duration_seconds"`
ExecutedAt time.Time `json:"executed_at" db:"executed_at"`
}
// UpdateLogRequest is sent by agents when reporting execution results
type UpdateLogRequest struct {
CommandID string `json:"command_id"`
Action string `json:"action" binding:"required"`
Result string `json:"result" binding:"required"`
Stdout string `json:"stdout"`
Stderr string `json:"stderr"`
ExitCode int `json:"exit_code"`
DurationSeconds int `json:"duration_seconds"`
}
// UpdateFilters for querying updates
type UpdateFilters struct {
AgentID *uuid.UUID
Status string
Severity string
PackageType string
Page int
PageSize int
}