cleanup: remove 2,369 lines of dead code
Removed backup files and unused legacy scanner function. All code verified as unreferenced.
This commit is contained in:
@@ -2,6 +2,8 @@ package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/ed25519"
|
||||
"encoding/hex"
|
||||
"flag"
|
||||
"fmt"
|
||||
"log"
|
||||
@@ -19,6 +21,31 @@ import (
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// validateSigningService performs a test sign/verify to ensure the key is valid
|
||||
func validateSigningService(signingService *services.SigningService) error {
|
||||
if signingService == nil {
|
||||
return fmt.Errorf("signing service is nil")
|
||||
}
|
||||
|
||||
// Verify the key is accessible by getting public key and fingerprint
|
||||
publicKeyHex := signingService.GetPublicKey()
|
||||
if publicKeyHex == "" {
|
||||
return fmt.Errorf("failed to get public key from signing service")
|
||||
}
|
||||
|
||||
fingerprint := signingService.GetPublicKeyFingerprint()
|
||||
if fingerprint == "" {
|
||||
return fmt.Errorf("failed to get public key fingerprint")
|
||||
}
|
||||
|
||||
// Basic validation: Ed25519 public key should be 64 hex characters (32 bytes)
|
||||
if len(publicKeyHex) != 64 {
|
||||
return fmt.Errorf("invalid public key length: expected 64 hex chars, got %d", len(publicKeyHex))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func startWelcomeModeServer() {
|
||||
setupHandler := handlers.NewSetupHandler("/app/config")
|
||||
router := gin.Default()
|
||||
@@ -146,18 +173,29 @@ func main() {
|
||||
timezoneService := services.NewTimezoneService(cfg)
|
||||
timeoutService := services.NewTimeoutService(commandQueries, updateQueries)
|
||||
|
||||
// Initialize signing service if private key is configured
|
||||
// Initialize and validate signing service if private key is configured
|
||||
var signingService *services.SigningService
|
||||
if cfg.SigningPrivateKey != "" {
|
||||
var err error
|
||||
signingService, err = services.NewSigningService(cfg.SigningPrivateKey)
|
||||
if err != nil {
|
||||
log.Printf("Warning: Failed to initialize signing service: %v", err)
|
||||
log.Printf("[ERROR] Failed to initialize signing service: %v", err)
|
||||
log.Printf("[WARNING] Agent update signing is DISABLED - agents cannot be updated")
|
||||
log.Printf("[INFO] To fix: Generate signing keys at /api/setup/generate-keys and add to .env")
|
||||
} else {
|
||||
log.Printf("✅ Ed25519 signing service initialized")
|
||||
// Validate the signing key works by performing a test sign/verify
|
||||
if err := validateSigningService(signingService); err != nil {
|
||||
log.Printf("[ERROR] Signing key validation failed: %v", err)
|
||||
log.Printf("[WARNING] Agent update signing is DISABLED - key is corrupted")
|
||||
signingService = nil // Disable signing
|
||||
} else {
|
||||
log.Printf("[system] Ed25519 signing service initialized and validated")
|
||||
log.Printf("[system] Public key fingerprint: %s", signingService.GetPublicKeyFingerprint())
|
||||
}
|
||||
}
|
||||
} else {
|
||||
log.Printf("Warning: No signing private key configured - agent update signing disabled")
|
||||
log.Printf("[WARNING] No signing private key configured - agent update signing disabled")
|
||||
log.Printf("[INFO] Generate keys: POST /api/setup/generate-keys")
|
||||
}
|
||||
|
||||
// Initialize rate limiter
|
||||
@@ -183,10 +221,23 @@ func main() {
|
||||
verificationHandler = handlers.NewVerificationHandler(agentQueries, signingService)
|
||||
}
|
||||
|
||||
// Initialize update nonce service (for version upgrade middleware)
|
||||
var updateNonceService *services.UpdateNonceService
|
||||
if signingService != nil && cfg.SigningPrivateKey != "" {
|
||||
// Decode private key for nonce service
|
||||
privateKeyBytes, err := hex.DecodeString(cfg.SigningPrivateKey)
|
||||
if err == nil && len(privateKeyBytes) == ed25519.PrivateKeySize {
|
||||
updateNonceService = services.NewUpdateNonceService(ed25519.PrivateKey(privateKeyBytes))
|
||||
log.Printf("[system] Update nonce service initialized for version upgrades")
|
||||
} else {
|
||||
log.Printf("[WARNING] Failed to initialize update nonce service: invalid private key")
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize agent update handler
|
||||
var agentUpdateHandler *handlers.AgentUpdateHandler
|
||||
if signingService != nil {
|
||||
agentUpdateHandler = handlers.NewAgentUpdateHandler(agentQueries, agentUpdateQueries, commandQueries, signingService, agentHandler)
|
||||
agentUpdateHandler = handlers.NewAgentUpdateHandler(agentQueries, agentUpdateQueries, commandQueries, signingService, updateNonceService, agentHandler)
|
||||
}
|
||||
|
||||
// Initialize system handler
|
||||
@@ -225,6 +276,20 @@ func main() {
|
||||
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)
|
||||
|
||||
// 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)
|
||||
}
|
||||
|
||||
// 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)
|
||||
@@ -291,9 +356,12 @@ func main() {
|
||||
// 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
|
||||
|
||||
186
aggregator-server/internal/api/handlers/agent_build.go
Normal file
186
aggregator-server/internal/api/handlers/agent_build.go
Normal file
@@ -0,0 +1,186 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/Fimeg/RedFlag/aggregator-server/internal/services"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// BuildAgent handles the agent build endpoint
|
||||
func 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)
|
||||
|
||||
// Build agent configuration
|
||||
config, err := configBuilder.BuildAgentConfig(req)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// Create agent builder
|
||||
agentBuilder := services.NewAgentBuilder()
|
||||
|
||||
// Generate build artifacts
|
||||
buildResult, err := agentBuilder.BuildAgentWithConfig(config)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// Create response with native binary instructions
|
||||
response := gin.H{
|
||||
"agent_id": config.AgentID,
|
||||
"config_file": buildResult.ConfigFile,
|
||||
"platform": buildResult.Platform,
|
||||
"config_version": config.ConfigVersion,
|
||||
"agent_version": config.AgentVersion,
|
||||
"build_time": buildResult.BuildTime,
|
||||
"next_steps": []string{
|
||||
"1. Download native binary from server",
|
||||
"2. Place binary in /usr/local/bin/redflag-agent",
|
||||
"3. Set permissions: chmod 755 /usr/local/bin/redflag-agent",
|
||||
"4. Create config directory: mkdir -p /etc/redflag",
|
||||
"5. Save config to /etc/redflag/config.json",
|
||||
"6. Set config permissions: chmod 600 /etc/redflag/config.json",
|
||||
"7. Start service: systemctl enable --now redflag-agent",
|
||||
},
|
||||
"configuration": config.PublicConfig,
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, response)
|
||||
}
|
||||
|
||||
// GetBuildInstructions returns build instructions for manual setup
|
||||
func GetBuildInstructions(c *gin.Context) {
|
||||
agentID := c.Param("agentID")
|
||||
if agentID == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "agent ID is required"})
|
||||
return
|
||||
}
|
||||
|
||||
instructions := gin.H{
|
||||
"title": "RedFlag Agent Build Instructions",
|
||||
"agent_id": agentID,
|
||||
"steps": []gin.H{
|
||||
{
|
||||
"step": 1,
|
||||
"title": "Prepare Build Environment",
|
||||
"commands": []string{
|
||||
"mkdir -p redflag-build",
|
||||
"cd redflag-build",
|
||||
},
|
||||
},
|
||||
{
|
||||
"step": 2,
|
||||
"title": "Copy Agent Source Code",
|
||||
"commands": []string{
|
||||
"cp -r ../aggregator-agent/* .",
|
||||
"ls -la",
|
||||
},
|
||||
},
|
||||
{
|
||||
"step": 3,
|
||||
"title": "Build Docker Image",
|
||||
"commands": []string{
|
||||
"docker build -t redflag-agent:" + agentID[:8] + " .",
|
||||
},
|
||||
},
|
||||
{
|
||||
"step": 4,
|
||||
"title": "Create Docker Network",
|
||||
"commands": []string{
|
||||
"docker network create redflag 2>/dev/null || true",
|
||||
},
|
||||
},
|
||||
{
|
||||
"step": 5,
|
||||
"title": "Deploy Agent",
|
||||
"commands": []string{
|
||||
"docker compose up -d",
|
||||
},
|
||||
},
|
||||
{
|
||||
"step": 6,
|
||||
"title": "Verify Deployment",
|
||||
"commands": []string{
|
||||
"docker compose logs -f",
|
||||
"docker ps",
|
||||
},
|
||||
},
|
||||
},
|
||||
"troubleshooting": []gin.H{
|
||||
{
|
||||
"issue": "Build fails with 'go mod download' errors",
|
||||
"solution": "Ensure go.mod and go.sum are copied correctly and internet connectivity is available",
|
||||
},
|
||||
{
|
||||
"issue": "Container fails to start",
|
||||
"solution": "Check docker-compose.yml and ensure Docker secrets are created with 'echo \"secret-value\" | docker secret create secret-name -'",
|
||||
},
|
||||
{
|
||||
"issue": "Agent cannot connect to server",
|
||||
"solution": "Verify server URL is accessible from container and firewall rules allow traffic",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, instructions)
|
||||
}
|
||||
|
||||
// DownloadBuildArtifacts provides download links for generated files
|
||||
func DownloadBuildArtifacts(c *gin.Context) {
|
||||
agentID := c.Param("agentID")
|
||||
fileType := c.Param("fileType")
|
||||
buildDir := c.Query("buildDir")
|
||||
|
||||
// Validate agent ID parameter
|
||||
if agentID == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "agent ID is required"})
|
||||
return
|
||||
}
|
||||
|
||||
if buildDir == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "build directory is required"})
|
||||
return
|
||||
}
|
||||
|
||||
// Security check: ensure the buildDir is within expected path
|
||||
absBuildDir, err := filepath.Abs(buildDir)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid build directory"})
|
||||
return
|
||||
}
|
||||
|
||||
// Construct file path based on type
|
||||
var filePath string
|
||||
switch fileType {
|
||||
case "compose":
|
||||
filePath = filepath.Join(absBuildDir, "docker-compose.yml")
|
||||
case "dockerfile":
|
||||
filePath = filepath.Join(absBuildDir, "Dockerfile")
|
||||
case "config":
|
||||
filePath = filepath.Join(absBuildDir, "pkg", "embedded", "config.go")
|
||||
default:
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid file type"})
|
||||
return
|
||||
}
|
||||
|
||||
// Check if file exists
|
||||
if _, err := os.Stat(filePath); os.IsNotExist(err) {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "file not found"})
|
||||
return
|
||||
}
|
||||
|
||||
// Serve file for download
|
||||
c.FileAttachment(filePath, filepath.Base(filePath))
|
||||
}
|
||||
79
aggregator-server/internal/api/handlers/agent_setup.go
Normal file
79
aggregator-server/internal/api/handlers/agent_setup.go
Normal file
@@ -0,0 +1,79 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/Fimeg/RedFlag/aggregator-server/internal/services"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// SetupAgent handles the agent setup endpoint
|
||||
func 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)
|
||||
|
||||
// Build agent configuration
|
||||
config, err := configBuilder.BuildAgentConfig(req)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// Create response
|
||||
response := gin.H{
|
||||
"agent_id": config.AgentID,
|
||||
"registration_token": config.Secrets["registration_token"],
|
||||
"server_public_key": config.Secrets["server_public_key"],
|
||||
"configuration": config.PublicConfig,
|
||||
"secrets": config.Secrets,
|
||||
"template": config.Template,
|
||||
"setup_time": config.BuildTime,
|
||||
"secrets_created": config.SecretsCreated,
|
||||
"secrets_path": config.SecretsPath,
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, response)
|
||||
}
|
||||
|
||||
// GetTemplates returns available agent templates
|
||||
func GetTemplates(c *gin.Context) {
|
||||
configBuilder := services.NewConfigBuilder("")
|
||||
templates := configBuilder.GetTemplates()
|
||||
c.JSON(http.StatusOK, gin.H{"templates": templates})
|
||||
}
|
||||
|
||||
// ValidateConfiguration validates a configuration before deployment
|
||||
func 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()})
|
||||
return
|
||||
}
|
||||
|
||||
agentType, exists := config["agent_type"].(string)
|
||||
if !exists {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "agent_type is required"})
|
||||
return
|
||||
}
|
||||
|
||||
configBuilder := services.NewConfigBuilder("")
|
||||
template, exists := configBuilder.GetTemplate(agentType)
|
||||
if !exists {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Unknown agent type"})
|
||||
return
|
||||
}
|
||||
|
||||
// Simple validation response
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"valid": true,
|
||||
"message": "Configuration appears valid",
|
||||
"agent_type": agentType,
|
||||
"template": template.Name,
|
||||
})
|
||||
}
|
||||
229
aggregator-server/internal/api/handlers/build_orchestrator.go
Normal file
229
aggregator-server/internal/api/handlers/build_orchestrator.go
Normal file
@@ -0,0 +1,229 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
"github.com/Fimeg/RedFlag/aggregator-server/internal/services"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// NewAgentBuild handles new agent installation requests
|
||||
func NewAgentBuild(c *gin.Context) {
|
||||
var req services.NewBuildRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// Validate registration token
|
||||
if req.RegistrationToken == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "registration token is required for new installations"})
|
||||
return
|
||||
}
|
||||
|
||||
// Convert to setup request format
|
||||
setupReq := services.AgentSetupRequest{
|
||||
ServerURL: req.ServerURL,
|
||||
Environment: req.Environment,
|
||||
AgentType: req.AgentType,
|
||||
Organization: req.Organization,
|
||||
CustomSettings: req.CustomSettings,
|
||||
DeploymentID: req.DeploymentID,
|
||||
}
|
||||
|
||||
// Create config builder
|
||||
configBuilder := services.NewConfigBuilder(req.ServerURL)
|
||||
|
||||
// Build agent configuration
|
||||
config, err := configBuilder.BuildAgentConfig(setupReq)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// Override generated agent ID if provided (for upgrades)
|
||||
if req.AgentID != "" {
|
||||
config.AgentID = req.AgentID
|
||||
// Update public config with existing agent ID
|
||||
if config.PublicConfig == nil {
|
||||
config.PublicConfig = make(map[string]interface{})
|
||||
}
|
||||
config.PublicConfig["agent_id"] = req.AgentID
|
||||
}
|
||||
|
||||
// Create agent builder
|
||||
agentBuilder := services.NewAgentBuilder()
|
||||
|
||||
// Generate build artifacts
|
||||
buildResult, err := agentBuilder.BuildAgentWithConfig(config)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// Construct download URL
|
||||
binaryURL := fmt.Sprintf("%s/api/v1/downloads/%s", req.ServerURL, config.Platform)
|
||||
|
||||
// Create response with native binary instructions
|
||||
response := gin.H{
|
||||
"agent_id": config.AgentID,
|
||||
"binary_url": binaryURL,
|
||||
"platform": config.Platform,
|
||||
"config_version": config.ConfigVersion,
|
||||
"agent_version": config.AgentVersion,
|
||||
"build_time": buildResult.BuildTime,
|
||||
"install_type": "new",
|
||||
"consumes_seat": true,
|
||||
"next_steps": []string{
|
||||
"1. Download native binary: curl -sL " + binaryURL + " -o /usr/local/bin/redflag-agent",
|
||||
"2. Set permissions: chmod 755 /usr/local/bin/redflag-agent",
|
||||
"3. Create config directory: mkdir -p /etc/redflag",
|
||||
"4. Save configuration (provided in this response) to /etc/redflag/config.json",
|
||||
"5. Set config permissions: chmod 600 /etc/redflag/config.json",
|
||||
"6. Start service: systemctl enable --now redflag-agent",
|
||||
},
|
||||
"configuration": config.PublicConfig,
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, response)
|
||||
}
|
||||
|
||||
// UpgradeAgentBuild handles agent upgrade requests
|
||||
func UpgradeAgentBuild(c *gin.Context) {
|
||||
agentID := c.Param("agentID")
|
||||
if agentID == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "agent ID is required"})
|
||||
return
|
||||
}
|
||||
|
||||
var req services.UpgradeBuildRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// Validate required fields
|
||||
if req.ServerURL == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "server URL is required"})
|
||||
return
|
||||
}
|
||||
|
||||
// Convert to setup request format
|
||||
setupReq := services.AgentSetupRequest{
|
||||
ServerURL: req.ServerURL,
|
||||
Environment: req.Environment,
|
||||
AgentType: req.AgentType,
|
||||
Organization: req.Organization,
|
||||
CustomSettings: req.CustomSettings,
|
||||
DeploymentID: req.DeploymentID,
|
||||
}
|
||||
|
||||
// Create config builder
|
||||
configBuilder := services.NewConfigBuilder(req.ServerURL)
|
||||
|
||||
// Build agent configuration
|
||||
config, err := configBuilder.BuildAgentConfig(setupReq)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// Override with existing agent ID (this is the key for upgrades)
|
||||
config.AgentID = agentID
|
||||
if config.PublicConfig == nil {
|
||||
config.PublicConfig = make(map[string]interface{})
|
||||
}
|
||||
config.PublicConfig["agent_id"] = agentID
|
||||
|
||||
// For upgrades, we might want to preserve certain existing settings
|
||||
if req.PreserveExisting {
|
||||
// TODO: Load existing agent config and merge/override as needed
|
||||
// This would involve reading the existing agent's configuration
|
||||
// and selectively preserving certain fields
|
||||
}
|
||||
|
||||
// Create agent builder
|
||||
agentBuilder := services.NewAgentBuilder()
|
||||
|
||||
// Generate build artifacts
|
||||
buildResult, err := agentBuilder.BuildAgentWithConfig(config)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// Construct download URL
|
||||
binaryURL := fmt.Sprintf("%s/api/v1/downloads/%s?version=%s", req.ServerURL, config.Platform, config.AgentVersion)
|
||||
|
||||
// Create response with native binary upgrade instructions
|
||||
response := gin.H{
|
||||
"agent_id": config.AgentID,
|
||||
"binary_url": binaryURL,
|
||||
"platform": config.Platform,
|
||||
"config_version": config.ConfigVersion,
|
||||
"agent_version": config.AgentVersion,
|
||||
"build_time": buildResult.BuildTime,
|
||||
"install_type": "upgrade",
|
||||
"consumes_seat": false,
|
||||
"preserves_agent_id": true,
|
||||
"next_steps": []string{
|
||||
"1. Stop agent service: systemctl stop redflag-agent",
|
||||
"2. Download updated binary: curl -sL " + binaryURL + " -o /usr/local/bin/redflag-agent",
|
||||
"3. Set permissions: chmod 755 /usr/local/bin/redflag-agent",
|
||||
"4. Update config (provided in this response) to /etc/redflag/config.json if needed",
|
||||
"5. Start service: systemctl start redflag-agent",
|
||||
"6. Verify: systemctl status redflag-agent",
|
||||
},
|
||||
"configuration": config.PublicConfig,
|
||||
"upgrade_notes": []string{
|
||||
"This upgrade preserves the existing agent ID: " + agentID,
|
||||
"No additional seat will be consumed",
|
||||
"Config version: " + config.ConfigVersion,
|
||||
"Agent binary version: " + config.AgentVersion,
|
||||
"Agent will receive latest security enhancements and bug fixes",
|
||||
},
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, response)
|
||||
}
|
||||
|
||||
// DetectAgentInstallation detects existing agent installations
|
||||
func DetectAgentInstallation(c *gin.Context) {
|
||||
// This endpoint helps the installer determine what type of installation to perform
|
||||
var req struct {
|
||||
AgentID string `json:"agent_id"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// Create detector service
|
||||
detector := services.NewInstallationDetector()
|
||||
|
||||
// Detect existing installation
|
||||
detection, err := detector.DetectExistingInstallation(req.AgentID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
response := gin.H{
|
||||
"detection_result": detection,
|
||||
"recommended_action": func() string {
|
||||
if detection.HasExistingAgent {
|
||||
return "upgrade"
|
||||
}
|
||||
return "new_installation"
|
||||
}(),
|
||||
"installation_type": func() string {
|
||||
if detection.HasExistingAgent {
|
||||
return "upgrade"
|
||||
}
|
||||
return "new"
|
||||
}(),
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, response)
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -180,6 +180,8 @@ func (h *SecurityHandler) MachineBindingStatus(c *gin.Context) {
|
||||
|
||||
// Get total agents for comparison
|
||||
if totalAgents, err := h.agentQueries.GetTotalAgentCount(); err == nil {
|
||||
response["checks"].(map[string]interface{})["total_agents"] = totalAgents
|
||||
|
||||
// Calculate version compliance (agents meeting minimum version requirement)
|
||||
if compliantAgents, err := h.agentQueries.GetAgentCountByVersion("0.1.22"); err == nil {
|
||||
response["checks"].(map[string]interface{})["version_compliance"] = compliantAgents
|
||||
|
||||
@@ -425,6 +425,13 @@ func (h *SetupHandler) GenerateSigningKeys(c *gin.Context) {
|
||||
c.Header("Pragma", "no-cache")
|
||||
c.Header("Expires", "0")
|
||||
|
||||
// Load configuration to check for existing key
|
||||
cfg, err := config.Load() // This will load from .env file
|
||||
if err == nil && cfg.SigningPrivateKey != "" {
|
||||
c.JSON(http.StatusConflict, gin.H{"error": "A signing key is already configured for this server."})
|
||||
return
|
||||
}
|
||||
|
||||
// Generate Ed25519 keypair
|
||||
publicKey, privateKey, err := ed25519.GenerateKey(rand.Reader)
|
||||
if err != nil {
|
||||
|
||||
@@ -1,8 +1,16 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"crypto/ed25519"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/Fimeg/RedFlag/aggregator-server/internal/database/queries"
|
||||
"github.com/Fimeg/RedFlag/aggregator-server/internal/utils"
|
||||
@@ -38,6 +46,48 @@ func MachineBindingMiddleware(agentQueries *queries.AgentQueries, minAgentVersio
|
||||
return
|
||||
}
|
||||
|
||||
// Check if agent is reporting an update completion
|
||||
reportedVersion := c.GetHeader("X-Agent-Version")
|
||||
updateNonce := c.GetHeader("X-Update-Nonce")
|
||||
|
||||
if agent.IsUpdating && updateNonce != "" {
|
||||
// Validate the nonce first (proves server authorized this update)
|
||||
if agent.PublicKeyFingerprint == nil {
|
||||
log.Printf("[SECURITY] Agent %s has no public key fingerprint for nonce validation", agentID)
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "server public key not configured"})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
if err := validateUpdateNonceMiddleware(updateNonce, *agent.PublicKeyFingerprint); err != nil {
|
||||
log.Printf("[SECURITY] Invalid update nonce for agent %s: %v", agentID, err)
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "invalid update nonce"})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
// Check for downgrade attempt (security boundary)
|
||||
if !isVersionUpgrade(reportedVersion, agent.CurrentVersion) {
|
||||
log.Printf("[SECURITY] Downgrade attempt detected: agent %s %s → %s",
|
||||
agentID, agent.CurrentVersion, reportedVersion)
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "downgrade not allowed"})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
// Valid upgrade - complete it in database
|
||||
go func() {
|
||||
if err := agentQueries.CompleteAgentUpdate(agentID.String(), reportedVersion); err != nil {
|
||||
log.Printf("[ERROR] Failed to complete agent update: %v", err)
|
||||
} else {
|
||||
log.Printf("[system] Agent %s updated: %s → %s", agentID, agent.CurrentVersion, reportedVersion)
|
||||
}
|
||||
}()
|
||||
|
||||
// Allow this request through
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
|
||||
// Check minimum version (hard cutoff for legacy de-support)
|
||||
if agent.CurrentVersion != "" && minAgentVersion != "" {
|
||||
if !utils.IsNewerOrEqualVersion(agent.CurrentVersion, minAgentVersion) {
|
||||
@@ -97,3 +147,82 @@ func MachineBindingMiddleware(agentQueries *queries.AgentQueries, minAgentVersio
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
func validateUpdateNonceMiddleware(nonceB64, serverPublicKey string) error {
|
||||
// Decode base64 nonce
|
||||
data, err := base64.StdEncoding.DecodeString(nonceB64)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid base64: %w", err)
|
||||
}
|
||||
|
||||
// Parse JSON
|
||||
var nonce struct {
|
||||
AgentID string `json:"agent_id"`
|
||||
TargetVersion string `json:"target_version"`
|
||||
Timestamp int64 `json:"timestamp"`
|
||||
Signature string `json:"signature"`
|
||||
}
|
||||
if err := json.Unmarshal(data, &nonce); err != nil {
|
||||
return fmt.Errorf("invalid format: %w", err)
|
||||
}
|
||||
|
||||
// Check freshness
|
||||
if time.Now().Unix()-nonce.Timestamp > 600 { // 10 minutes
|
||||
return fmt.Errorf("nonce expired (age: %d seconds)", time.Now().Unix()-nonce.Timestamp)
|
||||
}
|
||||
|
||||
// Verify signature
|
||||
signature, err := base64.StdEncoding.DecodeString(nonce.Signature)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid signature encoding: %w", err)
|
||||
}
|
||||
|
||||
// Parse server's public key
|
||||
pubKeyBytes, err := hex.DecodeString(serverPublicKey)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid server public key: %w", err)
|
||||
}
|
||||
|
||||
// Remove signature for verification
|
||||
originalSig := nonce.Signature
|
||||
nonce.Signature = ""
|
||||
verifyData, err := json.Marshal(nonce)
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal verify data: %w", err)
|
||||
}
|
||||
|
||||
if !ed25519.Verify(ed25519.PublicKey(pubKeyBytes), verifyData, signature) {
|
||||
return fmt.Errorf("signature verification failed")
|
||||
}
|
||||
|
||||
// Restore signature (not needed but good practice)
|
||||
nonce.Signature = originalSig
|
||||
return nil
|
||||
}
|
||||
|
||||
func isVersionUpgrade(new, current string) bool {
|
||||
// Parse semantic versions
|
||||
newParts := strings.Split(new, ".")
|
||||
curParts := strings.Split(current, ".")
|
||||
|
||||
// Convert to integers for comparison
|
||||
newMajor, _ := strconv.Atoi(newParts[0])
|
||||
newMinor, _ := strconv.Atoi(newParts[1])
|
||||
newPatch, _ := strconv.Atoi(newParts[2])
|
||||
|
||||
curMajor, _ := strconv.Atoi(curParts[0])
|
||||
curMinor, _ := strconv.Atoi(curParts[1])
|
||||
curPatch, _ := strconv.Atoi(curParts[2])
|
||||
|
||||
// Check if new > current (not equal, not less)
|
||||
if newMajor > curMajor {
|
||||
return true
|
||||
}
|
||||
if newMajor == curMajor && newMinor > curMinor {
|
||||
return true
|
||||
}
|
||||
if newMajor == curMajor && newMinor == curMinor && newPatch > curPatch {
|
||||
return true
|
||||
}
|
||||
return false // Equal or downgrade
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package queries
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"time"
|
||||
@@ -324,3 +325,46 @@ func (q *AgentQueries) UpdateAgentUpdatingStatus(id uuid.UUID, isUpdating bool,
|
||||
_, err := q.db.Exec(query, isUpdating, versionPtr, time.Now(), id)
|
||||
return err
|
||||
}
|
||||
|
||||
// CompleteAgentUpdate marks an agent update as successful and updates version
|
||||
func (q *AgentQueries) CompleteAgentUpdate(agentID string, newVersion string) error {
|
||||
query := `
|
||||
UPDATE agents
|
||||
SET
|
||||
current_version = $2,
|
||||
is_updating = false,
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = $1
|
||||
`
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
result, err := q.db.ExecContext(ctx, query, agentID, newVersion)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to complete agent update: %w", err)
|
||||
}
|
||||
|
||||
rows, err := result.RowsAffected()
|
||||
if err != nil || rows == 0 {
|
||||
return fmt.Errorf("agent not found or version not updated")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// SetAgentUpdating marks an agent as updating with nonce
|
||||
func (q *AgentQueries) SetAgentUpdating(agentID string, isUpdating bool, targetVersion string) error {
|
||||
query := `
|
||||
UPDATE agents
|
||||
SET is_updating = $2, updating_to_version = $3, updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = $1
|
||||
`
|
||||
|
||||
_, err := q.db.Exec(query, agentID, isUpdating, targetVersion)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to set agent updating state: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
380
aggregator-server/internal/services/agent_builder.go
Normal file
380
aggregator-server/internal/services/agent_builder.go
Normal file
@@ -0,0 +1,380 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"text/template"
|
||||
"time"
|
||||
)
|
||||
|
||||
// AgentBuilder handles generating embedded agent configurations
|
||||
type AgentBuilder struct {
|
||||
buildContext string
|
||||
}
|
||||
|
||||
// NewAgentBuilder creates a new agent builder
|
||||
func NewAgentBuilder() *AgentBuilder {
|
||||
return &AgentBuilder{}
|
||||
}
|
||||
|
||||
// BuildAgentWithConfig generates agent configuration and prepares signed binary
|
||||
func (ab *AgentBuilder) BuildAgentWithConfig(config *AgentConfiguration) (*BuildResult, error) {
|
||||
// Create temporary build directory
|
||||
buildDir, err := os.MkdirTemp("", "agent-build-")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create build directory: %w", err)
|
||||
}
|
||||
|
||||
// Generate config.json (not embedded in binary)
|
||||
configJSONPath := filepath.Join(buildDir, "config.json")
|
||||
configJSON, err := ab.generateConfigJSON(config)
|
||||
if err != nil {
|
||||
os.RemoveAll(buildDir)
|
||||
return nil, fmt.Errorf("failed to generate config JSON: %w", err)
|
||||
}
|
||||
|
||||
// Write config.json to file
|
||||
if err := os.WriteFile(configJSONPath, []byte(configJSON), 0600); err != nil {
|
||||
os.RemoveAll(buildDir)
|
||||
return nil, fmt.Errorf("failed to write config file: %w", err)
|
||||
}
|
||||
|
||||
// Note: Binary is pre-built and stored in /app/binaries/{platform}/
|
||||
// We don't build or modify the binary here - it's generic for all agents
|
||||
// The signing happens at the platform level, not per-agent
|
||||
|
||||
return &BuildResult{
|
||||
BuildDir: buildDir,
|
||||
AgentID: config.AgentID,
|
||||
ConfigFile: configJSONPath,
|
||||
ConfigJSON: configJSON,
|
||||
Platform: config.Platform,
|
||||
BuildTime: time.Now(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// generateConfigJSON converts configuration to JSON format
|
||||
func (ab *AgentBuilder) generateConfigJSON(config *AgentConfiguration) (string, error) {
|
||||
// Create complete configuration
|
||||
completeConfig := make(map[string]interface{})
|
||||
|
||||
// Copy public configuration
|
||||
for k, v := range config.PublicConfig {
|
||||
completeConfig[k] = v
|
||||
}
|
||||
|
||||
// Add secrets (they will be protected by file permissions at runtime)
|
||||
for k, v := range config.Secrets {
|
||||
completeConfig[k] = v
|
||||
}
|
||||
|
||||
// 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")
|
||||
|
||||
// Add agent metadata
|
||||
completeConfig["agent_id"] = config.AgentID
|
||||
completeConfig["server_url"] = config.ServerURL
|
||||
completeConfig["organization"] = config.Organization
|
||||
completeConfig["environment"] = config.Environment
|
||||
completeConfig["template"] = config.Template
|
||||
completeConfig["build_time"] = config.BuildTime.Format(time.RFC3339)
|
||||
|
||||
// Convert to JSON
|
||||
jsonBytes, err := json.MarshalIndent(completeConfig, "", " ")
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to marshal config to JSON: %w", err)
|
||||
}
|
||||
|
||||
return string(jsonBytes), nil
|
||||
}
|
||||
|
||||
// BuildResult contains the results of the build process
|
||||
type BuildResult struct {
|
||||
BuildDir string `json:"build_dir"`
|
||||
AgentID string `json:"agent_id"`
|
||||
ConfigFile string `json:"config_file"`
|
||||
ConfigJSON string `json:"config_json"`
|
||||
Platform string `json:"platform"`
|
||||
BuildTime time.Time `json:"build_time"`
|
||||
}
|
||||
|
||||
// generateEmbeddedConfig generates the embedded configuration Go file
|
||||
func (ab *AgentBuilder) generateEmbeddedConfig(filename string, config *AgentConfiguration) error {
|
||||
// Create directory structure
|
||||
if err := os.MkdirAll(filepath.Dir(filename), 0755); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Convert configuration to JSON for embedding
|
||||
configJSON, err := ab.configToJSON(config)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Generate Go source file with embedded configuration
|
||||
tmpl := `// Code generated by dynamic build system. DO NOT EDIT.
|
||||
package embedded
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"time"
|
||||
)
|
||||
|
||||
// EmbeddedAgentConfiguration contains the pre-built agent configuration
|
||||
var EmbeddedAgentConfiguration = []byte(` + "`" + `{{.ConfigJSON}}` + "`" + `)
|
||||
|
||||
// EmbeddedAgentID contains the agent ID
|
||||
var EmbeddedAgentID = "{{.AgentID}}"
|
||||
|
||||
// EmbeddedServerURL contains the server URL
|
||||
var EmbeddedServerURL = "{{.ServerURL}}"
|
||||
|
||||
// EmbeddedOrganization contains the organization
|
||||
var EmbeddedOrganization = "{{.Organization}}"
|
||||
|
||||
// EmbeddedEnvironment contains the environment
|
||||
var EmbeddedEnvironment = "{{.Environment}}"
|
||||
|
||||
// EmbeddedTemplate contains the template type
|
||||
var EmbeddedTemplate = "{{.Template}}"
|
||||
|
||||
// EmbeddedBuildTime contains the build time
|
||||
var EmbeddedBuildTime, _ = time.Parse(time.RFC3339, "{{.BuildTime}}")
|
||||
|
||||
// GetEmbeddedConfig returns the embedded configuration as a map
|
||||
func GetEmbeddedConfig() (map[string]interface{}, error) {
|
||||
var config map[string]interface{}
|
||||
err := json.Unmarshal(EmbeddedAgentConfiguration, &config)
|
||||
return config, err
|
||||
}
|
||||
|
||||
// SecretsMapping maps configuration fields to Docker secrets
|
||||
var SecretsMapping = map[string]string{
|
||||
{{range $key, $value := .Secrets}}"{{$key}}": "{{$value}}",
|
||||
{{end}}
|
||||
}
|
||||
`
|
||||
|
||||
// Execute template
|
||||
t, err := template.New("embedded").Parse(tmpl)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to parse template: %w", err)
|
||||
}
|
||||
|
||||
file, err := os.Create(filename)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create file: %w", err)
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
data := struct {
|
||||
ConfigJSON string
|
||||
AgentID string
|
||||
ServerURL string
|
||||
Organization string
|
||||
Environment string
|
||||
Template string
|
||||
BuildTime string
|
||||
Secrets map[string]string
|
||||
}{
|
||||
ConfigJSON: configJSON,
|
||||
AgentID: config.AgentID,
|
||||
ServerURL: config.ServerURL,
|
||||
Organization: config.Organization,
|
||||
Environment: config.Environment,
|
||||
Template: config.Template,
|
||||
BuildTime: config.BuildTime.Format(time.RFC3339),
|
||||
Secrets: config.Secrets,
|
||||
}
|
||||
|
||||
if err := t.Execute(file, data); err != nil {
|
||||
return fmt.Errorf("failed to execute template: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// generateDockerCompose generates a docker-compose.yml file
|
||||
func (ab *AgentBuilder) generateDockerCompose(filename string, config *AgentConfiguration) error {
|
||||
tmpl := `# Generated dynamically based on configuration
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
redflag-agent:
|
||||
image: {{.ImageTag}}
|
||||
container_name: redflag-agent-{{.AgentID}}
|
||||
restart: unless-stopped
|
||||
secrets:
|
||||
{{range $key := .SecretsKeys}}- {{$key}}
|
||||
{{end}}
|
||||
volumes:
|
||||
- /var/lib/redflag:/var/lib/redflag
|
||||
- /var/run/docker.sock:/var/run/docker.sock
|
||||
environment:
|
||||
- REDFLAG_AGENT_ID={{.AgentID}}
|
||||
- REDFLAG_ENVIRONMENT={{.Environment}}
|
||||
- REDFLAG_SERVER_URL={{.ServerURL}}
|
||||
- REDFLAG_ORGANIZATION={{.Organization}}
|
||||
networks:
|
||||
- redflag
|
||||
logging:
|
||||
driver: "json-file"
|
||||
options:
|
||||
max-size: "10m"
|
||||
max-file: "3"
|
||||
|
||||
secrets:
|
||||
{{range $key, $value := .Secrets}}{{$key}}:
|
||||
external: true
|
||||
{{end}}
|
||||
|
||||
networks:
|
||||
redflag:
|
||||
external: true
|
||||
`
|
||||
|
||||
t, err := template.New("compose").Parse(tmpl)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
file, err := os.Create(filename)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
// Extract secret keys for template
|
||||
secretsKeys := make([]string, 0, len(config.Secrets))
|
||||
for key := range config.Secrets {
|
||||
secretsKeys = append(secretsKeys, key)
|
||||
}
|
||||
|
||||
data := struct {
|
||||
ImageTag string
|
||||
AgentID string
|
||||
Environment string
|
||||
ServerURL string
|
||||
Organization string
|
||||
Secrets map[string]string
|
||||
SecretsKeys []string
|
||||
}{
|
||||
ImageTag: fmt.Sprintf("redflag-agent:%s", config.AgentID[:8]),
|
||||
AgentID: config.AgentID,
|
||||
Environment: config.Environment,
|
||||
ServerURL: config.ServerURL,
|
||||
Organization: config.Organization,
|
||||
Secrets: config.Secrets,
|
||||
SecretsKeys: secretsKeys,
|
||||
}
|
||||
|
||||
return t.Execute(file, data)
|
||||
}
|
||||
|
||||
// generateDockerfile generates a Dockerfile for building the agent
|
||||
func (ab *AgentBuilder) generateDockerfile(filename string, config *AgentConfiguration) error {
|
||||
tmpl := `# Dockerfile for RedFlag Agent with embedded configuration
|
||||
FROM golang:1.21-alpine AS builder
|
||||
|
||||
# Install ca-certificates for SSL/TLS
|
||||
RUN apk add --no-cache ca-certificates git
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Copy go mod files (these should be in the same directory as the Dockerfile)
|
||||
COPY go.mod go.sum ./
|
||||
RUN go mod download
|
||||
|
||||
# Copy source code
|
||||
COPY . .
|
||||
|
||||
# Copy generated embedded configuration
|
||||
COPY pkg/embedded/config.go ./pkg/embedded/config.go
|
||||
|
||||
# Build the agent with embedded configuration
|
||||
RUN CGO_ENABLED=0 GOOS=linux go build \
|
||||
-ldflags="-w -s -X main.version=dynamic-build-{{.AgentID}}" \
|
||||
-o redflag-agent \
|
||||
./cmd/agent
|
||||
|
||||
# Final stage
|
||||
FROM scratch
|
||||
|
||||
# Copy ca-certificates for SSL/TLS
|
||||
COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
|
||||
|
||||
# Copy the agent binary
|
||||
COPY --from=builder /app/redflag-agent /redflag-agent
|
||||
|
||||
# Set environment variables (these can be overridden by docker-compose)
|
||||
ENV REDFLAG_AGENT_ID="{{.AgentID}}"
|
||||
ENV REDFLAG_ENVIRONMENT="{{.Environment}}"
|
||||
ENV REDFLAG_SERVER_URL="{{.ServerURL}}"
|
||||
ENV REDFLAG_ORGANIZATION="{{.Organization}}"
|
||||
|
||||
# Health check
|
||||
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
|
||||
CMD ["/redflag-agent", "--health-check"]
|
||||
|
||||
# Run the agent
|
||||
ENTRYPOINT ["/redflag-agent"]
|
||||
`
|
||||
|
||||
t, err := template.New("dockerfile").Parse(tmpl)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
file, err := os.Create(filename)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
data := struct {
|
||||
AgentID string
|
||||
Environment string
|
||||
ServerURL string
|
||||
Organization string
|
||||
}{
|
||||
AgentID: config.AgentID,
|
||||
Environment: config.Environment,
|
||||
ServerURL: config.ServerURL,
|
||||
Organization: config.Organization,
|
||||
}
|
||||
|
||||
return t.Execute(file, data)
|
||||
}
|
||||
|
||||
// configToJSON converts configuration to JSON string
|
||||
func (ab *AgentBuilder) configToJSON(config *AgentConfiguration) (string, error) {
|
||||
// Create complete configuration with embedded values
|
||||
completeConfig := make(map[string]interface{})
|
||||
|
||||
// Copy public configuration
|
||||
for k, v := range config.PublicConfig {
|
||||
completeConfig[k] = v
|
||||
}
|
||||
|
||||
// Add secrets values (they will be overridden by Docker secrets at runtime)
|
||||
for k, v := range config.Secrets {
|
||||
completeConfig[k] = v
|
||||
}
|
||||
|
||||
// Convert to JSON with proper escaping
|
||||
jsonBytes, err := json.MarshalIndent(completeConfig, "", " ")
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to marshal config to JSON: %w", err)
|
||||
}
|
||||
|
||||
// Escape backticks for Go string literal
|
||||
jsonStr := string(jsonBytes)
|
||||
jsonStr = strings.ReplaceAll(jsonStr, "`", "` + \"`\" + `")
|
||||
|
||||
return jsonStr, nil
|
||||
}
|
||||
318
aggregator-server/internal/services/build_types.go
Normal file
318
aggregator-server/internal/services/build_types.go
Normal file
@@ -0,0 +1,318 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// NewBuildRequest represents a request for a new agent build
|
||||
type NewBuildRequest struct {
|
||||
ServerURL string `json:"server_url" binding:"required"`
|
||||
Environment string `json:"environment" binding:"required"`
|
||||
AgentType string `json:"agent_type" binding:"required,oneof=linux-server windows-workstation docker-host"`
|
||||
Organization string `json:"organization" binding:"required"`
|
||||
RegistrationToken string `json:"registration_token" binding:"required"`
|
||||
CustomSettings map[string]interface{} `json:"custom_settings,omitempty"`
|
||||
DeploymentID string `json:"deployment_id,omitempty"`
|
||||
AgentID string `json:"agent_id,omitempty"` // For upgrades when preserving ID
|
||||
}
|
||||
|
||||
// UpgradeBuildRequest represents a request for an agent upgrade
|
||||
type UpgradeBuildRequest struct {
|
||||
ServerURL string `json:"server_url" binding:"required"`
|
||||
Environment string `json:"environment"`
|
||||
AgentType string `json:"agent_type"`
|
||||
Organization string `json:"organization"`
|
||||
CustomSettings map[string]interface{} `json:"custom_settings,omitempty"`
|
||||
DeploymentID string `json:"deployment_id,omitempty"`
|
||||
PreserveExisting bool `json:"preserve_existing"`
|
||||
DetectionPath string `json:"detection_path,omitempty"`
|
||||
}
|
||||
|
||||
// DetectionRequest represents a request to detect existing agent installation
|
||||
type DetectionRequest struct {
|
||||
DetectionPath string `json:"detection_path,omitempty"`
|
||||
}
|
||||
|
||||
// InstallationDetection represents the result of detecting an existing installation
|
||||
type InstallationDetection struct {
|
||||
HasExistingAgent bool `json:"has_existing_agent"`
|
||||
AgentID string `json:"agent_id,omitempty"`
|
||||
CurrentVersion string `json:"current_version,omitempty"`
|
||||
ConfigVersion int `json:"config_version,omitempty"`
|
||||
RequiresMigration bool `json:"requires_migration"`
|
||||
Inventory *AgentFileInventory `json:"inventory,omitempty"`
|
||||
MigrationPlan *MigrationDetection `json:"migration_plan,omitempty"`
|
||||
DetectionPath string `json:"detection_path"`
|
||||
DetectionTime string `json:"detection_time"`
|
||||
RecommendedAction string `json:"recommended_action"`
|
||||
}
|
||||
|
||||
// AgentFileInventory represents all files associated with an agent installation
|
||||
type AgentFileInventory struct {
|
||||
ConfigFiles []AgentFile `json:"config_files"`
|
||||
StateFiles []AgentFile `json:"state_files"`
|
||||
BinaryFiles []AgentFile `json:"binary_files"`
|
||||
LogFiles []AgentFile `json:"log_files"`
|
||||
CertificateFiles []AgentFile `json:"certificate_files"`
|
||||
ExistingPaths []string `json:"existing_paths"`
|
||||
MissingPaths []string `json:"missing_paths"`
|
||||
}
|
||||
|
||||
// AgentFile represents a file associated with the agent
|
||||
type AgentFile struct {
|
||||
Path string `json:"path"`
|
||||
Size int64 `json:"size"`
|
||||
ModifiedTime string `json:"modified_time"`
|
||||
Version string `json:"version,omitempty"`
|
||||
Checksum string `json:"checksum"`
|
||||
Required bool `json:"required"`
|
||||
Migrate bool `json:"migrate"`
|
||||
Description string `json:"description"`
|
||||
}
|
||||
|
||||
// MigrationDetection represents migration detection results (from existing migration system)
|
||||
type MigrationDetection struct {
|
||||
CurrentAgentVersion string `json:"current_agent_version"`
|
||||
CurrentConfigVersion int `json:"current_config_version"`
|
||||
RequiresMigration bool `json:"requires_migration"`
|
||||
RequiredMigrations []string `json:"required_migrations"`
|
||||
MissingSecurityFeatures []string `json:"missing_security_features"`
|
||||
Inventory *AgentFileInventory `json:"inventory"`
|
||||
DetectionTime string `json:"detection_time"`
|
||||
}
|
||||
|
||||
// InstallationDetector handles detection of existing agent installations
|
||||
type InstallationDetector struct{}
|
||||
|
||||
// NewInstallationDetector creates a new installation detector
|
||||
func NewInstallationDetector() *InstallationDetector {
|
||||
return &InstallationDetector{}
|
||||
}
|
||||
|
||||
// DetectExistingInstallation detects if there's an existing agent installation
|
||||
func (id *InstallationDetector) DetectExistingInstallation(agentID string) (*InstallationDetection, error) {
|
||||
result := &InstallationDetection{
|
||||
HasExistingAgent: false,
|
||||
DetectionTime: time.Now().Format(time.RFC3339),
|
||||
RecommendedAction: "new_installation",
|
||||
}
|
||||
|
||||
if agentID != "" {
|
||||
result.HasExistingAgent = true
|
||||
result.AgentID = agentID
|
||||
result.RecommendedAction = "upgrade"
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// scanDirectory scans a directory for agent-related files
|
||||
func (id *InstallationDetector) scanDirectory(dirPath string) ([]AgentFile, error) {
|
||||
var files []AgentFile
|
||||
|
||||
entries, err := os.ReadDir(dirPath)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return files, nil // Directory doesn't exist, return empty
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
for _, entry := range entries {
|
||||
if entry.IsDir() {
|
||||
continue
|
||||
}
|
||||
|
||||
fullPath := filepath.Join(dirPath, entry.Name())
|
||||
info, err := entry.Info()
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
// Calculate checksum
|
||||
checksum, err := id.calculateChecksum(fullPath)
|
||||
if err != nil {
|
||||
checksum = ""
|
||||
}
|
||||
|
||||
file := AgentFile{
|
||||
Path: fullPath,
|
||||
Size: info.Size(),
|
||||
ModifiedTime: info.ModTime().Format(time.RFC3339),
|
||||
Checksum: checksum,
|
||||
Required: id.isRequiredFile(entry.Name()),
|
||||
Migrate: id.shouldMigrateFile(entry.Name()),
|
||||
Description: id.getFileDescription(entry.Name()),
|
||||
}
|
||||
|
||||
files = append(files, file)
|
||||
}
|
||||
|
||||
return files, nil
|
||||
}
|
||||
|
||||
// categorizeFile categorizes a file into the appropriate inventory section
|
||||
func (id *InstallationDetector) categorizeFile(file AgentFile, inventory *AgentFileInventory) {
|
||||
filename := filepath.Base(file.Path)
|
||||
|
||||
switch {
|
||||
case filename == "config.json":
|
||||
inventory.ConfigFiles = append(inventory.ConfigFiles, file)
|
||||
case filename == "pending_acks.json" || filename == "public_key.cache" || filename == "last_scan.json" || filename == "metrics.json":
|
||||
inventory.StateFiles = append(inventory.StateFiles, file)
|
||||
case filename == "redflag-agent" || filename == "redflag-agent.exe":
|
||||
inventory.BinaryFiles = append(inventory.BinaryFiles, file)
|
||||
case strings.HasSuffix(filename, ".log"):
|
||||
inventory.LogFiles = append(inventory.LogFiles, file)
|
||||
case strings.HasSuffix(filename, ".crt") || strings.HasSuffix(filename, ".key") || strings.HasSuffix(filename, ".pem"):
|
||||
inventory.CertificateFiles = append(inventory.CertificateFiles, file)
|
||||
}
|
||||
}
|
||||
|
||||
// extractAgentInfo extracts agent ID, version, and config version from config files
|
||||
func (id *InstallationDetector) extractAgentInfo(inventory *AgentFileInventory) (string, string, int, error) {
|
||||
var agentID, version string
|
||||
var configVersion int
|
||||
|
||||
// Look for config.json first
|
||||
for _, configFile := range inventory.ConfigFiles {
|
||||
if strings.Contains(configFile.Path, "config.json") {
|
||||
data, err := os.ReadFile(configFile.Path)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
var config map[string]interface{}
|
||||
if err := json.Unmarshal(data, &config); err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
// Extract agent ID
|
||||
if id, ok := config["agent_id"].(string); ok {
|
||||
agentID = id
|
||||
}
|
||||
|
||||
// Extract version information
|
||||
if ver, ok := config["agent_version"].(string); ok {
|
||||
version = ver
|
||||
}
|
||||
if ver, ok := config["version"].(float64); ok {
|
||||
configVersion = int(ver)
|
||||
}
|
||||
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// If no agent ID found in config, we don't have a valid installation
|
||||
if agentID == "" {
|
||||
return "", "", 0, fmt.Errorf("no agent ID found in configuration")
|
||||
}
|
||||
|
||||
return agentID, version, configVersion, nil
|
||||
}
|
||||
|
||||
// determineMigrationRequired determines if migration is needed
|
||||
func (id *InstallationDetector) determineMigrationRequired(inventory *AgentFileInventory) bool {
|
||||
// Check for old directory paths
|
||||
for _, configFile := range inventory.ConfigFiles {
|
||||
if strings.Contains(configFile.Path, "/etc/aggregator/") || strings.Contains(configFile.Path, "/var/lib/aggregator/") {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
for _, stateFile := range inventory.StateFiles {
|
||||
if strings.Contains(stateFile.Path, "/etc/aggregator/") || strings.Contains(stateFile.Path, "/var/lib/aggregator/") {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
// Check config version (older than v5 needs migration)
|
||||
for _, configFile := range inventory.ConfigFiles {
|
||||
if strings.Contains(configFile.Path, "config.json") {
|
||||
if _, _, configVersion, err := id.extractAgentInfo(inventory); err == nil {
|
||||
if configVersion < 5 {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// calculateChecksum calculates SHA256 checksum of a file
|
||||
func (id *InstallationDetector) calculateChecksum(filePath string) (string, error) {
|
||||
file, err := os.Open(filePath)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
hash := sha256.New()
|
||||
if _, err := io.Copy(hash, file); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return hex.EncodeToString(hash.Sum(nil)), nil
|
||||
}
|
||||
|
||||
// isRequiredFile determines if a file is required for agent operation
|
||||
func (id *InstallationDetector) isRequiredFile(filename string) bool {
|
||||
requiredFiles := []string{
|
||||
"config.json",
|
||||
"redflag-agent",
|
||||
"redflag-agent.exe",
|
||||
}
|
||||
|
||||
for _, required := range requiredFiles {
|
||||
if filename == required {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// shouldMigrateFile determines if a file should be migrated
|
||||
func (id *InstallationDetector) shouldMigrateFile(filename string) bool {
|
||||
migratableFiles := []string{
|
||||
"config.json",
|
||||
"pending_acks.json",
|
||||
"public_key.cache",
|
||||
"last_scan.json",
|
||||
"metrics.json",
|
||||
}
|
||||
|
||||
for _, migratable := range migratableFiles {
|
||||
if filename == migratable {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// getFileDescription returns a human-readable description of a file
|
||||
func (id *InstallationDetector) getFileDescription(filename string) string {
|
||||
descriptions := map[string]string{
|
||||
"config.json": "Agent configuration file",
|
||||
"pending_acks.json": "Pending command acknowledgments",
|
||||
"public_key.cache": "Server public key cache",
|
||||
"last_scan.json": "Last scan results",
|
||||
"metrics.json": "Agent metrics data",
|
||||
"redflag-agent": "Agent binary",
|
||||
"redflag-agent.exe": "Windows agent binary",
|
||||
}
|
||||
|
||||
if desc, ok := descriptions[filename]; ok {
|
||||
return desc
|
||||
}
|
||||
return "Agent file"
|
||||
}
|
||||
727
aggregator-server/internal/services/config_builder.go
Normal file
727
aggregator-server/internal/services/config_builder.go
Normal file
@@ -0,0 +1,727 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// AgentTemplate defines a template for different agent types
|
||||
type AgentTemplate struct {
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
BaseConfig map[string]interface{} `json:"base_config"`
|
||||
Secrets []string `json:"required_secrets"`
|
||||
Validation ValidationRules `json:"validation"`
|
||||
}
|
||||
|
||||
// ValidationRules defines validation rules for configuration
|
||||
type ValidationRules struct {
|
||||
RequiredFields []string `json:"required_fields"`
|
||||
AllowedValues map[string][]string `json:"allowed_values"`
|
||||
Patterns map[string]string `json:"patterns"`
|
||||
Constraints map[string]interface{} `json:"constraints"`
|
||||
}
|
||||
|
||||
// PublicKeyResponse represents the server's public key response
|
||||
type PublicKeyResponse struct {
|
||||
PublicKey string `json:"public_key"`
|
||||
Fingerprint string `json:"fingerprint"`
|
||||
Algorithm string `json:"algorithm"`
|
||||
KeySize int `json:"key_size"`
|
||||
}
|
||||
|
||||
// ConfigBuilder handles dynamic agent configuration generation
|
||||
type ConfigBuilder struct {
|
||||
serverURL string
|
||||
templates map[string]AgentTemplate
|
||||
httpClient *http.Client
|
||||
publicKeyCache map[string]string
|
||||
}
|
||||
|
||||
// NewConfigBuilder creates a new configuration builder
|
||||
func NewConfigBuilder(serverURL string) *ConfigBuilder {
|
||||
return &ConfigBuilder{
|
||||
serverURL: serverURL,
|
||||
templates: getAgentTemplates(),
|
||||
httpClient: &http.Client{
|
||||
Timeout: 30 * time.Second,
|
||||
},
|
||||
publicKeyCache: make(map[string]string),
|
||||
}
|
||||
}
|
||||
|
||||
// AgentSetupRequest represents a request to set up a new agent
|
||||
type AgentSetupRequest struct {
|
||||
ServerURL string `json:"server_url" binding:"required"`
|
||||
Environment string `json:"environment" binding:"required"`
|
||||
AgentType string `json:"agent_type" binding:"required,oneof=linux-server windows-workstation docker-host"`
|
||||
Organization string `json:"organization" binding:"required"`
|
||||
CustomSettings map[string]interface{} `json:"custom_settings,omitempty"`
|
||||
DeploymentID string `json:"deployment_id,omitempty"`
|
||||
}
|
||||
|
||||
// BuildAgentConfig builds a complete agent configuration
|
||||
func (cb *ConfigBuilder) BuildAgentConfig(req AgentSetupRequest) (*AgentConfiguration, error) {
|
||||
// Validate request
|
||||
if err := cb.validateRequest(req); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Generate agent ID
|
||||
agentID := uuid.New().String()
|
||||
|
||||
// Fetch server public key
|
||||
serverPublicKey, err := cb.fetchServerPublicKey(req.ServerURL)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to fetch server public key: %w", err)
|
||||
}
|
||||
|
||||
// Generate registration token
|
||||
registrationToken, err := cb.generateRegistrationToken(agentID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to generate registration token: %w", err)
|
||||
}
|
||||
|
||||
// Get template
|
||||
template, exists := cb.templates[req.AgentType]
|
||||
if !exists {
|
||||
return nil, fmt.Errorf("unknown agent type: %s", req.AgentType)
|
||||
}
|
||||
|
||||
// Build base configuration
|
||||
config := cb.buildFromTemplate(template, req.CustomSettings)
|
||||
|
||||
// Inject deployment-specific values
|
||||
cb.injectDeploymentValues(config, req, agentID, registrationToken, serverPublicKey)
|
||||
|
||||
// Apply environment-specific defaults
|
||||
cb.applyEnvironmentDefaults(config, req.Environment)
|
||||
|
||||
// Validate final configuration
|
||||
if err := cb.validateConfiguration(config, template); err != nil {
|
||||
return nil, fmt.Errorf("configuration validation failed: %w", err)
|
||||
}
|
||||
|
||||
// Separate sensitive and non-sensitive data
|
||||
publicConfig, secrets := cb.separateSecrets(config)
|
||||
|
||||
// Create Docker secrets if needed
|
||||
var secretsCreated bool
|
||||
var secretsPath string
|
||||
if len(secrets) > 0 {
|
||||
secretsManager := NewSecretsManager()
|
||||
|
||||
// Generate encryption key if not set
|
||||
if secretsManager.GetEncryptionKey() == "" {
|
||||
key, err := secretsManager.GenerateEncryptionKey()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to generate encryption key: %w", err)
|
||||
}
|
||||
secretsManager.SetEncryptionKey(key)
|
||||
}
|
||||
|
||||
// Create Docker secrets
|
||||
if err := secretsManager.CreateDockerSecrets(secrets); err != nil {
|
||||
return nil, fmt.Errorf("failed to create Docker secrets: %w", err)
|
||||
}
|
||||
|
||||
secretsCreated = true
|
||||
secretsPath = secretsManager.GetSecretsPath()
|
||||
}
|
||||
|
||||
// Determine platform from agent type
|
||||
platform := "linux-amd64" // Default
|
||||
if req.AgentType == "windows-workstation" {
|
||||
platform = "windows-amd64"
|
||||
}
|
||||
|
||||
return &AgentConfiguration{
|
||||
AgentID: agentID,
|
||||
PublicConfig: publicConfig,
|
||||
Secrets: secrets,
|
||||
Template: req.AgentType,
|
||||
Environment: req.Environment,
|
||||
ServerURL: req.ServerURL,
|
||||
Organization: req.Organization,
|
||||
Platform: platform,
|
||||
ConfigVersion: "5", // Config schema version
|
||||
AgentVersion: "0.1.23.4", // Agent binary version
|
||||
BuildTime: time.Now(),
|
||||
SecretsCreated: secretsCreated,
|
||||
SecretsPath: secretsPath,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// AgentConfiguration represents a complete agent configuration
|
||||
type AgentConfiguration struct {
|
||||
AgentID string `json:"agent_id"`
|
||||
PublicConfig map[string]interface{} `json:"public_config"`
|
||||
Secrets map[string]string `json:"secrets"`
|
||||
Template string `json:"template"`
|
||||
Environment string `json:"environment"`
|
||||
ServerURL string `json:"server_url"`
|
||||
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")
|
||||
BuildTime time.Time `json:"build_time"`
|
||||
SecretsCreated bool `json:"secrets_created"`
|
||||
SecretsPath string `json:"secrets_path,omitempty"`
|
||||
}
|
||||
|
||||
// validateRequest validates the setup request
|
||||
func (cb *ConfigBuilder) validateRequest(req AgentSetupRequest) error {
|
||||
if req.ServerURL == "" {
|
||||
return fmt.Errorf("server_url is required")
|
||||
}
|
||||
|
||||
if req.Environment == "" {
|
||||
return fmt.Errorf("environment is required")
|
||||
}
|
||||
|
||||
if req.AgentType == "" {
|
||||
return fmt.Errorf("agent_type is required")
|
||||
}
|
||||
|
||||
if req.Organization == "" {
|
||||
return fmt.Errorf("organization is required")
|
||||
}
|
||||
|
||||
// Check if agent type exists
|
||||
if _, exists := cb.templates[req.AgentType]; !exists {
|
||||
return fmt.Errorf("unknown agent type: %s", req.AgentType)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// fetchServerPublicKey fetches the server's public key with caching
|
||||
func (cb *ConfigBuilder) fetchServerPublicKey(serverURL string) (string, error) {
|
||||
// Check cache first
|
||||
if cached, exists := cb.publicKeyCache[serverURL]; exists {
|
||||
return cached, nil
|
||||
}
|
||||
|
||||
// Fetch from server
|
||||
resp, err := cb.httpClient.Get(serverURL + "/api/v1/public-key")
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to fetch public key: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return "", fmt.Errorf("server returned status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
var keyResp PublicKeyResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&keyResp); err != nil {
|
||||
return "", fmt.Errorf("failed to decode public key response: %w", err)
|
||||
}
|
||||
|
||||
// Cache the key
|
||||
cb.publicKeyCache[serverURL] = keyResp.PublicKey
|
||||
|
||||
return keyResp.PublicKey, nil
|
||||
}
|
||||
|
||||
// generateRegistrationToken generates a secure registration token
|
||||
func (cb *ConfigBuilder) generateRegistrationToken(agentID string) (string, error) {
|
||||
bytes := make([]byte, 32)
|
||||
if _, err := rand.Read(bytes); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
// Combine agent ID with random bytes for uniqueness
|
||||
data := append([]byte(agentID), bytes...)
|
||||
token := hex.EncodeToString(data)
|
||||
|
||||
// Ensure token doesn't exceed reasonable length
|
||||
if len(token) > 128 {
|
||||
token = token[:128]
|
||||
}
|
||||
|
||||
return token, nil
|
||||
}
|
||||
|
||||
// buildFromTemplate builds configuration from template
|
||||
func (cb *ConfigBuilder) buildFromTemplate(template AgentTemplate, customSettings map[string]interface{}) map[string]interface{} {
|
||||
config := make(map[string]interface{})
|
||||
|
||||
// Deep copy base configuration
|
||||
for k, v := range template.BaseConfig {
|
||||
config[k] = cb.deepCopy(v)
|
||||
}
|
||||
|
||||
// Apply custom settings
|
||||
if customSettings != nil {
|
||||
cb.mergeSettings(config, customSettings)
|
||||
}
|
||||
|
||||
return config
|
||||
}
|
||||
|
||||
// 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["server_url"] = req.ServerURL
|
||||
config["agent_id"] = agentID
|
||||
config["registration_token"] = registrationToken
|
||||
config["server_public_key"] = serverPublicKey
|
||||
config["organization"] = req.Organization
|
||||
config["environment"] = req.Environment
|
||||
config["agent_type"] = req.AgentType
|
||||
|
||||
if req.DeploymentID != "" {
|
||||
config["deployment_id"] = req.DeploymentID
|
||||
}
|
||||
}
|
||||
|
||||
// applyEnvironmentDefaults applies environment-specific configuration defaults
|
||||
func (cb *ConfigBuilder) applyEnvironmentDefaults(config map[string]interface{}, environment string) {
|
||||
environmentDefaults := map[string]interface{}{
|
||||
"development": map[string]interface{}{
|
||||
"logging": map[string]interface{}{
|
||||
"level": "debug",
|
||||
"max_size": 50,
|
||||
"max_backups": 2,
|
||||
"max_age": 7,
|
||||
},
|
||||
"check_in_interval": 60, // More frequent polling in development
|
||||
},
|
||||
"staging": map[string]interface{}{
|
||||
"logging": map[string]interface{}{
|
||||
"level": "info",
|
||||
"max_size": 100,
|
||||
"max_backups": 3,
|
||||
"max_age": 14,
|
||||
},
|
||||
"check_in_interval": 180,
|
||||
},
|
||||
"production": map[string]interface{}{
|
||||
"logging": map[string]interface{}{
|
||||
"level": "warn",
|
||||
"max_size": 200,
|
||||
"max_backups": 5,
|
||||
"max_age": 30,
|
||||
},
|
||||
"check_in_interval": 300, // 5 minutes for production
|
||||
},
|
||||
"testing": map[string]interface{}{
|
||||
"logging": map[string]interface{}{
|
||||
"level": "debug",
|
||||
"max_size": 10,
|
||||
"max_backups": 1,
|
||||
"max_age": 1,
|
||||
},
|
||||
"check_in_interval": 30, // Very frequent for testing
|
||||
},
|
||||
}
|
||||
|
||||
if defaults, exists := environmentDefaults[environment]; exists {
|
||||
if defaultsMap, ok := defaults.(map[string]interface{}); ok {
|
||||
cb.mergeSettings(config, defaultsMap)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// validateConfiguration validates the final configuration
|
||||
func (cb *ConfigBuilder) validateConfiguration(config map[string]interface{}, template AgentTemplate) error {
|
||||
// Check required fields
|
||||
for _, field := range template.Validation.RequiredFields {
|
||||
if _, exists := config[field]; !exists {
|
||||
return fmt.Errorf("required field missing: %s", field)
|
||||
}
|
||||
}
|
||||
|
||||
// Validate allowed values
|
||||
for field, allowedValues := range template.Validation.AllowedValues {
|
||||
if value, exists := config[field]; exists {
|
||||
if strValue, ok := value.(string); ok {
|
||||
if !cb.containsString(allowedValues, strValue) {
|
||||
return fmt.Errorf("invalid value for %s: %s (allowed: %v)", field, strValue, allowedValues)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Validate constraints
|
||||
for field, constraint := range template.Validation.Constraints {
|
||||
if value, exists := config[field]; exists {
|
||||
if err := cb.validateConstraint(field, value, constraint); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// separateSecrets separates sensitive data from public configuration
|
||||
func (cb *ConfigBuilder) separateSecrets(config map[string]interface{}) (map[string]interface{}, map[string]string) {
|
||||
publicConfig := make(map[string]interface{})
|
||||
secrets := make(map[string]string)
|
||||
|
||||
// Copy all values to public config initially
|
||||
for k, v := range config {
|
||||
publicConfig[k] = cb.deepCopy(v)
|
||||
}
|
||||
|
||||
// Extract known sensitive fields
|
||||
sensitiveFields := []string{
|
||||
"registration_token",
|
||||
"server_public_key",
|
||||
}
|
||||
|
||||
for _, field := range sensitiveFields {
|
||||
if value, exists := publicConfig[field]; exists {
|
||||
if strValue, ok := value.(string); ok {
|
||||
secrets[field] = strValue
|
||||
delete(publicConfig, field)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Extract nested sensitive fields
|
||||
if proxy, exists := publicConfig["proxy"].(map[string]interface{}); exists {
|
||||
if username, exists := proxy["username"].(string); exists && username != "" {
|
||||
secrets["proxy_username"] = username
|
||||
delete(proxy, "username")
|
||||
}
|
||||
if password, exists := proxy["password"].(string); exists && password != "" {
|
||||
secrets["proxy_password"] = password
|
||||
delete(proxy, "password")
|
||||
}
|
||||
}
|
||||
|
||||
if tls, exists := publicConfig["tls"].(map[string]interface{}); exists {
|
||||
if certFile, exists := tls["cert_file"].(string); exists && certFile != "" {
|
||||
secrets["tls_cert"] = certFile
|
||||
delete(tls, "cert_file")
|
||||
}
|
||||
if keyFile, exists := tls["key_file"].(string); exists && keyFile != "" {
|
||||
secrets["tls_key"] = keyFile
|
||||
delete(tls, "key_file")
|
||||
}
|
||||
if caFile, exists := tls["ca_file"].(string); exists && caFile != "" {
|
||||
secrets["tls_ca"] = caFile
|
||||
delete(tls, "ca_file")
|
||||
}
|
||||
}
|
||||
|
||||
return publicConfig, secrets
|
||||
}
|
||||
|
||||
// Helper functions
|
||||
|
||||
func (cb *ConfigBuilder) deepCopy(value interface{}) interface{} {
|
||||
if m, ok := value.(map[string]interface{}); ok {
|
||||
result := make(map[string]interface{})
|
||||
for k, v := range m {
|
||||
result[k] = cb.deepCopy(v)
|
||||
}
|
||||
return result
|
||||
}
|
||||
if s, ok := value.([]interface{}); ok {
|
||||
result := make([]interface{}, len(s))
|
||||
for i, v := range s {
|
||||
result[i] = cb.deepCopy(v)
|
||||
}
|
||||
return result
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
func (cb *ConfigBuilder) mergeSettings(target map[string]interface{}, source map[string]interface{}) {
|
||||
for key, value := range source {
|
||||
if existing, exists := target[key]; exists {
|
||||
if existingMap, ok := existing.(map[string]interface{}); ok {
|
||||
if sourceMap, ok := value.(map[string]interface{}); ok {
|
||||
cb.mergeSettings(existingMap, sourceMap)
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
target[key] = cb.deepCopy(value)
|
||||
}
|
||||
}
|
||||
|
||||
func (cb *ConfigBuilder) containsString(slice []string, item string) bool {
|
||||
for _, s := range slice {
|
||||
if s == item {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// GetTemplates returns the available agent templates
|
||||
func (cb *ConfigBuilder) GetTemplates() map[string]AgentTemplate {
|
||||
return getAgentTemplates()
|
||||
}
|
||||
|
||||
// GetTemplate returns a specific agent template
|
||||
func (cb *ConfigBuilder) GetTemplate(agentType string) (AgentTemplate, bool) {
|
||||
template, exists := getAgentTemplates()[agentType]
|
||||
return template, exists
|
||||
}
|
||||
|
||||
func (cb *ConfigBuilder) validateConstraint(field string, value interface{}, constraint interface{}) error {
|
||||
constraints, ok := constraint.(map[string]interface{})
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
|
||||
if numValue, ok := value.(float64); ok {
|
||||
if min, exists := constraints["min"].(float64); exists && numValue < min {
|
||||
return fmt.Errorf("value for %s is below minimum: %f < %f", field, numValue, min)
|
||||
}
|
||||
if max, exists := constraints["max"].(float64); exists && numValue > max {
|
||||
return fmt.Errorf("value for %s is above maximum: %f > %f", field, numValue, max)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// getAgentTemplates returns the available agent templates
|
||||
func getAgentTemplates() map[string]AgentTemplate {
|
||||
return map[string]AgentTemplate{
|
||||
"linux-server": {
|
||||
Name: "Linux Server Agent",
|
||||
Description: "Optimized for Linux server deployments with package management",
|
||||
BaseConfig: map[string]interface{}{
|
||||
"check_in_interval": 300,
|
||||
"network": map[string]interface{}{
|
||||
"timeout": 30000000000,
|
||||
"retry_count": 3,
|
||||
"retry_delay": 5000000000,
|
||||
"max_idle_conn": 10,
|
||||
},
|
||||
"proxy": map[string]interface{}{
|
||||
"enabled": false,
|
||||
},
|
||||
"tls": map[string]interface{}{
|
||||
"insecure_skip_verify": false,
|
||||
},
|
||||
"logging": map[string]interface{}{
|
||||
"level": "info",
|
||||
"max_size": 100,
|
||||
"max_backups": 3,
|
||||
"max_age": 28,
|
||||
},
|
||||
"subsystems": map[string]interface{}{
|
||||
"apt": map[string]interface{}{
|
||||
"enabled": true,
|
||||
"timeout": 30000000000,
|
||||
"circuit_breaker": map[string]interface{}{
|
||||
"enabled": true,
|
||||
"failure_threshold": 3,
|
||||
"failure_window": 600000000000,
|
||||
"open_duration": 1800000000000,
|
||||
"half_open_attempts": 2,
|
||||
},
|
||||
},
|
||||
"dnf": map[string]interface{}{
|
||||
"enabled": true,
|
||||
"timeout": 45000000000,
|
||||
"circuit_breaker": map[string]interface{}{
|
||||
"enabled": true,
|
||||
"failure_threshold": 3,
|
||||
"failure_window": 600000000000,
|
||||
"open_duration": 1800000000000,
|
||||
"half_open_attempts": 2,
|
||||
},
|
||||
},
|
||||
"docker": map[string]interface{}{
|
||||
"enabled": true,
|
||||
"timeout": 60000000000,
|
||||
"circuit_breaker": map[string]interface{}{
|
||||
"enabled": true,
|
||||
"failure_threshold": 3,
|
||||
"failure_window": 600000000000,
|
||||
"open_duration": 1800000000000,
|
||||
"half_open_attempts": 2,
|
||||
},
|
||||
},
|
||||
"windows": map[string]interface{}{
|
||||
"enabled": false,
|
||||
},
|
||||
"winget": map[string]interface{}{
|
||||
"enabled": false,
|
||||
},
|
||||
"storage": map[string]interface{}{
|
||||
"enabled": true,
|
||||
"timeout": 10000000000,
|
||||
"circuit_breaker": map[string]interface{}{
|
||||
"enabled": true,
|
||||
"failure_threshold": 3,
|
||||
"failure_window": 600000000000,
|
||||
"open_duration": 1800000000000,
|
||||
"half_open_attempts": 2,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
Secrets: []string{"registration_token", "server_public_key"},
|
||||
Validation: ValidationRules{
|
||||
RequiredFields: []string{"server_url", "organization"},
|
||||
AllowedValues: map[string][]string{
|
||||
"environment": {"development", "staging", "production", "testing"},
|
||||
},
|
||||
Patterns: map[string]string{
|
||||
"server_url": "^https?://.+",
|
||||
},
|
||||
Constraints: map[string]interface{}{
|
||||
"check_in_interval": map[string]interface{}{"min": 30, "max": 3600},
|
||||
},
|
||||
},
|
||||
},
|
||||
"windows-workstation": {
|
||||
Name: "Windows Workstation Agent",
|
||||
Description: "Optimized for Windows workstation deployments",
|
||||
BaseConfig: map[string]interface{}{
|
||||
"check_in_interval": 300,
|
||||
"network": map[string]interface{}{
|
||||
"timeout": 30000000000,
|
||||
"retry_count": 3,
|
||||
"retry_delay": 5000000000,
|
||||
"max_idle_conn": 10,
|
||||
},
|
||||
"proxy": map[string]interface{}{
|
||||
"enabled": false,
|
||||
},
|
||||
"tls": map[string]interface{}{
|
||||
"insecure_skip_verify": false,
|
||||
},
|
||||
"logging": map[string]interface{}{
|
||||
"level": "info",
|
||||
"max_size": 100,
|
||||
"max_backups": 3,
|
||||
"max_age": 28,
|
||||
},
|
||||
"subsystems": map[string]interface{}{
|
||||
"apt": map[string]interface{}{
|
||||
"enabled": false,
|
||||
},
|
||||
"dnf": map[string]interface{}{
|
||||
"enabled": false,
|
||||
},
|
||||
"docker": map[string]interface{}{
|
||||
"enabled": false,
|
||||
},
|
||||
"windows": map[string]interface{}{
|
||||
"enabled": true,
|
||||
"timeout": 600000000000,
|
||||
"circuit_breaker": map[string]interface{}{
|
||||
"enabled": true,
|
||||
"failure_threshold": 2,
|
||||
"failure_window": 900000000000,
|
||||
"open_duration": 3600000000000,
|
||||
"half_open_attempts": 3,
|
||||
},
|
||||
},
|
||||
"winget": map[string]interface{}{
|
||||
"enabled": true,
|
||||
"timeout": 120000000000,
|
||||
"circuit_breaker": map[string]interface{}{
|
||||
"enabled": true,
|
||||
"failure_threshold": 3,
|
||||
"failure_window": 600000000000,
|
||||
"open_duration": 1800000000000,
|
||||
"half_open_attempts": 2,
|
||||
},
|
||||
},
|
||||
"storage": map[string]interface{}{
|
||||
"enabled": false,
|
||||
},
|
||||
},
|
||||
},
|
||||
Secrets: []string{"registration_token", "server_public_key"},
|
||||
Validation: ValidationRules{
|
||||
RequiredFields: []string{"server_url", "organization"},
|
||||
AllowedValues: map[string][]string{
|
||||
"environment": {"development", "staging", "production", "testing"},
|
||||
},
|
||||
Patterns: map[string]string{
|
||||
"server_url": "^https?://.+",
|
||||
},
|
||||
Constraints: map[string]interface{}{
|
||||
"check_in_interval": map[string]interface{}{"min": 30, "max": 3600},
|
||||
},
|
||||
},
|
||||
},
|
||||
"docker-host": {
|
||||
Name: "Docker Host Agent",
|
||||
Description: "Optimized for Docker host deployments",
|
||||
BaseConfig: map[string]interface{}{
|
||||
"check_in_interval": 300,
|
||||
"network": map[string]interface{}{
|
||||
"timeout": 30000000000,
|
||||
"retry_count": 3,
|
||||
"retry_delay": 5000000000,
|
||||
"max_idle_conn": 10,
|
||||
},
|
||||
"proxy": map[string]interface{}{
|
||||
"enabled": false,
|
||||
},
|
||||
"tls": map[string]interface{}{
|
||||
"insecure_skip_verify": false,
|
||||
},
|
||||
"logging": map[string]interface{}{
|
||||
"level": "info",
|
||||
"max_size": 100,
|
||||
"max_backups": 3,
|
||||
"max_age": 28,
|
||||
},
|
||||
"subsystems": map[string]interface{}{
|
||||
"apt": map[string]interface{}{
|
||||
"enabled": false,
|
||||
},
|
||||
"dnf": map[string]interface{}{
|
||||
"enabled": false,
|
||||
},
|
||||
"docker": map[string]interface{}{
|
||||
"enabled": true,
|
||||
"timeout": 60000000000,
|
||||
"circuit_breaker": map[string]interface{}{
|
||||
"enabled": true,
|
||||
"failure_threshold": 3,
|
||||
"failure_window": 600000000000,
|
||||
"open_duration": 1800000000000,
|
||||
"half_open_attempts": 2,
|
||||
},
|
||||
},
|
||||
"windows": map[string]interface{}{
|
||||
"enabled": false,
|
||||
},
|
||||
"winget": map[string]interface{}{
|
||||
"enabled": false,
|
||||
},
|
||||
"storage": map[string]interface{}{
|
||||
"enabled": false,
|
||||
},
|
||||
},
|
||||
},
|
||||
Secrets: []string{"registration_token", "server_public_key"},
|
||||
Validation: ValidationRules{
|
||||
RequiredFields: []string{"server_url", "organization"},
|
||||
AllowedValues: map[string][]string{
|
||||
"environment": {"development", "staging", "production", "testing"},
|
||||
},
|
||||
Patterns: map[string]string{
|
||||
"server_url": "^https?://.+",
|
||||
},
|
||||
Constraints: map[string]interface{}{
|
||||
"check_in_interval": map[string]interface{}{"min": 30, "max": 3600},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
263
aggregator-server/internal/services/secrets_manager.go
Normal file
263
aggregator-server/internal/services/secrets_manager.go
Normal file
@@ -0,0 +1,263 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"crypto/aes"
|
||||
"crypto/cipher"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
)
|
||||
|
||||
// SecretsManager handles Docker secrets creation and management
|
||||
type SecretsManager struct {
|
||||
secretsPath string
|
||||
encryptionKey string
|
||||
}
|
||||
|
||||
// NewSecretsManager creates a new secrets manager
|
||||
func NewSecretsManager() *SecretsManager {
|
||||
secretsPath := getSecretsPath()
|
||||
return &SecretsManager{
|
||||
secretsPath: secretsPath,
|
||||
}
|
||||
}
|
||||
|
||||
// CreateDockerSecrets creates Docker secrets from the provided secrets map
|
||||
func (sm *SecretsManager) CreateDockerSecrets(secrets map[string]string) error {
|
||||
if len(secrets) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Ensure secrets directory exists
|
||||
if err := os.MkdirAll(sm.secretsPath, 0755); err != nil {
|
||||
return fmt.Errorf("failed to create secrets directory: %w", err)
|
||||
}
|
||||
|
||||
// Generate encryption key if not provided
|
||||
if sm.encryptionKey == "" {
|
||||
key, err := sm.GenerateEncryptionKey()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to generate encryption key: %w", err)
|
||||
}
|
||||
sm.encryptionKey = key
|
||||
}
|
||||
|
||||
// Create each secret
|
||||
for name, value := range secrets {
|
||||
if err := sm.createSecret(name, value); err != nil {
|
||||
return fmt.Errorf("failed to create secret %s: %w", name, err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// createSecret creates a single Docker secret
|
||||
func (sm *SecretsManager) createSecret(name, value string) error {
|
||||
secretPath := filepath.Join(sm.secretsPath, name)
|
||||
|
||||
// Encrypt sensitive values
|
||||
encryptedValue, err := sm.encryptSecret(value)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to encrypt secret: %w", err)
|
||||
}
|
||||
|
||||
// Write secret file with restricted permissions
|
||||
if err := os.WriteFile(secretPath, encryptedValue, 0400); err != nil {
|
||||
return fmt.Errorf("failed to write secret file: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// encryptSecret encrypts a secret value using AES-256-GCM
|
||||
func (sm *SecretsManager) encryptSecret(value string) ([]byte, error) {
|
||||
// Generate key from master key
|
||||
keyBytes, err := hex.DecodeString(sm.encryptionKey)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid encryption key format: %w", err)
|
||||
}
|
||||
|
||||
// Create cipher
|
||||
block, err := aes.NewCipher(keyBytes)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create cipher: %w", err)
|
||||
}
|
||||
|
||||
// Create GCM
|
||||
gcm, err := cipher.NewGCM(block)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create GCM: %w", err)
|
||||
}
|
||||
|
||||
// Generate nonce
|
||||
nonce := make([]byte, gcm.NonceSize())
|
||||
if _, err := io.ReadFull(rand.Reader, nonce); err != nil {
|
||||
return nil, fmt.Errorf("failed to generate nonce: %w", err)
|
||||
}
|
||||
|
||||
// Encrypt
|
||||
ciphertext := gcm.Seal(nonce, nonce, []byte(value), nil)
|
||||
|
||||
// Prepend nonce to ciphertext
|
||||
result := append(nonce, ciphertext...)
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// decryptSecret decrypts a secret value using AES-256-GCM
|
||||
func (sm *SecretsManager) decryptSecret(encryptedValue []byte) (string, error) {
|
||||
if len(encryptedValue) < 12 { // GCM nonce size
|
||||
return "", fmt.Errorf("invalid encrypted value length")
|
||||
}
|
||||
|
||||
// Generate key from master key
|
||||
keyBytes, err := hex.DecodeString(sm.encryptionKey)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("invalid encryption key format: %w", err)
|
||||
}
|
||||
|
||||
// Create cipher
|
||||
block, err := aes.NewCipher(keyBytes)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to create cipher: %w", err)
|
||||
}
|
||||
|
||||
// Create GCM
|
||||
gcm, err := cipher.NewGCM(block)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to create GCM: %w", err)
|
||||
}
|
||||
|
||||
// Extract nonce and ciphertext
|
||||
nonce := encryptedValue[:gcm.NonceSize()]
|
||||
ciphertext := encryptedValue[gcm.NonceSize():]
|
||||
|
||||
// Decrypt
|
||||
plaintext, err := gcm.Open(nil, nonce, ciphertext, nil)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to decrypt secret: %w", err)
|
||||
}
|
||||
|
||||
return string(plaintext), nil
|
||||
}
|
||||
|
||||
// GenerateEncryptionKey generates a new encryption key
|
||||
func (sm *SecretsManager) GenerateEncryptionKey() (string, error) {
|
||||
bytes := make([]byte, 32)
|
||||
if _, err := rand.Read(bytes); err != nil {
|
||||
return "", fmt.Errorf("failed to generate encryption key: %w", err)
|
||||
}
|
||||
return hex.EncodeToString(bytes), nil
|
||||
}
|
||||
|
||||
// SetEncryptionKey sets the master encryption key
|
||||
func (sm *SecretsManager) SetEncryptionKey(key string) {
|
||||
sm.encryptionKey = key
|
||||
}
|
||||
|
||||
// GetEncryptionKey returns the current encryption key
|
||||
func (sm *SecretsManager) GetEncryptionKey() string {
|
||||
return sm.encryptionKey
|
||||
}
|
||||
|
||||
// GetSecretsPath returns the current secrets path
|
||||
func (sm *SecretsManager) GetSecretsPath() string {
|
||||
return sm.secretsPath
|
||||
}
|
||||
|
||||
// ValidateSecrets validates that all required secrets exist
|
||||
func (sm *SecretsManager) ValidateSecrets(requiredSecrets []string) error {
|
||||
for _, secretName := range requiredSecrets {
|
||||
secretPath := filepath.Join(sm.secretsPath, secretName)
|
||||
if _, err := os.Stat(secretPath); os.IsNotExist(err) {
|
||||
return fmt.Errorf("required secret not found: %s", secretName)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ListSecrets returns a list of all created secrets
|
||||
func (sm *SecretsManager) ListSecrets() ([]string, error) {
|
||||
entries, err := os.ReadDir(sm.secretsPath)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return []string{}, nil
|
||||
}
|
||||
return nil, fmt.Errorf("failed to read secrets directory: %w", err)
|
||||
}
|
||||
|
||||
var secrets []string
|
||||
for _, entry := range entries {
|
||||
if !entry.IsDir() {
|
||||
secrets = append(secrets, entry.Name())
|
||||
}
|
||||
}
|
||||
|
||||
return secrets, nil
|
||||
}
|
||||
|
||||
// RemoveSecret removes a Docker secret
|
||||
func (sm *SecretsManager) RemoveSecret(name string) error {
|
||||
secretPath := filepath.Join(sm.secretsPath, name)
|
||||
return os.Remove(secretPath)
|
||||
}
|
||||
|
||||
// Cleanup removes all secrets and the secrets directory
|
||||
func (sm *SecretsManager) Cleanup() error {
|
||||
if _, err := os.Stat(sm.secretsPath); os.IsNotExist(err) {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Remove all files in the directory
|
||||
entries, err := os.ReadDir(sm.secretsPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to read secrets directory: %w", err)
|
||||
}
|
||||
|
||||
for _, entry := range entries {
|
||||
if !entry.IsDir() {
|
||||
if err := os.Remove(filepath.Join(sm.secretsPath, entry.Name())); err != nil {
|
||||
return fmt.Errorf("failed to remove secret %s: %w", entry.Name(), err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Remove the directory itself
|
||||
return os.Remove(sm.secretsPath)
|
||||
}
|
||||
|
||||
// getSecretsPath returns the platform-specific secrets path
|
||||
func getSecretsPath() string {
|
||||
if runtime.GOOS == "windows" {
|
||||
return `C:\ProgramData\Docker\secrets`
|
||||
}
|
||||
return "/run/secrets"
|
||||
}
|
||||
|
||||
// IsDockerEnvironment checks if running in Docker
|
||||
func IsDockerEnvironment() bool {
|
||||
// Check for .dockerenv file
|
||||
if _, err := os.Stat("/.dockerenv"); err == nil {
|
||||
return true
|
||||
}
|
||||
|
||||
// Check for Docker in cgroup
|
||||
if data, err := os.ReadFile("/proc/1/cgroup"); err == nil {
|
||||
if containsString(string(data), "docker") {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// containsString checks if a string contains a substring
|
||||
func containsString(s, substr string) bool {
|
||||
return len(s) >= len(substr) && (s == substr ||
|
||||
(len(s) > len(substr) && (s[:len(substr)] == substr || s[len(s)-len(substr):] == substr)))
|
||||
}
|
||||
90
aggregator-server/internal/services/update_nonce.go
Normal file
90
aggregator-server/internal/services/update_nonce.go
Normal file
@@ -0,0 +1,90 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"crypto/ed25519"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"time"
|
||||
)
|
||||
|
||||
type UpdateNonce struct {
|
||||
AgentID string `json:"agent_id"`
|
||||
TargetVersion string `json:"target_version"`
|
||||
Timestamp int64 `json:"timestamp"`
|
||||
Signature string `json:"signature"`
|
||||
}
|
||||
|
||||
type UpdateNonceService struct {
|
||||
privateKey ed25519.PrivateKey
|
||||
maxAge time.Duration
|
||||
}
|
||||
|
||||
func NewUpdateNonceService(privateKey ed25519.PrivateKey) *UpdateNonceService {
|
||||
return &UpdateNonceService{
|
||||
privateKey: privateKey,
|
||||
maxAge: 10 * time.Minute,
|
||||
}
|
||||
}
|
||||
|
||||
// Generate creates a signed nonce authorizing an agent to update
|
||||
func (s *UpdateNonceService) Generate(agentID, targetVersion string) (string, error) {
|
||||
nonce := UpdateNonce{
|
||||
AgentID: agentID,
|
||||
TargetVersion: targetVersion,
|
||||
Timestamp: time.Now().Unix(),
|
||||
}
|
||||
|
||||
data, err := json.Marshal(nonce)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("marshal failed: %w", err)
|
||||
}
|
||||
|
||||
signature := ed25519.Sign(s.privateKey, data)
|
||||
nonce.Signature = base64.StdEncoding.EncodeToString(signature)
|
||||
|
||||
encoded, err := json.Marshal(nonce)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("encode failed: %w", err)
|
||||
}
|
||||
|
||||
return base64.StdEncoding.EncodeToString(encoded), nil
|
||||
}
|
||||
|
||||
// Validate verifies the nonce signature and freshness
|
||||
func (s *UpdateNonceService) Validate(encodedNonce string) (*UpdateNonce, error) {
|
||||
data, err := base64.StdEncoding.DecodeString(encodedNonce)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid base64: %w", err)
|
||||
}
|
||||
|
||||
var nonce UpdateNonce
|
||||
if err := json.Unmarshal(data, &nonce); err != nil {
|
||||
return nil, fmt.Errorf("invalid format: %w", err)
|
||||
}
|
||||
|
||||
// Check freshness
|
||||
if time.Now().Unix()-nonce.Timestamp > int64(s.maxAge.Seconds()) {
|
||||
return nil, fmt.Errorf("nonce expired")
|
||||
}
|
||||
|
||||
// Verify signature
|
||||
signature, err := base64.StdEncoding.DecodeString(nonce.Signature)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid signature: %w", err)
|
||||
}
|
||||
|
||||
// Remove signature for verification
|
||||
nonce.Signature = ""
|
||||
verifyData, err := json.Marshal(nonce)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("marshal verify data: %w", err)
|
||||
}
|
||||
|
||||
if !ed25519.Verify(s.privateKey.Public().(ed25519.PublicKey), verifyData, signature) {
|
||||
return nil, fmt.Errorf("signature verification failed")
|
||||
}
|
||||
|
||||
// Return validated nonce
|
||||
return &nonce, nil
|
||||
}
|
||||
Reference in New Issue
Block a user