WIP: Save current state - security subsystems, migrations, logging
This commit is contained in:
@@ -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"]
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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=
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
54
aggregator-server/internal/api/handlers/agent_events.go
Normal file
54
aggregator-server/internal/api/handlers/agent_events.go
Normal 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),
|
||||
})
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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 ""
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
146
aggregator-server/internal/api/handlers/scanner_config.go
Normal file
146
aggregator-server/internal/api/handlers/scanner_config.go
Normal 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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
44
aggregator-server/internal/common/agentfile.go
Normal file
44
aggregator-server/internal/common/agentfile.go
Normal 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
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
@@ -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.)';
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
`
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
118
aggregator-server/internal/logging/example_integration.go
Normal file
118
aggregator-server/internal/logging/example_integration.go
Normal 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()
|
||||
}
|
||||
}
|
||||
363
aggregator-server/internal/logging/security_logger.go
Normal file
363
aggregator-server/internal/logging/security_logger.go
Normal 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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"`
|
||||
|
||||
111
aggregator-server/internal/models/security_event.go
Normal file
111
aggregator-server/internal/models/security_event.go
Normal 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
|
||||
}
|
||||
32
aggregator-server/internal/models/security_settings.go
Normal file
32
aggregator-server/internal/models/security_settings.go
Normal 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"`
|
||||
}
|
||||
79
aggregator-server/internal/models/system_event.go
Normal file
79
aggregator-server/internal/models/system_event.go
Normal 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"
|
||||
)
|
||||
@@ -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"`
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
138
aggregator-server/internal/services/build_orchestrator.go
Normal file
138
aggregator-server/internal/services/build_orchestrator.go
Normal 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)
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
116
aggregator-server/internal/services/docker_secrets.go
Normal file
116
aggregator-server/internal/services/docker_secrets.go
Normal 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
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
469
aggregator-server/internal/services/security_settings_service.go
Normal file
469
aggregator-server/internal/services/security_settings_service.go
Normal 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
|
||||
}
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
74
aggregator-server/internal/version/versions.go
Normal file
74
aggregator-server/internal/version/versions.go
Normal 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.
Reference in New Issue
Block a user