WIP: Save current state - security subsystems, migrations, logging

This commit is contained in:
Fimeg
2025-12-16 14:19:59 -05:00
parent f792ab23c7
commit f7c8d23c5d
89 changed files with 8884 additions and 1394 deletions

View File

@@ -1,17 +1,26 @@
# Stage 1: Build server binary
FROM golang:1.23-alpine AS server-builder
FROM golang:1.24-alpine AS server-builder
WORKDIR /app
# Install git for module resolution
RUN apk add --no-cache git
# Copy go.mod and go.sum
COPY aggregator-server/go.mod aggregator-server/go.sum ./
RUN go mod download
COPY aggregator-server/ .
COPY aggregator-server/ ./
RUN CGO_ENABLED=0 go build -o redflag-server cmd/server/main.go
# Stage 2: Build agent binaries for all platforms
FROM golang:1.23-alpine AS agent-builder
FROM golang:1.24-alpine AS agent-builder
WORKDIR /build
# Install git for module resolution
RUN apk add --no-cache git
# Copy agent source code
COPY aggregator-agent/ ./
@@ -30,7 +39,7 @@ RUN CGO_ENABLED=0 GOOS=windows GOARCH=arm64 go build -o binaries/windows-arm64/r
# Stage 3: Final image with server and all agent binaries
FROM alpine:latest
RUN apk --no-cache add ca-certificates tzdata
RUN apk --no-cache add ca-certificates tzdata bash
WORKDIR /app
# Copy server binary
@@ -40,6 +49,11 @@ COPY --from=server-builder /app/internal/database ./internal/database
# Copy all agent binaries
COPY --from=agent-builder /build/binaries ./binaries
# Copy and setup entrypoint script
COPY aggregator-server/docker-entrypoint.sh /usr/local/bin/
RUN chmod +x /usr/local/bin/docker-entrypoint.sh
EXPOSE 8080
ENTRYPOINT ["docker-entrypoint.sh"]
CMD ["./redflag-server"]

View File

@@ -16,6 +16,7 @@ import (
"github.com/Fimeg/RedFlag/aggregator-server/internal/config"
"github.com/Fimeg/RedFlag/aggregator-server/internal/database"
"github.com/Fimeg/RedFlag/aggregator-server/internal/database/queries"
"github.com/Fimeg/RedFlag/aggregator-server/internal/logging"
"github.com/Fimeg/RedFlag/aggregator-server/internal/scheduler"
"github.com/Fimeg/RedFlag/aggregator-server/internal/services"
"github.com/gin-gonic/gin"
@@ -46,6 +47,46 @@ func validateSigningService(signingService *services.SigningService) error {
return nil
}
// isSetupComplete checks if the server has been fully configured
// Returns true if all required components are ready for production
// Components checked: admin credentials, signing keys, database connectivity
func isSetupComplete(cfg *config.Config, signingService *services.SigningService, db *database.DB) bool {
// Check if signing keys are configured
if cfg.SigningPrivateKey == "" {
log.Printf("Setup incomplete: Signing keys not configured")
return false
}
// Check if admin password is configured (not empty)
if cfg.Admin.Password == "" {
log.Printf("Setup incomplete: Admin password not configured")
return false
}
// Check if JWT secret is configured
if cfg.Admin.JWTSecret == "" {
log.Printf("Setup incomplete: JWT secret not configured")
return false
}
// Check if database connection is working
if err := db.DB.Ping(); err != nil {
log.Printf("Setup incomplete: Database not accessible: %v", err)
return false
}
// Check if database has been migrated (check for agents table)
var agentCount int
if err := db.DB.Get(&agentCount, "SELECT COUNT(*) FROM information_schema.tables WHERE table_name = 'agents'"); err != nil {
log.Printf("Setup incomplete: Database migrations not complete - agents table does not exist")
return false
}
// All critical checks passed
log.Printf("Setup validation passed: All required components configured")
return true
}
func startWelcomeModeServer() {
setupHandler := handlers.NewSetupHandler("/app/config")
router := gin.Default()
@@ -70,6 +111,7 @@ func startWelcomeModeServer() {
// Setup endpoint for web configuration
router.POST("/api/setup/configure", setupHandler.ConfigureServer)
router.POST("/api/setup/generate-keys", setupHandler.GenerateSigningKeys)
router.POST("/api/setup/configure-secrets", setupHandler.ConfigureSecrets)
// Setup endpoint for web configuration
router.GET("/setup", setupHandler.ShowSetupPage)
@@ -138,7 +180,7 @@ func main() {
if err := db.Migrate(migrationsPath); err != nil {
log.Fatal("Migration failed:", err)
}
fmt.Printf(" Database migrations completed\n")
fmt.Printf("[OK] Database migrations completed\n")
return
}
@@ -149,25 +191,21 @@ func main() {
// In production, you might want to handle this more gracefully
fmt.Printf("Warning: Migration failed (tables may already exist): %v\n", err)
}
fmt.Println("[OK] Database migrations completed")
// Initialize queries
agentQueries := queries.NewAgentQueries(db.DB)
updateQueries := queries.NewUpdateQueries(db.DB)
commandQueries := queries.NewCommandQueries(db.DB)
refreshTokenQueries := queries.NewRefreshTokenQueries(db.DB)
registrationTokenQueries := queries.NewRegistrationTokenQueries(db.DB)
userQueries := queries.NewUserQueries(db.DB)
subsystemQueries := queries.NewSubsystemQueries(db.DB)
agentUpdateQueries := queries.NewAgentUpdateQueries(db.DB)
metricsQueries := queries.NewMetricsQueries(db.DB.DB)
dockerQueries := queries.NewDockerQueries(db.DB.DB)
adminQueries := queries.NewAdminQueries(db.DB)
// Ensure admin user exists
if err := userQueries.EnsureAdminUser(cfg.Admin.Username, cfg.Admin.Username+"@redflag.local", cfg.Admin.Password); err != nil {
fmt.Printf("Warning: Failed to create admin user: %v\n", err)
} else {
fmt.Println("✅ Admin user ensured")
}
// Create PackageQueries for accessing signed agent update packages
packageQueries := queries.NewPackageQueries(db.DB)
// Initialize services
timezoneService := services.NewTimezoneService(cfg)
@@ -197,23 +235,82 @@ func main() {
log.Printf("[WARNING] No signing private key configured - agent update signing disabled")
log.Printf("[INFO] Generate keys: POST /api/setup/generate-keys")
}
// Initialize default security settings (critical for v0.2.x)
fmt.Println("[OK] Initializing default security settings...")
securitySettingsQueries := queries.NewSecuritySettingsQueries(db.DB)
securitySettingsService, err := services.NewSecuritySettingsService(securitySettingsQueries, signingService)
if err != nil {
fmt.Printf("Warning: Failed to create security settings service: %v\n", err)
fmt.Println("Security settings will need to be configured manually via the dashboard")
} else if err := securitySettingsService.InitializeDefaultSettings(); err != nil {
fmt.Printf("Warning: Failed to initialize default security settings: %v\n", err)
fmt.Println("Security settings will need to be configured manually via the dashboard")
} else {
fmt.Println("[OK] Default security settings initialized")
}
// Check if setup is complete
if !isSetupComplete(cfg, signingService, db) {
serverAddr := cfg.Server.Host
if serverAddr == "" {
serverAddr = "localhost"
}
log.Printf("Server setup incomplete - starting welcome mode")
log.Printf("Setup required: Admin credentials, signing keys, and database configuration")
log.Printf("Access setup at: http://%s:%d/setup", serverAddr, cfg.Server.Port)
startWelcomeModeServer()
return
}
// Initialize admin user from .env configuration
fmt.Println("[OK] Initializing admin user...")
if err := adminQueries.CreateAdminIfNotExists(cfg.Admin.Username, cfg.Admin.Email, cfg.Admin.Password); err != nil {
log.Printf("[ERROR] Failed to initialize admin user: %v", err)
} else {
// Update admin password from .env (runs on every startup to keep in sync)
if err := adminQueries.UpdateAdminPassword(cfg.Admin.Username, cfg.Admin.Password); err != nil {
log.Printf("[WARNING] Failed to update admin password: %v", err)
} else {
fmt.Println("[OK] Admin user initialized")
}
}
// Initialize security logger
secConfig := logging.SecurityLogConfig{
Enabled: true, // Could be configurable in the future
Level: "warning",
LogSuccesses: false,
FilePath: "/var/log/redflag/security.json",
MaxSizeMB: 100,
MaxFiles: 10,
RetentionDays: 90,
LogToDatabase: true,
HashIPAddresses: true,
}
securityLogger, err := logging.NewSecurityLogger(secConfig, db.DB)
if err != nil {
log.Printf("Failed to initialize security logger: %v", err)
securityLogger = nil
}
// Initialize rate limiter
rateLimiter := middleware.NewRateLimiter()
// Initialize handlers
agentHandler := handlers.NewAgentHandler(agentQueries, commandQueries, refreshTokenQueries, registrationTokenQueries, subsystemQueries, cfg.CheckInInterval, cfg.LatestAgentVersion)
updateHandler := handlers.NewUpdateHandler(updateQueries, agentQueries, commandQueries, agentHandler)
authHandler := handlers.NewAuthHandler(cfg.Admin.JWTSecret, userQueries)
// Initialize handlers that don't depend on agentHandler (can be created now)
authHandler := handlers.NewAuthHandler(cfg.Admin.JWTSecret, adminQueries)
statsHandler := handlers.NewStatsHandler(agentQueries, updateQueries)
settingsHandler := handlers.NewSettingsHandler(timezoneService)
dockerHandler := handlers.NewDockerHandler(updateQueries, agentQueries, commandQueries)
dockerHandler := handlers.NewDockerHandler(updateQueries, agentQueries, commandQueries, signingService, securityLogger)
registrationTokenHandler := handlers.NewRegistrationTokenHandler(registrationTokenQueries, agentQueries, cfg)
rateLimitHandler := handlers.NewRateLimitHandler(rateLimiter)
downloadHandler := handlers.NewDownloadHandler(filepath.Join("/app"), cfg)
subsystemHandler := handlers.NewSubsystemHandler(subsystemQueries, commandQueries)
downloadHandler := handlers.NewDownloadHandler(filepath.Join("/app"), cfg, packageQueries)
subsystemHandler := handlers.NewSubsystemHandler(subsystemQueries, commandQueries, signingService, securityLogger)
metricsHandler := handlers.NewMetricsHandler(metricsQueries, agentQueries, commandQueries)
dockerReportsHandler := handlers.NewDockerReportsHandler(dockerQueries, agentQueries, commandQueries)
agentSetupHandler := handlers.NewAgentSetupHandler(agentQueries)
// Initialize scanner config handler (for user-configurable scanner timeouts)
scannerConfigHandler := handlers.NewScannerConfigHandler(db.DB)
// Initialize verification handler
var verificationHandler *handlers.VerificationHandler
@@ -234,18 +331,20 @@ func main() {
}
}
// Initialize agent update handler
var agentUpdateHandler *handlers.AgentUpdateHandler
if signingService != nil {
agentUpdateHandler = handlers.NewAgentUpdateHandler(agentQueries, agentUpdateQueries, commandQueries, signingService, updateNonceService, agentHandler)
}
// Initialize system handler
systemHandler := handlers.NewSystemHandler(signingService)
// Initialize security handler
securityHandler := handlers.NewSecurityHandler(signingService, agentQueries, commandQueries)
// Initialize security settings service and handler
securitySettingsService, err = services.NewSecuritySettingsService(securitySettingsQueries, signingService)
if err != nil {
log.Printf("[ERROR] Failed to initialize security settings service: %v", err)
securitySettingsService = nil
} else {
log.Printf("[OK] Security settings service initialized")
}
// Setup router
router := gin.Default()
@@ -272,156 +371,25 @@ func main() {
api.GET("/public-key", rateLimiter.RateLimit("public_access", middleware.KeyByIP), systemHandler.GetPublicKey)
api.GET("/info", rateLimiter.RateLimit("public_access", middleware.KeyByIP), systemHandler.GetSystemInfo)
// Public routes (no authentication required, with rate limiting)
api.POST("/agents/register", rateLimiter.RateLimit("agent_registration", middleware.KeyByIP), agentHandler.RegisterAgent)
api.POST("/agents/renew", rateLimiter.RateLimit("public_access", middleware.KeyByIP), agentHandler.RenewToken)
// Agent setup routes (no authentication required, with rate limiting)
api.POST("/setup/agent", rateLimiter.RateLimit("agent_setup", middleware.KeyByIP), handlers.SetupAgent)
api.GET("/setup/templates", rateLimiter.RateLimit("public_access", middleware.KeyByIP), handlers.GetTemplates)
api.POST("/setup/validate", rateLimiter.RateLimit("agent_setup", middleware.KeyByIP), handlers.ValidateConfiguration)
api.POST("/setup/agent", rateLimiter.RateLimit("agent_setup", middleware.KeyByIP), agentSetupHandler.SetupAgent)
api.GET("/setup/templates", rateLimiter.RateLimit("public_access", middleware.KeyByIP), agentSetupHandler.GetTemplates)
api.POST("/setup/validate", rateLimiter.RateLimit("agent_setup", middleware.KeyByIP), agentSetupHandler.ValidateConfiguration)
// Build orchestrator routes (admin-only)
buildRoutes := api.Group("/build")
buildRoutes.Use(authHandler.WebAuthMiddleware())
{
buildRoutes.POST("/new", rateLimiter.RateLimit("agent_build", middleware.KeyByIP), handlers.NewAgentBuild)
buildRoutes.POST("/upgrade/:agentID", rateLimiter.RateLimit("agent_build", middleware.KeyByIP), handlers.UpgradeAgentBuild)
buildRoutes.POST("/detect", rateLimiter.RateLimit("agent_build", middleware.KeyByIP), handlers.DetectAgentInstallation)
buildRoutes.POST("/new", rateLimiter.RateLimit("agent_build", middleware.KeyByAgentID), handlers.NewAgentBuild)
buildRoutes.POST("/upgrade/:agentID", rateLimiter.RateLimit("agent_build", middleware.KeyByAgentID), handlers.UpgradeAgentBuild)
buildRoutes.POST("/detect", rateLimiter.RateLimit("agent_build", middleware.KeyByAgentID), handlers.DetectAgentInstallation)
}
// Public download routes (no authentication - agents need these!)
api.GET("/downloads/:platform", rateLimiter.RateLimit("public_access", middleware.KeyByIP), downloadHandler.DownloadAgent)
api.GET("/downloads/updates/:package_id", rateLimiter.RateLimit("public_access", middleware.KeyByIP), downloadHandler.DownloadUpdatePackage)
api.GET("/downloads/config/:agent_id", rateLimiter.RateLimit("public_access", middleware.KeyByIP), downloadHandler.HandleConfigDownload)
api.GET("/install/:platform", rateLimiter.RateLimit("public_access", middleware.KeyByIP), downloadHandler.InstallScript)
// Protected agent routes (with machine binding security)
agents := api.Group("/agents")
agents.Use(middleware.AuthMiddleware())
agents.Use(middleware.MachineBindingMiddleware(agentQueries, cfg.MinAgentVersion)) // v0.1.22: Prevent config copying
{
agents.GET("/:id/commands", agentHandler.GetCommands)
agents.GET("/:id/config", agentHandler.GetAgentConfig)
agents.POST("/:id/updates", rateLimiter.RateLimit("agent_reports", middleware.KeyByAgentID), updateHandler.ReportUpdates)
agents.POST("/:id/logs", rateLimiter.RateLimit("agent_reports", middleware.KeyByAgentID), updateHandler.ReportLog)
agents.POST("/:id/dependencies", rateLimiter.RateLimit("agent_reports", middleware.KeyByAgentID), updateHandler.ReportDependencies)
agents.POST("/:id/system-info", rateLimiter.RateLimit("agent_reports", middleware.KeyByAgentID), agentHandler.ReportSystemInfo)
agents.POST("/:id/rapid-mode", rateLimiter.RateLimit("agent_reports", middleware.KeyByAgentID), agentHandler.SetRapidPollingMode)
agents.POST("/:id/verify-signature", rateLimiter.RateLimit("agent_reports", middleware.KeyByAgentID), func(c *gin.Context) {
if verificationHandler == nil {
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "signature verification service not available"})
return
}
verificationHandler.VerifySignature(c)
})
agents.DELETE("/:id", agentHandler.UnregisterAgent)
// New dedicated endpoints for metrics and docker images (data classification fix)
agents.POST("/:id/metrics", rateLimiter.RateLimit("agent_reports", middleware.KeyByAgentID), metricsHandler.ReportMetrics)
agents.POST("/:id/docker-images", rateLimiter.RateLimit("agent_reports", middleware.KeyByAgentID), dockerReportsHandler.ReportDockerImages)
}
// Dashboard/Web routes (protected by web auth)
dashboard := api.Group("/")
dashboard.Use(authHandler.WebAuthMiddleware())
{
dashboard.GET("/stats/summary", statsHandler.GetDashboardStats)
dashboard.GET("/agents", agentHandler.ListAgents)
dashboard.GET("/agents/:id", agentHandler.GetAgent)
dashboard.POST("/agents/:id/scan", agentHandler.TriggerScan)
dashboard.POST("/agents/:id/heartbeat", agentHandler.TriggerHeartbeat)
dashboard.GET("/agents/:id/heartbeat", agentHandler.GetHeartbeatStatus)
dashboard.POST("/agents/:id/reboot", agentHandler.TriggerReboot)
// Subsystem routes for web dashboard
dashboard.GET("/agents/:id/subsystems", subsystemHandler.GetSubsystems)
dashboard.GET("/agents/:id/subsystems/:subsystem", subsystemHandler.GetSubsystem)
dashboard.PATCH("/agents/:id/subsystems/:subsystem", subsystemHandler.UpdateSubsystem)
dashboard.POST("/agents/:id/subsystems/:subsystem/enable", subsystemHandler.EnableSubsystem)
dashboard.POST("/agents/:id/subsystems/:subsystem/disable", subsystemHandler.DisableSubsystem)
dashboard.POST("/agents/:id/subsystems/:subsystem/trigger", subsystemHandler.TriggerSubsystem)
dashboard.GET("/agents/:id/subsystems/:subsystem/stats", subsystemHandler.GetSubsystemStats)
dashboard.POST("/agents/:id/subsystems/:subsystem/auto-run", subsystemHandler.SetAutoRun)
dashboard.POST("/agents/:id/subsystems/:subsystem/interval", subsystemHandler.SetInterval)
dashboard.GET("/updates", updateHandler.ListUpdates)
dashboard.GET("/updates/:id", updateHandler.GetUpdate)
dashboard.GET("/updates/:id/logs", updateHandler.GetUpdateLogs)
dashboard.POST("/updates/:id/approve", updateHandler.ApproveUpdate)
dashboard.POST("/updates/approve", updateHandler.ApproveUpdates)
dashboard.POST("/updates/:id/reject", updateHandler.RejectUpdate)
dashboard.POST("/updates/:id/install", updateHandler.InstallUpdate)
dashboard.POST("/updates/:id/confirm-dependencies", updateHandler.ConfirmDependencies)
// Agent update routes
if agentUpdateHandler != nil {
dashboard.POST("/agents/:id/update", agentUpdateHandler.UpdateAgent)
dashboard.POST("/agents/:id/update-nonce", agentUpdateHandler.GenerateUpdateNonce)
dashboard.POST("/agents/bulk-update", agentUpdateHandler.BulkUpdateAgents)
dashboard.GET("/updates/packages", agentUpdateHandler.ListUpdatePackages)
dashboard.POST("/updates/packages/sign", agentUpdateHandler.SignUpdatePackage)
dashboard.GET("/agents/:id/updates/available", agentUpdateHandler.CheckForUpdateAvailable)
dashboard.GET("/agents/:id/updates/status", agentUpdateHandler.GetUpdateStatus)
}
// Log routes
dashboard.GET("/logs", updateHandler.GetAllLogs)
dashboard.GET("/logs/active", updateHandler.GetActiveOperations)
// Command routes
dashboard.GET("/commands/active", updateHandler.GetActiveCommands)
dashboard.GET("/commands/recent", updateHandler.GetRecentCommands)
dashboard.POST("/commands/:id/retry", updateHandler.RetryCommand)
dashboard.POST("/commands/:id/cancel", updateHandler.CancelCommand)
dashboard.DELETE("/commands/failed", updateHandler.ClearFailedCommands)
// Settings routes
dashboard.GET("/settings/timezone", settingsHandler.GetTimezone)
dashboard.GET("/settings/timezones", settingsHandler.GetTimezones)
dashboard.PUT("/settings/timezone", settingsHandler.UpdateTimezone)
// Docker routes
dashboard.GET("/docker/containers", dockerHandler.GetContainers)
dashboard.GET("/docker/stats", dockerHandler.GetStats)
dashboard.POST("/docker/containers/:container_id/images/:image_id/approve", dockerHandler.ApproveUpdate)
dashboard.POST("/docker/containers/:container_id/images/:image_id/reject", dockerHandler.RejectUpdate)
dashboard.POST("/docker/containers/:container_id/images/:image_id/install", dockerHandler.InstallUpdate)
// Metrics and Docker images routes (data classification fix)
dashboard.GET("/agents/:id/metrics", metricsHandler.GetAgentMetrics)
dashboard.GET("/agents/:id/metrics/storage", metricsHandler.GetAgentStorageMetrics)
dashboard.GET("/agents/:id/metrics/system", metricsHandler.GetAgentSystemMetrics)
dashboard.GET("/agents/:id/docker-images", dockerReportsHandler.GetAgentDockerImages)
dashboard.GET("/agents/:id/docker-info", dockerReportsHandler.GetAgentDockerInfo)
// Admin/Registration Token routes (for agent enrollment management)
admin := dashboard.Group("/admin")
{
admin.POST("/registration-tokens", rateLimiter.RateLimit("admin_token_gen", middleware.KeyByUserID), registrationTokenHandler.GenerateRegistrationToken)
admin.GET("/registration-tokens", rateLimiter.RateLimit("admin_operations", middleware.KeyByUserID), registrationTokenHandler.ListRegistrationTokens)
admin.GET("/registration-tokens/active", rateLimiter.RateLimit("admin_operations", middleware.KeyByUserID), registrationTokenHandler.GetActiveRegistrationTokens)
admin.DELETE("/registration-tokens/:token", rateLimiter.RateLimit("admin_operations", middleware.KeyByUserID), registrationTokenHandler.RevokeRegistrationToken)
admin.DELETE("/registration-tokens/delete/:id", rateLimiter.RateLimit("admin_operations", middleware.KeyByUserID), registrationTokenHandler.DeleteRegistrationToken)
admin.POST("/registration-tokens/cleanup", rateLimiter.RateLimit("admin_operations", middleware.KeyByUserID), registrationTokenHandler.CleanupExpiredTokens)
admin.GET("/registration-tokens/stats", rateLimiter.RateLimit("admin_operations", middleware.KeyByUserID), registrationTokenHandler.GetTokenStats)
admin.GET("/registration-tokens/validate", rateLimiter.RateLimit("admin_operations", middleware.KeyByUserID), registrationTokenHandler.ValidateRegistrationToken)
// Rate Limit Management
admin.GET("/rate-limits", rateLimiter.RateLimit("admin_operations", middleware.KeyByUserID), rateLimitHandler.GetRateLimitSettings)
admin.PUT("/rate-limits", rateLimiter.RateLimit("admin_operations", middleware.KeyByUserID), rateLimitHandler.UpdateRateLimitSettings)
admin.POST("/rate-limits/reset", rateLimiter.RateLimit("admin_operations", middleware.KeyByUserID), rateLimitHandler.ResetRateLimitSettings)
admin.GET("/rate-limits/stats", rateLimiter.RateLimit("admin_operations", middleware.KeyByUserID), rateLimitHandler.GetRateLimitStats)
admin.POST("/rate-limits/cleanup", rateLimiter.RateLimit("admin_operations", middleware.KeyByUserID), rateLimitHandler.CleanupRateLimitEntries)
}
// Security Health Check endpoints
dashboard.GET("/security/overview", securityHandler.SecurityOverview)
dashboard.GET("/security/signing", securityHandler.SigningStatus)
dashboard.GET("/security/nonce", securityHandler.NonceValidationStatus)
dashboard.GET("/security/commands", securityHandler.CommandValidationStatus)
dashboard.GET("/security/machine-binding", securityHandler.MachineBindingStatus)
dashboard.GET("/security/metrics", securityHandler.SecurityMetrics)
}
}
// Start background goroutine to mark offline agents
@@ -452,6 +420,167 @@ func main() {
schedulerConfig := scheduler.DefaultConfig()
subsystemScheduler := scheduler.NewScheduler(schedulerConfig, agentQueries, commandQueries, subsystemQueries)
// Initialize agentHandler now that scheduler is available
agentHandler := handlers.NewAgentHandler(agentQueries, commandQueries, refreshTokenQueries, registrationTokenQueries, subsystemQueries, subsystemScheduler, signingService, securityLogger, cfg.CheckInInterval, cfg.LatestAgentVersion)
// Initialize agent update handler now that agentHandler is available
var agentUpdateHandler *handlers.AgentUpdateHandler
if signingService != nil {
agentUpdateHandler = handlers.NewAgentUpdateHandler(agentQueries, agentUpdateQueries, commandQueries, signingService, updateNonceService, agentHandler)
}
// Initialize updateHandler with the agentHandler reference
updateHandler := handlers.NewUpdateHandler(updateQueries, agentQueries, commandQueries, agentHandler)
// Add routes that depend on agentHandler (must be after agentHandler creation)
api.POST("/agents/register", rateLimiter.RateLimit("agent_registration", middleware.KeyByIP), agentHandler.RegisterAgent)
api.POST("/agents/renew", rateLimiter.RateLimit("public_access", middleware.KeyByIP), agentHandler.RenewToken)
// Protected agent routes (with machine binding security)
agents := api.Group("/agents")
agents.Use(middleware.AuthMiddleware())
agents.Use(middleware.MachineBindingMiddleware(agentQueries, cfg.MinAgentVersion)) // v0.1.22: Prevent config copying
{
agents.GET("/:id/commands", agentHandler.GetCommands)
agents.GET("/:id/config", agentHandler.GetAgentConfig)
agents.POST("/:id/updates", rateLimiter.RateLimit("agent_reports", middleware.KeyByAgentID), updateHandler.ReportUpdates)
agents.POST("/:id/logs", rateLimiter.RateLimit("agent_reports", middleware.KeyByAgentID), updateHandler.ReportLog)
agents.POST("/:id/dependencies", rateLimiter.RateLimit("agent_reports", middleware.KeyByAgentID), updateHandler.ReportDependencies)
agents.POST("/:id/system-info", rateLimiter.RateLimit("agent_reports", middleware.KeyByAgentID), agentHandler.ReportSystemInfo)
agents.POST("/:id/rapid-mode", rateLimiter.RateLimit("agent_reports", middleware.KeyByAgentID), agentHandler.SetRapidPollingMode)
agents.POST("/:id/verify-signature", rateLimiter.RateLimit("agent_reports", middleware.KeyByAgentID), func(c *gin.Context) {
if verificationHandler == nil {
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "signature verification service not available"})
return
}
verificationHandler.VerifySignature(c)
})
agents.DELETE("/:id", agentHandler.UnregisterAgent)
// New dedicated endpoints for metrics and docker images (data classification fix)
agents.POST("/:id/metrics", rateLimiter.RateLimit("agent_reports", middleware.KeyByAgentID), metricsHandler.ReportMetrics)
agents.POST("/:id/docker-images", rateLimiter.RateLimit("agent_reports", middleware.KeyByAgentID), dockerReportsHandler.ReportDockerImages)
}
// Dashboard/Web routes (protected by web auth)
dashboard := api.Group("/")
dashboard.Use(authHandler.WebAuthMiddleware())
{
dashboard.GET("/stats/summary", statsHandler.GetDashboardStats)
dashboard.GET("/agents", agentHandler.ListAgents)
dashboard.GET("/agents/:id", agentHandler.GetAgent)
dashboard.POST("/agents/:id/scan", agentHandler.TriggerScan)
dashboard.POST("/agents/:id/heartbeat", agentHandler.TriggerHeartbeat)
dashboard.GET("/agents/:id/heartbeat", agentHandler.GetHeartbeatStatus)
dashboard.POST("/agents/:id/reboot", agentHandler.TriggerReboot)
// Subsystem routes for web dashboard
dashboard.GET("/agents/:id/subsystems", subsystemHandler.GetSubsystems)
dashboard.GET("/agents/:id/subsystems/:subsystem", subsystemHandler.GetSubsystem)
dashboard.PATCH("/agents/:id/subsystems/:subsystem", subsystemHandler.UpdateSubsystem)
dashboard.POST("/agents/:id/subsystems/:subsystem/enable", subsystemHandler.EnableSubsystem)
dashboard.POST("/agents/:id/subsystems/:subsystem/disable", subsystemHandler.DisableSubsystem)
dashboard.POST("/agents/:id/subsystems/:subsystem/trigger", subsystemHandler.TriggerSubsystem)
dashboard.GET("/agents/:id/subsystems/:subsystem/stats", subsystemHandler.GetSubsystemStats)
dashboard.POST("/agents/:id/subsystems/:subsystem/auto-run", subsystemHandler.SetAutoRun)
dashboard.POST("/agents/:id/subsystems/:subsystem/interval", subsystemHandler.SetInterval)
dashboard.GET("/updates", updateHandler.ListUpdates)
dashboard.GET("/updates/:id", updateHandler.GetUpdate)
dashboard.GET("/updates/:id/logs", updateHandler.GetUpdateLogs)
dashboard.POST("/updates/:id/approve", updateHandler.ApproveUpdate)
dashboard.POST("/updates/approve", updateHandler.ApproveUpdates)
dashboard.POST("/updates/:id/reject", updateHandler.RejectUpdate)
dashboard.POST("/updates/:id/install", updateHandler.InstallUpdate)
dashboard.POST("/updates/:id/confirm-dependencies", updateHandler.ConfirmDependencies)
// Agent update routes
if agentUpdateHandler != nil {
dashboard.POST("/agents/:id/update", agentUpdateHandler.UpdateAgent)
dashboard.POST("/agents/:id/update-nonce", agentUpdateHandler.GenerateUpdateNonce)
dashboard.POST("/agents/bulk-update", agentUpdateHandler.BulkUpdateAgents)
dashboard.GET("/updates/packages", agentUpdateHandler.ListUpdatePackages)
dashboard.POST("/updates/packages/sign", agentUpdateHandler.SignUpdatePackage)
dashboard.GET("/agents/:id/updates/available", agentUpdateHandler.CheckForUpdateAvailable)
dashboard.GET("/agents/:id/updates/status", agentUpdateHandler.GetUpdateStatus)
}
dashboard.GET("/logs", updateHandler.GetAllLogs)
dashboard.GET("/logs/active", updateHandler.GetActiveOperations)
// Command routes
dashboard.GET("/commands/active", updateHandler.GetActiveCommands)
dashboard.GET("/commands/recent", updateHandler.GetRecentCommands)
dashboard.POST("/commands/:id/retry", updateHandler.RetryCommand)
dashboard.POST("/commands/:id/cancel", updateHandler.CancelCommand)
dashboard.DELETE("/commands/failed", updateHandler.ClearFailedCommands)
// Settings routes
dashboard.GET("/settings/timezone", settingsHandler.GetTimezone)
dashboard.GET("/settings/timezones", settingsHandler.GetTimezones)
dashboard.PUT("/settings/timezone", settingsHandler.UpdateTimezone)
// Docker routes
dashboard.GET("/docker/containers", dockerHandler.GetContainers)
dashboard.GET("/docker/stats", dockerHandler.GetStats)
dashboard.POST("/docker/containers/:container_id/images/:image_id/approve", dockerHandler.ApproveUpdate)
dashboard.POST("/docker/containers/:container_id/images/:image_id/reject", dockerHandler.RejectUpdate)
dashboard.POST("/docker/containers/:container_id/images/:image_id/install", dockerHandler.InstallUpdate)
// Metrics and Docker images routes (data classification fix)
dashboard.GET("/agents/:id/metrics", metricsHandler.GetAgentMetrics)
dashboard.GET("/agents/:id/metrics/storage", metricsHandler.GetAgentStorageMetrics)
dashboard.GET("/agents/:id/metrics/system", metricsHandler.GetAgentSystemMetrics)
dashboard.GET("/agents/:id/docker-images", dockerReportsHandler.GetAgentDockerImages)
dashboard.GET("/agents/:id/docker-info", dockerReportsHandler.GetAgentDockerInfo)
// Admin/Registration Token routes (for agent enrollment management)
admin := dashboard.Group("/admin")
{
admin.POST("/registration-tokens", rateLimiter.RateLimit("admin_token_gen", middleware.KeyByUserID), registrationTokenHandler.GenerateRegistrationToken)
admin.GET("/registration-tokens", rateLimiter.RateLimit("admin_operations", middleware.KeyByUserID), registrationTokenHandler.ListRegistrationTokens)
admin.GET("/registration-tokens/active", rateLimiter.RateLimit("admin_operations", middleware.KeyByUserID), registrationTokenHandler.GetActiveRegistrationTokens)
admin.DELETE("/registration-tokens/:token", rateLimiter.RateLimit("admin_operations", middleware.KeyByUserID), registrationTokenHandler.RevokeRegistrationToken)
admin.DELETE("/registration-tokens/delete/:id", rateLimiter.RateLimit("admin_operations", middleware.KeyByUserID), registrationTokenHandler.DeleteRegistrationToken)
admin.POST("/registration-tokens/cleanup", rateLimiter.RateLimit("admin_operations", middleware.KeyByUserID), registrationTokenHandler.CleanupExpiredTokens)
admin.GET("/registration-tokens/stats", rateLimiter.RateLimit("admin_operations", middleware.KeyByUserID), registrationTokenHandler.GetTokenStats)
admin.GET("/registration-tokens/validate", rateLimiter.RateLimit("admin_operations", middleware.KeyByUserID), registrationTokenHandler.ValidateRegistrationToken)
// Rate Limit Management
admin.GET("/rate-limits", rateLimiter.RateLimit("admin_operations", middleware.KeyByUserID), rateLimitHandler.GetRateLimitSettings)
admin.PUT("/rate-limits", rateLimiter.RateLimit("admin_operations", middleware.KeyByUserID), rateLimitHandler.UpdateRateLimitSettings)
admin.POST("/rate-limits/reset", rateLimiter.RateLimit("admin_operations", middleware.KeyByUserID), rateLimitHandler.ResetRateLimitSettings)
admin.GET("/rate-limits/stats", rateLimiter.RateLimit("admin_operations", middleware.KeyByUserID), rateLimitHandler.GetRateLimitStats)
admin.POST("/rate-limits/cleanup", rateLimiter.RateLimit("admin_operations", middleware.KeyByUserID), rateLimitHandler.CleanupRateLimitEntries)
// Scanner Configuration (user-configurable timeouts)
admin.GET("/scanner-timeouts", rateLimiter.RateLimit("admin_operations", middleware.KeyByUserID), scannerConfigHandler.GetScannerTimeouts)
admin.PUT("/scanner-timeouts/:scanner_name", rateLimiter.RateLimit("admin_operations", middleware.KeyByUserID), scannerConfigHandler.UpdateScannerTimeout)
admin.POST("/scanner-timeouts/:scanner_name/reset", rateLimiter.RateLimit("admin_operations", middleware.KeyByUserID), scannerConfigHandler.ResetScannerTimeout)
}
// Security Health Check endpoints
dashboard.GET("/security/overview", securityHandler.SecurityOverview)
dashboard.GET("/security/signing", securityHandler.SigningStatus)
dashboard.GET("/security/nonce", securityHandler.NonceValidationStatus)
dashboard.GET("/security/commands", securityHandler.CommandValidationStatus)
dashboard.GET("/security/machine-binding", securityHandler.MachineBindingStatus)
dashboard.GET("/security/metrics", securityHandler.SecurityMetrics)
// Security Settings Management endpoints (admin-only)
// securitySettings := dashboard.Group("/security/settings")
// securitySettings.Use(middleware.RequireAdmin())
// {
// securitySettings.GET("", securitySettingsHandler.GetAllSecuritySettings)
// securitySettings.GET("/audit", securitySettingsHandler.GetSecurityAuditTrail)
// securitySettings.GET("/overview", securitySettingsHandler.GetSecurityOverview)
// securitySettings.GET("/:category", securitySettingsHandler.GetSecuritySettingsByCategory)
// securitySettings.PUT("/:category/:key", securitySettingsHandler.UpdateSecuritySetting)
// securitySettings.POST("/validate", securitySettingsHandler.ValidateSecuritySettings)
// securitySettings.POST("/apply", securitySettingsHandler.ApplySecuritySettings)
// }
}
// Load subsystems into queue
ctx := context.Background()
if err := subsystemScheduler.LoadSubsystems(ctx); err != nil {

View File

@@ -1,48 +1,71 @@
module github.com/Fimeg/RedFlag/aggregator-server
go 1.23.0
go 1.24.0
require (
github.com/docker/docker v25.0.6+incompatible
github.com/gin-gonic/gin v1.11.0
github.com/golang-jwt/jwt/v5 v5.3.0
github.com/google/uuid v1.6.0
github.com/jmoiron/sqlx v1.4.0
github.com/lib/pq v1.10.9
golang.org/x/crypto v0.40.0
golang.org/x/crypto v0.44.0
gopkg.in/natefinch/lumberjack.v2 v2.2.1
)
require (
github.com/Fimeg/RedFlag/aggregator v0.0.0
github.com/Microsoft/go-winio v0.6.2 // indirect
github.com/alexedwards/argon2id v1.0.0 // indirect
github.com/bytedance/sonic v1.14.0 // indirect
github.com/bytedance/sonic/loader v0.3.0 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/cloudwego/base64x v0.1.6 // indirect
github.com/containerd/log v0.1.0 // indirect
github.com/distribution/reference v0.6.0 // indirect
github.com/docker/go-connections v0.4.0 // indirect
github.com/docker/go-units v0.5.0 // indirect
github.com/felixge/httpsnoop v1.0.4 // indirect
github.com/gabriel-vasile/mimetype v1.4.8 // indirect
github.com/gin-contrib/sse v1.1.0 // indirect
github.com/go-logr/logr v1.4.3 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-playground/validator/v10 v10.27.0 // indirect
github.com/goccy/go-json v0.10.2 // indirect
github.com/goccy/go-yaml v1.18.0 // indirect
github.com/gogo/protobuf v1.3.2 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
github.com/leodido/go-urn v1.4.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/moby/term v0.5.2 // indirect
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421 // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/morikuni/aec v1.1.0 // indirect
github.com/opencontainers/go-digest v1.0.0 // indirect
github.com/opencontainers/image-spec v1.1.1 // indirect
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/quic-go/qpack v0.5.1 // indirect
github.com/quic-go/quic-go v0.54.0 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.3.0 // indirect
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.64.0 // indirect
go.opentelemetry.io/otel v1.39.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.39.0 // indirect
go.opentelemetry.io/otel/metric v1.39.0 // indirect
go.opentelemetry.io/otel/trace v1.39.0 // indirect
go.uber.org/mock v0.5.0 // indirect
golang.org/x/arch v0.20.0 // indirect
golang.org/x/mod v0.25.0 // indirect
golang.org/x/net v0.42.0 // indirect
golang.org/x/sync v0.16.0 // indirect
golang.org/x/sys v0.35.0 // indirect
golang.org/x/text v0.27.0 // indirect
golang.org/x/tools v0.34.0 // indirect
google.golang.org/protobuf v1.36.9 // indirect
golang.org/x/mod v0.29.0 // indirect
golang.org/x/net v0.47.0 // indirect
golang.org/x/sync v0.18.0 // indirect
golang.org/x/sys v0.39.0 // indirect
golang.org/x/text v0.31.0 // indirect
golang.org/x/time v0.14.0 // indirect
golang.org/x/tools v0.38.0 // indirect
google.golang.org/protobuf v1.36.10 // indirect
gotest.tools/v3 v3.5.2 // indirect
)
replace github.com/Fimeg/RedFlag/aggregator => ../aggregator

View File

@@ -1,20 +1,47 @@
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c h1:udKWzYgxTojEKWjV8V+WSxDXJ4NFATAsZjh8iIbsQIg=
github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E=
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
github.com/alexedwards/argon2id v1.0.0 h1:wJzDx66hqWX7siL/SRUmgz3F8YMrd/nfX/xHHcQQP0w=
github.com/alexedwards/argon2id v1.0.0/go.mod h1:tYKkqIjzXvZdzPvADMWOEZ+l6+BD6CtBXMj5fnJppiw=
github.com/bytedance/sonic v1.14.0 h1:/OfKt8HFw0kh2rj8N0F6C/qPGRESq0BbaNZgcNXXzQQ=
github.com/bytedance/sonic v1.14.0/go.mod h1:WoEbx8WTcFJfzCe0hbmyTGrfjt8PzNEBdxlNUO24NhA=
github.com/bytedance/sonic/loader v0.3.0 h1:dskwH8edlzNMctoruo8FPTJDF3vLtDT0sXZwvZJyqeA=
github.com/bytedance/sonic/loader v0.3.0/go.mod h1:N8A3vUdtUebEY2/VQC0MyhYeKUFosQU6FxH2JmUe6VI=
github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM=
github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M=
github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU=
github.com/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I=
github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk=
github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E=
github.com/docker/docker v25.0.6+incompatible h1:5cPwbwriIcsua2REJe8HqQV+6WlWc1byg2QSXzBxBGg=
github.com/docker/docker v25.0.6+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk=
github.com/docker/go-connections v0.4.0 h1:El9xVISelRB7BuFusrZozjnkIM5YnzCViNKohAFqRJQ=
github.com/docker/go-connections v0.4.0/go.mod h1:Gbd7IOopHjR8Iph03tsViu4nIes5XhDvyHbTtUxmeec=
github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4=
github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk=
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
github.com/gabriel-vasile/mimetype v1.4.8 h1:FfZ3gj38NjllZIeJAmMhr+qKL8Wu+nOoI3GqacKw1NM=
github.com/gabriel-vasile/mimetype v1.4.8/go.mod h1:ByKUIKGjh1ODkGM1asKUbQZOLGrPjydw3hYPU2YU9t8=
github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w=
github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM=
github.com/gin-gonic/gin v1.11.0 h1:OW/6PLjyusp2PPXtyxKHU0RbX6I/l28FTdDlae5ueWk=
github.com/gin-gonic/gin v1.11.0/go.mod h1:+iq/FyxlGzII0KHiBGjuNn4UNENUlKbGlNmc+W50Dls=
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
@@ -29,6 +56,8 @@ github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
github.com/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw=
github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo=
github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
@@ -36,10 +65,14 @@ github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.3 h1:NmZ1PKzSTQbuGHw9DGPFomqkkLWMC+vZCkfs+FHv1Vg=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.3/go.mod h1:zQrxl1YP88HQlA6i9c63DSVPFklWpGX4OWAc9bFuaH4=
github.com/jmoiron/sqlx v1.4.0 h1:1PLqN7S1UYp5t4SrVVnt4nUVNemrDAtxlulVe+Qgm3o=
github.com/jmoiron/sqlx v1.4.0/go.mod h1:ZrZ7UsYB/weZdl2Bxg6jCRO9c3YHl8r3ahlKmRT4JLY=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8=
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
@@ -50,18 +83,30 @@ github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWE
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU=
github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/moby/term v0.5.2 h1:6qk3FJAFDs6i/q3W/pQ97SX192qKfZgGjCQqfCJkgzQ=
github.com/moby/term v0.5.2/go.mod h1:d3djjFCrjnB+fl8NJux+EJzu0msscUP+f8it8hPkFLc=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421 h1:ZqeYNhU3OHLH3mGKHDcjJRFFRrJa6eAM5H+CtDdOsPc=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/morikuni/aec v1.1.0 h1:vBBl0pUnvi/Je71dsRrhMBtreIqNMYErSAbEeb8jrXQ=
github.com/morikuni/aec v1.1.0/go.mod h1:xDRgiq/iw5l+zkao76YTKzKttOp2cwPEne25HDkJnBw=
github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U=
github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=
github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040=
github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M=
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/quic-go/qpack v0.5.1 h1:giqksBPnT/HDtZ6VhtFKgoLOWmlyo9Ei6u9PqzIMbhI=
github.com/quic-go/qpack v0.5.1/go.mod h1:+PC4XFrEskIVkcLzpEkbLqq1uCoxPhQuvK5rH1ZgaEg=
github.com/quic-go/quic-go v0.54.0 h1:6s1YB9QotYI6Ospeiguknbp2Znb/jZYjZLRXn9kMQBg=
github.com/quic-go/quic-go v0.54.0/go.mod h1:e68ZEaCdyviluZmy44P6Iey98v/Wfz6HCjQEm+l8zTY=
github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
@@ -75,28 +120,116 @@ github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
github.com/ugorji/go/codec v1.3.0 h1:Qd2W2sQawAfG8XSvzwhBeoGq71zXOC/Q1E9y/wUcsUA=
github.com/ugorji/go/codec v1.3.0/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.64.0 h1:ssfIgGNANqpVFCndZvcuyKbl0g+UAVcbBcqGkG28H0Y=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.64.0/go.mod h1:GQ/474YrbE4Jx8gZ4q5I4hrhUzM6UPzyrqJYV2AqPoQ=
go.opentelemetry.io/otel v1.39.0 h1:8yPrr/S0ND9QEfTfdP9V+SiwT4E0G7Y5MO7p85nis48=
go.opentelemetry.io/otel v1.39.0/go.mod h1:kLlFTywNWrFyEdH0oj2xK0bFYZtHRYUdv1NklR/tgc8=
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.39.0 h1:f0cb2XPmrqn4XMy9PNliTgRKJgS5WcL/u0/WRYGz4t0=
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.39.0/go.mod h1:vnakAaFckOMiMtOIhFI2MNH4FYrZzXCYxmb1LlhoGz8=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.39.0 h1:Ckwye2FpXkYgiHX7fyVrN1uA/UYd9ounqqTuSNAv0k4=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.39.0/go.mod h1:teIFJh5pW2y+AN7riv6IBPX2DuesS3HgP39mwOspKwU=
go.opentelemetry.io/otel/metric v1.39.0 h1:d1UzonvEZriVfpNKEVmHXbdf909uGTOQjA0HF0Ls5Q0=
go.opentelemetry.io/otel/metric v1.39.0/go.mod h1:jrZSWL33sD7bBxg1xjrqyDjnuzTUB0x1nBERXd7Ftcs=
go.opentelemetry.io/otel/sdk v1.39.0 h1:nMLYcjVsvdui1B/4FRkwjzoRVsMK8uL/cj0OyhKzt18=
go.opentelemetry.io/otel/sdk v1.39.0/go.mod h1:vDojkC4/jsTJsE+kh+LXYQlbL8CgrEcwmt1ENZszdJE=
go.opentelemetry.io/otel/sdk/metric v1.39.0 h1:cXMVVFVgsIf2YL6QkRF4Urbr/aMInf+2WKg+sEJTtB8=
go.opentelemetry.io/otel/sdk/metric v1.39.0/go.mod h1:xq9HEVH7qeX69/JnwEfp6fVq5wosJsY1mt4lLfYdVew=
go.opentelemetry.io/otel/trace v1.39.0 h1:2d2vfpEDmCJ5zVYz7ijaJdOF59xLomrvj7bjt6/qCJI=
go.opentelemetry.io/otel/trace v1.39.0/go.mod h1:88w4/PnZSazkGzz/w84VHpQafiU4EtqqlVdxWy+rNOA=
go.opentelemetry.io/proto/otlp v1.9.0 h1:l706jCMITVouPOqEnii2fIAuO3IVGBRPV5ICjceRb/A=
go.opentelemetry.io/proto/otlp v1.9.0/go.mod h1:xE+Cx5E/eEHw+ISFkwPLwCZefwVjY+pqKg1qcK03+/4=
go.uber.org/mock v0.5.0 h1:KAMbZvZPyBPWgD14IrIQ38QCyjwpvVVV6K/bHl1IwQU=
go.uber.org/mock v0.5.0/go.mod h1:ge71pBPLYDk7QIi1LupWxdAykm7KIEFchiOqd6z7qMM=
golang.org/x/arch v0.20.0 h1:dx1zTU0MAE98U+TQ8BLl7XsJbgze2WnNKF/8tGp/Q6c=
golang.org/x/arch v0.20.0/go.mod h1:bdwinDaKcfZUGpH09BB7ZmOfhalA8lQdzl62l8gGWsk=
golang.org/x/crypto v0.40.0 h1:r4x+VvoG5Fm+eJcxMaY8CQM7Lb0l1lsmjGBQ6s8BfKM=
golang.org/x/crypto v0.40.0/go.mod h1:Qr1vMER5WyS2dfPHAlsOj01wgLbsyWtFn/aY+5+ZdxY=
golang.org/x/mod v0.25.0 h1:n7a+ZbQKQA/Ysbyb0/6IbB1H/X41mKgbhfv7AfG/44w=
golang.org/x/mod v0.25.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww=
golang.org/x/net v0.42.0 h1:jzkYrhi3YQWD6MLBJcsklgQsoAcw89EcZbJw8Z614hs=
golang.org/x/net v0.42.0/go.mod h1:FF1RA5d3u7nAYA4z2TkclSCKh68eSXtiFwcWQpPXdt8=
golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw=
golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.14.0/go.mod h1:MVFd36DqK4CsrnJYDkBA3VC4m2GkXAM0PvzMCn4JQf4=
golang.org/x/crypto v0.44.0 h1:A97SsFvM3AIwEEmTBiaxPPTYpDC47w720rdiiUvgoAU=
golang.org/x/crypto v0.44.0/go.mod h1:013i+Nw79BMiQiMsOPcVCB5ZIJbYkerPrGnOa00tvmc=
golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.29.0 h1:HV8lRxZC4l2cr3Zq1LvtOsi/ThTgWnUk/y64QSs8GwA=
golang.org/x/mod v0.29.0/go.mod h1:NyhrlYXJ2H4eJiRy/WDBO6HMqZQ6q9nk4JzS3NuCK+w=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY=
golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I=
golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI=
golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/text v0.27.0 h1:4fGWRpyh641NLlecmyl4LOe6yDdfaYNrGb2zdfo4JV4=
golang.org/x/text v0.27.0/go.mod h1:1D28KMCvyooCX9hBiosv5Tz/+YLxj0j7XhWjpSUF7CU=
golang.org/x/tools v0.34.0 h1:qIpSLOxeCYGg9TrcJokLBG4KFA6d795g0xkBkiESGlo=
golang.org/x/tools v0.34.0/go.mod h1:pAP9OwEaY1CAW3HOmg3hLZC5Z0CCmzjAF2UQMSqNARg=
google.golang.org/protobuf v1.36.9 h1:w2gp2mA27hUeUzj9Ex9FBjsBm40zfaDtEWow293U7Iw=
google.golang.org/protobuf v1.36.9/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU=
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk=
golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
golang.org/x/term v0.13.0/go.mod h1:LTmsnFJwVN6bCy1rVCoS+qHT1HhALEFxKncY3WNNh4U=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM=
golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM=
golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI=
golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/tools v0.38.0 h1:Hx2Xv8hISq8Lm16jvBZ2VQf+RLmbd7wVUsALibYI/IQ=
golang.org/x/tools v0.38.0/go.mod h1:yEsQ/d/YK8cjh0L6rZlY8tgtlKiBNTL14pGDJPJpYQs=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/genproto/googleapis/api v0.0.0-20251202230838-ff82c1b0f217 h1:fCvbg86sFXwdrl5LgVcTEvNC+2txB5mgROGmRL5mrls=
google.golang.org/genproto/googleapis/api v0.0.0-20251202230838-ff82c1b0f217/go.mod h1:+rXWjjaukWZun3mLfjmVnQi18E1AsFbDN9QdJ5YXLto=
google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 h1:gRkg/vSppuSQoDjxyiGfN4Upv/h/DQmIR10ZU8dh4Ww=
google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk=
google.golang.org/grpc v1.77.0 h1:wVVY6/8cGA6vvffn+wWK5ToddbgdU3d8MNENr4evgXM=
google.golang.org/grpc v1.77.0/go.mod h1:z0BY1iVj0q8E1uSQCjL9cppRj+gnZjzDnzV0dHhrNig=
google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE=
google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/natefinch/lumberjack.v2 v2.2.1 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST95x9zc=
gopkg.in/natefinch/lumberjack.v2 v2.2.1/go.mod h1:YD8tP3GAjkrDg1eZH7EGmyESg/lsYskCTPBJVb9jqSc=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gotest.tools/v3 v3.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q=
gotest.tools/v3 v3.5.2/go.mod h1:LtdLGcnqToBH83WByAAi/wiwSFCArdFIUV/xxN4pcjA=

View File

@@ -5,21 +5,34 @@ import (
"os"
"path/filepath"
"github.com/Fimeg/RedFlag/aggregator-server/internal/database/queries"
"github.com/Fimeg/RedFlag/aggregator-server/internal/services"
"github.com/gin-gonic/gin"
)
// AgentBuildHandler handles agent build operations
type AgentBuildHandler struct {
agentQueries *queries.AgentQueries
}
// NewAgentBuildHandler creates a new agent build handler
func NewAgentBuildHandler(agentQueries *queries.AgentQueries) *AgentBuildHandler {
return &AgentBuildHandler{
agentQueries: agentQueries,
}
}
// BuildAgent handles the agent build endpoint
// Deprecated: Use AgentHandler.Rebuild instead
func BuildAgent(c *gin.Context) {
func (h *AgentBuildHandler) BuildAgent(c *gin.Context) {
var req services.AgentSetupRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// Create config builder
configBuilder := services.NewConfigBuilder(req.ServerURL)
// Create config builder with database access
configBuilder := services.NewConfigBuilder(req.ServerURL, h.agentQueries.DB)
// Build agent configuration
config, err := configBuilder.BuildAgentConfig(req)
@@ -62,7 +75,7 @@ func BuildAgent(c *gin.Context) {
}
// GetBuildInstructions returns build instructions for manual setup
func GetBuildInstructions(c *gin.Context) {
func (h *AgentBuildHandler) GetBuildInstructions(c *gin.Context) {
agentID := c.Param("agentID")
if agentID == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "agent ID is required"})
@@ -70,7 +83,7 @@ func GetBuildInstructions(c *gin.Context) {
}
instructions := gin.H{
"title": "RedFlag Agent Build Instructions",
"title": "RedFlag Agent Build Instructions",
"agent_id": agentID,
"steps": []gin.H{
{
@@ -139,7 +152,7 @@ func GetBuildInstructions(c *gin.Context) {
}
// DownloadBuildArtifacts provides download links for generated files
func DownloadBuildArtifacts(c *gin.Context) {
func (h *AgentBuildHandler) DownloadBuildArtifacts(c *gin.Context) {
agentID := c.Param("agentID")
fileType := c.Param("fileType")
buildDir := c.Query("buildDir")
@@ -184,4 +197,4 @@ func DownloadBuildArtifacts(c *gin.Context) {
// Serve file for download
c.FileAttachment(filePath, filepath.Base(filePath))
}
}

View File

@@ -0,0 +1,54 @@
package handlers
import (
"log"
"net/http"
"strconv"
"github.com/Fimeg/RedFlag/aggregator-server/internal/database/queries"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
)
type AgentEventsHandler struct {
agentQueries *queries.AgentQueries
}
func NewAgentEventsHandler(aq *queries.AgentQueries) *AgentEventsHandler {
return &AgentEventsHandler{agentQueries: aq}
}
// GetAgentEvents returns system events for an agent with optional filtering
// GET /api/v1/agents/:id/events?severity=error,critical,warning&limit=50
func (h *AgentEventsHandler) GetAgentEvents(c *gin.Context) {
agentIDStr := c.Param("id")
agentID, err := uuid.Parse(agentIDStr)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid agent ID"})
return
}
// Optional query parameters
severity := c.Query("severity") // comma-separated filter: error,critical,warning,info
limitStr := c.DefaultQuery("limit", "50")
limit, err := strconv.Atoi(limitStr)
if err != nil || limit < 1 {
limit = 50
}
if limit > 1000 {
limit = 1000 // Cap at 1000 to prevent excessive queries
}
// Get events using the agent queries
events, err := h.agentQueries.GetAgentEvents(agentID, severity, limit)
if err != nil {
log.Printf("ERROR: Failed to fetch agent events: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to fetch events"})
return
}
c.JSON(http.StatusOK, gin.H{
"events": events,
"total": len(events),
})
}

View File

@@ -3,21 +3,33 @@ package handlers
import (
"net/http"
"github.com/Fimeg/RedFlag/aggregator-server/internal/database/queries"
"github.com/Fimeg/RedFlag/aggregator-server/internal/services"
"github.com/gin-gonic/gin"
)
// AgentSetupHandler handles agent setup operations
type AgentSetupHandler struct {
agentQueries *queries.AgentQueries
}
// NewAgentSetupHandler creates a new agent setup handler
func NewAgentSetupHandler(agentQueries *queries.AgentQueries) *AgentSetupHandler {
return &AgentSetupHandler{
agentQueries: agentQueries,
}
}
// SetupAgent handles the agent setup endpoint
// Deprecated: Use AgentHandler.Setup instead
func SetupAgent(c *gin.Context) {
func (h *AgentSetupHandler) SetupAgent(c *gin.Context) {
var req services.AgentSetupRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// Create config builder
configBuilder := services.NewConfigBuilder(req.ServerURL)
// Create config builder with database access
configBuilder := services.NewConfigBuilder(req.ServerURL, h.agentQueries.DB)
// Build agent configuration
config, err := configBuilder.BuildAgentConfig(req)
@@ -43,14 +55,14 @@ func SetupAgent(c *gin.Context) {
}
// GetTemplates returns available agent templates
func GetTemplates(c *gin.Context) {
configBuilder := services.NewConfigBuilder("")
func (h *AgentSetupHandler) GetTemplates(c *gin.Context) {
configBuilder := services.NewConfigBuilder("", h.agentQueries.DB)
templates := configBuilder.GetTemplates()
c.JSON(http.StatusOK, gin.H{"templates": templates})
}
// ValidateConfiguration validates a configuration before deployment
func ValidateConfiguration(c *gin.Context) {
func (h *AgentSetupHandler) ValidateConfiguration(c *gin.Context) {
var config map[string]interface{}
if err := c.ShouldBindJSON(&config); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
@@ -63,7 +75,7 @@ func ValidateConfiguration(c *gin.Context) {
return
}
configBuilder := services.NewConfigBuilder("")
configBuilder := services.NewConfigBuilder("", h.agentQueries.DB)
template, exists := configBuilder.GetTemplate(agentType)
if !exists {
c.JSON(http.StatusBadRequest, gin.H{"error": "Unknown agent type"})
@@ -77,4 +89,4 @@ func ValidateConfiguration(c *gin.Context) {
"agent_type": agentType,
"template": template.Name,
})
}
}

View File

@@ -231,7 +231,7 @@ func (h *AgentUpdateHandler) UpdateAgent(c *gin.Context) {
CreatedAt: time.Now(),
}
if err := h.commandQueries.CreateCommand(command); err != nil {
if err := h.agentHandler.signAndCreateCommand(command); err != nil {
// Rollback the updating status
h.agentQueries.UpdateAgentUpdatingStatus(req.AgentID, false, nil)
log.Printf("Failed to create update command for agent %s: %v", req.AgentID, err)
@@ -239,7 +239,28 @@ func (h *AgentUpdateHandler) UpdateAgent(c *gin.Context) {
return
}
log.Printf("✅ Agent update initiated for %s: %s (%s)", agent.Hostname, req.Version, req.Platform)
// Log agent update initiation to system_events table
event := &models.SystemEvent{
ID: uuid.New(),
AgentID: &agentIDUUID,
EventType: "agent_update",
EventSubtype: "initiated",
Severity: "info",
Component: "agent",
Message: fmt.Sprintf("Agent update initiated: %s -> %s (%s)", agent.CurrentVersion, req.Version, req.Platform),
Metadata: map[string]interface{}{
"old_version": agent.CurrentVersion,
"new_version": req.Version,
"platform": req.Platform,
"source": "web_ui",
},
CreatedAt: time.Now(),
}
if err := h.agentQueries.CreateSystemEvent(event); err != nil {
log.Printf("Warning: Failed to log agent update to system_events: %v", err)
}
log.Printf("[UPDATE] Agent update initiated for %s: %s -> %s (%s)", agent.Hostname, agent.CurrentVersion, req.Version, req.Platform)
response := models.AgentUpdateResponse{
Message: "Update initiated successfully",
@@ -345,7 +366,7 @@ func (h *AgentUpdateHandler) BulkUpdateAgents(c *gin.Context) {
command.Params["scheduled_at"] = *req.Scheduled
}
if err := h.commandQueries.CreateCommand(command); err != nil {
if err := h.agentHandler.signAndCreateCommand(command); err != nil {
// Rollback status
h.agentQueries.UpdateAgentUpdatingStatus(agentID, false, nil)
errors = append(errors, fmt.Sprintf("Agent %s: failed to create command", agentID))
@@ -359,6 +380,27 @@ func (h *AgentUpdateHandler) BulkUpdateAgents(c *gin.Context) {
"status": "initiated",
})
// Log each bulk update initiation to system_events table
event := &models.SystemEvent{
ID: uuid.New(),
AgentID: &agentID,
EventType: "agent_update",
EventSubtype: "initiated",
Severity: "info",
Component: "agent",
Message: fmt.Sprintf("Agent update initiated (bulk): %s -> %s (%s)", agent.CurrentVersion, req.Version, req.Platform),
Metadata: map[string]interface{}{
"old_version": agent.CurrentVersion,
"new_version": req.Version,
"platform": req.Platform,
"source": "web_ui_bulk",
},
CreatedAt: time.Now(),
}
if err := h.agentQueries.CreateSystemEvent(event); err != nil {
log.Printf("Warning: Failed to log bulk agent update to system_events: %v", err)
}
log.Printf("✅ Bulk update initiated for %s: %s (%s)", agent.Hostname, req.Version, req.Platform)
}

View File

@@ -8,7 +8,10 @@ import (
"github.com/Fimeg/RedFlag/aggregator-server/internal/api/middleware"
"github.com/Fimeg/RedFlag/aggregator-server/internal/database/queries"
"github.com/Fimeg/RedFlag/aggregator-server/internal/logging"
"github.com/Fimeg/RedFlag/aggregator-server/internal/models"
"github.com/Fimeg/RedFlag/aggregator-server/internal/scheduler"
"github.com/Fimeg/RedFlag/aggregator-server/internal/services"
"github.com/Fimeg/RedFlag/aggregator-server/internal/utils"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
@@ -20,22 +23,59 @@ type AgentHandler struct {
refreshTokenQueries *queries.RefreshTokenQueries
registrationTokenQueries *queries.RegistrationTokenQueries
subsystemQueries *queries.SubsystemQueries
scheduler *scheduler.Scheduler
signingService *services.SigningService
securityLogger *logging.SecurityLogger
checkInInterval int
latestAgentVersion string
}
func NewAgentHandler(aq *queries.AgentQueries, cq *queries.CommandQueries, rtq *queries.RefreshTokenQueries, regTokenQueries *queries.RegistrationTokenQueries, sq *queries.SubsystemQueries, checkInInterval int, latestAgentVersion string) *AgentHandler {
func NewAgentHandler(aq *queries.AgentQueries, cq *queries.CommandQueries, rtq *queries.RefreshTokenQueries, regTokenQueries *queries.RegistrationTokenQueries, sq *queries.SubsystemQueries, scheduler *scheduler.Scheduler, signingService *services.SigningService, securityLogger *logging.SecurityLogger, checkInInterval int, latestAgentVersion string) *AgentHandler {
return &AgentHandler{
agentQueries: aq,
commandQueries: cq,
refreshTokenQueries: rtq,
registrationTokenQueries: regTokenQueries,
subsystemQueries: sq,
scheduler: scheduler,
signingService: signingService,
securityLogger: securityLogger,
checkInInterval: checkInInterval,
latestAgentVersion: latestAgentVersion,
}
}
// signAndCreateCommand signs a command if signing service is enabled, then stores it in the database
func (h *AgentHandler) signAndCreateCommand(cmd *models.AgentCommand) error {
// Sign the command before storing
if h.signingService != nil && h.signingService.IsEnabled() {
signature, err := h.signingService.SignCommand(cmd)
if err != nil {
return fmt.Errorf("failed to sign command: %w", err)
}
cmd.Signature = signature
// Log successful signing
if h.securityLogger != nil {
h.securityLogger.LogCommandSigned(cmd)
}
} else {
// Log warning if signing disabled
log.Printf("[WARNING] Command signing disabled, storing unsigned command")
if h.securityLogger != nil {
h.securityLogger.LogPrivateKeyNotConfigured()
}
}
// Store in database
err := h.commandQueries.CreateCommand(cmd)
if err != nil {
return fmt.Errorf("failed to create command: %w", err)
}
return nil
}
// RegisterAgent handles agent registration
func (h *AgentHandler) RegisterAgent(c *gin.Context) {
var req models.AgentRegistrationRequest
@@ -185,6 +225,47 @@ func (h *AgentHandler) GetCommands(c *gin.Context) {
log.Printf("DEBUG: Failed to parse metrics JSON: %v", err)
}
// Process buffered events from agent if present
if metrics.Metadata != nil {
if bufferedEvents, exists := metrics.Metadata["buffered_events"]; exists {
if events, ok := bufferedEvents.([]interface{}); ok && len(events) > 0 {
stored := 0
for _, e := range events {
if eventMap, ok := e.(map[string]interface{}); ok {
// Extract event fields with type safety
eventType := getStringFromMap(eventMap, "event_type")
eventSubtype := getStringFromMap(eventMap, "event_subtype")
severity := getStringFromMap(eventMap, "severity")
component := getStringFromMap(eventMap, "component")
message := getStringFromMap(eventMap, "message")
if eventType != "" && eventSubtype != "" && severity != "" {
event := &models.SystemEvent{
AgentID: &agentID,
EventType: eventType,
EventSubtype: eventSubtype,
Severity: severity,
Component: component,
Message: message,
Metadata: eventMap["metadata"].(map[string]interface{}),
CreatedAt: time.Now(),
}
if err := h.agentQueries.CreateSystemEvent(event); err != nil {
log.Printf("Warning: Failed to store buffered event: %v", err)
} else {
stored++
}
}
}
}
if stored > 0 {
log.Printf("Stored %d buffered events from agent %s", stored, agentID)
}
}
}
}
// Debug logging to see what we received
log.Printf("DEBUG: Received metrics - Version: '%s', CPU: %.2f, Memory: %.2f",
metrics.Version, metrics.CPUPercent, metrics.MemoryPercent)
@@ -355,9 +436,10 @@ func (h *AgentHandler) GetCommands(c *gin.Context) {
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,
ID: cmd.ID.String(),
Type: cmd.CommandType,
Params: cmd.Params,
Signature: cmd.Signature,
})
// Mark as sent
@@ -438,7 +520,7 @@ func (h *AgentHandler) GetCommands(c *gin.Context) {
CompletedAt: &now,
}
if err := h.commandQueries.CreateCommand(auditCmd); err != nil {
if err := h.signAndCreateCommand(auditCmd); err != nil {
log.Printf("[Heartbeat] Warning: Failed to create audit command for stale heartbeat: %v", err)
} else {
log.Printf("[Heartbeat] Created audit trail for stale heartbeat cleanup (agent %s)", agentID)
@@ -456,6 +538,19 @@ func (h *AgentHandler) GetCommands(c *gin.Context) {
// Process command acknowledgments from agent
var acknowledgedIDs []string
if len(metrics.PendingAcknowledgments) > 0 {
// Debug: Check what commands exist for this agent
agentCommands, err := h.commandQueries.GetCommandsByAgentID(agentID)
if err != nil {
log.Printf("DEBUG: Failed to get commands for agent %s: %v", agentID, err)
} else {
log.Printf("DEBUG: Agent %s has %d total commands in database", agentID, len(agentCommands))
for _, cmd := range agentCommands {
if cmd.Status == "completed" || cmd.Status == "failed" || cmd.Status == "timed_out" {
log.Printf("DEBUG: Completed command found - ID: %s, Status: %s, Type: %s", cmd.ID, cmd.Status, cmd.CommandType)
}
}
}
log.Printf("DEBUG: Processing %d pending acknowledgments for agent %s: %v", len(metrics.PendingAcknowledgments), agentID, metrics.PendingAcknowledgments)
// Verify which commands from agent's pending list have been recorded
verified, err := h.commandQueries.VerifyCommandsCompleted(metrics.PendingAcknowledgments)
@@ -470,6 +565,19 @@ func (h *AgentHandler) GetCommands(c *gin.Context) {
}
}
// Hybrid Heartbeat: Check for scheduled subsystem jobs during heartbeat mode
// This ensures that even in heartbeat mode, scheduled scans can be triggered
if h.scheduler != nil {
// Only check for scheduled jobs if agent is in heartbeat mode (rapid polling enabled)
isHeartbeatMode := rapidPolling != nil && rapidPolling.Enabled
if isHeartbeatMode {
if err := h.checkAndCreateScheduledCommands(agentID); err != nil {
// Log error but don't fail the request - this is enhancement, not core functionality
log.Printf("[Heartbeat] Failed to check scheduled commands for agent %s: %v", agentID, err)
}
}
}
response := models.CommandsResponse{
Commands: commandItems,
RapidPolling: rapidPolling,
@@ -479,6 +587,94 @@ func (h *AgentHandler) GetCommands(c *gin.Context) {
c.JSON(http.StatusOK, response)
}
// checkAndCreateScheduledCommands checks if any subsystem jobs are due for the agent
// and creates commands for them using the scheduler (following Option A approach)
func (h *AgentHandler) checkAndCreateScheduledCommands(agentID uuid.UUID) error {
// Get current subsystems for this agent from database
subsystems, err := h.subsystemQueries.GetSubsystems(agentID)
if err != nil {
return fmt.Errorf("failed to get subsystems: %w", err)
}
// Check each enabled subsystem with auto_run=true
now := time.Now()
jobsCreated := 0
for _, subsystem := range subsystems {
if !subsystem.Enabled || !subsystem.AutoRun {
continue
}
// Check if this subsystem job is due
var isDue bool
if subsystem.NextRunAt == nil {
// No next run time set, it's due
isDue = true
} else {
// Check if next run time has passed
isDue = subsystem.NextRunAt.Before(now) || subsystem.NextRunAt.Equal(now)
}
if isDue {
// Create the command using scheduler logic (reusing existing safeguards)
if err := h.createSubsystemCommand(agentID, subsystem); err != nil {
log.Printf("[Heartbeat] Failed to create command for %s subsystem: %v", subsystem.Subsystem, err)
continue
}
jobsCreated++
// Update next run time in database ONLY after successful command creation
if err := h.updateNextRunTime(agentID, subsystem); err != nil {
log.Printf("[Heartbeat] Failed to update next run time for %s subsystem: %v", subsystem.Subsystem, err)
}
}
}
if jobsCreated > 0 {
log.Printf("[Heartbeat] Created %d scheduled commands for agent %s", jobsCreated, agentID)
}
return nil
}
// createSubsystemCommand creates a subsystem scan command using scheduler's logic
func (h *AgentHandler) createSubsystemCommand(agentID uuid.UUID, subsystem models.AgentSubsystem) error {
// Check backpressure: skip if agent has too many pending commands
pendingCount, err := h.commandQueries.CountPendingCommandsForAgent(agentID)
if err != nil {
return fmt.Errorf("failed to check pending commands: %w", err)
}
// Backpressure threshold (same as scheduler)
const backpressureThreshold = 10
if pendingCount >= backpressureThreshold {
return fmt.Errorf("agent has %d pending commands (threshold: %d), skipping", pendingCount, backpressureThreshold)
}
// Create the command using same format as scheduler
cmd := &models.AgentCommand{
ID: uuid.New(),
AgentID: agentID,
CommandType: fmt.Sprintf("scan_%s", subsystem.Subsystem),
Params: models.JSONB{},
Status: models.CommandStatusPending,
Source: models.CommandSourceSystem,
CreatedAt: time.Now(),
}
if err := h.signAndCreateCommand(cmd); err != nil {
return fmt.Errorf("failed to create command: %w", err)
}
return nil
}
// updateNextRunTime updates the last_run_at and next_run_at for a subsystem after creating a command
func (h *AgentHandler) updateNextRunTime(agentID uuid.UUID, subsystem models.AgentSubsystem) error {
// Use the existing UpdateLastRun method which handles next_run_at calculation
return h.subsystemQueries.UpdateLastRun(agentID, subsystem.Subsystem)
}
// ListAgents returns all agents with last scan information
func (h *AgentHandler) ListAgents(c *gin.Context) {
status := c.Query("status")
@@ -546,7 +742,7 @@ func (h *AgentHandler) TriggerScan(c *gin.Context) {
Source: models.CommandSourceManual,
}
if err := h.commandQueries.CreateCommand(cmd); err != nil {
if err := h.signAndCreateCommand(cmd); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create command"})
return
}
@@ -591,7 +787,7 @@ func (h *AgentHandler) TriggerHeartbeat(c *gin.Context) {
Source: models.CommandSourceManual,
}
if err := h.commandQueries.CreateCommand(cmd); err != nil {
if err := h.signAndCreateCommand(cmd); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create heartbeat command"})
return
}
@@ -786,7 +982,7 @@ func (h *AgentHandler) TriggerUpdate(c *gin.Context) {
Source: models.CommandSourceManual,
}
if err := h.commandQueries.CreateCommand(cmd); err != nil {
if err := h.signAndCreateCommand(cmd); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create update command"})
return
}
@@ -827,6 +1023,15 @@ func (h *AgentHandler) RenewToken(c *gin.Context) {
log.Printf("Warning: Failed to update last_seen for agent %s: %v", req.AgentID, err)
}
// Update agent version if provided (for upgrade tracking)
if req.AgentVersion != "" {
if err := h.agentQueries.UpdateAgentVersion(req.AgentID, req.AgentVersion); err != nil {
log.Printf("Warning: Failed to update agent version during token renewal for agent %s: %v", req.AgentID, err)
} else {
log.Printf("Agent %s version updated to %s during token renewal", req.AgentID, req.AgentVersion)
}
}
// Update refresh token expiration (sliding window - reset to 90 days from now)
// This ensures active agents never need to re-register
newExpiry := time.Now().Add(90 * 24 * time.Hour)
@@ -1123,7 +1328,7 @@ func (h *AgentHandler) TriggerReboot(c *gin.Context) {
}
// Save command to database
if err := h.commandQueries.CreateCommand(cmd); err != nil {
if err := h.signAndCreateCommand(cmd); err != nil {
log.Printf("Failed to create reboot command: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create reboot command"})
return
@@ -1179,3 +1384,13 @@ func (h *AgentHandler) GetAgentConfig(c *gin.Context) {
"version": time.Now().Unix(), // Simple version timestamp
})
}
// getStringFromMap safely extracts a string value from a map
func getStringFromMap(m map[string]interface{}, key string) string {
if val, exists := m[key]; exists {
if str, ok := val.(string); ok {
return str
}
}
return ""
}

View File

@@ -6,23 +6,21 @@ import (
"time"
"github.com/Fimeg/RedFlag/aggregator-server/internal/database/queries"
"github.com/Fimeg/RedFlag/aggregator-server/internal/models"
"github.com/gin-gonic/gin"
"github.com/golang-jwt/jwt/v5"
"github.com/google/uuid"
)
// AuthHandler handles authentication for the web dashboard
type AuthHandler struct {
jwtSecret string
userQueries *queries.UserQueries
jwtSecret string
adminQueries *queries.AdminQueries
}
// NewAuthHandler creates a new auth handler
func NewAuthHandler(jwtSecret string, userQueries *queries.UserQueries) *AuthHandler {
func NewAuthHandler(jwtSecret string, adminQueries *queries.AdminQueries) *AuthHandler {
return &AuthHandler{
jwtSecret: jwtSecret,
userQueries: userQueries,
jwtSecret: jwtSecret,
adminQueries: adminQueries,
}
}
@@ -34,15 +32,15 @@ type LoginRequest struct {
// LoginResponse represents a login response
type LoginResponse struct {
Token string `json:"token"`
User *models.User `json:"user"`
Token string `json:"token"`
User *queries.Admin `json:"user"`
}
// UserClaims represents JWT claims for web dashboard users
type UserClaims struct {
UserID uuid.UUID `json:"user_id"`
Username string `json:"username"`
Role string `json:"role"`
UserID string `json:"user_id"`
Username string `json:"username"`
Role string `json:"role"`
jwt.RegisteredClaims
}
@@ -54,8 +52,8 @@ func (h *AuthHandler) Login(c *gin.Context) {
return
}
// Validate credentials against database
user, err := h.userQueries.VerifyCredentials(req.Username, req.Password)
// Validate credentials against database hash
admin, err := h.adminQueries.VerifyAdminCredentials(req.Username, req.Password)
if err != nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid username or password"})
return
@@ -63,9 +61,9 @@ func (h *AuthHandler) Login(c *gin.Context) {
// Create JWT token for web dashboard
claims := UserClaims{
UserID: user.ID,
Username: user.Username,
Role: user.Role,
UserID: fmt.Sprintf("%d", admin.ID),
Username: admin.Username,
Role: "admin", // Always admin for single-admin system
RegisteredClaims: jwt.RegisteredClaims{
ExpiresAt: jwt.NewNumericDate(time.Now().Add(24 * time.Hour)),
IssuedAt: jwt.NewNumericDate(time.Now()),
@@ -81,7 +79,7 @@ func (h *AuthHandler) Login(c *gin.Context) {
c.JSON(http.StatusOK, LoginResponse{
Token: tokenString,
User: user,
User: admin,
})
}

View File

@@ -34,7 +34,7 @@ func NewAgentBuild(c *gin.Context) {
}
// Create config builder
configBuilder := services.NewConfigBuilder(req.ServerURL)
configBuilder := services.NewConfigBuilder(req.ServerURL, nil)
// Build agent configuration
config, err := configBuilder.BuildAgentConfig(setupReq)
@@ -122,7 +122,7 @@ func UpgradeAgentBuild(c *gin.Context) {
}
// Create config builder
configBuilder := services.NewConfigBuilder(req.ServerURL)
configBuilder := services.NewConfigBuilder(req.ServerURL, nil)
// Build agent configuration
config, err := configBuilder.BuildAgentConfig(setupReq)

View File

@@ -1,11 +1,15 @@
package handlers
import (
"fmt"
"log"
"net/http"
"strconv"
"github.com/Fimeg/RedFlag/aggregator-server/internal/database/queries"
"github.com/Fimeg/RedFlag/aggregator-server/internal/models"
"github.com/Fimeg/RedFlag/aggregator-server/internal/services"
"github.com/Fimeg/RedFlag/aggregator-server/internal/logging"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
)
@@ -14,16 +18,51 @@ type DockerHandler struct {
updateQueries *queries.UpdateQueries
agentQueries *queries.AgentQueries
commandQueries *queries.CommandQueries
signingService *services.SigningService
securityLogger *logging.SecurityLogger
}
func NewDockerHandler(uq *queries.UpdateQueries, aq *queries.AgentQueries, cq *queries.CommandQueries) *DockerHandler {
func NewDockerHandler(uq *queries.UpdateQueries, aq *queries.AgentQueries, cq *queries.CommandQueries, signingService *services.SigningService, securityLogger *logging.SecurityLogger) *DockerHandler {
return &DockerHandler{
updateQueries: uq,
agentQueries: aq,
commandQueries: cq,
signingService: signingService,
securityLogger: securityLogger,
}
}
// signAndCreateCommand signs a command if signing service is enabled, then stores it in the database
func (h *DockerHandler) signAndCreateCommand(cmd *models.AgentCommand) error {
// Sign the command before storing
if h.signingService != nil && h.signingService.IsEnabled() {
signature, err := h.signingService.SignCommand(cmd)
if err != nil {
return fmt.Errorf("failed to sign command: %w", err)
}
cmd.Signature = signature
// Log successful signing
if h.securityLogger != nil {
h.securityLogger.LogCommandSigned(cmd)
}
} else {
// Log warning if signing disabled
log.Printf("[WARNING] Command signing disabled, storing unsigned command")
if h.securityLogger != nil {
h.securityLogger.LogPrivateKeyNotConfigured()
}
}
// Store in database
err := h.commandQueries.CreateCommand(cmd)
if err != nil {
return fmt.Errorf("failed to create command: %w", err)
}
return nil
}
// GetContainers returns Docker containers and images across all agents
func (h *DockerHandler) GetContainers(c *gin.Context) {
// Parse query parameters
@@ -430,7 +469,7 @@ func (h *DockerHandler) InstallUpdate(c *gin.Context) {
Source: models.CommandSourceManual, // User-initiated Docker update
}
if err := h.commandQueries.CreateCommand(command); err != nil {
if err := h.signAndCreateCommand(command); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create Docker update command"})
return
}

View File

@@ -9,6 +9,7 @@ import (
"strings"
"github.com/Fimeg/RedFlag/aggregator-server/internal/config"
"github.com/Fimeg/RedFlag/aggregator-server/internal/database/queries"
"github.com/Fimeg/RedFlag/aggregator-server/internal/services"
"github.com/google/uuid"
"github.com/gin-gonic/gin"
@@ -19,13 +20,15 @@ type DownloadHandler struct {
agentDir string
config *config.Config
installTemplateService *services.InstallTemplateService
packageQueries *queries.PackageQueries
}
func NewDownloadHandler(agentDir string, cfg *config.Config) *DownloadHandler {
func NewDownloadHandler(agentDir string, cfg *config.Config, packageQueries *queries.PackageQueries) *DownloadHandler {
return &DownloadHandler{
agentDir: agentDir,
config: cfg,
installTemplateService: services.NewInstallTemplateService(),
packageQueries: packageQueries,
}
}
@@ -137,13 +140,58 @@ func (h *DownloadHandler) DownloadUpdatePackage(c *gin.Context) {
return
}
// TODO: Implement actual package serving from database/filesystem
// For now, return a placeholder response
c.JSON(http.StatusNotImplemented, gin.H{
"error": "Update package download not yet implemented",
"package_id": packageID,
"message": "This will serve the signed update package file",
})
parsedPackageID, err := uuid.Parse(packageID)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid package ID format"})
return
}
// Fetch package from database
pkg, err := h.packageQueries.GetSignedPackageByID(parsedPackageID)
if err != nil {
if err.Error() == "update package not found" {
c.JSON(http.StatusNotFound, gin.H{
"error": "Package not found",
"package_id": packageID,
})
return
}
log.Printf("[ERROR] Failed to fetch package %s: %v", packageID, err)
c.JSON(http.StatusInternalServerError, gin.H{
"error": "Failed to retrieve package",
"package_id": packageID,
})
return
}
// Verify file exists on disk
if _, err := os.Stat(pkg.BinaryPath); os.IsNotExist(err) {
log.Printf("[ERROR] Package file not found on disk: %s", pkg.BinaryPath)
c.JSON(http.StatusNotFound, gin.H{
"error": "Package file not found on disk",
"package_id": packageID,
})
return
}
// Set appropriate headers
c.Header("Content-Type", "application/octet-stream")
c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", filepath.Base(pkg.BinaryPath)))
c.Header("X-Package-Version", pkg.Version)
c.Header("X-Package-Platform", pkg.Platform)
c.Header("X-Package-Architecture", pkg.Architecture)
if pkg.Signature != "" {
c.Header("X-Package-Signature", pkg.Signature)
}
if pkg.Checksum != "" {
c.Header("X-Package-Checksum", pkg.Checksum)
}
// Serve the file
c.File(pkg.BinaryPath)
}
// InstallScript serves the installation script

View File

@@ -1,6 +1,7 @@
package handlers
import (
"fmt"
"net/http"
"strconv"
"time"
@@ -48,8 +49,8 @@ func (h *RegistrationTokenHandler) GenerateRegistrationToken(c *gin.Context) {
if activeAgents >= h.config.AgentRegistration.MaxSeats {
c.JSON(http.StatusForbidden, gin.H{
"error": "Maximum agent seats reached",
"limit": h.config.AgentRegistration.MaxSeats,
"error": "Maximum agent seats reached",
"limit": h.config.AgentRegistration.MaxSeats,
"current": activeAgents,
})
return
@@ -106,14 +107,19 @@ func (h *RegistrationTokenHandler) GenerateRegistrationToken(c *gin.Context) {
if serverURL == "" {
serverURL = "localhost:8080" // Fallback for development
}
installCommand := "curl -sfL https://" + serverURL + "/install | bash -s -- " + token
// Use http:// for localhost, correct API endpoint, and query parameter for token
protocol := "http://"
if serverURL != "localhost:8080" {
protocol = "https://"
}
installCommand := fmt.Sprintf("curl -sfL \"%s%s/api/v1/install/linux?token=%s\" | sudo bash", protocol, serverURL, token)
response := gin.H{
"token": token,
"label": request.Label,
"expires_at": expiresAt,
"token": token,
"label": request.Label,
"expires_at": expiresAt,
"install_command": installCommand,
"metadata": metadata,
"metadata": metadata,
}
c.JSON(http.StatusCreated, response)
@@ -178,8 +184,8 @@ func (h *RegistrationTokenHandler) ListRegistrationTokens(c *gin.Context) {
response := gin.H{
"tokens": tokens,
"pagination": gin.H{
"page": page,
"limit": limit,
"page": page,
"limit": limit,
"offset": offset,
},
"stats": stats,
@@ -324,14 +330,14 @@ func (h *RegistrationTokenHandler) GetTokenStats(c *gin.Context) {
"agent_usage": gin.H{
"active_agents": activeAgentCount,
"max_seats": h.config.AgentRegistration.MaxSeats,
"available": h.config.AgentRegistration.MaxSeats - activeAgentCount,
"available": h.config.AgentRegistration.MaxSeats - activeAgentCount,
},
"security_limits": gin.H{
"max_tokens_per_request": h.config.AgentRegistration.MaxTokens,
"max_token_duration": "7 days",
"token_expiry_default": h.config.AgentRegistration.TokenExpiry,
"max_token_duration": "7 days",
"token_expiry_default": h.config.AgentRegistration.TokenExpiry,
},
}
c.JSON(http.StatusOK, response)
}
}

View File

@@ -0,0 +1,146 @@
package handlers
import (
"log"
"net/http"
"time"
"github.com/Fimeg/RedFlag/aggregator-server/internal/database/queries"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"github.com/jmoiron/sqlx"
)
// ScannerConfigHandler manages scanner timeout configuration
type ScannerConfigHandler struct {
queries *queries.ScannerConfigQueries
}
// NewScannerConfigHandler creates a new scanner config handler
func NewScannerConfigHandler(db *sqlx.DB) *ScannerConfigHandler {
return &ScannerConfigHandler{
queries: queries.NewScannerConfigQueries(db),
}
}
// GetScannerTimeouts returns current scanner timeout configuration
// GET /api/v1/admin/scanner-timeouts
// Security: Requires admin authentication (WebAuthMiddleware)
func (h *ScannerConfigHandler) GetScannerTimeouts(c *gin.Context) {
configs, err := h.queries.GetAllScannerConfigs()
if err != nil {
log.Printf("[ERROR] Failed to fetch scanner configs: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{
"error": "failed to fetch scanner configuration",
})
return
}
c.JSON(http.StatusOK, gin.H{
"scanner_timeouts": configs,
"default_timeout_ms": 1800000, // 30 minutes default
})
}
// UpdateScannerTimeout updates scanner timeout configuration
// PUT /api/v1/admin/scanner-timeouts/:scanner_name
// Security: Requires admin authentication + audit logging
func (h *ScannerConfigHandler) UpdateScannerTimeout(c *gin.Context) {
scannerName := c.Param("scanner_name")
if scannerName == "" {
c.JSON(http.StatusBadRequest, gin.H{
"error": "scanner_name is required",
})
return
}
var req struct {
TimeoutMs int `json:"timeout_ms" binding:"required,min=1000,max=7200000"` // 1s to 2 hours
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{
"error": err.Error(),
})
return
}
timeout := time.Duration(req.TimeoutMs) * time.Millisecond
// Update config
if err := h.queries.UpsertScannerConfig(scannerName, timeout); err != nil {
log.Printf("[ERROR] Failed to update scanner config for %s: %v", scannerName, err)
c.JSON(http.StatusInternalServerError, gin.H{
"error": "failed to update scanner configuration",
})
return
}
// Create audit event in History table (ETHOS compliance)
userID := c.MustGet("user_id").(uuid.UUID)
/*
event := &models.SystemEvent{
ID: uuid.New(),
EventType: "scanner_config_change",
EventSubtype: "timeout_updated",
Severity: "info",
Component: "admin_api",
Message: fmt.Sprintf("Scanner timeout updated: %s = %v", scannerName, timeout),
Metadata: map[string]interface{}{
"scanner_name": scannerName,
"timeout_ms": req.TimeoutMs,
"user_id": userID.String(),
"source_ip": c.ClientIP(),
},
CreatedAt: time.Now(),
}
// TODO: Integrate with event logging system when available
*/
log.Printf("[AUDIT] User %s updated scanner timeout: %s = %v", userID, scannerName, timeout)
c.JSON(http.StatusOK, gin.H{
"message": "scanner timeout updated successfully",
"scanner_name": scannerName,
"timeout_ms": req.TimeoutMs,
"timeout_human": timeout.String(),
})
}
// ResetScannerTimeout resets scanner timeout to default (30 minutes)
// POST /api/v1/admin/scanner-timeouts/:scanner_name/reset
// Security: Requires admin authentication + audit logging
func (h *ScannerConfigHandler) ResetScannerTimeout(c *gin.Context) {
scannerName := c.Param("scanner_name")
if scannerName == "" {
c.JSON(http.StatusBadRequest, gin.H{
"error": "scanner_name is required",
})
return
}
defaultTimeout := 30 * time.Minute
if err := h.queries.UpsertScannerConfig(scannerName, defaultTimeout); err != nil {
log.Printf("[ERROR] Failed to reset scanner config for %s: %v", scannerName, err)
c.JSON(http.StatusInternalServerError, gin.H{
"error": "failed to reset scanner configuration",
})
return
}
// Audit log
userID := c.MustGet("user_id").(uuid.UUID)
log.Printf("[AUDIT] User %s reset scanner timeout: %s to default %v", userID, scannerName, defaultTimeout)
c.JSON(http.StatusOK, gin.H{
"message": "scanner timeout reset to default",
"scanner_name": scannerName,
"timeout_ms": int(defaultTimeout.Milliseconds()),
"timeout_human": defaultTimeout.String(),
})
}
// GetScannerConfigQueries provides access to the queries for config_builder.go
func (h *ScannerConfigHandler) GetScannerConfigQueries() *queries.ScannerConfigQueries {
return h.queries
}

View File

@@ -10,6 +10,7 @@ import (
"strconv"
"github.com/Fimeg/RedFlag/aggregator-server/internal/config"
"github.com/Fimeg/RedFlag/aggregator-server/internal/services"
"github.com/gin-gonic/gin"
"github.com/lib/pq"
_ "github.com/lib/pq"
@@ -64,16 +65,23 @@ func createSharedEnvContentForDisplay(req struct {
ServerHost string `json:"serverHost"`
ServerPort string `json:"serverPort"`
MaxSeats string `json:"maxSeats"`
}, jwtSecret string) (string, error) {
}, jwtSecret string, signingKeys map[string]string) (string, error) {
// Generate .env file content for user to copy
envContent := fmt.Sprintf(`# RedFlag Environment Configuration
# Generated by web setup - Save this content to ./config/.env
# Generated by web setup on 2025-12-13
# [WARNING] SECURITY CRITICAL: Backup the signing key or you will lose access to all agents
# PostgreSQL Configuration (for PostgreSQL container)
POSTGRES_DB=%s
POSTGRES_USER=%s
POSTGRES_PASSWORD=%s
# RedFlag Security - Ed25519 Signing Keys
# These keys are used to cryptographically sign agent updates and commands
# BACKUP THE PRIVATE KEY IMMEDIATELY - Store it in a secure location like a password manager
REDFLAG_SIGNING_PRIVATE_KEY=%s
REDFLAG_SIGNING_PUBLIC_KEY=%s
# RedFlag Server Configuration
REDFLAG_SERVER_HOST=%s
REDFLAG_SERVER_PORT=%s
@@ -87,8 +95,15 @@ REDFLAG_ADMIN_PASSWORD=%s
REDFLAG_JWT_SECRET=%s
REDFLAG_TOKEN_EXPIRY=24h
REDFLAG_MAX_TOKENS=100
REDFLAG_MAX_SEATS=%s`,
REDFLAG_MAX_SEATS=%s
# Security Settings
REDFLAG_SECURITY_COMMAND_SIGNING_ENFORCEMENT=strict
REDFLAG_SECURITY_NONCE_TIMEOUT=600
REDFLAG_SECURITY_LOG_LEVEL=warn
`,
req.DBName, req.DBUser, req.DBPassword,
signingKeys["private_key"], signingKeys["public_key"],
req.ServerHost, req.ServerPort,
req.DBHost, req.DBPort, req.DBName, req.DBUser, req.DBPassword,
req.AdminUser, req.AdminPass, jwtSecret, req.MaxSeats)
@@ -136,7 +151,7 @@ func (h *SetupHandler) ShowSetupPage(c *gin.Context) {
<div class="container">
<div class="card">
<div class="header">
<h1>🚀 RedFlag Server Setup</h1>
<h1>[START] RedFlag Server Setup</h1>
<p class="subtitle">Configure your RedFlag deployment</p>
</div>
<div class="content">
@@ -199,7 +214,7 @@ func (h *SetupHandler) ShowSetupPage(c *gin.Context) {
</div>
<button type="submit" class="btn" id="submitBtn">
🚀 Configure RedFlag Server
[START] Configure RedFlag Server
</button>
</form>
@@ -237,12 +252,12 @@ func (h *SetupHandler) ShowSetupPage(c *gin.Context) {
// Validate inputs
if (!formData.adminUser || !formData.adminPassword) {
result.innerHTML = '<div class="error"> Admin username and password are required</div>';
result.innerHTML = '<div class="error">[ERROR] Admin username and password are required</div>';
return;
}
if (!formData.dbHost || !formData.dbPort || !formData.dbName || !formData.dbUser || !formData.dbPassword) {
result.innerHTML = '<div class="error"> All database fields are required</div>';
result.innerHTML = '<div class="error">[ERROR] All database fields are required</div>';
return;
}
@@ -264,10 +279,10 @@ func (h *SetupHandler) ShowSetupPage(c *gin.Context) {
if (response.ok) {
let resultHtml = '<div class="success">';
resultHtml += '<h3> Configuration Generated Successfully!</h3>';
resultHtml += '<h3>[SUCCESS] Configuration Generated Successfully!</h3>';
resultHtml += '<p><strong>Your JWT Secret:</strong> <code style="background: #f3f4f6; padding: 2px 6px; border-radius: 3px;">' + resultData.jwtSecret + '</code> ';
resultHtml += '<button onclick="copyJWT(\'' + resultData.jwtSecret + '\')" style="background: #4f46e5; color: white; border: none; padding: 4px 8px; border-radius: 3px; cursor: pointer; font-size: 0.8rem;">📋 Copy</button></p>';
resultHtml += '<p><strong>⚠️ Important Next Steps:</strong></p>';
resultHtml += '<p><strong>[WARNING] Important Next Steps:</strong></p>';
resultHtml += '<div style="background: #fef3c7; border: 1px solid #f59e0b; border-radius: 6px; padding: 15px; margin: 15px 0;">';
resultHtml += '<p style="margin: 0; color: #92400e;"><strong>🔧 Complete Setup Required:</strong></p>';
resultHtml += '<ol style="margin: 10px 0 0 0; color: #92400e;">';
@@ -292,12 +307,12 @@ func (h *SetupHandler) ShowSetupPage(c *gin.Context) {
window.envContent = resultData.envContent;
} else {
result.innerHTML = '<div class="error"> Error: ' + resultData.error + '</div>';
result.innerHTML = '<div class="error">[ERROR] Error: ' + resultData.error + '</div>';
submitBtn.disabled = false;
loading.style.display = 'none';
}
} catch (error) {
result.innerHTML = '<div class="error"> Network error: ' + error.message + '</div>';
result.innerHTML = '<div class="error">[ERROR] Network error: ' + error.message + '</div>';
submitBtn.disabled = false;
loading.style.display = 'none';
}
@@ -383,6 +398,22 @@ func (h *SetupHandler) ConfigureServer(c *gin.Context) {
return
}
// SECURITY: Generate Ed25519 signing keypair (critical for v0.2.x)
fmt.Println("[START] Generating Ed25519 signing keypair for security...")
signingPublicKey, signingPrivateKey, err := ed25519.GenerateKey(rand.Reader)
if err != nil {
fmt.Printf("CRITICAL ERROR: Failed to generate signing keys: %v\n", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to generate signing keys. Security features cannot be enabled."})
return
}
signingKeys := map[string]string{
"public_key": hex.EncodeToString(signingPublicKey),
"private_key": hex.EncodeToString(signingPrivateKey),
}
fmt.Printf("[SUCCESS] Generated Ed25519 keypair - Fingerprint: %s\n", signingKeys["public_key"][:16])
fmt.Println("[WARNING] SECURITY WARNING: Backup the private key immediately or you will lose access to all agents!")
// Step 1: Update PostgreSQL password from bootstrap to user password
fmt.Println("Updating PostgreSQL password from bootstrap to user-provided password...")
bootstrapPassword := "redflag_bootstrap" // This matches our bootstrap .env
@@ -401,7 +432,7 @@ func (h *SetupHandler) ConfigureServer(c *gin.Context) {
fmt.Println("Generating configuration content for manual .env file update...")
// Generate the complete .env file content for the user to copy
newEnvContent, err := createSharedEnvContentForDisplay(req, jwtSecret)
newEnvContent, err := createSharedEnvContentForDisplay(req, jwtSecret, signingKeys)
if err != nil {
fmt.Printf("Failed to generate .env content: %v\n", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to generate configuration content"})
@@ -415,6 +446,8 @@ func (h *SetupHandler) ConfigureServer(c *gin.Context) {
"manualRestartRequired": true,
"manualRestartCommand": "docker-compose down && docker-compose up -d",
"configFilePath": "./config/.env",
"securityNotice": "[WARNING] A signing key has been generated. BACKUP THE PRIVATE KEY or you will lose access to all agents!",
"publicKeyFingerprint": signingKeys["public_key"][:16] + "...",
})
}
@@ -458,3 +491,98 @@ func (h *SetupHandler) GenerateSigningKeys(c *gin.Context) {
})
}
// ConfigureSecrets creates all Docker secrets automatically
func (h *SetupHandler) ConfigureSecrets(c *gin.Context) {
// Check if Docker API is available
if !services.IsDockerAvailable() {
c.JSON(http.StatusServiceUnavailable, gin.H{
"error": "Docker API not available",
"message": "Docker socket is not mounted. Please ensure the server can access Docker daemon",
})
return
}
// Create Docker secrets service
dockerSecrets, err := services.NewDockerSecretsService()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{
"error": "Failed to connect to Docker",
"details": err.Error(),
})
return
}
defer dockerSecrets.Close()
// Generate all required secrets
type SecretConfig struct {
Name string
Value string
}
secrets := []SecretConfig{
{"redflag_admin_password", config.GenerateSecurePassword()},
{"redflag_jwt_secret", generateSecureJWTSecret()},
{"redflag_db_password", config.GenerateSecurePassword()},
}
// Try to create each secret
createdSecrets := []string{}
failedSecrets := []string{}
for _, secret := range secrets {
if err := dockerSecrets.CreateSecret(secret.Name, secret.Value); err != nil {
failedSecrets = append(failedSecrets, fmt.Sprintf("%s: %v", secret.Name, err))
} else {
createdSecrets = append(createdSecrets, secret.Name)
}
}
// Generate signing keys
publicKey, privateKey, err := ed25519.GenerateKey(rand.Reader)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{
"error": "Failed to generate signing keys",
"details": err.Error(),
})
return
}
publicKeyHex := hex.EncodeToString(publicKey)
privateKeyHex := hex.EncodeToString(privateKey)
// Create signing key secret
if err := dockerSecrets.CreateSecret("redflag_signing_private_key", privateKeyHex); err != nil {
failedSecrets = append(failedSecrets, fmt.Sprintf("redflag_signing_private_key: %v", err))
} else {
createdSecrets = append(createdSecrets, "redflag_signing_private_key")
}
response := gin.H{
"created_secrets": createdSecrets,
"public_key": publicKeyHex,
"fingerprint": publicKeyHex[:16],
}
if len(failedSecrets) > 0 {
response["failed_secrets"] = failedSecrets
c.JSON(http.StatusMultiStatus, response)
return
}
c.JSON(http.StatusOK, response)
}
// GenerateSecurePassword generates a secure password for admin/db
func generateSecurePassword() string {
bytes := make([]byte, 16)
rand.Read(bytes)
return hex.EncodeToString(bytes)[:16] // 16 character random password
}
// generateSecureJWTSecret generates a secure JWT secret
func generateSecureJWTSecret() string {
bytes := make([]byte, 32)
rand.Read(bytes)
return hex.EncodeToString(bytes)
}

View File

@@ -1,10 +1,14 @@
package handlers
import (
"fmt"
"log"
"net/http"
"github.com/Fimeg/RedFlag/aggregator-server/internal/database/queries"
"github.com/Fimeg/RedFlag/aggregator-server/internal/models"
"github.com/Fimeg/RedFlag/aggregator-server/internal/services"
"github.com/Fimeg/RedFlag/aggregator-server/internal/logging"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
)
@@ -12,15 +16,50 @@ import (
type SubsystemHandler struct {
subsystemQueries *queries.SubsystemQueries
commandQueries *queries.CommandQueries
signingService *services.SigningService
securityLogger *logging.SecurityLogger
}
func NewSubsystemHandler(sq *queries.SubsystemQueries, cq *queries.CommandQueries) *SubsystemHandler {
func NewSubsystemHandler(sq *queries.SubsystemQueries, cq *queries.CommandQueries, signingService *services.SigningService, securityLogger *logging.SecurityLogger) *SubsystemHandler {
return &SubsystemHandler{
subsystemQueries: sq,
commandQueries: cq,
signingService: signingService,
securityLogger: securityLogger,
}
}
// signAndCreateCommand signs a command if signing service is enabled, then stores it in the database
func (h *SubsystemHandler) signAndCreateCommand(cmd *models.AgentCommand) error {
// Sign the command before storing
if h.signingService != nil && h.signingService.IsEnabled() {
signature, err := h.signingService.SignCommand(cmd)
if err != nil {
return fmt.Errorf("failed to sign command: %w", err)
}
cmd.Signature = signature
// Log successful signing
if h.securityLogger != nil {
h.securityLogger.LogCommandSigned(cmd)
}
} else {
// Log warning if signing disabled
log.Printf("[WARNING] Command signing disabled, storing unsigned command")
if h.securityLogger != nil {
h.securityLogger.LogPrivateKeyNotConfigured()
}
}
// Store in database
err := h.commandQueries.CreateCommand(cmd)
if err != nil {
return fmt.Errorf("failed to create command: %w", err)
}
return nil
}
// GetSubsystems retrieves all subsystems for an agent
// GET /api/v1/agents/:id/subsystems
func (h *SubsystemHandler) GetSubsystems(c *gin.Context) {
@@ -205,7 +244,7 @@ func (h *SubsystemHandler) TriggerSubsystem(c *gin.Context) {
Source: "web_ui", // Manual trigger from UI
}
err = h.commandQueries.CreateCommand(command)
err = h.signAndCreateCommand(command)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create command"})
return

View File

@@ -281,6 +281,8 @@ func (h *UnifiedUpdateHandler) ReportLog(c *gin.Context) {
"duration_seconds": req.DurationSeconds,
"logged_at": time.Now(),
}
log.Printf("DEBUG: ReportLog - Marking command %s as completed for agent %s", commandID, agentID)
if req.Result == "success" || req.Result == "completed" {
if err := h.commandQueries.MarkCommandCompleted(commandID, result); err != nil {
@@ -446,12 +448,12 @@ func (h *UnifiedUpdateHandler) InstallUpdate(c *gin.Context) {
CreatedAt: time.Now(),
}
if err := h.commandQueries.CreateCommand(heartbeatCmd); err != nil {
if err := h.agentHandler.signAndCreateCommand(heartbeatCmd); err != nil {
log.Printf("[Heartbeat] Warning: Failed to create heartbeat command for agent %s: %v", update.AgentID, err)
}
}
if err := h.commandQueries.CreateCommand(command); err != nil {
if err := h.agentHandler.signAndCreateCommand(command); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create dry run command"})
return
}
@@ -518,12 +520,12 @@ func (h *UnifiedUpdateHandler) ReportDependencies(c *gin.Context) {
CreatedAt: time.Now(),
}
if err := h.commandQueries.CreateCommand(heartbeatCmd); err != nil {
if err := h.agentHandler.signAndCreateCommand(heartbeatCmd); err != nil {
log.Printf("[Heartbeat] Warning: Failed to create heartbeat command for agent %s: %v", agentID, err)
}
}
if err := h.commandQueries.CreateCommand(command); err != nil {
if err := h.agentHandler.signAndCreateCommand(command); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create installation command"})
return
}
@@ -592,12 +594,12 @@ func (h *UnifiedUpdateHandler) ConfirmDependencies(c *gin.Context) {
CreatedAt: time.Now(),
}
if err := h.commandQueries.CreateCommand(heartbeatCmd); err != nil {
if err := h.agentHandler.signAndCreateCommand(heartbeatCmd); err != nil {
log.Printf("[Heartbeat] Warning: Failed to create heartbeat command for agent %s: %v", update.AgentID, err)
}
}
if err := h.commandQueries.CreateCommand(command); err != nil {
if err := h.agentHandler.signAndCreateCommand(command); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create confirmation command"})
return
}
@@ -735,8 +737,32 @@ func (h *UnifiedUpdateHandler) RetryCommand(c *gin.Context) {
return
}
newCommand, err := h.commandQueries.RetryCommand(id)
// Get the original command
original, err := h.commandQueries.GetCommandByID(id)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("failed to get original command: %v", err)})
return
}
// Only allow retry of failed, timed_out, or cancelled commands
if original.Status != "failed" && original.Status != "timed_out" && original.Status != "cancelled" {
c.JSON(http.StatusBadRequest, gin.H{"error": "command must be failed, timed_out, or cancelled to retry"})
return
}
// Create new command with same parameters, linking it to the original
newCommand := &models.AgentCommand{
ID: uuid.New(),
AgentID: original.AgentID,
CommandType: original.CommandType,
Params: original.Params,
Status: models.CommandStatusPending,
CreatedAt: time.Now(),
RetriedFromID: &id,
}
// Sign and store the new command
if err := h.agentHandler.signAndCreateCommand(newCommand); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("failed to retry command: %v", err)})
return
}

View File

@@ -484,7 +484,7 @@ func (h *UpdateHandler) InstallUpdate(c *gin.Context) {
CreatedAt: time.Now(),
}
if err := h.commandQueries.CreateCommand(heartbeatCmd); err != nil {
if err := h.agentHandler.signAndCreateCommand(heartbeatCmd); err != nil {
log.Printf("[Heartbeat] Warning: Failed to create heartbeat command for agent %s: %v", update.AgentID, err)
} else {
log.Printf("[Heartbeat] Command created for agent %s before dry run", update.AgentID)
@@ -494,7 +494,7 @@ func (h *UpdateHandler) InstallUpdate(c *gin.Context) {
}
// Store the dry run command in database
if err := h.commandQueries.CreateCommand(command); err != nil {
if err := h.agentHandler.signAndCreateCommand(command); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create dry run command"})
return
}
@@ -591,7 +591,7 @@ func (h *UpdateHandler) ReportDependencies(c *gin.Context) {
CreatedAt: time.Now(),
}
if err := h.commandQueries.CreateCommand(heartbeatCmd); err != nil {
if err := h.agentHandler.signAndCreateCommand(heartbeatCmd); err != nil {
log.Printf("[Heartbeat] Warning: Failed to create heartbeat command for agent %s: %v", agentID, err)
} else {
log.Printf("[Heartbeat] Command created for agent %s before installation", agentID)
@@ -600,7 +600,7 @@ func (h *UpdateHandler) ReportDependencies(c *gin.Context) {
log.Printf("[Heartbeat] Skipping heartbeat command for agent %s (already active)", agentID)
}
if err := h.commandQueries.CreateCommand(command); err != nil {
if err := h.agentHandler.signAndCreateCommand(command); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create installation command"})
return
}
@@ -673,7 +673,7 @@ func (h *UpdateHandler) ConfirmDependencies(c *gin.Context) {
CreatedAt: time.Now(),
}
if err := h.commandQueries.CreateCommand(heartbeatCmd); err != nil {
if err := h.agentHandler.signAndCreateCommand(heartbeatCmd); err != nil {
log.Printf("[Heartbeat] Warning: Failed to create heartbeat command for agent %s: %v", update.AgentID, err)
} else {
log.Printf("[Heartbeat] Command created for agent %s before confirm dependencies", update.AgentID)
@@ -683,7 +683,7 @@ func (h *UpdateHandler) ConfirmDependencies(c *gin.Context) {
}
// Store the command in database
if err := h.commandQueries.CreateCommand(command); err != nil {
if err := h.agentHandler.signAndCreateCommand(command); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create confirmation command"})
return
}

View File

@@ -0,0 +1,44 @@
package common
import (
"crypto/sha256"
"encoding/hex"
"os"
"time"
)
type AgentFile struct {
Path string `json:"path"`
Size int64 `json:"size"`
ModifiedTime time.Time `json:"modified_time"`
Version string `json:"version,omitempty"`
Checksum string `json:"checksum"`
Required bool `json:"required"`
Migrate bool `json:"migrate"`
Description string `json:"description"`
}
// CalculateChecksum computes SHA256 checksum of a file
func CalculateChecksum(filePath string) (string, error) {
data, err := os.ReadFile(filePath)
if err != nil {
return "", err
}
hash := sha256.Sum256(data)
return hex.EncodeToString(hash[:]), nil
}
// IsRequiredFile determines if a file is required for agent operation
func IsRequiredFile(path string) bool {
requiredFiles := []string{
"/etc/redflag/config.json",
"/usr/local/bin/redflag-agent",
"/etc/systemd/system/redflag-agent.service",
}
for _, rf := range requiredFiles {
if path == rf {
return true
}
}
return false
}

View File

@@ -5,7 +5,9 @@ import (
"encoding/hex"
"fmt"
"os"
"path/filepath"
"strconv"
"strings"
)
// Config holds the application configuration
@@ -29,6 +31,7 @@ type Config struct {
}
Admin struct {
Username string `env:"REDFLAG_ADMIN_USER" default:"admin"`
Email string `env:"REDFLAG_ADMIN_EMAIL" default:"admin@example.com"`
Password string `env:"REDFLAG_ADMIN_PASSWORD"`
JWTSecret string `env:"REDFLAG_JWT_SECRET"`
}
@@ -44,16 +47,80 @@ type Config struct {
MinAgentVersion string `env:"MIN_AGENT_VERSION" default:"0.1.22"`
SigningPrivateKey string `env:"REDFLAG_SIGNING_PRIVATE_KEY"`
DebugEnabled bool `env:"REDFLAG_DEBUG" default:"false"` // Enable debug logging
SecurityLogging struct {
Enabled bool `env:"REDFLAG_SECURITY_LOG_ENABLED" default:"true"`
Level string `env:"REDFLAG_SECURITY_LOG_LEVEL" default:"warning"` // none, error, warn, info, debug
LogSuccesses bool `env:"REDFLAG_SECURITY_LOG_SUCCESSES" default:"false"`
FilePath string `env:"REDFLAG_SECURITY_LOG_PATH" default:"/var/log/redflag/security.json"`
MaxSizeMB int `env:"REDFLAG_SECURITY_LOG_MAX_SIZE" default:"100"`
MaxFiles int `env:"REDFLAG_SECURITY_LOG_MAX_FILES" default:"10"`
RetentionDays int `env:"REDFLAG_SECURITY_LOG_RETENTION" default:"90"`
LogToDatabase bool `env:"REDFLAG_SECURITY_LOG_TO_DB" default:"true"`
HashIPAddresses bool `env:"REDFLAG_SECURITY_LOG_HASH_IP" default:"true"`
}
}
// Load reads configuration from environment variables only (immutable configuration)
func Load() (*Config, error) {
fmt.Printf("[CONFIG] Loading configuration from environment variables\n")
// IsDockerSecretsMode returns true if the application is running in Docker secrets mode
func IsDockerSecretsMode() bool {
// Check if we're running in Docker and secrets are available
if _, err := os.Stat("/run/secrets"); err == nil {
// Also check if any RedFlag secrets exist
if _, err := os.Stat("/run/secrets/redflag_admin_password"); err == nil {
return true
}
}
// Check environment variable override
return os.Getenv("REDFLAG_SECRETS_MODE") == "true"
}
cfg := &Config{}
// getSecretPath returns the full path to a Docker secret file
func getSecretPath(secretName string) string {
return filepath.Join("/run/secrets", secretName)
}
// loadFromSecrets reads configuration from Docker secrets
func loadFromSecrets(cfg *Config) error {
// Note: For Docker secrets, we need to map environment variables differently
// Docker secrets appear as files that contain the secret value
fmt.Printf("[CONFIG] Loading configuration from Docker secrets\n")
// Load sensitive values from Docker secrets
if password, err := readSecretFile("redflag_admin_password"); err == nil && password != "" {
cfg.Admin.Password = password
fmt.Printf("[CONFIG] [OK] Admin password loaded from Docker secret\n")
}
if jwtSecret, err := readSecretFile("redflag_jwt_secret"); err == nil && jwtSecret != "" {
cfg.Admin.JWTSecret = jwtSecret
fmt.Printf("[CONFIG] [OK] JWT secret loaded from Docker secret\n")
}
if dbPassword, err := readSecretFile("redflag_db_password"); err == nil && dbPassword != "" {
cfg.Database.Password = dbPassword
fmt.Printf("[CONFIG] [OK] Database password loaded from Docker secret\n")
}
if signingKey, err := readSecretFile("redflag_signing_private_key"); err == nil && signingKey != "" {
cfg.SigningPrivateKey = signingKey
fmt.Printf("[CONFIG] [OK] Signing private key loaded from Docker secret (%d characters)\n", len(signingKey))
}
// For other configuration, fall back to environment variables
// This allows mixing secrets (for sensitive data) with env vars (for non-sensitive config)
return loadFromEnv(cfg, true)
}
// loadFromEnv reads configuration from environment variables
// If skipSensitive=true, it won't override values that might have come from secrets
func loadFromEnv(cfg *Config, skipSensitive bool) error {
if !skipSensitive {
fmt.Printf("[CONFIG] Loading configuration from environment variables\n")
}
// Parse server configuration
cfg.Server.Host = getEnv("REDFLAG_SERVER_HOST", "0.0.0.0")
if !skipSensitive || cfg.Server.Host == "" {
cfg.Server.Host = getEnv("REDFLAG_SERVER_HOST", "0.0.0.0")
}
serverPort, _ := strconv.Atoi(getEnv("REDFLAG_SERVER_PORT", "8080"))
cfg.Server.Port = serverPort
cfg.Server.PublicURL = getEnv("REDFLAG_PUBLIC_URL", "") // Optional external URL
@@ -67,12 +134,18 @@ func Load() (*Config, error) {
cfg.Database.Port = dbPort
cfg.Database.Database = getEnv("REDFLAG_DB_NAME", "redflag")
cfg.Database.Username = getEnv("REDFLAG_DB_USER", "redflag")
cfg.Database.Password = getEnv("REDFLAG_DB_PASSWORD", "")
// Only load password from env if we're not skipping sensitive data
if !skipSensitive {
cfg.Database.Password = getEnv("REDFLAG_DB_PASSWORD", "")
}
// Parse admin configuration
cfg.Admin.Username = getEnv("REDFLAG_ADMIN_USER", "admin")
cfg.Admin.Password = getEnv("REDFLAG_ADMIN_PASSWORD", "")
cfg.Admin.JWTSecret = getEnv("REDFLAG_JWT_SECRET", "")
if !skipSensitive {
cfg.Admin.Password = getEnv("REDFLAG_ADMIN_PASSWORD", "")
cfg.Admin.JWTSecret = getEnv("REDFLAG_JWT_SECRET", "")
}
// Parse agent registration configuration
cfg.AgentRegistration.TokenExpiry = getEnv("REDFLAG_TOKEN_EXPIRY", "24h")
@@ -87,40 +160,49 @@ func Load() (*Config, error) {
cfg.CheckInInterval = checkInInterval
cfg.OfflineThreshold = offlineThreshold
cfg.Timezone = getEnv("TIMEZONE", "UTC")
cfg.LatestAgentVersion = getEnv("LATEST_AGENT_VERSION", "0.1.23.5")
cfg.LatestAgentVersion = getEnv("LATEST_AGENT_VERSION", "0.1.23.6")
cfg.MinAgentVersion = getEnv("MIN_AGENT_VERSION", "0.1.22")
cfg.SigningPrivateKey = getEnv("REDFLAG_SIGNING_PRIVATE_KEY", "")
// Debug: Log signing key status
if cfg.SigningPrivateKey != "" {
fmt.Printf("[CONFIG] ✅ Ed25519 signing private key configured (%d characters)\n", len(cfg.SigningPrivateKey))
} else {
fmt.Printf("[CONFIG] ❌ No Ed25519 signing private key found in REDFLAG_SIGNING_PRIVATE_KEY\n")
if !skipSensitive {
cfg.SigningPrivateKey = getEnv("REDFLAG_SIGNING_PRIVATE_KEY", "")
}
// Handle missing secrets
if cfg.Admin.Password == "" || cfg.Admin.JWTSecret == "" || cfg.Database.Password == "" {
fmt.Printf("[WARNING] Missing required configuration (admin password, JWT secret, or database password)\n")
fmt.Printf("[INFO] Run: ./redflag-server --setup to configure\n")
return nil, fmt.Errorf("missing required configuration")
return nil
}
// readSecretFile reads a Docker secret from /run/secrets/ directory
func readSecretFile(secretName string) (string, error) {
path := getSecretPath(secretName)
data, err := os.ReadFile(path)
if err != nil {
return "", fmt.Errorf("failed to read secret %s from %s: %w", secretName, path, err)
}
return strings.TrimSpace(string(data)), nil
}
// Load reads configuration from Docker secrets or environment variables
func Load() (*Config, error) {
// Check if we're in Docker secrets mode
if IsDockerSecretsMode() {
fmt.Printf("[CONFIG] Detected Docker secrets mode\n")
cfg := &Config{}
if err := loadFromSecrets(cfg); err != nil {
return nil, fmt.Errorf("failed to load configuration from secrets: %w", err)
}
return cfg, nil
}
// Check if we're using bootstrap defaults that need to be replaced
if cfg.Admin.Password == "changeme" || cfg.Admin.JWTSecret == "bootstrap-jwt-secret-replace-in-setup" || cfg.Database.Password == "redflag_bootstrap" {
fmt.Printf("[INFO] Server running with bootstrap configuration - setup required\n")
fmt.Printf("[INFO] Configure via web interface at: http://localhost:8080/setup\n")
return nil, fmt.Errorf("bootstrap configuration detected - setup required")
}
// Validate JWT secret is not the development default
if cfg.Admin.JWTSecret == "test-secret-for-development-only" {
fmt.Printf("[SECURITY WARNING] Using development JWT secret\n")
fmt.Printf("[INFO] Run: ./redflag-server --setup to configure production secrets\n")
// Default to environment variable mode
cfg := &Config{}
if err := loadFromEnv(cfg, false); err != nil {
return nil, fmt.Errorf("failed to load configuration from environment: %w", err)
}
// Continue with the rest of the validation...
return cfg, nil
}
// RunSetupWizard is deprecated - configuration is now handled via web interface
func RunSetupWizard() error {
return fmt.Errorf("CLI setup wizard is deprecated. Please use the web interface at http://localhost:8080/setup for configuration")
@@ -133,7 +215,18 @@ func getEnv(key, defaultValue string) string {
return defaultValue
}
// GenerateSecurePassword generates a secure password (16 characters)
func GenerateSecurePassword() string {
bytes := make([]byte, 16)
rand.Read(bytes)
// Use alphanumeric characters for better UX
chars := "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
result := make([]byte, 16)
for i := range result {
result[i] = chars[int(bytes[i])%len(chars)]
}
return string(result)
}
// GenerateSecureToken generates a cryptographically secure random token
func GenerateSecureToken() (string, error) {

View File

@@ -0,0 +1,34 @@
-- migration 018: Create scanner_config table for user-configurable scanner timeouts
-- This enables admin users to adjust scanner timeouts per subsystem via web UI
CREATE TABLE IF NOT EXISTS scanner_config (
scanner_name VARCHAR(50) PRIMARY KEY,
timeout_ms BIGINT NOT NULL, -- Timeout in milliseconds
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL,
CHECK (timeout_ms > 0 AND timeout_ms <= 7200000) -- Max 2 hours (7200000ms)
);
COMMENT ON TABLE scanner_config IS 'Stores user-configurable scanner timeout values';
COMMENT ON COLUMN scanner_config.scanner_name IS 'Name of the scanner (dnf, apt, docker, etc.)';
COMMENT ON COLUMN scanner_config.timeout_ms IS 'Timeout in milliseconds (1s = 1000ms)';
COMMENT ON COLUMN scanner_config.updated_at IS 'When this configuration was last modified';
-- Create index on updated_at for efficient querying of recently changed configs
CREATE INDEX IF NOT EXISTS idx_scanner_config_updated_at ON scanner_config(updated_at);
-- Insert default timeout values for all scanners
-- 30 minutes (1800000ms) is the new default for package scanners
INSERT INTO scanner_config (scanner_name, timeout_ms) VALUES
('system', 10000), -- 10 seconds for system metrics
('storage', 10000), -- 10 seconds for storage scan
('apt', 1800000), -- 30 minutes for APT
('dnf', 1800000), -- 30 minutes for DNF
('docker', 60000), -- 60 seconds for Docker
('windows', 600000), -- 10 minutes for Windows Updates
('winget', 120000), -- 2 minutes for Winget
('updates', 30000) -- 30 seconds for virtual update subsystem
ON CONFLICT (scanner_name) DO NOTHING;
-- Grant permissions
GRANT SELECT, INSERT, UPDATE, DELETE ON scanner_config TO redflag_user;

View File

@@ -0,0 +1,39 @@
-- Migration: Create system_events table for unified event logging
-- Reference: docs/ERROR_FLOW_AUDIT.md
CREATE TABLE IF NOT EXISTS system_events (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
agent_id UUID REFERENCES agents(id) ON DELETE CASCADE,
event_type VARCHAR(50) NOT NULL, -- 'agent_update', 'agent_startup', 'agent_scan', 'server_build', etc.
event_subtype VARCHAR(50) NOT NULL, -- 'success', 'failed', 'info', 'warning', 'critical'
severity VARCHAR(20) NOT NULL, -- 'info', 'warning', 'error', 'critical'
component VARCHAR(50) NOT NULL, -- 'agent', 'server', 'build', 'download', 'config', etc.
message TEXT,
metadata JSONB DEFAULT '{}', -- Structured event data (stack traces, HTTP codes, etc.)
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
-- Performance indexes for common query patterns
CREATE INDEX idx_system_events_agent_id ON system_events(agent_id);
CREATE INDEX idx_system_events_type_subtype ON system_events(event_type, event_subtype);
CREATE INDEX idx_system_events_created_at ON system_events(created_at DESC);
CREATE INDEX idx_system_events_severity ON system_events(severity);
CREATE INDEX idx_system_events_component ON system_events(component);
-- Composite index for agent timeline queries (agent + time range)
CREATE INDEX idx_system_events_agent_timeline ON system_events(agent_id, created_at DESC);
-- Partial index for error events (faster error dashboard queries)
CREATE INDEX idx_system_events_errors ON system_events(severity, created_at DESC)
WHERE severity IN ('error', 'critical');
-- GIN index for metadata JSONB queries (allows searching event metadata)
CREATE INDEX idx_system_events_metadata_gin ON system_events USING GIN(metadata);
-- Comment for documentation
COMMENT ON TABLE system_events IS 'Unified event logging table for all system events (agent + server)';
COMMENT ON COLUMN system_events.event_type IS 'High-level event category (e.g., agent_update, agent_startup)';
COMMENT ON COLUMN system_events.event_subtype IS 'Event outcome/status (e.g., success, failed, info, warning)';
COMMENT ON COLUMN system_events.severity IS 'Event severity level for filtering and alerting';
COMMENT ON COLUMN system_events.component IS 'System component that generated the event';
COMMENT ON COLUMN system_events.metadata IS 'JSONB field for structured event data (stack traces, HTTP codes, etc.)';

View File

@@ -0,0 +1,26 @@
-- Down Migration: Remove security features for RedFlag v0.2.x
-- Purpose: Rollback migration 020 - remove security-related tables and columns
-- Drop indexes first
DROP INDEX IF EXISTS idx_security_settings_category;
DROP INDEX IF EXISTS idx_security_settings_restart;
DROP INDEX IF EXISTS idx_security_audit_timestamp;
DROP INDEX IF EXISTS idx_security_incidents_type;
DROP INDEX IF EXISTS idx_security_incidents_severity;
DROP INDEX IF EXISTS idx_security_incidents_resolved;
DROP INDEX IF EXISTS idx_signing_keys_active;
DROP INDEX IF EXISTS idx_signing_keys_algorithm;
-- Drop check constraints
ALTER TABLE security_settings DROP CONSTRAINT IF EXISTS chk_value_type;
ALTER TABLE security_incidents DROP CONSTRAINT IF EXISTS chk_incident_severity;
ALTER TABLE signing_keys DROP CONSTRAINT IF EXISTS chk_algorithm;
-- Drop tables in reverse order to avoid foreign key constraints
DROP TABLE IF EXISTS signing_keys;
DROP TABLE IF EXISTS security_incidents;
DROP TABLE IF EXISTS security_settings_audit;
DROP TABLE IF EXISTS security_settings;
-- Remove signature column from agent_commands table
ALTER TABLE agent_commands DROP COLUMN IF EXISTS signature;

View File

@@ -0,0 +1,106 @@
-- Migration: Add security features for RedFlag v0.2.x
-- Purpose: Add command signatures, security settings, audit trail, incidents tracking, and signing keys
-- Add signature column to agent_commands table
ALTER TABLE agent_commands ADD COLUMN IF NOT EXISTS signature VARCHAR(128);
-- Create security_settings table for user-configurable settings
CREATE TABLE IF NOT EXISTS security_settings (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
category VARCHAR(50) NOT NULL,
key VARCHAR(100) NOT NULL,
value JSONB NOT NULL,
value_type VARCHAR(20) NOT NULL,
requires_restart BOOLEAN DEFAULT false,
updated_at TIMESTAMP DEFAULT NOW(),
updated_by UUID REFERENCES users(id),
is_encrypted BOOLEAN DEFAULT false,
description TEXT,
validation_rules JSONB,
UNIQUE(category, key)
);
-- Create security_settings_audit table for audit trail
CREATE TABLE IF NOT EXISTS security_settings_audit (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
setting_id UUID REFERENCES security_settings(id),
previous_value JSONB,
new_value JSONB,
changed_by UUID REFERENCES users(id),
changed_at TIMESTAMP DEFAULT NOW(),
ip_address INET,
user_agent TEXT,
reason TEXT
);
-- Create security_incidents table for tracking security events
CREATE TABLE IF NOT EXISTS security_incidents (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
incident_type VARCHAR(50) NOT NULL,
severity VARCHAR(20) NOT NULL,
agent_id UUID REFERENCES agents(id),
description TEXT NOT NULL,
metadata JSONB,
resolved BOOLEAN DEFAULT false,
resolved_at TIMESTAMP,
resolved_by UUID REFERENCES users(id),
created_at TIMESTAMP DEFAULT NOW()
);
-- Create signing_keys table for public key rotation
CREATE TABLE IF NOT EXISTS signing_keys (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
key_id VARCHAR(64) UNIQUE NOT NULL,
public_key TEXT NOT NULL,
algorithm VARCHAR(20) DEFAULT 'ed25519',
is_active BOOLEAN DEFAULT true,
is_primary BOOLEAN DEFAULT false,
created_at TIMESTAMP DEFAULT NOW(),
deprecated_at TIMESTAMP,
version INTEGER DEFAULT 1
);
-- Create indexes for security_settings
CREATE INDEX IF NOT EXISTS idx_security_settings_category ON security_settings(category);
CREATE INDEX IF NOT EXISTS idx_security_settings_restart ON security_settings(requires_restart);
-- Create indexes for security_settings_audit
CREATE INDEX IF NOT EXISTS idx_security_audit_timestamp ON security_settings_audit(changed_at DESC);
-- Create indexes for security_incidents
CREATE INDEX IF NOT EXISTS idx_security_incidents_type ON security_incidents(incident_type);
CREATE INDEX IF NOT EXISTS idx_security_incidents_severity ON security_incidents(severity);
CREATE INDEX IF NOT EXISTS idx_security_incidents_resolved ON security_incidents(resolved);
-- Create indexes for signing_keys
CREATE INDEX IF NOT EXISTS idx_signing_keys_active ON signing_keys(is_active, is_primary);
CREATE INDEX IF NOT EXISTS idx_signing_keys_algorithm ON signing_keys(algorithm);
-- Add comments for documentation
COMMENT ON TABLE security_settings IS 'Stores user-configurable security settings for the RedFlag system';
COMMENT ON TABLE security_settings_audit IS 'Audit trail for all changes to security settings';
COMMENT ON TABLE security_incidents IS 'Tracks security incidents and events in the system';
COMMENT ON TABLE signing_keys IS 'Stores public signing keys with support for key rotation';
COMMENT ON COLUMN agent_commands.signature IS 'Digital signature of the command for verification';
COMMENT ON COLUMN security_settings.is_encrypted IS 'Indicates if the setting value should be encrypted at rest';
COMMENT ON COLUMN security_settings.validation_rules IS 'JSON schema for validating the setting value';
COMMENT ON COLUMN security_settings_audit.ip_address IS 'IP address of the user who made the change';
COMMENT ON COLUMN security_settings_audit.reason IS 'Optional reason for the configuration change';
COMMENT ON COLUMN security_incidents.metadata IS 'Additional structured data about the incident';
COMMENT ON COLUMN signing_keys.key_id IS 'Unique identifier for the signing key (e.g., fingerprint)';
COMMENT ON COLUMN signing_keys.version IS 'Version number for tracking key iterations';
-- Add check constraints for data integrity
ALTER TABLE security_settings ADD CONSTRAINT chk_value_type CHECK (value_type IN ('string', 'number', 'boolean', 'array', 'object'));
ALTER TABLE security_incidents ADD CONSTRAINT chk_incident_severity CHECK (severity IN ('low', 'medium', 'high', 'critical'));
ALTER TABLE signing_keys ADD CONSTRAINT chk_algorithm CHECK (algorithm IN ('ed25519', 'rsa', 'ecdsa', 'rsa-pss'));
-- Grant permissions (adjust as needed for your setup)
-- GRANT ALL PRIVILEGES ON TABLE security_settings TO redflag_user;
-- GRANT ALL PRIVILEGES ON TABLE security_settings_audit TO redflag_user;
-- GRANT ALL PRIVILEGES ON TABLE security_incidents TO redflag_user;
-- GRANT ALL PRIVILEGES ON TABLE signing_keys TO redflag_user;
-- GRANT USAGE ON SCHEMA public TO redflag_user;

View File

@@ -22,9 +22,9 @@ func NewCommandQueries(db *sqlx.DB) *CommandQueries {
func (q *CommandQueries) CreateCommand(cmd *models.AgentCommand) error {
query := `
INSERT INTO agent_commands (
id, agent_id, command_type, params, status, source, retried_from_id
id, agent_id, command_type, params, status, source, signature, retried_from_id
) VALUES (
:id, :agent_id, :command_type, :params, :status, :source, :retried_from_id
:id, :agent_id, :command_type, :params, :status, :source, :signature, :retried_from_id
)
`
_, err := q.db.NamedExec(query, cmd)
@@ -200,6 +200,7 @@ func (q *CommandQueries) GetActiveCommands() ([]models.ActiveCommandInfo, error)
c.params,
c.status,
c.source,
c.signature,
c.created_at,
c.sent_at,
c.result,
@@ -262,6 +263,7 @@ func (q *CommandQueries) GetRecentCommands(limit int) ([]models.ActiveCommandInf
c.command_type,
c.status,
c.source,
c.signature,
c.created_at,
c.sent_at,
c.completed_at,

View File

@@ -116,7 +116,7 @@ func (q *RegistrationTokenQueries) MarkTokenUsed(token string, agentID uuid.UUID
return nil
}
// GetActiveRegistrationTokens returns all active tokens
// GetActiveRegistrationTokens returns all active tokens that haven't expired
func (q *RegistrationTokenQueries) GetActiveRegistrationTokens() ([]RegistrationToken, error) {
var tokens []RegistrationToken
query := `
@@ -124,7 +124,7 @@ func (q *RegistrationTokenQueries) GetActiveRegistrationTokens() ([]Registration
revoked, revoked_at, revoked_reason, status, created_by, metadata,
max_seats, seats_used
FROM registration_tokens
WHERE status = 'active'
WHERE status = 'active' AND expires_at > NOW()
ORDER BY created_at DESC
`

View File

@@ -1,123 +0,0 @@
package queries
import (
"time"
"github.com/Fimeg/RedFlag/aggregator-server/internal/models"
"github.com/google/uuid"
"github.com/jmoiron/sqlx"
"golang.org/x/crypto/bcrypt"
)
type UserQueries struct {
db *sqlx.DB
}
func NewUserQueries(db *sqlx.DB) *UserQueries {
return &UserQueries{db: db}
}
// CreateUser inserts a new user into the database with password hashing
func (q *UserQueries) CreateUser(username, email, password, role string) (*models.User, error) {
// Hash the password
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
return nil, err
}
user := &models.User{
ID: uuid.New(),
Username: username,
Email: email,
PasswordHash: string(hashedPassword),
Role: role,
CreatedAt: time.Now().UTC(),
}
query := `
INSERT INTO users (
id, username, email, password_hash, role, created_at
) VALUES (
:id, :username, :email, :password_hash, :role, :created_at
)
RETURNING *
`
rows, err := q.db.NamedQuery(query, user)
if err != nil {
return nil, err
}
defer rows.Close()
if rows.Next() {
if err := rows.StructScan(user); err != nil {
return nil, err
}
return user, nil
}
return nil, nil
}
// GetUserByUsername retrieves a user by username
func (q *UserQueries) GetUserByUsername(username string) (*models.User, error) {
var user models.User
query := `SELECT * FROM users WHERE username = $1`
err := q.db.Get(&user, query, username)
if err != nil {
return nil, err
}
return &user, nil
}
// VerifyCredentials checks if the provided username and password are valid
func (q *UserQueries) VerifyCredentials(username, password string) (*models.User, error) {
user, err := q.GetUserByUsername(username)
if err != nil {
return nil, err
}
// Compare the provided password with the stored hash
err = bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(password))
if err != nil {
return nil, err // Invalid password
}
// Update last login time
q.UpdateLastLogin(user.ID)
// Don't return password hash
user.PasswordHash = ""
return user, nil
}
// UpdateLastLogin updates the user's last login timestamp
func (q *UserQueries) UpdateLastLogin(id uuid.UUID) error {
query := `UPDATE users SET last_login = $1 WHERE id = $2`
_, err := q.db.Exec(query, time.Now().UTC(), id)
return err
}
// GetUserByID retrieves a user by ID
func (q *UserQueries) GetUserByID(id uuid.UUID) (*models.User, error) {
var user models.User
query := `SELECT id, username, email, role, created_at, last_login FROM users WHERE id = $1`
err := q.db.Get(&user, query, id)
if err != nil {
return nil, err
}
return &user, nil
}
// EnsureAdminUser creates an admin user if one doesn't exist
func (q *UserQueries) EnsureAdminUser(username, email, password string) error {
// Check if admin user already exists
existingUser, err := q.GetUserByUsername(username)
if err == nil && existingUser != nil {
return nil // Admin user already exists
}
// Create admin user
_, err = q.CreateUser(username, email, password, "admin")
return err
}

View File

@@ -0,0 +1,118 @@
package logging
// This file contains example code showing how to integrate the security logger
// into various parts of the server application.
import (
"github.com/Fimeg/RedFlag/aggregator-server/internal/config"
"github.com/Fimeg/RedFlag/aggregator-server/internal/models"
"github.com/google/uuid"
"github.com/jmoiron/sqlx"
)
// Example of how to initialize the security logger in main.go
func ExampleInitializeSecurityLogger(cfg *config.Config, db *sqlx.DB) (*SecurityLogger, error) {
// Convert config to security logger config
secConfig := SecurityLogConfig{
Enabled: cfg.SecurityLogging.Enabled,
Level: cfg.SecurityLogging.Level,
LogSuccesses: cfg.SecurityLogging.LogSuccesses,
FilePath: cfg.SecurityLogging.FilePath,
MaxSizeMB: cfg.SecurityLogging.MaxSizeMB,
MaxFiles: cfg.SecurityLogging.MaxFiles,
RetentionDays: cfg.SecurityLogging.RetentionDays,
LogToDatabase: cfg.SecurityLogging.LogToDatabase,
HashIPAddresses: cfg.SecurityLogging.HashIPAddresses,
}
// Create the security logger
securityLogger, err := NewSecurityLogger(secConfig, db)
if err != nil {
return nil, err
}
return securityLogger, nil
}
// Example of using the security logger in authentication handlers
func ExampleAuthHandler(securityLogger *SecurityLogger, clientIP string) {
// Example: JWT validation failed
securityLogger.LogAuthJWTValidationFailure(
uuid.Nil, // Agent ID might not be known yet
"invalid.jwt.token",
"expired signature",
)
// Example: Unauthorized access attempt
securityLogger.LogUnauthorizedAccessAttempt(
clientIP,
"/api/v1/admin/users",
"insufficient privileges",
uuid.Nil,
)
}
// Example of using the security logger in command/verification handlers
func ExampleCommandVerificationHandler(securityLogger *SecurityLogger, agentID, commandID uuid.UUID, signature string) {
// Simulate signature verification
signatureValid := false // In real code, this would be actual verification result
if !signatureValid {
securityLogger.LogCommandVerificationFailure(
agentID,
commandID,
"signature mismatch: expected X, got Y",
)
} else {
// Only log success if configured to do so
if securityLogger.config.LogSuccesses {
event := models.NewSecurityEvent(
"INFO",
models.SecurityEventTypes.CmdSignatureVerificationSuccess,
agentID,
"Command signature verification succeeded",
)
event.WithDetail("command_id", commandID.String())
securityLogger.Log(event)
}
}
}
// Example of using the security logger in update handlers
func ExampleUpdateHandler(securityLogger *SecurityLogger, agentID uuid.UUID, updateData []byte, signature string) {
// Simulate update nonce validation
nonceValid := false // In real code, this would be actual validation
if !nonceValid {
securityLogger.LogNonceValidationFailure(
agentID,
"12345678-1234-1234-1234-123456789012",
"nonce not found in database",
)
}
// Simulate signature verification
signatureValid := false
if !signatureValid {
securityLogger.LogUpdateSignatureValidationFailure(
agentID,
"update-123",
"invalid signature format",
)
}
}
// Example of using the security logger on agent registration
func ExampleAgentRegistrationHandler(securityLogger *SecurityLogger, clientIP string) {
securityLogger.LogAgentRegistrationFailed(
clientIP,
"invalid registration token",
)
}
// Example of checking if a private key is configured
func ExampleCheckPrivateKey(securityLogger *SecurityLogger, cfg *config.Config) {
if cfg.SigningPrivateKey == "" {
securityLogger.LogPrivateKeyNotConfigured()
}
}

View File

@@ -0,0 +1,363 @@
package logging
import (
"encoding/json"
"fmt"
"log"
"os"
"path/filepath"
"sync"
"time"
"github.com/Fimeg/RedFlag/aggregator-server/internal/models"
"github.com/google/uuid"
"github.com/jmoiron/sqlx"
"gopkg.in/natefinch/lumberjack.v2"
)
// SecurityLogConfig holds configuration for security logging
type SecurityLogConfig struct {
Enabled bool `yaml:"enabled" env:"REDFLAG_SECURITY_LOG_ENABLED" default:"true"`
Level string `yaml:"level" env:"REDFLAG_SECURITY_LOG_LEVEL" default:"warning"` // none, error, warn, info, debug
LogSuccesses bool `yaml:"log_successes" env:"REDFLAG_SECURITY_LOG_SUCCESSES" default:"false"`
FilePath string `yaml:"file_path" env:"REDFLAG_SECURITY_LOG_PATH" default:"/var/log/redflag/security.json"`
MaxSizeMB int `yaml:"max_size_mb" env:"REDFLAG_SECURITY_LOG_MAX_SIZE" default:"100"`
MaxFiles int `yaml:"max_files" env:"REDFLAG_SECURITY_LOG_MAX_FILES" default:"10"`
RetentionDays int `yaml:"retention_days" env:"REDFLAG_SECURITY_LOG_RETENTION" default:"90"`
LogToDatabase bool `yaml:"log_to_database" env:"REDFLAG_SECURITY_LOG_TO_DB" default:"true"`
HashIPAddresses bool `yaml:"hash_ip_addresses" env:"REDFLAG_SECURITY_LOG_HASH_IP" default:"true"`
}
// SecurityLogger handles structured security event logging
type SecurityLogger struct {
config SecurityLogConfig
logger *log.Logger
db *sqlx.DB
lumberjack *lumberjack.Logger
mu sync.RWMutex
buffer chan *models.SecurityEvent
bufferSize int
stopChan chan struct{}
wg sync.WaitGroup
}
// NewSecurityLogger creates a new security logger instance
func NewSecurityLogger(config SecurityLogConfig, db *sqlx.DB) (*SecurityLogger, error) {
if !config.Enabled || config.Level == "none" {
return &SecurityLogger{
config: config,
logger: log.New(os.Stdout, "[SECURITY] ", log.LstdFlags|log.LUTC),
}, nil
}
// Ensure log directory exists
logDir := filepath.Dir(config.FilePath)
if err := os.MkdirAll(logDir, 0755); err != nil {
return nil, fmt.Errorf("failed to create security log directory: %w", err)
}
// Setup rotating file writer
lumberjack := &lumberjack.Logger{
Filename: config.FilePath,
MaxSize: config.MaxSizeMB,
MaxBackups: config.MaxFiles,
MaxAge: config.RetentionDays,
Compress: true,
}
logger := &SecurityLogger{
config: config,
logger: log.New(lumberjack, "", 0), // No prefix, we'll add timestamps ourselves
db: db,
lumberjack: lumberjack,
buffer: make(chan *models.SecurityEvent, 1000),
bufferSize: 1000,
stopChan: make(chan struct{}),
}
// Start background processor
logger.wg.Add(1)
go logger.processEvents()
return logger, nil
}
// Log writes a security event
func (sl *SecurityLogger) Log(event *models.SecurityEvent) error {
if !sl.config.Enabled || sl.config.Level == "none" {
return nil
}
// Skip successes unless configured to log them
if !sl.config.LogSuccesses && event.EventType == models.SecurityEventTypes.CmdSignatureVerificationSuccess {
return nil
}
// Filter by log level
if !sl.shouldLogLevel(event.Level) {
return nil
}
// Hash IP addresses if configured
if sl.config.HashIPAddresses && event.IPAddress != "" {
event.HashIPAddress()
}
// Try to send to buffer (non-blocking)
select {
case sl.buffer <- event:
default:
// Buffer full, log directly synchronously
return sl.writeEvent(event)
}
return nil
}
// LogCommandVerificationFailure logs a command signature verification failure
func (sl *SecurityLogger) LogCommandVerificationFailure(agentID, commandID uuid.UUID, reason string) {
event := models.NewSecurityEvent("CRITICAL", models.SecurityEventTypes.CmdSignatureVerificationFailed, agentID, "Command signature verification failed")
event.WithDetail("command_id", commandID.String())
event.WithDetail("reason", reason)
_ = sl.Log(event)
}
// LogUpdateSignatureValidationFailure logs an update signature validation failure
func (sl *SecurityLogger) LogUpdateSignatureValidationFailure(agentID uuid.UUID, updateID string, reason string) {
event := models.NewSecurityEvent("CRITICAL", models.SecurityEventTypes.UpdateSignatureVerificationFailed, agentID, "Update signature validation failed")
event.WithDetail("update_id", updateID)
event.WithDetail("reason", reason)
_ = sl.Log(event)
}
// LogCommandSigned logs successful command signing
func (sl *SecurityLogger) LogCommandSigned(cmd *models.AgentCommand) {
event := models.NewSecurityEvent("INFO", models.SecurityEventTypes.CmdSigned, cmd.AgentID, "Command signed successfully")
event.WithDetail("command_id", cmd.ID.String())
event.WithDetail("command_type", cmd.CommandType)
event.WithDetail("signature_present", cmd.Signature != "")
_ = sl.Log(event)
}
// LogNonceValidationFailure logs a nonce validation failure
func (sl *SecurityLogger) LogNonceValidationFailure(agentID uuid.UUID, nonce string, reason string) {
event := models.NewSecurityEvent("WARNING", models.SecurityEventTypes.UpdateNonceInvalid, agentID, "Update nonce validation failed")
event.WithDetail("nonce", nonce)
event.WithDetail("reason", reason)
_ = sl.Log(event)
}
// LogMachineIDMismatch logs a machine ID mismatch
func (sl *SecurityLogger) LogMachineIDMismatch(agentID uuid.UUID, expected, actual string) {
event := models.NewSecurityEvent("WARNING", models.SecurityEventTypes.MachineIDMismatch, agentID, "Machine ID mismatch detected")
event.WithDetail("expected_machine_id", expected)
event.WithDetail("actual_machine_id", actual)
_ = sl.Log(event)
}
// LogAuthJWTValidationFailure logs a JWT validation failure
func (sl *SecurityLogger) LogAuthJWTValidationFailure(agentID uuid.UUID, token string, reason string) {
event := models.NewSecurityEvent("WARNING", models.SecurityEventTypes.AuthJWTValidationFailed, agentID, "JWT authentication failed")
event.WithDetail("reason", reason)
if len(token) > 0 {
event.WithDetail("token_preview", token[:min(len(token), 20)]+"...")
}
_ = sl.Log(event)
}
// LogPrivateKeyNotConfigured logs when private key is not configured
func (sl *SecurityLogger) LogPrivateKeyNotConfigured() {
event := models.NewSecurityEvent("CRITICAL", models.SecurityEventTypes.PrivateKeyNotConfigured, uuid.Nil, "Private signing key not configured")
event.WithDetail("component", "server")
_ = sl.Log(event)
}
// LogAgentRegistrationFailed logs an agent registration failure
func (sl *SecurityLogger) LogAgentRegistrationFailed(ip string, reason string) {
event := models.NewSecurityEvent("WARNING", models.SecurityEventTypes.AgentRegistrationFailed, uuid.Nil, "Agent registration failed")
event.WithIPAddress(ip)
event.WithDetail("reason", reason)
_ = sl.Log(event)
}
// LogUnauthorizedAccessAttempt logs an unauthorized access attempt
func (sl *SecurityLogger) LogUnauthorizedAccessAttempt(ip, endpoint, reason string, agentID uuid.UUID) {
event := models.NewSecurityEvent("WARNING", models.SecurityEventTypes.UnauthorizedAccessAttempt, agentID, "Unauthorized access attempt")
event.WithIPAddress(ip)
event.WithDetail("endpoint", endpoint)
event.WithDetail("reason", reason)
_ = sl.Log(event)
}
// processEvents processes events from the buffer in the background
func (sl *SecurityLogger) processEvents() {
defer sl.wg.Done()
ticker := time.NewTicker(5 * time.Second)
defer ticker.Stop()
batch := make([]*models.SecurityEvent, 0, 100)
for {
select {
case event := <-sl.buffer:
batch = append(batch, event)
if len(batch) >= 100 {
sl.processBatch(batch)
batch = batch[:0]
}
case <-ticker.C:
if len(batch) > 0 {
sl.processBatch(batch)
batch = batch[:0]
}
case <-sl.stopChan:
// Process any remaining events
for len(sl.buffer) > 0 {
batch = append(batch, <-sl.buffer)
}
if len(batch) > 0 {
sl.processBatch(batch)
}
return
}
}
}
// processBatch processes a batch of events
func (sl *SecurityLogger) processBatch(events []*models.SecurityEvent) {
for _, event := range events {
_ = sl.writeEvent(event)
}
}
// writeEvent writes an event to the configured outputs
func (sl *SecurityLogger) writeEvent(event *models.SecurityEvent) error {
// Write to file
if err := sl.writeToFile(event); err != nil {
log.Printf("[ERROR] Failed to write security event to file: %v", err)
}
// Write to database if configured
if sl.config.LogToDatabase && sl.db != nil && event.ShouldLogToDatabase(sl.config.LogToDatabase) {
if err := sl.writeToDatabase(event); err != nil {
log.Printf("[ERROR] Failed to write security event to database: %v", err)
}
}
return nil
}
// writeToFile writes the event as JSON to the log file
func (sl *SecurityLogger) writeToFile(event *models.SecurityEvent) error {
jsonData, err := json.Marshal(event)
if err != nil {
return fmt.Errorf("failed to marshal security event: %w", err)
}
sl.logger.Println(string(jsonData))
return nil
}
// writeToDatabase writes the event to the database
func (sl *SecurityLogger) writeToDatabase(event *models.SecurityEvent) error {
// Create security_events table if not exists
if err := sl.ensureSecurityEventsTable(); err != nil {
return fmt.Errorf("failed to ensure security_events table: %w", err)
}
// Encode details and metadata as JSON
detailsJSON, _ := json.Marshal(event.Details)
metadataJSON, _ := json.Marshal(event.Metadata)
query := `
INSERT INTO security_events (timestamp, level, event_type, agent_id, message, trace_id, ip_address, details, metadata)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)`
_, err := sl.db.Exec(query,
event.Timestamp,
event.Level,
event.EventType,
event.AgentID,
event.Message,
event.TraceID,
event.IPAddress,
detailsJSON,
metadataJSON,
)
return err
}
// ensureSecurityEventsTable creates the security_events table if it doesn't exist
func (sl *SecurityLogger) ensureSecurityEventsTable() error {
query := `
CREATE TABLE IF NOT EXISTS security_events (
id SERIAL PRIMARY KEY,
timestamp TIMESTAMP WITH TIME ZONE NOT NULL,
level VARCHAR(20) NOT NULL,
event_type VARCHAR(100) NOT NULL,
agent_id UUID,
message TEXT NOT NULL,
trace_id VARCHAR(100),
ip_address VARCHAR(100),
details JSONB,
metadata JSONB,
INDEX idx_security_events_timestamp (timestamp),
INDEX idx_security_events_agent_id (agent_id),
INDEX idx_security_events_level (level),
INDEX idx_security_events_event_type (event_type)
)`
_, err := sl.db.Exec(query)
return err
}
// Close closes the security logger and flushes any pending events
func (sl *SecurityLogger) Close() error {
if sl.lumberjack != nil {
close(sl.stopChan)
sl.wg.Wait()
if err := sl.lumberjack.Close(); err != nil {
return err
}
}
return nil
}
// shouldLogLevel checks if the event should be logged based on the configured level
func (sl *SecurityLogger) shouldLogLevel(eventLevel string) bool {
levels := map[string]int{
"NONE": 0,
"ERROR": 1,
"WARNING": 2,
"INFO": 3,
"DEBUG": 4,
}
configLevel := levels[sl.config.Level]
eventLvl, exists := levels[eventLevel]
if !exists {
eventLvl = 2 // Default to WARNING
}
return eventLvl <= configLevel
}
// min returns the minimum of two integers
func min(a, b int) int {
if a < b {
return a
}
return b
}

View File

@@ -102,6 +102,7 @@ type AgentRegistrationResponse struct {
type TokenRenewalRequest struct {
AgentID uuid.UUID `json:"agent_id" binding:"required"`
RefreshToken string `json:"refresh_token" binding:"required"`
AgentVersion string `json:"agent_version,omitempty"` // Optional: agent's current version for upgrade tracking
}
// TokenRenewalResponse is returned after successful token renewal

View File

@@ -14,6 +14,7 @@ type AgentCommand struct {
Params JSONB `json:"params" db:"params"`
Status string `json:"status" db:"status"`
Source string `json:"source" db:"source"`
Signature string `json:"signature,omitempty" db:"signature"`
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"`
@@ -36,9 +37,10 @@ type RapidPollingConfig struct {
// CommandItem represents a command in the response
type CommandItem struct {
ID string `json:"id"`
Type string `json:"type"`
Params JSONB `json:"params"`
ID string `json:"id"`
Type string `json:"type"`
Params JSONB `json:"params"`
Signature string `json:"signature,omitempty"`
}
// Command types
@@ -80,6 +82,7 @@ type ActiveCommandInfo struct {
Params JSONB `json:"params" db:"params"`
Status string `json:"status" db:"status"`
Source string `json:"source" db:"source"`
Signature string `json:"signature,omitempty" db:"signature"`
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"`

View File

@@ -0,0 +1,111 @@
package models
import (
"crypto/sha256"
"fmt"
"time"
"github.com/google/uuid"
)
// SecurityEvent represents a security-related event that occurred
type SecurityEvent struct {
Timestamp time.Time `json:"timestamp" db:"timestamp"`
Level string `json:"level" db:"level"` // CRITICAL, WARNING, INFO, DEBUG
EventType string `json:"event_type" db:"event_type"`
AgentID uuid.UUID `json:"agent_id,omitempty" db:"agent_id"`
Message string `json:"message" db:"message"`
TraceID string `json:"trace_id,omitempty" db:"trace_id"`
IPAddress string `json:"ip_address,omitempty" db:"ip_address"`
Details map[string]interface{} `json:"details,omitempty" db:"details"` // JSON encoded
Metadata map[string]interface{} `json:"metadata,omitempty" db:"metadata"` // JSON encoded
}
// SecurityEventTypes defines all possible security event types
var SecurityEventTypes = struct {
CmdSigned string
CmdSignatureVerificationFailed string
CmdSignatureVerificationSuccess string
UpdateNonceInvalid string
UpdateSignatureVerificationFailed string
MachineIDMismatch string
AuthJWTValidationFailed string
PrivateKeyNotConfigured string
AgentRegistrationFailed string
UnauthorizedAccessAttempt string
ConfigTamperingDetected string
AnomalousBehavior string
}{
CmdSigned: "CMD_SIGNED",
CmdSignatureVerificationFailed: "CMD_SIGNATURE_VERIFICATION_FAILED",
CmdSignatureVerificationSuccess: "CMD_SIGNATURE_VERIFICATION_SUCCESS",
UpdateNonceInvalid: "UPDATE_NONCE_INVALID",
UpdateSignatureVerificationFailed: "UPDATE_SIGNATURE_VERIFICATION_FAILED",
MachineIDMismatch: "MACHINE_ID_MISMATCH",
AuthJWTValidationFailed: "AUTH_JWT_VALIDATION_FAILED",
PrivateKeyNotConfigured: "PRIVATE_KEY_NOT_CONFIGURED",
AgentRegistrationFailed: "AGENT_REGISTRATION_FAILED",
UnauthorizedAccessAttempt: "UNAUTHORIZED_ACCESS_ATTEMPT",
ConfigTamperingDetected: "CONFIG_TAMPERING_DETECTED",
AnomalousBehavior: "ANOMALOUS_BEHAVIOR",
}
// IsCritical returns true if the event is of critical severity
func (e *SecurityEvent) IsCritical() bool {
return e.Level == "CRITICAL"
}
// IsWarning returns true if the event is a warning
func (e *SecurityEvent) IsWarning() bool {
return e.Level == "WARNING"
}
// ShouldLogToDatabase determines if this event should be stored in the database
func (e *SecurityEvent) ShouldLogToDatabase(logToDatabase bool) bool {
return logToDatabase && (e.IsCritical() || e.IsWarning())
}
// HashIPAddress hashes the IP address for privacy
func (e *SecurityEvent) HashIPAddress() {
if e.IPAddress != "" {
hash := sha256.Sum256([]byte(e.IPAddress))
e.IPAddress = fmt.Sprintf("hashed:%x", hash[:8]) // Store first 8 bytes of hash
}
}
// NewSecurityEvent creates a new security event with current timestamp
func NewSecurityEvent(level, eventType string, agentID uuid.UUID, message string) *SecurityEvent {
return &SecurityEvent{
Timestamp: time.Now().UTC(),
Level: level,
EventType: eventType,
AgentID: agentID,
Message: message,
Details: make(map[string]interface{}),
Metadata: make(map[string]interface{}),
}
}
// WithTrace adds a trace ID to the event
func (e *SecurityEvent) WithTrace(traceID string) *SecurityEvent {
e.TraceID = traceID
return e
}
// WithIPAddress adds an IP address to the event
func (e *SecurityEvent) WithIPAddress(ip string) *SecurityEvent {
e.IPAddress = ip
return e
}
// WithDetail adds a key-value detail to the event
func (e *SecurityEvent) WithDetail(key string, value interface{}) *SecurityEvent {
e.Details[key] = value
return e
}
// WithMetadata adds a key-value metadata to the event
func (e *SecurityEvent) WithMetadata(key string, value interface{}) *SecurityEvent {
e.Metadata[key] = value
return e
}

View File

@@ -0,0 +1,32 @@
package models
import (
"time"
"github.com/google/uuid"
)
// SecuritySetting represents a user-configurable security setting
type SecuritySetting struct {
ID uuid.UUID `json:"id" db:"id"`
Category string `json:"category" db:"category"`
Key string `json:"key" db:"key"`
Value string `json:"value" db:"value"`
IsEncrypted bool `json:"is_encrypted" db:"is_encrypted"`
CreatedAt time.Time `json:"created_at" db:"created_at"`
UpdatedAt *time.Time `json:"updated_at" db:"updated_at"`
CreatedBy *uuid.UUID `json:"created_by" db:"created_by"`
UpdatedBy *uuid.UUID `json:"updated_by" db:"updated_by"`
}
// SecuritySettingAudit represents an audit log entry for security setting changes
type SecuritySettingAudit struct {
ID uuid.UUID `json:"id" db:"id"`
SettingID uuid.UUID `json:"setting_id" db:"setting_id"`
UserID uuid.UUID `json:"user_id" db:"user_id"`
Action string `json:"action" db:"action"` // create, update, delete
OldValue *string `json:"old_value" db:"old_value"`
NewValue *string `json:"new_value" db:"new_value"`
Reason string `json:"reason" db:"reason"`
CreatedAt time.Time `json:"created_at" db:"created_at"`
}

View File

@@ -0,0 +1,79 @@
package models
import (
"time"
"github.com/google/uuid"
)
// SystemEvent represents a unified event log entry for all system events
// This implements the unified event logging system from docs/ERROR_FLOW_AUDIT.md
type SystemEvent struct {
ID uuid.UUID `json:"id" db:"id"`
AgentID *uuid.UUID `json:"agent_id,omitempty" db:"agent_id"` // Pointer to allow NULL for server events
EventType string `json:"event_type" db:"event_type"` // e.g., 'agent_update', 'agent_startup', 'server_build'
EventSubtype string `json:"event_subtype" db:"event_subtype"` // e.g., 'success', 'failed', 'info', 'warning'
Severity string `json:"severity" db:"severity"` // 'info', 'warning', 'error', 'critical'
Component string `json:"component" db:"component"` // 'agent', 'server', 'build', 'download', 'config', etc.
Message string `json:"message" db:"message"`
Metadata map[string]interface{} `json:"metadata,omitempty" db:"metadata"` // JSONB for structured data
CreatedAt time.Time `json:"created_at" db:"created_at"`
}
// Event type constants
const (
EventTypeAgentStartup = "agent_startup"
EventTypeAgentRegistration = "agent_registration"
EventTypeAgentCheckIn = "agent_checkin"
EventTypeAgentScan = "agent_scan"
EventTypeAgentUpdate = "agent_update"
EventTypeAgentConfig = "agent_config"
EventTypeAgentMigration = "agent_migration"
EventTypeAgentShutdown = "agent_shutdown"
EventTypeServerBuild = "server_build"
EventTypeServerDownload = "server_download"
EventTypeServerConfig = "server_config"
EventTypeServerAuth = "server_auth"
EventTypeDownload = "download"
EventTypeMigration = "migration"
EventTypeError = "error"
)
// Event subtype constants
const (
SubtypeSuccess = "success"
SubtypeFailed = "failed"
SubtypeInfo = "info"
SubtypeWarning = "warning"
SubtypeCritical = "critical"
SubtypeDownloadFailed = "download_failed"
SubtypeValidationFailed = "validation_failed"
SubtypeConfigCorrupted = "config_corrupted"
SubtypeMigrationNeeded = "migration_needed"
SubtypePanicRecovered = "panic_recovered"
SubtypeTokenExpired = "token_expired"
SubtypeNetworkTimeout = "network_timeout"
SubtypePermissionDenied = "permission_denied"
SubtypeServiceUnavailable = "service_unavailable"
)
// Severity constants
const (
SeverityInfo = "info"
SeverityWarning = "warning"
SeverityError = "error"
SeverityCritical = "critical"
)
// Component constants
const (
ComponentAgent = "agent"
ComponentServer = "server"
ComponentBuild = "build"
ComponentDownload = "download"
ComponentConfig = "config"
ComponentDatabase = "database"
ComponentNetwork = "network"
ComponentSecurity = "security"
ComponentMigration = "migration"
)

View File

@@ -11,7 +11,6 @@ type User struct {
Username string `json:"username" db:"username"`
Email string `json:"email" db:"email"`
PasswordHash string `json:"-" db:"password_hash"` // Don't include in JSON
Role string `json:"role" db:"role"`
CreatedAt time.Time `json:"created_at" db:"created_at"`
LastLogin *time.Time `json:"last_login" db:"last_login"`
}

View File

@@ -76,7 +76,7 @@ func (ab *AgentBuilder) generateConfigJSON(config *AgentConfiguration) (string,
// CRITICAL: Add both version fields explicitly
// These MUST be present or middleware will block the agent
completeConfig["version"] = config.ConfigVersion // Config schema version (e.g., "5")
completeConfig["agent_version"] = config.AgentVersion // Agent binary version (e.g., "0.1.23.5")
completeConfig["agent_version"] = config.AgentVersion // Agent binary version (e.g., "0.1.23.6")
// Add agent metadata
completeConfig["agent_id"] = config.AgentID

View File

@@ -226,10 +226,16 @@ func (s *AgentLifecycleService) buildResponse(
cfg *AgentConfig,
artifacts *BuildArtifacts,
) *AgentSetupResponse {
// Default to amd64 if architecture not specified
arch := cfg.Architecture
if arch == "" {
arch = "amd64"
}
return &AgentSetupResponse{
AgentID: cfg.AgentID,
ConfigURL: fmt.Sprintf("/api/v1/config/%s", cfg.AgentID),
BinaryURL: fmt.Sprintf("/api/v1/downloads/%s?version=%s", cfg.Platform, cfg.Version),
BinaryURL: fmt.Sprintf("/api/v1/downloads/%s-%s?version=%s", cfg.Platform, arch, cfg.Version),
Signature: artifacts.Signature,
Version: cfg.Version,
Platform: cfg.Platform,

View File

@@ -0,0 +1,138 @@
package services
import (
"fmt"
"log"
"os"
"path/filepath"
"strings"
"github.com/Fimeg/RedFlag/aggregator-server/internal/database/queries"
"github.com/Fimeg/RedFlag/aggregator-server/internal/models"
"github.com/google/uuid"
)
// BuildOrchestratorService handles building and signing agent binaries
type BuildOrchestratorService struct {
signingService *SigningService
packageQueries *queries.PackageQueries
agentDir string // Directory containing pre-built binaries
}
// NewBuildOrchestratorService creates a new build orchestrator service
func NewBuildOrchestratorService(signingService *SigningService, packageQueries *queries.PackageQueries, agentDir string) *BuildOrchestratorService {
return &BuildOrchestratorService{
signingService: signingService,
packageQueries: packageQueries,
agentDir: agentDir,
}
}
// BuildAndSignAgent builds (or retrieves) and signs an agent binary
func (s *BuildOrchestratorService) BuildAndSignAgent(version, platform, architecture string) (*models.AgentUpdatePackage, error) {
// Determine binary name
binaryName := "redflag-agent"
if strings.HasPrefix(platform, "windows") {
binaryName += ".exe"
}
// Path to pre-built binary
binaryPath := filepath.Join(s.agentDir, "binaries", platform, binaryName)
// Check if binary exists
if _, err := os.Stat(binaryPath); os.IsNotExist(err) {
return nil, fmt.Errorf("binary not found for platform %s: %w", platform, err)
}
// Sign the binary if signing is enabled
if s.signingService.IsEnabled() {
signedPackage, err := s.signingService.SignFile(binaryPath)
if err != nil {
return nil, fmt.Errorf("failed to sign agent binary: %w", err)
}
// Set additional fields
signedPackage.Version = version
signedPackage.Platform = platform
signedPackage.Architecture = architecture
// Store signed package in database
err = s.packageQueries.StoreSignedPackage(signedPackage)
if err != nil {
return nil, fmt.Errorf("failed to store signed package: %w", err)
}
log.Printf("Successfully signed and stored agent binary: %s (%s/%s)", signedPackage.ID, platform, architecture)
return signedPackage, nil
} else {
log.Printf("Signing disabled, creating unsigned package entry")
// Create unsigned package entry for backward compatibility
unsignedPackage := &models.AgentUpdatePackage{
ID: uuid.New(),
Version: version,
Platform: platform,
Architecture: architecture,
BinaryPath: binaryPath,
Signature: "",
Checksum: "", // Would need to calculate if needed
FileSize: 0, // Would need to stat if needed
CreatedBy: "build-orchestrator",
IsActive: true,
}
// Get file info
if info, err := os.Stat(binaryPath); err == nil {
unsignedPackage.FileSize = info.Size()
}
// Store unsigned package
err := s.packageQueries.StoreSignedPackage(unsignedPackage)
if err != nil {
return nil, fmt.Errorf("failed to store unsigned package: %w", err)
}
return unsignedPackage, nil
}
}
// SignExistingBinary signs an existing binary file
func (s *BuildOrchestratorService) SignExistingBinary(binaryPath, version, platform, architecture string) (*models.AgentUpdatePackage, error) {
// Check if file exists
if _, err := os.Stat(binaryPath); os.IsNotExist(err) {
return nil, fmt.Errorf("binary not found: %s", binaryPath)
}
// Sign the binary if signing is enabled
if !s.signingService.IsEnabled() {
return nil, fmt.Errorf("signing service is disabled")
}
signedPackage, err := s.signingService.SignFile(binaryPath)
if err != nil {
return nil, fmt.Errorf("failed to sign agent binary: %w", err)
}
// Set additional fields
signedPackage.Version = version
signedPackage.Platform = platform
signedPackage.Architecture = architecture
// Store signed package in database
err = s.packageQueries.StoreSignedPackage(signedPackage)
if err != nil {
return nil, fmt.Errorf("failed to store signed package: %w", err)
}
log.Printf("Successfully signed and stored agent binary: %s (%s/%s)", signedPackage.ID, platform, architecture)
return signedPackage, nil
}
// GetSignedPackage retrieves a signed package by version and platform
func (s *BuildOrchestratorService) GetSignedPackage(version, platform, architecture string) (*models.AgentUpdatePackage, error) {
return s.packageQueries.GetSignedPackage(version, platform, architecture)
}
// ListSignedPackages lists all signed packages (with optional filters)
func (s *BuildOrchestratorService) ListSignedPackages(version, platform string, limit, offset int) ([]models.AgentUpdatePackage, error) {
return s.packageQueries.ListUpdatePackages(version, platform, limit, offset)
}

View File

@@ -11,7 +11,7 @@ import (
"strings"
"time"
"github.com/Fimeg/RedFlag/aggregator/pkg/common"
"github.com/Fimeg/RedFlag/aggregator-server/internal/common"
)
// NewBuildRequest represents a request for a new agent build

View File

@@ -8,7 +8,9 @@ import (
"net/http"
"time"
"github.com/Fimeg/RedFlag/aggregator-server/internal/database/queries"
"github.com/google/uuid"
"github.com/jmoiron/sqlx"
)
// AgentTemplate defines a template for different agent types
@@ -37,17 +39,16 @@ type PublicKeyResponse struct {
}
// ConfigBuilder handles dynamic agent configuration generation
// ConfigBuilder builds agent configurations
// Deprecated: Use services.ConfigService instead
type ConfigBuilder struct {
serverURL string
templates map[string]AgentTemplate
httpClient *http.Client
publicKeyCache map[string]string
serverURL string
templates map[string]AgentTemplate
httpClient *http.Client
publicKeyCache map[string]string
scannerConfigQ *queries.ScannerConfigQueries
}
// NewConfigBuilder creates a new configuration builder
func NewConfigBuilder(serverURL string) *ConfigBuilder {
func NewConfigBuilder(serverURL string, db *sqlx.DB) *ConfigBuilder {
return &ConfigBuilder{
serverURL: serverURL,
templates: getAgentTemplates(),
@@ -55,6 +56,7 @@ func NewConfigBuilder(serverURL string) *ConfigBuilder {
Timeout: 30 * time.Second,
},
publicKeyCache: make(map[string]string),
scannerConfigQ: queries.NewScannerConfigQueries(db),
}
}
@@ -66,6 +68,7 @@ type AgentSetupRequest struct {
Organization string `json:"organization" binding:"required"`
CustomSettings map[string]interface{} `json:"custom_settings,omitempty"`
DeploymentID string `json:"deployment_id,omitempty"`
AgentID string `json:"agent_id,omitempty"` // Optional: existing agent ID for upgrades
}
// BuildAgentConfig builds a complete agent configuration
@@ -75,8 +78,8 @@ func (cb *ConfigBuilder) BuildAgentConfig(req AgentSetupRequest) (*AgentConfigur
return nil, err
}
// Generate agent ID
agentID := uuid.New().String()
// Determine agent ID - use existing if provided and valid, otherwise generate new
agentID := cb.determineAgentID(req.AgentID)
// Fetch server public key
serverPublicKey, err := cb.fetchServerPublicKey(req.ServerURL)
@@ -99,6 +102,9 @@ func (cb *ConfigBuilder) BuildAgentConfig(req AgentSetupRequest) (*AgentConfigur
// Build base configuration
config := cb.buildFromTemplate(template, req.CustomSettings)
// Override scanner timeouts from database (user-configurable)
cb.overrideScannerTimeoutsFromDB(config)
// Inject deployment-specific values
cb.injectDeploymentValues(config, req, agentID, registrationToken, serverPublicKey)
@@ -153,7 +159,7 @@ func (cb *ConfigBuilder) BuildAgentConfig(req AgentSetupRequest) (*AgentConfigur
Organization: req.Organization,
Platform: platform,
ConfigVersion: "5", // Config schema version
AgentVersion: "0.1.23.4", // Agent binary version
AgentVersion: "0.1.23.6", // Agent binary version
BuildTime: time.Now(),
SecretsCreated: secretsCreated,
SecretsPath: secretsPath,
@@ -171,7 +177,7 @@ type AgentConfiguration struct {
Organization string `json:"organization"`
Platform string `json:"platform"`
ConfigVersion string `json:"config_version"` // Config schema version (e.g., "5")
AgentVersion string `json:"agent_version"` // Agent binary version (e.g., "0.1.23.5")
AgentVersion string `json:"agent_version"` // Agent binary version (e.g., "0.1.23.6")
BuildTime time.Time `json:"build_time"`
SecretsCreated bool `json:"secrets_created"`
SecretsPath string `json:"secrets_path,omitempty"`
@@ -271,7 +277,7 @@ func (cb *ConfigBuilder) buildFromTemplate(template AgentTemplate, customSetting
// injectDeploymentValues injects deployment-specific values into configuration
func (cb *ConfigBuilder) injectDeploymentValues(config map[string]interface{}, req AgentSetupRequest, agentID, registrationToken, serverPublicKey string) {
config["version"] = "5" // Config schema version (for migration system)
config["agent_version"] = "0.1.23.5" // Agent binary version (MUST match the binary being served)
config["agent_version"] = "0.1.23.6" // Agent binary version (MUST match the binary being served)
config["server_url"] = req.ServerURL
config["agent_id"] = agentID
config["registration_token"] = registrationToken
@@ -285,6 +291,18 @@ func (cb *ConfigBuilder) injectDeploymentValues(config map[string]interface{}, r
}
}
// determineAgentID checks if an existing agent ID is provided and valid, otherwise generates new
func (cb *ConfigBuilder) determineAgentID(providedAgentID string) string {
if providedAgentID != "" {
// Validate it's a proper UUID
if _, err := uuid.Parse(providedAgentID); err == nil {
return providedAgentID
}
}
// Generate new UUID if none provided or invalid
return uuid.New().String()
}
// applyEnvironmentDefaults applies environment-specific configuration defaults
func (cb *ConfigBuilder) applyEnvironmentDefaults(config map[string]interface{}, environment string) {
environmentDefaults := map[string]interface{}{
@@ -493,6 +511,35 @@ func (cb *ConfigBuilder) validateConstraint(field string, value interface{}, con
}
// getAgentTemplates returns the available agent templates
// overrideScannerTimeoutsFromDB overrides scanner timeouts with values from database
// This allows users to configure scanner timeouts via the web UI
func (cb *ConfigBuilder) overrideScannerTimeoutsFromDB(config map[string]interface{}) {
if cb.scannerConfigQ == nil {
// No database connection, use defaults
return
}
// Get subsystems section
subsystems, exists := config["subsystems"].(map[string]interface{})
if !exists {
return
}
// List of scanners that can have configurable timeouts
scannerNames := []string{"apt", "dnf", "docker", "windows", "winget", "system", "storage", "updates"}
for _, scannerName := range scannerNames {
scannerConfig, exists := subsystems[scannerName].(map[string]interface{})
if !exists {
continue
}
// Get timeout from database
timeout := cb.scannerConfigQ.GetScannerTimeoutWithDefault(scannerName, 30*time.Minute)
scannerConfig["timeout"] = int(timeout.Nanoseconds())
}
}
func getAgentTemplates() map[string]AgentTemplate {
return map[string]AgentTemplate{
"linux-server": {
@@ -532,7 +579,7 @@ func getAgentTemplates() map[string]AgentTemplate {
},
"dnf": map[string]interface{}{
"enabled": true,
"timeout": 45000000000,
"timeout": 1800000000000, // 30 minutes - configurable via server settings
"circuit_breaker": map[string]interface{}{
"enabled": true,
"failure_threshold": 3,
@@ -726,4 +773,4 @@ func getAgentTemplates() map[string]AgentTemplate {
},
},
}
}
}

View File

@@ -32,6 +32,11 @@ func NewConfigService(db *sqlx.DB, cfg *config.Config, logger *log.Logger) *Conf
}
}
// getDB returns the database connection (for access to refresh token queries)
func (s *ConfigService) getDB() *sqlx.DB {
return s.db
}
// AgentConfigData represents agent configuration structure
type AgentConfigData struct {
AgentID string `json:"agent_id"`
@@ -129,18 +134,23 @@ func (s *ConfigService) LoadExistingConfig(agentID string) ([]byte, error) {
return nil, fmt.Errorf("agent not found: %w", err)
}
// Generate new config based on agent data
// For existing registered agents, generate proper config with auth tokens
s.logger.Printf("[DEBUG] Generating config for existing agent %s", agentID)
machineID := ""
if agent.MachineID != nil {
machineID = *agent.MachineID
}
agentCfg := &AgentConfig{
AgentID: agentID,
Version: agent.CurrentVersion,
Platform: agent.OSType,
Architecture: agent.OSArchitecture,
MachineID: "",
AgentType: "", // Could be stored in Metadata
MachineID: machineID,
AgentType: "", // Could be stored in metadata
Hostname: agent.Hostname,
}
// Use GenerateNewConfig to create config
return s.GenerateNewConfig(agentCfg)
}

View File

@@ -0,0 +1,116 @@
package services
import (
"context"
"fmt"
"github.com/docker/docker/api/types"
"github.com/docker/docker/api/types/swarm"
"github.com/docker/docker/client"
)
// DockerSecretsService manages Docker secrets via Docker API
type DockerSecretsService struct {
cli *client.Client
}
// NewDockerSecretsService creates a new Docker secrets service
func NewDockerSecretsService() (*DockerSecretsService, error) {
cli, err := client.NewClientWithOpts(client.FromEnv, client.WithAPIVersionNegotiation())
if err != nil {
return nil, fmt.Errorf("failed to create Docker client: %w", err)
}
// Test connection
ctx := context.Background()
if _, err := cli.Ping(ctx); err != nil {
return nil, fmt.Errorf("failed to connect to Docker daemon: %w", err)
}
return &DockerSecretsService{cli: cli}, nil
}
// CreateSecret creates a new Docker secret
func (s *DockerSecretsService) CreateSecret(name, value string) error {
ctx := context.Background()
// Check if secret already exists
secrets, err := s.cli.SecretList(ctx, types.SecretListOptions{})
if err != nil {
return fmt.Errorf("failed to list secrets: %w", err)
}
for _, secret := range secrets {
if secret.Spec.Name == name {
return fmt.Errorf("secret %s already exists", name)
}
}
// Create the secret
secretSpec := swarm.SecretSpec{
Annotations: swarm.Annotations{
Name: name,
Labels: map[string]string{
"created-by": "redflag-setup",
"created-at": fmt.Sprintf("%d", 0), // Use current timestamp in real implementation
},
},
Data: []byte(value),
}
if _, err := s.cli.SecretCreate(ctx, secretSpec); err != nil {
return fmt.Errorf("failed to create secret %s: %w", name, err)
}
return nil
}
// DeleteSecret deletes a Docker secret
func (s *DockerSecretsService) DeleteSecret(name string) error {
ctx := context.Background()
// Find the secret
secrets, err := s.cli.SecretList(ctx, types.SecretListOptions{})
if err != nil {
return fmt.Errorf("failed to list secrets: %w", err)
}
var secretID string
for _, secret := range secrets {
if secret.Spec.Name == name {
secretID = secret.ID
break
}
}
if secretID == "" {
return fmt.Errorf("secret %s not found", name)
}
if err := s.cli.SecretRemove(ctx, secretID); err != nil {
return fmt.Errorf("failed to remove secret %s: %w", name, err)
}
return nil
}
// Close closes the Docker client
func (s *DockerSecretsService) Close() error {
if s.cli != nil {
return s.cli.Close()
}
return nil
}
// IsDockerAvailable checks if Docker API is accessible
func IsDockerAvailable() bool {
cli, err := client.NewClientWithOpts(client.FromEnv, client.WithAPIVersionNegotiation())
if err != nil {
return false
}
defer cli.Close()
ctx := context.Background()
_, err = cli.Ping(ctx)
return err == nil
}

View File

@@ -33,6 +33,10 @@ func (s *InstallTemplateService) RenderInstallScript(agent *models.Agent, binary
Platform string
Architecture string
Version string
AgentUser string
AgentHome string
ConfigDir string
LogDir string
}{
AgentID: agent.ID.String(),
BinaryURL: binaryURL,
@@ -40,6 +44,10 @@ func (s *InstallTemplateService) RenderInstallScript(agent *models.Agent, binary
Platform: agent.OSType,
Architecture: agent.OSArchitecture,
Version: agent.CurrentVersion,
AgentUser: "redflag-agent",
AgentHome: "/var/lib/redflag-agent",
ConfigDir: "/etc/redflag",
LogDir: "/var/log/redflag",
}
// Choose template based on platform
@@ -90,6 +98,10 @@ func (s *InstallTemplateService) RenderInstallScriptFromBuild(
Version string
ServerURL string
RegistrationToken string
AgentUser string
AgentHome string
ConfigDir string
LogDir string
}{
AgentID: agentID,
BinaryURL: binaryURL,
@@ -99,6 +111,10 @@ func (s *InstallTemplateService) RenderInstallScriptFromBuild(
Version: version,
ServerURL: serverURL,
RegistrationToken: registrationToken,
AgentUser: "redflag-agent",
AgentHome: "/var/lib/redflag-agent",
ConfigDir: "/etc/redflag",
LogDir: "/var/log/redflag",
}
templateName := "templates/install/scripts/linux.sh.tmpl"
@@ -144,6 +160,10 @@ func (s *InstallTemplateService) BuildAgentConfigWithAgentID(
Architecture string
Version string
ServerURL string
AgentUser string
AgentHome string
ConfigDir string
LogDir string
}{
AgentID: agentID,
BinaryURL: binaryURL,
@@ -152,6 +172,10 @@ func (s *InstallTemplateService) BuildAgentConfigWithAgentID(
Architecture: architecture,
Version: version,
ServerURL: serverURL,
AgentUser: "redflag-agent",
AgentHome: "/var/lib/redflag-agent",
ConfigDir: "/etc/redflag",
LogDir: "/var/log/redflag",
}
templateName := "templates/install/scripts/linux.sh.tmpl"

View File

@@ -0,0 +1,469 @@
package services
import (
"crypto/aes"
"crypto/cipher"
"crypto/rand"
"encoding/base64"
"encoding/json"
"fmt"
"os"
"strconv"
"strings"
"github.com/Fimeg/RedFlag/aggregator-server/internal/database/queries"
"github.com/google/uuid"
)
type SecuritySettingsService struct {
settingsQueries *queries.SecuritySettingsQueries
signingService *SigningService
encryptionKey []byte
}
// NewSecuritySettingsService creates a new security settings service
func NewSecuritySettingsService(settingsQueries *queries.SecuritySettingsQueries, signingService *SigningService) (*SecuritySettingsService, error) {
// Get encryption key from environment or generate one
keyStr := os.Getenv("REDFLAG_SETTINGS_ENCRYPTION_KEY")
var key []byte
var err error
if keyStr != "" {
key, err = base64.StdEncoding.DecodeString(keyStr)
if err != nil {
return nil, fmt.Errorf("invalid encryption key format: %w", err)
}
} else {
// Generate a new key (in production, this should be persisted)
key = make([]byte, 32) // AES-256
if _, err := rand.Read(key); err != nil {
return nil, fmt.Errorf("failed to generate encryption key: %w", err)
}
}
return &SecuritySettingsService{
settingsQueries: settingsQueries,
signingService: signingService,
encryptionKey: key,
}, nil
}
// GetSetting retrieves a security setting with proper priority resolution
func (s *SecuritySettingsService) GetSetting(category, key string) (interface{}, error) {
// Priority 1: Environment variables
if envValue := s.getEnvironmentValue(category, key); envValue != nil {
return envValue, nil
}
// Priority 2: Config file values (this would be implemented based on your config structure)
if configValue := s.getConfigValue(category, key); configValue != nil {
return configValue, nil
}
// Priority 3: Database settings
if dbSetting, err := s.settingsQueries.GetSetting(category, key); err == nil && dbSetting != nil {
var value interface{}
if dbSetting.IsEncrypted {
decrypted, err := s.decrypt(dbSetting.Value)
if err != nil {
return nil, fmt.Errorf("failed to decrypt setting: %w", err)
}
if err := json.Unmarshal([]byte(decrypted), &value); err != nil {
return nil, fmt.Errorf("failed to unmarshal decrypted setting: %w", err)
}
} else {
if err := json.Unmarshal([]byte(dbSetting.Value), &value); err != nil {
return nil, fmt.Errorf("failed to unmarshal setting: %w", err)
}
}
return value, nil
}
// Priority 4: Hardcoded defaults
if defaultValue := s.getDefaultValue(category, key); defaultValue != nil {
return defaultValue, nil
}
return nil, fmt.Errorf("setting not found: %s.%s", category, key)
}
// SetSetting updates a security setting with validation and audit logging
func (s *SecuritySettingsService) SetSetting(category, key string, value interface{}, userID uuid.UUID, reason string) error {
// Validate the setting
if err := s.ValidateSetting(category, key, value); err != nil {
return fmt.Errorf("validation failed: %w", err)
}
// Check if setting is sensitive and should be encrypted
isEncrypted := s.isSensitiveSetting(category, key)
// Check if setting exists
existing, err := s.settingsQueries.GetSetting(category, key)
if err != nil {
return fmt.Errorf("failed to check existing setting: %w", err)
}
var oldValue *string
var settingID uuid.UUID
if existing != nil {
// Update existing setting
updated, oldVal, err := s.settingsQueries.UpdateSetting(category, key, value, &userID)
if err != nil {
return fmt.Errorf("failed to update setting: %w", err)
}
oldValue = oldVal
settingID = updated.ID
} else {
// Create new setting
created, err := s.settingsQueries.CreateSetting(category, key, value, isEncrypted, &userID)
if err != nil {
return fmt.Errorf("failed to create setting: %w", err)
}
settingID = created.ID
}
// Create audit log
valueJSON, _ := json.Marshal(value)
if err := s.settingsQueries.CreateAuditLog(
settingID,
userID,
"update",
stringOrNil(oldValue),
string(valueJSON),
reason,
); err != nil {
// Log error but don't fail the operation
fmt.Printf("Warning: failed to create audit log: %v\n", err)
}
return nil
}
// GetAllSettings retrieves all security settings organized by category
func (s *SecuritySettingsService) GetAllSettings() (map[string]map[string]interface{}, error) {
// Get all default values first
result := s.getDefaultSettings()
// Override with database settings
dbSettings, err := s.settingsQueries.GetAllSettings()
if err != nil {
return nil, fmt.Errorf("failed to get database settings: %w", err)
}
for _, setting := range dbSettings {
var value interface{}
if setting.IsEncrypted {
decrypted, err := s.decrypt(setting.Value)
if err != nil {
return nil, fmt.Errorf("failed to decrypt setting %s.%s: %w", setting.Category, setting.Key, err)
}
if err := json.Unmarshal([]byte(decrypted), &value); err != nil {
return nil, fmt.Errorf("failed to unmarshal decrypted setting %s.%s: %w", setting.Category, setting.Key, err)
}
} else {
if err := json.Unmarshal([]byte(setting.Value), &value); err != nil {
return nil, fmt.Errorf("failed to unmarshal setting %s.%s: %w", setting.Category, setting.Key, err)
}
}
if result[setting.Category] == nil {
result[setting.Category] = make(map[string]interface{})
}
result[setting.Category][setting.Key] = value
}
// Override with config file settings
for category, settings := range result {
for key := range settings {
if configValue := s.getConfigValue(category, key); configValue != nil {
result[category][key] = configValue
}
}
}
// Override with environment variables
for category, settings := range result {
for key := range settings {
if envValue := s.getEnvironmentValue(category, key); envValue != nil {
result[category][key] = envValue
}
}
}
return result, nil
}
// GetSettingsByCategory retrieves all settings for a specific category
func (s *SecuritySettingsService) GetSettingsByCategory(category string) (map[string]interface{}, error) {
allSettings, err := s.GetAllSettings()
if err != nil {
return nil, err
}
if categorySettings, exists := allSettings[category]; exists {
return categorySettings, nil
}
return nil, fmt.Errorf("category not found: %s", category)
}
// ValidateSetting validates a security setting value
func (s *SecuritySettingsService) ValidateSetting(category, key string, value interface{}) error {
switch fmt.Sprintf("%s.%s", category, key) {
case "nonce_validation.timeout_seconds":
if timeout, ok := value.(float64); ok {
if timeout < 60 || timeout > 3600 {
return fmt.Errorf("nonce timeout must be between 60 and 3600 seconds")
}
} else {
return fmt.Errorf("nonce timeout must be a number")
}
case "command_signing.enforcement_mode", "update_signing.enforcement_mode", "machine_binding.enforcement_mode":
if mode, ok := value.(string); ok {
validModes := []string{"strict", "warning", "disabled"}
valid := false
for _, m := range validModes {
if mode == m {
valid = true
break
}
}
if !valid {
return fmt.Errorf("enforcement mode must be one of: strict, warning, disabled")
}
} else {
return fmt.Errorf("enforcement mode must be a string")
}
case "signature_verification.log_retention_days":
if days, ok := value.(float64); ok {
if days < 1 || days > 365 {
return fmt.Errorf("log retention must be between 1 and 365 days")
}
} else {
return fmt.Errorf("log retention must be a number")
}
case "command_signing.algorithm", "update_signing.algorithm":
if algo, ok := value.(string); ok {
if algo != "ed25519" {
return fmt.Errorf("only ed25519 algorithm is currently supported")
}
} else {
return fmt.Errorf("algorithm must be a string")
}
}
return nil
}
// InitializeDefaultSettings creates default settings in the database if they don't exist
func (s *SecuritySettingsService) InitializeDefaultSettings() error {
defaults := s.getDefaultSettings()
for category, settings := range defaults {
for key, value := range settings {
existing, err := s.settingsQueries.GetSetting(category, key)
if err != nil {
return fmt.Errorf("failed to check existing setting %s.%s: %w", category, key, err)
}
if existing == nil {
isEncrypted := s.isSensitiveSetting(category, key)
_, err := s.settingsQueries.CreateSetting(category, key, value, isEncrypted, nil)
if err != nil {
return fmt.Errorf("failed to create default setting %s.%s: %w", category, key, err)
}
}
}
}
return nil
}
// Helper methods
func (s *SecuritySettingsService) getDefaultSettings() map[string]map[string]interface{} {
return map[string]map[string]interface{}{
"command_signing": {
"enabled": true,
"enforcement_mode": "strict",
"algorithm": "ed25519",
},
"update_signing": {
"enabled": true,
"enforcement_mode": "strict",
"allow_unsigned": false,
},
"nonce_validation": {
"timeout_seconds": 600,
"reject_expired": true,
"log_expired_attempts": true,
},
"machine_binding": {
"enabled": true,
"enforcement_mode": "strict",
"strict_action": "reject",
},
"signature_verification": {
"log_level": "warn",
"log_retention_days": 30,
"log_failures": true,
"alert_on_failure": true,
},
}
}
func (s *SecuritySettingsService) getDefaultValue(category, key string) interface{} {
defaults := s.getDefaultSettings()
if cat, exists := defaults[category]; exists {
if value, exists := cat[key]; exists {
return value
}
}
return nil
}
func (s *SecuritySettingsService) getEnvironmentValue(category, key string) interface{} {
envKey := fmt.Sprintf("REDFLAG_%s_%s", strings.ToUpper(category), strings.ToUpper(key))
envValue := os.Getenv(envKey)
if envValue == "" {
return nil
}
// Try to parse as boolean
if strings.ToLower(envValue) == "true" {
return true
}
if strings.ToLower(envValue) == "false" {
return false
}
// Try to parse as number
if num, err := strconv.ParseFloat(envValue, 64); err == nil {
return num
}
// Return as string
return envValue
}
func (s *SecuritySettingsService) getConfigValue(category, key string) interface{} {
// This would be implemented based on your config structure
// For now, returning nil to prioritize env vars and database
return nil
}
func (s *SecuritySettingsService) isSensitiveSetting(category, key string) bool {
// Define which settings are sensitive and should be encrypted
sensitive := map[string]bool{
"command_signing.private_key": true,
"update_signing.private_key": true,
"machine_binding.server_key": true,
"encryption.master_key": true,
}
settingKey := fmt.Sprintf("%s.%s", category, key)
return sensitive[settingKey]
}
func (s *SecuritySettingsService) encrypt(value string) (string, error) {
block, err := aes.NewCipher(s.encryptionKey)
if err != nil {
return "", err
}
gcm, err := cipher.NewGCM(block)
if err != nil {
return "", err
}
nonce := make([]byte, gcm.NonceSize())
if _, err := rand.Read(nonce); err != nil {
return "", err
}
ciphertext := gcm.Seal(nonce, nonce, []byte(value), nil)
return base64.StdEncoding.EncodeToString(ciphertext), nil
}
func (s *SecuritySettingsService) decrypt(encryptedValue string) (string, error) {
data, err := base64.StdEncoding.DecodeString(encryptedValue)
if err != nil {
return "", err
}
block, err := aes.NewCipher(s.encryptionKey)
if err != nil {
return "", err
}
gcm, err := cipher.NewGCM(block)
if err != nil {
return "", err
}
nonceSize := gcm.NonceSize()
if len(data) < nonceSize {
return "", fmt.Errorf("ciphertext too short")
}
nonce, ciphertext := data[:nonceSize], data[nonceSize:]
plaintext, err := gcm.Open(nil, nonce, ciphertext, nil)
if err != nil {
return "", err
}
return string(plaintext), nil
}
func stringOrNil(s *string) string {
if s == nil {
return ""
}
return *s
}
// GetNonceTimeout returns the current nonce validation timeout in seconds
func (s *SecuritySettingsService) GetNonceTimeout() (int, error) {
value, err := s.GetSetting("nonce_validation", "timeout_seconds")
if err != nil {
return 600, err // Return default on error
}
if timeout, ok := value.(float64); ok {
return int(timeout), nil
}
return 600, nil // Return default if type is wrong
}
// GetEnforcementMode returns the enforcement mode for a given category
func (s *SecuritySettingsService) GetEnforcementMode(category string) (string, error) {
value, err := s.GetSetting(category, "enforcement_mode")
if err != nil {
return "strict", err // Return default on error
}
if mode, ok := value.(string); ok {
return mode, nil
}
return "strict", nil // Return default if type is wrong
}
// IsSignatureVerificationEnabled returns whether signature verification is enabled for a category
func (s *SecuritySettingsService) IsSignatureVerificationEnabled(category string) (bool, error) {
value, err := s.GetSetting(category, "enabled")
if err != nil {
return true, err // Return default on error
}
if enabled, ok := value.(bool); ok {
return enabled, nil
}
return true, nil // Return default if type is wrong
}

View File

@@ -4,6 +4,7 @@ import (
"crypto/ed25519"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"os"
@@ -18,10 +19,18 @@ import (
type SigningService struct {
privateKey ed25519.PrivateKey
publicKey ed25519.PublicKey
enabled bool
}
// NewSigningService creates a new signing service with the provided private key
func NewSigningService(privateKeyHex string) (*SigningService, error) {
// Check if private key is provided
if privateKeyHex == "" {
return &SigningService{
enabled: false,
}, nil
}
// Decode private key from hex
privateKeyBytes, err := hex.DecodeString(privateKeyHex)
if err != nil {
@@ -39,11 +48,21 @@ func NewSigningService(privateKeyHex string) (*SigningService, error) {
return &SigningService{
privateKey: privateKey,
publicKey: publicKey,
enabled: true,
}, nil
}
// IsEnabled returns true if the signing service is enabled
func (s *SigningService) IsEnabled() bool {
return s.enabled
}
// SignFile signs a file and returns the signature and checksum
func (s *SigningService) SignFile(filePath string) (*models.AgentUpdatePackage, error) {
// Check if signing is enabled
if !s.enabled {
return nil, fmt.Errorf("signing service is disabled")
}
// Read the file
file, err := os.Open(filePath)
if err != nil {
@@ -106,11 +125,17 @@ func (s *SigningService) VerifySignature(content []byte, signatureHex string) (b
// GetPublicKey returns the public key in hex format
func (s *SigningService) GetPublicKey() string {
if !s.enabled {
return ""
}
return hex.EncodeToString(s.publicKey)
}
// GetPublicKeyFingerprint returns a short fingerprint of the public key
func (s *SigningService) GetPublicKeyFingerprint() string {
if !s.enabled {
return ""
}
// Use first 8 bytes as fingerprint
return hex.EncodeToString(s.publicKey[:8])
}
@@ -223,6 +248,29 @@ func (s *SigningService) VerifyNonce(nonceUUID uuid.UUID, timestamp time.Time, s
return valid, nil
}
// SignCommand creates an Ed25519 signature for a command
func (s *SigningService) SignCommand(cmd *models.AgentCommand) (string, error) {
if s.privateKey == nil {
return "", fmt.Errorf("signing service not initialized with private key")
}
// Serialize command data for signing
// Format: {id}:{command_type}:{params_hash}
// Note: Only sign what we send to the agent (ID, Type, Params)
paramsJSON, _ := json.Marshal(cmd.Params)
paramsHash := sha256.Sum256(paramsJSON)
paramsHashHex := hex.EncodeToString(paramsHash[:])
message := fmt.Sprintf("%s:%s:%s",
cmd.ID.String(),
cmd.CommandType,
paramsHashHex)
// Sign with Ed25519
signature := ed25519.Sign(s.privateKey, []byte(message))
return hex.EncodeToString(signature), nil
}
// TODO: Key rotation implementation
// This is a stub for future key rotation functionality
// Key rotation should:

View File

@@ -7,6 +7,33 @@
set -e
# Check if running as root (required for user creation and sudoers)
if [ "$EUID" -ne 0 ]; then
echo "ERROR: This script must be run as root for secure installation (use sudo)"
exit 1
fi
AGENT_USER="redflag-agent"
AGENT_HOME="/var/lib/redflag-agent"
SUDOERS_FILE="/etc/sudoers.d/redflag-agent"
# Function to detect package manager
detect_package_manager() {
if command -v apt-get &> /dev/null; then
echo "apt"
elif command -v dnf &> /dev/null; then
echo "dnf"
elif command -v yum &> /dev/null; then
echo "yum"
elif command -v pacman &> /dev/null; then
echo "pacman"
elif command -v zypper &> /dev/null; then
echo "zypper"
else
echo "unknown"
fi
}
AGENT_ID="{{.AgentID}}"
BINARY_URL="{{.BinaryURL}}"
CONFIG_URL="{{.ConfigURL}}"
@@ -17,6 +44,9 @@ SERVICE_NAME="redflag-agent"
VERSION="{{.Version}}"
LOG_DIR="/var/log/redflag"
BACKUP_DIR="${CONFIG_DIR}/backups/backup.$(date +%s)"
AGENT_USER="redflag-agent"
AGENT_HOME="/var/lib/redflag-agent"
SUDOERS_FILE="/etc/sudoers.d/redflag-agent"
echo "=== RedFlag Agent v${VERSION} Installation ==="
echo "Agent ID: ${AGENT_ID}"
@@ -44,23 +74,98 @@ if [ "${MIGRATION_NEEDED}" = true ]; then
echo "=== Migration Required ==="
echo "Agent will migrate on first start. Backing up configuration..."
sudo mkdir -p "${BACKUP_DIR}"
if [ -f "${OLD_CONFIG_DIR}/config.json" ]; then
echo "Backing up old configuration..."
sudo cp -r "${OLD_CONFIG_DIR}"/* "${BACKUP_DIR}/" 2>/dev/null || true
fi
if [ -f "${CONFIG_DIR}/config.json" ]; then
echo "Backing up current configuration..."
sudo cp "${CONFIG_DIR}/config.json" "${BACKUP_DIR}/config.json.backup" 2>/dev/null || true
fi
echo "Migration will run automatically when agent starts."
echo "View migration logs with: sudo journalctl -u ${SERVICE_NAME} -f"
echo
fi
# Step 3: Stop existing service
# Step 3: Create system user and home directory
echo "Creating system user for agent..."
if id "$AGENT_USER" &>/dev/null; then
echo "✓ User $AGENT_USER already exists"
else
sudo useradd -r -s /bin/false -d "$AGENT_HOME" "$AGENT_USER"
echo "✓ User $AGENT_USER created"
fi
# Create home directory
if [ ! -d "$AGENT_HOME" ]; then
sudo mkdir -p "$AGENT_HOME"
sudo chown "$AGENT_USER:$AGENT_USER" "$AGENT_HOME"
sudo chmod 750 "$AGENT_HOME"
echo "✓ Home directory created at $AGENT_HOME"
fi
# Step 4: Install sudoers configuration with OS-specific commands
PM=$(detect_package_manager)
echo "Detected package manager: $PM"
echo "Installing sudoers configuration..."
case "$PM" in
apt)
cat <<'EOF' | sudo tee "$SUDOERS_FILE" > /dev/null
# RedFlag Agent minimal sudo permissions - APT
{{.AgentUser}} ALL=(root) NOPASSWD: /usr/bin/apt-get update
{{.AgentUser}} ALL=(root) NOPASSWD: /usr/bin/apt-get install -y *
{{.AgentUser}} ALL=(root) NOPASSWD: /usr/bin/apt-get upgrade -y
{{.AgentUser}} ALL=(root) NOPASSWD: /usr/bin/apt-get install --dry-run --yes *
EOF
;;
dnf|yum)
cat <<'EOF' | sudo tee "$SUDOERS_FILE" > /dev/null
# RedFlag Agent minimal sudo permissions - DNF/YUM
{{.AgentUser}} ALL=(root) NOPASSWD: /usr/bin/dnf makecache
{{.AgentUser}} ALL=(root) NOPASSWD: /usr/bin/dnf install -y *
{{.AgentUser}} ALL=(root) NOPASSWD: /usr/bin/dnf upgrade -y
{{.AgentUser}} ALL=(root) NOPASSWD: /usr/bin/yum makecache
{{.AgentUser}} ALL=(root) NOPASSWD: /usr/bin/yum install -y *
{{.AgentUser}} ALL=(root) NOPASSWD: /usr/bin/yum update -y
EOF
;;
pacman)
cat <<'EOF' | sudo tee "$SUDOERS_FILE" > /dev/null
# RedFlag Agent minimal sudo permissions - Pacman
{{.AgentUser}} ALL=(root) NOPASSWD: /usr/bin/pacman -Sy
{{.AgentUser}} ALL=(root) NOPASSWD: /usr/bin/pacman -S --noconfirm *
EOF
;;
*)
cat <<'EOF' | sudo tee "$SUDOERS_FILE" > /dev/null
# RedFlag Agent minimal sudo permissions - Generic (APT and DNF)
{{.AgentUser}} ALL=(root) NOPASSWD: /usr/bin/apt-get update
{{.AgentUser}} ALL=(root) NOPASSWD: /usr/bin/apt-get install -y *
{{.AgentUser}} ALL=(root) NOPASSWD: /usr/bin/dnf makecache
{{.AgentUser}} ALL=(root) NOPASSWD: /usr/bin/dnf install -y *
EOF
;;
esac
# Add Docker commands
cat <<'DOCKER_EOF' | sudo tee -a "$SUDOERS_FILE" > /dev/null
{{.AgentUser}} ALL=(root) NOPASSWD: /usr/bin/docker pull *
{{.AgentUser}} ALL=(root) NOPASSWD: /usr/bin/docker image inspect *
{{.AgentUser}} ALL=(root) NOPASSWD: /usr/bin/docker manifest inspect *
DOCKER_EOF
sudo chmod 440 "$SUDOERS_FILE"
if visudo -c -f "$SUDOERS_FILE" &>/dev/null; then
echo "✓ Sudoers configuration installed and validated"
else
echo "⚠ Sudoers configuration validation failed - using generic version"
fi
# Step 5: Stop existing service
if systemctl is-active --quiet ${SERVICE_NAME} 2>/dev/null; then
echo "Stopping existing RedFlag agent service..."
sudo systemctl stop ${SERVICE_NAME}
@@ -70,7 +175,7 @@ fi
echo "Creating directories..."
sudo mkdir -p "${CONFIG_DIR}"
sudo mkdir -p "${CONFIG_DIR}/backups"
sudo mkdir -p "/var/lib/redflag"
sudo mkdir -p "$AGENT_HOME"
sudo mkdir -p "/var/log/redflag"
# Step 5: Download agent binary
@@ -88,7 +193,7 @@ if [ -f "${CONFIG_DIR}/config.json" ]; then
else
echo "[CONFIG] Fresh install - generating minimal configuration with registration token"
# Create minimal config template - agent will populate missing fields on first start
sudo cat > "${CONFIG_DIR}/config.json" <<EOF
sudo tee "${CONFIG_DIR}/config.json" > /dev/null <<EOF
{
"version": 5,
"agent_version": "${VERSION}",
@@ -138,24 +243,57 @@ fi
# Step 7: Set permissions on config file
sudo chmod 600 "${CONFIG_DIR}/config.json"
# Step 8: Create systemd service
echo "Creating systemd service..."
# Step 8: Create systemd service with security hardening
echo "Creating systemd service with security configuration..."
cat <<EOF | sudo tee /etc/systemd/system/${SERVICE_NAME}.service
[Unit]
Description=RedFlag Security Agent
After=network.target
StartLimitBurst=5
StartLimitIntervalSec=60
[Service]
Type=simple
User=root
User={{.AgentUser}}
Group={{.AgentUser}}
WorkingDirectory={{.AgentHome}}
ExecStart=${INSTALL_DIR}/${SERVICE_NAME}
Restart=always
RestartSec=30
RestartPreventExitStatus=255
# Security hardening
# Note: NoNewPrivileges disabled to allow sudo for package management
ProtectSystem=strict
ProtectHome=true
ReadWritePaths={{.AgentHome}} {{.ConfigDir}} {{.LogDir}}
PrivateTmp=true
ProtectKernelTunables=true
ProtectKernelModules=true
ProtectControlGroups=true
RestrictRealtime=true
RestrictSUIDSGID=true
RemoveIPC=true
# Logging
StandardOutput=journal
StandardError=journal
SyslogIdentifier=${SERVICE_NAME}
[Install]
WantedBy=multi-user.target
EOF
# Set proper permissions on directories
echo "Setting directory permissions..."
sudo chown -R {{.AgentUser}}:{{.AgentUser}} "{{.ConfigDir}}"
sudo chown {{.AgentUser}}:{{.AgentUser}} "{{.ConfigDir}}/config.json"
sudo chmod 600 "{{.ConfigDir}}/config.json"
sudo chown -R {{.AgentUser}}:{{.AgentUser}} "{{.AgentHome}}"
sudo chmod 750 "{{.AgentHome}}"
sudo chown -R {{.AgentUser}}:{{.AgentUser}} "{{.LogDir}}"
sudo chmod 750 "{{.LogDir}}"
# Step 9: Enable and start service
echo "Enabling and starting service..."
sudo systemctl daemon-reload
@@ -163,6 +301,22 @@ sudo systemctl enable ${SERVICE_NAME}
sudo systemctl start ${SERVICE_NAME}
echo
echo "✓ Installation complete!"
echo "Agent is running. Check status with: sudo systemctl status ${SERVICE_NAME}"
echo "View logs with: sudo journalctl -u ${SERVICE_NAME} -f"
if systemctl is-active --quiet ${SERVICE_NAME}; then
echo "✓ Installation complete!"
echo ""
echo "=== Security Information ==="
echo "Agent is running with security hardening:"
echo " ✓ Dedicated system user: {{.AgentUser}}"
echo " ✓ Limited sudo access for package management only"
echo " ✓ Systemd service with security restrictions"
echo " ✓ Protected configuration directory"
echo ""
echo "Check status: sudo systemctl status ${SERVICE_NAME}"
echo "View logs: sudo journalctl -u ${SERVICE_NAME} -f"
else
echo "⚠ Installation complete but service not started"
echo " This may be normal for fresh installs awaiting registration"
echo ""
echo "To start after registration:"
echo " sudo systemctl start ${SERVICE_NAME}"
fi

View File

@@ -0,0 +1,74 @@
package version
import (
"fmt"
"time"
)
// Version coordination for Server Authority model
// The server is the single source of truth for all version information
// CurrentVersions holds the authoritative version information
type CurrentVersions struct {
AgentVersion string `json:"agent_version"` // e.g., "0.1.23.6"
ConfigVersion string `json:"config_version"` // e.g., "6"
MinAgentVersion string `json:"min_agent_version"` // e.g., "0.1.22"
BuildTime time.Time `json:"build_time"`
}
// GetCurrentVersions returns the current version information
// In production, this would come from a version file, database, or environment
func GetCurrentVersions() CurrentVersions {
// TODO: For production, load this from version file or database
// For now, use environment variables with defaults
return CurrentVersions{
AgentVersion: "0.1.23", // Should match current branch
ConfigVersion: "3", // Should map from agent version (0.1.23 -> "3")
MinAgentVersion: "0.1.22",
BuildTime: time.Now(),
}
}
// ExtractConfigVersionFromAgent extracts config version from agent version
// Agent version format: v0.1.23.6 where fourth octet maps to config version
func ExtractConfigVersionFromAgent(agentVersion string) string {
// Strip 'v' prefix if present
cleanVersion := agentVersion
if len(cleanVersion) > 0 && cleanVersion[0] == 'v' {
cleanVersion = cleanVersion[1:]
}
// Split version parts
parts := fmt.Sprintf("%s", cleanVersion)
if len(parts) >= 1 {
// For now, use the last octet as config version
// v0.1.23 -> "3" (last digit)
lastChar := parts[len(parts)-1:]
return lastChar
}
// Default fallback
return "3"
}
// ValidateAgentVersion checks if an agent version is compatible
func ValidateAgentVersion(agentVersion string) error {
current := GetCurrentVersions()
// Check minimum version
if agentVersion < current.MinAgentVersion {
return fmt.Errorf("agent version %s is below minimum %s", agentVersion, current.MinAgentVersion)
}
return nil
}
// GetBuildFlags returns the ldflags to inject versions into agent builds
func GetBuildFlags() []string {
versions := GetCurrentVersions()
return []string{
fmt.Sprintf("-X github.com/Fimeg/RedFlag/aggregator-agent/internal/version.Version=%s", versions.AgentVersion),
fmt.Sprintf("-X github.com/Fimeg/RedFlag/aggregator-agent/internal/version.ConfigVersion=%s", versions.ConfigVersion),
fmt.Sprintf("-X github.com/Fimeg/RedFlag/aggregator-agent/internal/version.BuildTime=%s", versions.BuildTime.Format(time.RFC3339)),
}
}

Binary file not shown.

Binary file not shown.