v0.1.17: UI fixes, Linux improvements, documentation overhaul
UI/UX: - Fix heartbeat auto-refresh and rate-limiting page - Add navigation breadcrumbs to settings pages - New screenshots added Linux Agent v0.1.17: - Fix disk detection for multiple mount points - Improve installer idempotency - Prevent duplicate registrations Documentation: - README rewrite: 538→229 lines, homelab-focused - Split docs: API.md, CONFIGURATION.md, DEVELOPMENT.md - Add NOTICE for Apache 2.0 attribution
This commit is contained in:
@@ -1,18 +1,44 @@
|
||||
FROM golang:1.23-alpine AS builder
|
||||
# Stage 1: Build server binary
|
||||
FROM golang:1.23-alpine AS server-builder
|
||||
|
||||
WORKDIR /app
|
||||
COPY go.mod go.sum ./
|
||||
COPY aggregator-server/go.mod aggregator-server/go.sum ./
|
||||
RUN go mod download
|
||||
|
||||
COPY . .
|
||||
COPY aggregator-server/ .
|
||||
RUN CGO_ENABLED=0 go build -o redflag-server cmd/server/main.go
|
||||
|
||||
# Stage 2: Build agent binaries for all platforms
|
||||
FROM golang:1.23-alpine AS agent-builder
|
||||
|
||||
WORKDIR /build
|
||||
# Copy agent source code
|
||||
COPY aggregator-agent/ ./
|
||||
|
||||
# Build for Linux amd64
|
||||
RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o binaries/linux-amd64/redflag-agent cmd/agent/main.go
|
||||
|
||||
# Build for Linux arm64
|
||||
RUN CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -o binaries/linux-arm64/redflag-agent cmd/agent/main.go
|
||||
|
||||
# Build for Windows amd64
|
||||
RUN CGO_ENABLED=0 GOOS=windows GOARCH=amd64 go build -o binaries/windows-amd64/redflag-agent.exe cmd/agent/main.go
|
||||
|
||||
# Build for Windows arm64
|
||||
RUN CGO_ENABLED=0 GOOS=windows GOARCH=arm64 go build -o binaries/windows-arm64/redflag-agent.exe cmd/agent/main.go
|
||||
|
||||
# Stage 3: Final image with server and all agent binaries
|
||||
FROM alpine:latest
|
||||
|
||||
RUN apk --no-cache add ca-certificates tzdata
|
||||
WORKDIR /app
|
||||
|
||||
COPY --from=builder /app/redflag-server .
|
||||
# Copy server binary
|
||||
COPY --from=server-builder /app/redflag-server .
|
||||
COPY --from=server-builder /app/internal/database ./internal/database
|
||||
|
||||
# Copy all agent binaries
|
||||
COPY --from=agent-builder /build/binaries ./binaries
|
||||
|
||||
EXPOSE 8080
|
||||
|
||||
|
||||
@@ -35,15 +35,10 @@ func startWelcomeModeServer() {
|
||||
router.GET("/", setupHandler.ShowSetupPage)
|
||||
|
||||
// Setup endpoint for web configuration
|
||||
router.POST("/api/v1/setup", setupHandler.ConfigureServer)
|
||||
router.POST("/api/setup/configure", setupHandler.ConfigureServer)
|
||||
|
||||
// Setup endpoint for web configuration (future)
|
||||
router.GET("/setup", func(c *gin.Context) {
|
||||
c.JSON(200, gin.H{
|
||||
"message": "Web setup coming soon",
|
||||
"instructions": "Use: docker-compose exec server ./redflag-server --setup",
|
||||
})
|
||||
})
|
||||
// Setup endpoint for web configuration
|
||||
router.GET("/setup", setupHandler.ShowSetupPage)
|
||||
|
||||
log.Printf("Welcome mode server started on :8080")
|
||||
log.Printf("Waiting for configuration...")
|
||||
@@ -127,6 +122,14 @@ func main() {
|
||||
commandQueries := queries.NewCommandQueries(db.DB)
|
||||
refreshTokenQueries := queries.NewRefreshTokenQueries(db.DB)
|
||||
registrationTokenQueries := queries.NewRegistrationTokenQueries(db.DB)
|
||||
userQueries := queries.NewUserQueries(db.DB)
|
||||
|
||||
// Ensure admin user exists
|
||||
if err := userQueries.EnsureAdminUser(cfg.Admin.Username, cfg.Admin.Username+"@redflag.local", cfg.Admin.Password); err != nil {
|
||||
fmt.Printf("Warning: Failed to create admin user: %v\n", err)
|
||||
} else {
|
||||
fmt.Println("✅ Admin user ensured")
|
||||
}
|
||||
|
||||
// Initialize services
|
||||
timezoneService := services.NewTimezoneService(cfg)
|
||||
@@ -136,15 +139,15 @@ func main() {
|
||||
rateLimiter := middleware.NewRateLimiter()
|
||||
|
||||
// Initialize handlers
|
||||
agentHandler := handlers.NewAgentHandler(agentQueries, commandQueries, refreshTokenQueries, cfg.CheckInInterval, cfg.LatestAgentVersion)
|
||||
agentHandler := handlers.NewAgentHandler(agentQueries, commandQueries, refreshTokenQueries, registrationTokenQueries, cfg.CheckInInterval, cfg.LatestAgentVersion)
|
||||
updateHandler := handlers.NewUpdateHandler(updateQueries, agentQueries, commandQueries, agentHandler)
|
||||
authHandler := handlers.NewAuthHandler(cfg.Admin.JWTSecret)
|
||||
authHandler := handlers.NewAuthHandler(cfg.Admin.JWTSecret, userQueries)
|
||||
statsHandler := handlers.NewStatsHandler(agentQueries, updateQueries)
|
||||
settingsHandler := handlers.NewSettingsHandler(timezoneService)
|
||||
dockerHandler := handlers.NewDockerHandler(updateQueries, agentQueries, commandQueries)
|
||||
registrationTokenHandler := handlers.NewRegistrationTokenHandler(registrationTokenQueries, agentQueries, cfg)
|
||||
rateLimitHandler := handlers.NewRateLimitHandler(rateLimiter)
|
||||
downloadHandler := handlers.NewDownloadHandler(filepath.Join(".", "redflag-agent"))
|
||||
downloadHandler := handlers.NewDownloadHandler(filepath.Join("/app"), cfg)
|
||||
|
||||
// Setup router
|
||||
router := gin.Default()
|
||||
@@ -169,6 +172,10 @@ 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)
|
||||
|
||||
// Public download routes (no authentication - agents need these!)
|
||||
api.GET("/downloads/:platform", rateLimiter.RateLimit("public_access", middleware.KeyByIP), downloadHandler.DownloadAgent)
|
||||
api.GET("/install/:platform", rateLimiter.RateLimit("public_access", middleware.KeyByIP), downloadHandler.InstallScript)
|
||||
|
||||
// Protected agent routes
|
||||
agents := api.Group("/agents")
|
||||
agents.Use(middleware.AuthMiddleware())
|
||||
@@ -225,10 +232,6 @@ func main() {
|
||||
dashboard.POST("/docker/containers/:container_id/images/:image_id/reject", dockerHandler.RejectUpdate)
|
||||
dashboard.POST("/docker/containers/:container_id/images/:image_id/install", dockerHandler.InstallUpdate)
|
||||
|
||||
// Download routes (authenticated)
|
||||
dashboard.GET("/downloads/:platform", downloadHandler.DownloadAgent)
|
||||
dashboard.GET("/install/:platform", downloadHandler.InstallScript)
|
||||
|
||||
// Admin/Registration Token routes (for agent enrollment management)
|
||||
admin := dashboard.Group("/admin")
|
||||
{
|
||||
@@ -236,6 +239,7 @@ func main() {
|
||||
admin.GET("/registration-tokens", rateLimiter.RateLimit("admin_operations", middleware.KeyByUserID), registrationTokenHandler.ListRegistrationTokens)
|
||||
admin.GET("/registration-tokens/active", rateLimiter.RateLimit("admin_operations", middleware.KeyByUserID), registrationTokenHandler.GetActiveRegistrationTokens)
|
||||
admin.DELETE("/registration-tokens/:token", rateLimiter.RateLimit("admin_operations", middleware.KeyByUserID), registrationTokenHandler.RevokeRegistrationToken)
|
||||
admin.DELETE("/registration-tokens/delete/:id", rateLimiter.RateLimit("admin_operations", middleware.KeyByUserID), registrationTokenHandler.DeleteRegistrationToken)
|
||||
admin.POST("/registration-tokens/cleanup", rateLimiter.RateLimit("admin_operations", middleware.KeyByUserID), registrationTokenHandler.CleanupExpiredTokens)
|
||||
admin.GET("/registration-tokens/stats", rateLimiter.RateLimit("admin_operations", middleware.KeyByUserID), registrationTokenHandler.GetTokenStats)
|
||||
admin.GET("/registration-tokens/validate", rateLimiter.RateLimit("admin_operations", middleware.KeyByUserID), registrationTokenHandler.ValidateRegistrationToken)
|
||||
|
||||
@@ -15,20 +15,22 @@ import (
|
||||
)
|
||||
|
||||
type AgentHandler struct {
|
||||
agentQueries *queries.AgentQueries
|
||||
commandQueries *queries.CommandQueries
|
||||
refreshTokenQueries *queries.RefreshTokenQueries
|
||||
checkInInterval int
|
||||
latestAgentVersion string
|
||||
agentQueries *queries.AgentQueries
|
||||
commandQueries *queries.CommandQueries
|
||||
refreshTokenQueries *queries.RefreshTokenQueries
|
||||
registrationTokenQueries *queries.RegistrationTokenQueries
|
||||
checkInInterval int
|
||||
latestAgentVersion string
|
||||
}
|
||||
|
||||
func NewAgentHandler(aq *queries.AgentQueries, cq *queries.CommandQueries, rtq *queries.RefreshTokenQueries, checkInInterval int, latestAgentVersion string) *AgentHandler {
|
||||
func NewAgentHandler(aq *queries.AgentQueries, cq *queries.CommandQueries, rtq *queries.RefreshTokenQueries, regTokenQueries *queries.RegistrationTokenQueries, checkInInterval int, latestAgentVersion string) *AgentHandler {
|
||||
return &AgentHandler{
|
||||
agentQueries: aq,
|
||||
commandQueries: cq,
|
||||
refreshTokenQueries: rtq,
|
||||
checkInInterval: checkInInterval,
|
||||
latestAgentVersion: latestAgentVersion,
|
||||
agentQueries: aq,
|
||||
commandQueries: cq,
|
||||
refreshTokenQueries: rtq,
|
||||
registrationTokenQueries: regTokenQueries,
|
||||
checkInInterval: checkInInterval,
|
||||
latestAgentVersion: latestAgentVersion,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -40,6 +42,35 @@ func (h *AgentHandler) RegisterAgent(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// Validate registration token (critical security check)
|
||||
// Extract token from Authorization header or request body
|
||||
var registrationToken string
|
||||
|
||||
// Try Authorization header first (Bearer token)
|
||||
if authHeader := c.GetHeader("Authorization"); authHeader != "" {
|
||||
if len(authHeader) > 7 && authHeader[:7] == "Bearer " {
|
||||
registrationToken = authHeader[7:]
|
||||
}
|
||||
}
|
||||
|
||||
// If not in header, try request body (fallback)
|
||||
if registrationToken == "" && req.RegistrationToken != "" {
|
||||
registrationToken = req.RegistrationToken
|
||||
}
|
||||
|
||||
// Reject if no registration token provided
|
||||
if registrationToken == "" {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "registration token required"})
|
||||
return
|
||||
}
|
||||
|
||||
// Validate the registration token
|
||||
tokenInfo, err := h.registrationTokenQueries.ValidateRegistrationToken(registrationToken)
|
||||
if err != nil || tokenInfo == nil {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid or expired registration token"})
|
||||
return
|
||||
}
|
||||
|
||||
// Create new agent
|
||||
agent := &models.Agent{
|
||||
ID: uuid.New(),
|
||||
@@ -66,6 +97,17 @@ func (h *AgentHandler) RegisterAgent(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// Mark registration token as used (CRITICAL: must succeed or delete agent)
|
||||
if err := h.registrationTokenQueries.MarkTokenUsed(registrationToken, agent.ID); err != nil {
|
||||
// Token marking failed - rollback agent creation to prevent token reuse
|
||||
log.Printf("ERROR: Failed to mark registration token as used: %v - rolling back agent creation", err)
|
||||
if deleteErr := h.agentQueries.DeleteAgent(agent.ID); deleteErr != nil {
|
||||
log.Printf("ERROR: Failed to delete agent during rollback: %v", deleteErr)
|
||||
}
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "registration token could not be consumed - token may be expired, revoked, or all seats may be used"})
|
||||
return
|
||||
}
|
||||
|
||||
// Generate JWT access token (short-lived: 24 hours)
|
||||
token, err := middleware.GenerateAgentToken(agent.ID)
|
||||
if err != nil {
|
||||
|
||||
@@ -5,6 +5,8 @@ import (
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/Fimeg/RedFlag/aggregator-server/internal/database/queries"
|
||||
"github.com/Fimeg/RedFlag/aggregator-server/internal/models"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
"github.com/google/uuid"
|
||||
@@ -12,29 +14,35 @@ import (
|
||||
|
||||
// AuthHandler handles authentication for the web dashboard
|
||||
type AuthHandler struct {
|
||||
jwtSecret string
|
||||
jwtSecret string
|
||||
userQueries *queries.UserQueries
|
||||
}
|
||||
|
||||
// NewAuthHandler creates a new auth handler
|
||||
func NewAuthHandler(jwtSecret string) *AuthHandler {
|
||||
func NewAuthHandler(jwtSecret string, userQueries *queries.UserQueries) *AuthHandler {
|
||||
return &AuthHandler{
|
||||
jwtSecret: jwtSecret,
|
||||
jwtSecret: jwtSecret,
|
||||
userQueries: userQueries,
|
||||
}
|
||||
}
|
||||
|
||||
// LoginRequest represents a login request
|
||||
type LoginRequest struct {
|
||||
Token string `json:"token" binding:"required"`
|
||||
Username string `json:"username" binding:"required"`
|
||||
Password string `json:"password" binding:"required"`
|
||||
}
|
||||
|
||||
// LoginResponse represents a login response
|
||||
type LoginResponse struct {
|
||||
Token string `json:"token"`
|
||||
Token string `json:"token"`
|
||||
User *models.User `json:"user"`
|
||||
}
|
||||
|
||||
// UserClaims represents JWT claims for web dashboard users
|
||||
type UserClaims struct {
|
||||
UserID uuid.UUID `json:"user_id"`
|
||||
UserID uuid.UUID `json:"user_id"`
|
||||
Username string `json:"username"`
|
||||
Role string `json:"role"`
|
||||
jwt.RegisteredClaims
|
||||
}
|
||||
|
||||
@@ -46,16 +54,18 @@ func (h *AuthHandler) Login(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// For development, accept any non-empty token
|
||||
// In production, implement proper authentication
|
||||
if req.Token == "" {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid token"})
|
||||
// Validate credentials against database
|
||||
user, err := h.userQueries.VerifyCredentials(req.Username, req.Password)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid username or password"})
|
||||
return
|
||||
}
|
||||
|
||||
// Create JWT token for web dashboard
|
||||
claims := UserClaims{
|
||||
UserID: uuid.New(), // Generate a user ID for this session
|
||||
UserID: user.ID,
|
||||
Username: user.Username,
|
||||
Role: user.Role,
|
||||
RegisteredClaims: jwt.RegisteredClaims{
|
||||
ExpiresAt: jwt.NewNumericDate(time.Now().Add(24 * time.Hour)),
|
||||
IssuedAt: jwt.NewNumericDate(time.Now()),
|
||||
@@ -69,7 +79,10 @@ func (h *AuthHandler) Login(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, LoginResponse{Token: tokenString})
|
||||
c.JSON(http.StatusOK, LoginResponse{
|
||||
Token: tokenString,
|
||||
User: user,
|
||||
})
|
||||
}
|
||||
|
||||
// VerifyToken handles token verification
|
||||
|
||||
@@ -1,49 +1,101 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/Fimeg/RedFlag/aggregator-server/internal/config"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// DownloadHandler handles agent binary downloads
|
||||
type DownloadHandler struct {
|
||||
agentDir string
|
||||
config *config.Config
|
||||
}
|
||||
|
||||
func NewDownloadHandler(agentDir string) *DownloadHandler {
|
||||
func NewDownloadHandler(agentDir string, cfg *config.Config) *DownloadHandler {
|
||||
return &DownloadHandler{
|
||||
agentDir: agentDir,
|
||||
config: cfg,
|
||||
}
|
||||
}
|
||||
|
||||
// getServerURL determines the server URL with proper protocol detection
|
||||
func (h *DownloadHandler) getServerURL(c *gin.Context) string {
|
||||
// Priority 1: Use configured public URL if set
|
||||
if h.config.Server.PublicURL != "" {
|
||||
return h.config.Server.PublicURL
|
||||
}
|
||||
|
||||
// Priority 2: Detect from request with TLS/proxy awareness
|
||||
scheme := "http"
|
||||
|
||||
// Check if TLS is enabled in config
|
||||
if h.config.Server.TLS.Enabled {
|
||||
scheme = "https"
|
||||
}
|
||||
|
||||
// Check if request came through HTTPS (direct or via proxy)
|
||||
if c.Request.TLS != nil {
|
||||
scheme = "https"
|
||||
}
|
||||
|
||||
// Check X-Forwarded-Proto for reverse proxy setups
|
||||
if forwardedProto := c.GetHeader("X-Forwarded-Proto"); forwardedProto == "https" {
|
||||
scheme = "https"
|
||||
}
|
||||
|
||||
// Use the Host header exactly as received (includes port if present)
|
||||
host := c.GetHeader("X-Forwarded-Host")
|
||||
if host == "" {
|
||||
host = c.Request.Host
|
||||
}
|
||||
|
||||
return fmt.Sprintf("%s://%s", scheme, host)
|
||||
}
|
||||
|
||||
// DownloadAgent serves agent binaries for different platforms
|
||||
func (h *DownloadHandler) DownloadAgent(c *gin.Context) {
|
||||
platform := c.Param("platform")
|
||||
|
||||
// Validate platform to prevent directory traversal
|
||||
// Validate platform to prevent directory traversal (removed darwin - no macOS support)
|
||||
validPlatforms := map[string]bool{
|
||||
"linux-amd64": true,
|
||||
"linux-arm64": true,
|
||||
"windows-amd64": true,
|
||||
"windows-arm64": true,
|
||||
"darwin-amd64": true,
|
||||
"darwin-arm64": true,
|
||||
}
|
||||
|
||||
if !validPlatforms[platform] {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid platform"})
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid or unsupported platform"})
|
||||
return
|
||||
}
|
||||
|
||||
// Build filename based on platform
|
||||
filename := "redflag-agent"
|
||||
if strings.HasPrefix(platform, "windows") {
|
||||
filename += ".exe"
|
||||
}
|
||||
|
||||
agentPath := filepath.Join(h.agentDir, filename)
|
||||
// Serve from platform-specific directory: binaries/{platform}/redflag-agent
|
||||
agentPath := filepath.Join(h.agentDir, "binaries", platform, filename)
|
||||
|
||||
// Check if file exists
|
||||
if _, err := os.Stat(agentPath); os.IsNotExist(err) {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Agent binary not found"})
|
||||
return
|
||||
}
|
||||
|
||||
// Handle both GET and HEAD requests
|
||||
if c.Request.Method == "HEAD" {
|
||||
c.Status(http.StatusOK)
|
||||
return
|
||||
}
|
||||
|
||||
c.File(agentPath)
|
||||
}
|
||||
|
||||
@@ -51,75 +103,604 @@ func (h *DownloadHandler) DownloadAgent(c *gin.Context) {
|
||||
func (h *DownloadHandler) InstallScript(c *gin.Context) {
|
||||
platform := c.Param("platform")
|
||||
|
||||
// Validate platform
|
||||
// Validate platform (removed darwin - no macOS support)
|
||||
validPlatforms := map[string]bool{
|
||||
"linux": true,
|
||||
"darwin": true,
|
||||
"linux": true,
|
||||
"windows": true,
|
||||
}
|
||||
|
||||
if !validPlatforms[platform] {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid platform"})
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid or unsupported platform"})
|
||||
return
|
||||
}
|
||||
|
||||
scriptContent := h.generateInstallScript(platform, c.Request.Host)
|
||||
serverURL := h.getServerURL(c)
|
||||
scriptContent := h.generateInstallScript(platform, serverURL)
|
||||
c.Header("Content-Type", "text/plain")
|
||||
c.String(http.StatusOK, scriptContent)
|
||||
}
|
||||
|
||||
func (h *DownloadHandler) generateInstallScript(platform, serverHost string) string {
|
||||
baseURL := "http://" + serverHost
|
||||
|
||||
func (h *DownloadHandler) generateInstallScript(platform, baseURL string) string {
|
||||
switch platform {
|
||||
case "linux":
|
||||
return `#!/bin/bash
|
||||
set -e
|
||||
|
||||
# RedFlag Agent Installation Script
|
||||
# This script installs the RedFlag agent as a systemd service with proper security hardening
|
||||
|
||||
REDFLAG_SERVER="` + baseURL + `"
|
||||
AGENT_DIR="/usr/local/bin"
|
||||
SERVICE_NAME="redflag-agent"
|
||||
AGENT_USER="redflag-agent"
|
||||
AGENT_HOME="/var/lib/redflag-agent"
|
||||
AGENT_BINARY="/usr/local/bin/redflag-agent"
|
||||
SUDOERS_FILE="/etc/sudoers.d/redflag-agent"
|
||||
SERVICE_FILE="/etc/systemd/system/redflag-agent.service"
|
||||
CONFIG_DIR="/etc/aggregator"
|
||||
|
||||
echo "=== RedFlag Agent Installation ==="
|
||||
echo ""
|
||||
|
||||
# Check if running as root
|
||||
if [ "$EUID" -ne 0 ]; then
|
||||
echo "Please run as root or with sudo"
|
||||
echo "ERROR: This script must be run as root (use sudo)"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Installing RedFlag agent from ${REDFLAG_SERVER}..."
|
||||
# Detect architecture
|
||||
ARCH=$(uname -m)
|
||||
case "$ARCH" in
|
||||
x86_64)
|
||||
DOWNLOAD_ARCH="amd64"
|
||||
;;
|
||||
aarch64|arm64)
|
||||
DOWNLOAD_ARCH="arm64"
|
||||
;;
|
||||
*)
|
||||
echo "ERROR: Unsupported architecture: $ARCH"
|
||||
echo "Supported: x86_64 (amd64), aarch64 (arm64)"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
# Download agent
|
||||
curl -sfL "${REDFLAG_SERVER}/api/v1/downloads/linux-amd64" -o "${AGENT_DIR}/redflag-agent"
|
||||
chmod +x "${AGENT_DIR}/redflag-agent"
|
||||
echo "Detected architecture: $ARCH (using linux-$DOWNLOAD_ARCH)"
|
||||
echo ""
|
||||
|
||||
echo "Agent downloaded. Please visit ${REDFLAG_SERVER}/admin to get a registration token."
|
||||
echo "Then run: ${AGENT_DIR}/redflag-agent --server ${REDFLAG_SERVER} --token <YOUR_TOKEN>"`
|
||||
# Step 1: Create system user
|
||||
echo "Step 1: Creating system user..."
|
||||
if id "$AGENT_USER" &>/dev/null; then
|
||||
echo "✓ User $AGENT_USER already exists"
|
||||
else
|
||||
useradd -r -s /bin/false -d "$AGENT_HOME" -m "$AGENT_USER"
|
||||
echo "✓ User $AGENT_USER created"
|
||||
fi
|
||||
|
||||
case "darwin":
|
||||
return `#!/bin/bash
|
||||
set -e
|
||||
# Create home directory if it doesn't exist
|
||||
if [ ! -d "$AGENT_HOME" ]; then
|
||||
mkdir -p "$AGENT_HOME"
|
||||
chown "$AGENT_USER:$AGENT_USER" "$AGENT_HOME"
|
||||
echo "✓ Home directory created"
|
||||
fi
|
||||
|
||||
REDFLAG_SERVER="` + baseURL + `"
|
||||
AGENT_DIR="/usr/local/bin"
|
||||
# Stop existing service if running (to allow binary update)
|
||||
if systemctl is-active --quiet redflag-agent 2>/dev/null; then
|
||||
echo ""
|
||||
echo "Existing service detected - stopping to allow update..."
|
||||
systemctl stop redflag-agent
|
||||
sleep 2
|
||||
echo "✓ Service stopped"
|
||||
fi
|
||||
|
||||
echo "Installing RedFlag agent from ${REDFLAG_SERVER}..."
|
||||
# Step 2: Download agent binary
|
||||
echo ""
|
||||
echo "Step 2: Downloading agent binary..."
|
||||
echo "Downloading from ${REDFLAG_SERVER}/api/v1/downloads/linux-${DOWNLOAD_ARCH}..."
|
||||
|
||||
# Download agent
|
||||
curl -sfL "${REDFLAG_SERVER}/api/v1/downloads/darwin-amd64" -o "${AGENT_DIR}/redflag-agent"
|
||||
chmod +x "${AGENT_DIR}/redflag-agent"
|
||||
# Download to temporary file first (to avoid root permission issues)
|
||||
TEMP_FILE="/tmp/redflag-agent-${DOWNLOAD_ARCH}"
|
||||
echo "Downloading to temporary file: $TEMP_FILE"
|
||||
|
||||
echo "Agent downloaded. Please visit ${REDFLAG_SERVER}/admin to get a registration token."
|
||||
echo "Then run: ${AGENT_DIR}/redflag-agent --server ${REDFLAG_SERVER} --token <YOUR_TOKEN>"`
|
||||
# Try curl first (most reliable)
|
||||
if curl -sL "${REDFLAG_SERVER}/api/v1/downloads/linux-${DOWNLOAD_ARCH}" -o "$TEMP_FILE"; then
|
||||
echo "✓ Download successful, moving to final location"
|
||||
mv "$TEMP_FILE" "${AGENT_BINARY}"
|
||||
chmod 755 "${AGENT_BINARY}"
|
||||
chown root:root "${AGENT_BINARY}"
|
||||
echo "✓ Agent binary downloaded and installed"
|
||||
else
|
||||
echo "✗ Download with curl failed"
|
||||
# Fallback to wget if available
|
||||
if command -v wget >/dev/null 2>&1; then
|
||||
echo "Trying wget fallback..."
|
||||
if wget -q "${REDFLAG_SERVER}/api/v1/downloads/linux-${DOWNLOAD_ARCH}" -O "$TEMP_FILE"; then
|
||||
echo "✓ Download successful with wget, moving to final location"
|
||||
mv "$TEMP_FILE" "${AGENT_BINARY}"
|
||||
chmod 755 "${AGENT_BINARY}"
|
||||
chown root:root "${AGENT_BINARY}"
|
||||
echo "✓ Agent binary downloaded and installed (using wget fallback)"
|
||||
else
|
||||
echo "ERROR: Failed to download agent binary"
|
||||
echo "Both curl and wget failed"
|
||||
echo "Please ensure ${REDFLAG_SERVER} is accessible"
|
||||
# Clean up temp file if it exists
|
||||
rm -f "$TEMP_FILE"
|
||||
exit 1
|
||||
fi
|
||||
else
|
||||
echo "ERROR: Failed to download agent binary"
|
||||
echo "curl failed and wget is not available"
|
||||
echo "Please ensure ${REDFLAG_SERVER} is accessible"
|
||||
# Clean up temp file if it exists
|
||||
rm -f "$TEMP_FILE"
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
# Clean up temp file if it still exists
|
||||
rm -f "$TEMP_FILE"
|
||||
|
||||
# Set SELinux context for binary if SELinux is enabled
|
||||
if command -v getenforce >/dev/null 2>&1 && [ "$(getenforce)" != "Disabled" ]; then
|
||||
echo "SELinux detected, setting file context for binary..."
|
||||
restorecon -v "${AGENT_BINARY}" 2>/dev/null || true
|
||||
echo "✓ SELinux context set for binary"
|
||||
fi
|
||||
|
||||
# Step 3: Install sudoers configuration
|
||||
echo ""
|
||||
echo "Step 3: Installing sudoers configuration..."
|
||||
cat > "$SUDOERS_FILE" <<'SUDOERS_EOF'
|
||||
# RedFlag Agent minimal sudo permissions
|
||||
# This file grants the redflag-agent user limited sudo access for package management
|
||||
# Generated automatically during RedFlag agent installation
|
||||
|
||||
# APT package management commands (Debian/Ubuntu)
|
||||
redflag-agent ALL=(root) NOPASSWD: /usr/bin/apt-get update
|
||||
redflag-agent ALL=(root) NOPASSWD: /usr/bin/apt-get install -y *
|
||||
redflag-agent ALL=(root) NOPASSWD: /usr/bin/apt-get upgrade -y *
|
||||
redflag-agent ALL=(root) NOPASSWD: /usr/bin/apt-get install --dry-run --yes *
|
||||
|
||||
# DNF package management commands (RHEL/Fedora/Rocky/Alma)
|
||||
redflag-agent ALL=(root) NOPASSWD: /usr/bin/dnf makecache
|
||||
redflag-agent ALL=(root) NOPASSWD: /usr/bin/dnf install -y *
|
||||
redflag-agent ALL=(root) NOPASSWD: /usr/bin/dnf upgrade -y *
|
||||
redflag-agent ALL=(root) NOPASSWD: /usr/bin/dnf install --assumeno --downloadonly *
|
||||
|
||||
# Docker operations
|
||||
redflag-agent ALL=(root) NOPASSWD: /usr/bin/docker pull *
|
||||
redflag-agent ALL=(root) NOPASSWD: /usr/bin/docker image inspect *
|
||||
redflag-agent ALL=(root) NOPASSWD: /usr/bin/docker manifest inspect *
|
||||
SUDOERS_EOF
|
||||
|
||||
chmod 440 "$SUDOERS_FILE"
|
||||
|
||||
# Validate sudoers file
|
||||
if visudo -c -f "$SUDOERS_FILE" &>/dev/null; then
|
||||
echo "✓ Sudoers configuration installed and validated"
|
||||
else
|
||||
echo "ERROR: Sudoers configuration is invalid"
|
||||
rm -f "$SUDOERS_FILE"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Step 4: Create configuration directory
|
||||
echo ""
|
||||
echo "Step 4: Creating configuration directory..."
|
||||
mkdir -p "$CONFIG_DIR"
|
||||
chown "$AGENT_USER:$AGENT_USER" "$CONFIG_DIR"
|
||||
chmod 755 "$CONFIG_DIR"
|
||||
echo "✓ Configuration directory created"
|
||||
|
||||
# Set SELinux context for config directory if SELinux is enabled
|
||||
if command -v getenforce >/dev/null 2>&1 && [ "$(getenforce)" != "Disabled" ]; then
|
||||
echo "Setting SELinux context for config directory..."
|
||||
restorecon -Rv "$CONFIG_DIR" 2>/dev/null || true
|
||||
echo "✓ SELinux context set for config directory"
|
||||
fi
|
||||
|
||||
# Step 5: Install systemd service
|
||||
echo ""
|
||||
echo "Step 5: Installing systemd service..."
|
||||
cat > "$SERVICE_FILE" <<SERVICE_EOF
|
||||
[Unit]
|
||||
Description=RedFlag Update Agent
|
||||
After=network.target
|
||||
Documentation=https://github.com/Fimeg/RedFlag
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=$AGENT_USER
|
||||
Group=$AGENT_USER
|
||||
WorkingDirectory=$AGENT_HOME
|
||||
ExecStart=$AGENT_BINARY
|
||||
Restart=always
|
||||
RestartSec=30
|
||||
|
||||
# Security hardening
|
||||
# NoNewPrivileges=true - DISABLED: Prevents sudo from working, which agent needs for package management
|
||||
ProtectSystem=strict
|
||||
ProtectHome=true
|
||||
ReadWritePaths=$AGENT_HOME /var/log $CONFIG_DIR
|
||||
PrivateTmp=true
|
||||
|
||||
# Logging
|
||||
StandardOutput=journal
|
||||
StandardError=journal
|
||||
SyslogIdentifier=redflag-agent
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
SERVICE_EOF
|
||||
|
||||
chmod 644 "$SERVICE_FILE"
|
||||
echo "✓ Systemd service installed"
|
||||
|
||||
# Step 6: Register agent with server
|
||||
echo ""
|
||||
echo "Step 6: Agent registration"
|
||||
echo "=========================================="
|
||||
echo ""
|
||||
|
||||
# Check if token was provided as parameter (for one-liner support)
|
||||
if [ -n "$1" ]; then
|
||||
REGISTRATION_TOKEN="$1"
|
||||
echo "Using provided registration token"
|
||||
else
|
||||
# Check if stdin is a terminal (not being piped)
|
||||
if [ -t 0 ]; then
|
||||
echo "Registration token required to enroll this agent with the server."
|
||||
echo ""
|
||||
echo "To get a token:"
|
||||
echo " 1. Visit: ${REDFLAG_SERVER}/settings/tokens"
|
||||
echo " 2. Copy the active token from the list"
|
||||
echo ""
|
||||
echo "Enter registration token (or press Enter to skip):"
|
||||
read -p "> " REGISTRATION_TOKEN
|
||||
else
|
||||
echo ""
|
||||
echo "IMPORTANT: Registration token required!"
|
||||
echo ""
|
||||
echo "Since you're running this via pipe, you need to:"
|
||||
echo ""
|
||||
echo "Option 1 - One-liner with token:"
|
||||
echo " curl -sfL ${REDFLAG_SERVER}/api/v1/install/linux | sudo bash -s -- YOUR_TOKEN"
|
||||
echo ""
|
||||
echo "Option 2 - Download and run interactively:"
|
||||
echo " curl -sfL ${REDFLAG_SERVER}/api/v1/install/linux -o install.sh"
|
||||
echo " chmod +x install.sh"
|
||||
echo " sudo ./install.sh"
|
||||
echo ""
|
||||
echo "Skipping registration for now."
|
||||
echo "Please register manually after installation."
|
||||
fi
|
||||
fi
|
||||
|
||||
# Check if agent is already registered
|
||||
if [ -f "$CONFIG_DIR/config.json" ]; then
|
||||
echo ""
|
||||
echo "[INFO] Agent already registered - configuration file exists"
|
||||
echo "[INFO] Skipping registration to preserve agent history"
|
||||
echo "[INFO] If you need to re-register, delete: $CONFIG_DIR/config.json"
|
||||
echo ""
|
||||
elif [ -n "$REGISTRATION_TOKEN" ]; then
|
||||
echo ""
|
||||
echo "Registering agent..."
|
||||
|
||||
# Create config file and register
|
||||
cat > "$CONFIG_DIR/config.json" <<EOF
|
||||
{
|
||||
"server_url": "${REDFLAG_SERVER}",
|
||||
"registration_token": "${REGISTRATION_TOKEN}"
|
||||
}
|
||||
EOF
|
||||
|
||||
# Set proper permissions
|
||||
chown "$AGENT_USER:$AGENT_USER" "$CONFIG_DIR/config.json"
|
||||
chmod 600 "$CONFIG_DIR/config.json"
|
||||
|
||||
# Run agent registration as the agent user with explicit server and token
|
||||
echo "Running: sudo -u $AGENT_USER ${AGENT_BINARY} --server ${REDFLAG_SERVER} --token $REGISTRATION_TOKEN --register"
|
||||
if sudo -u "$AGENT_USER" "${AGENT_BINARY}" --server "${REDFLAG_SERVER}" --token "$REGISTRATION_TOKEN" --register; then
|
||||
echo "✓ Agent registered successfully"
|
||||
|
||||
# Update config file with the new agent credentials
|
||||
if [ -f "$CONFIG_DIR/config.json" ]; then
|
||||
chown "$AGENT_USER:$AGENT_USER" "$CONFIG_DIR/config.json"
|
||||
chmod 600 "$CONFIG_DIR/config.json"
|
||||
echo "✓ Configuration file updated and secured"
|
||||
fi
|
||||
else
|
||||
echo "ERROR: Agent registration failed"
|
||||
echo "Please check the token and server URL, then try again"
|
||||
echo ""
|
||||
echo "To retry manually:"
|
||||
echo " sudo -u $AGENT_USER ${AGENT_BINARY} --server ${REDFLAG_SERVER} --token $REGISTRATION_TOKEN --register"
|
||||
exit 1
|
||||
fi
|
||||
else
|
||||
echo ""
|
||||
echo "Skipping registration. You'll need to register manually before starting the service."
|
||||
echo ""
|
||||
echo "To register later:"
|
||||
echo " 1. Visit ${REDFLAG_SERVER}/settings/tokens"
|
||||
echo " 2. Copy a registration token"
|
||||
echo " 3. Run: sudo -u $AGENT_USER ${AGENT_BINARY} --server ${REDFLAG_SERVER} --token YOUR_TOKEN"
|
||||
echo ""
|
||||
echo "Installation will continue, but the service will not start until registered."
|
||||
fi
|
||||
|
||||
# Step 7: Enable and start service
|
||||
echo ""
|
||||
echo "Step 7: Enabling and starting service..."
|
||||
systemctl daemon-reload
|
||||
|
||||
# Check if agent is registered
|
||||
if [ -f "$CONFIG_DIR/config.json" ]; then
|
||||
systemctl enable redflag-agent
|
||||
systemctl restart redflag-agent
|
||||
|
||||
# Wait for service to start
|
||||
sleep 2
|
||||
|
||||
if systemctl is-active --quiet redflag-agent; then
|
||||
echo "✓ Service started successfully"
|
||||
else
|
||||
echo "⚠ Service failed to start. Check logs:"
|
||||
echo " sudo journalctl -u redflag-agent -n 50"
|
||||
exit 1
|
||||
fi
|
||||
else
|
||||
echo "⚠ Service not started (agent not registered)"
|
||||
echo " Run registration command above, then:"
|
||||
echo " sudo systemctl enable redflag-agent"
|
||||
echo " sudo systemctl start redflag-agent"
|
||||
fi
|
||||
|
||||
# Step 8: Show status
|
||||
echo ""
|
||||
echo "=== Installation Complete ==="
|
||||
echo ""
|
||||
echo "The RedFlag agent has been installed with the following security features:"
|
||||
echo " ✓ Dedicated system user (redflag-agent)"
|
||||
echo " ✓ Limited sudo access via /etc/sudoers.d/redflag-agent"
|
||||
echo " ✓ Systemd service with security hardening"
|
||||
echo " ✓ Protected configuration directory"
|
||||
echo ""
|
||||
if systemctl is-active --quiet redflag-agent; then
|
||||
echo "Service Status: ✓ RUNNING"
|
||||
echo ""
|
||||
systemctl status redflag-agent --no-pager -l | head -n 15
|
||||
echo ""
|
||||
else
|
||||
echo "Service Status: ⚠ NOT RUNNING (waiting for registration)"
|
||||
echo ""
|
||||
fi
|
||||
echo "Useful commands:"
|
||||
echo " Check status: sudo systemctl status redflag-agent"
|
||||
echo " View logs: sudo journalctl -u redflag-agent -f"
|
||||
echo " Restart: sudo systemctl restart redflag-agent"
|
||||
echo " Stop: sudo systemctl stop redflag-agent"
|
||||
echo ""
|
||||
echo "Configuration:"
|
||||
echo " Config file: $CONFIG_DIR/config.json"
|
||||
echo " Binary: $AGENT_BINARY"
|
||||
echo " Service: $SERVICE_FILE"
|
||||
echo " Sudoers: $SUDOERS_FILE"
|
||||
echo ""
|
||||
`
|
||||
|
||||
case "windows":
|
||||
return `@echo off
|
||||
REM RedFlag Agent Installation Script for Windows
|
||||
REM This script downloads the agent and sets up Windows service
|
||||
REM
|
||||
REM Usage:
|
||||
REM install.bat - Interactive mode (prompts for token)
|
||||
REM install.bat YOUR_TOKEN_HERE - Automatic mode (uses provided token)
|
||||
|
||||
set REDFLAG_SERVER=` + baseURL + `
|
||||
set AGENT_DIR=%ProgramFiles%\RedFlag
|
||||
set AGENT_BINARY=%AGENT_DIR%\redflag-agent.exe
|
||||
set CONFIG_DIR=%ProgramData%\RedFlag
|
||||
|
||||
echo Downloading RedFlag agent from %REDFLAG_SERVER%...
|
||||
curl -sfL "%REDFLAG_SERVER%/api/v1/downloads/windows-amd64" -o redflag-agent.exe
|
||||
echo === RedFlag Agent Installation ===
|
||||
echo.
|
||||
|
||||
echo Agent downloaded. Please visit %REDFLAG_SERVER%/admin to get a registration token.
|
||||
echo Then run: redflag-agent.exe --server %REDFLAG_SERVER% --token <YOUR_TOKEN%`
|
||||
REM Check for admin privileges
|
||||
net session >nul 2>&1
|
||||
if %errorLevel% neq 0 (
|
||||
echo ERROR: This script must be run as Administrator
|
||||
echo Right-click and select "Run as administrator"
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
REM Detect architecture
|
||||
if "%PROCESSOR_ARCHITECTURE%"=="AMD64" (
|
||||
set DOWNLOAD_ARCH=amd64
|
||||
) else if "%PROCESSOR_ARCHITECTURE%"=="ARM64" (
|
||||
set DOWNLOAD_ARCH=arm64
|
||||
) else (
|
||||
echo ERROR: Unsupported architecture: %PROCESSOR_ARCHITECTURE%
|
||||
echo Supported: AMD64, ARM64
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
echo Detected architecture: %PROCESSOR_ARCHITECTURE% (using windows-%DOWNLOAD_ARCH%)
|
||||
echo.
|
||||
|
||||
REM Create installation directory
|
||||
echo Creating installation directory...
|
||||
if not exist "%AGENT_DIR%" mkdir "%AGENT_DIR%"
|
||||
echo [OK] Installation directory created
|
||||
|
||||
REM Create config directory
|
||||
if not exist "%CONFIG_DIR%" mkdir "%CONFIG_DIR%"
|
||||
echo [OK] Configuration directory created
|
||||
|
||||
REM Grant full permissions to SYSTEM and Administrators on config directory
|
||||
echo Setting permissions on configuration directory...
|
||||
icacls "%CONFIG_DIR%" /grant "SYSTEM:(OI)(CI)F"
|
||||
icacls "%CONFIG_DIR%" /grant "Administrators:(OI)(CI)F"
|
||||
echo [OK] Permissions set
|
||||
echo.
|
||||
|
||||
REM Stop existing service if running (to allow binary update)
|
||||
sc query RedFlagAgent >nul 2>&1
|
||||
if %errorLevel% equ 0 (
|
||||
echo Existing service detected - stopping to allow update...
|
||||
sc stop RedFlagAgent >nul 2>&1
|
||||
timeout /t 3 /nobreak >nul
|
||||
echo [OK] Service stopped
|
||||
)
|
||||
|
||||
REM Download agent binary
|
||||
echo Downloading agent binary...
|
||||
echo From: %REDFLAG_SERVER%/api/v1/downloads/windows-%DOWNLOAD_ARCH%
|
||||
curl -sfL "%REDFLAG_SERVER%/api/v1/downloads/windows-%DOWNLOAD_ARCH%" -o "%AGENT_BINARY%"
|
||||
if %errorLevel% neq 0 (
|
||||
echo ERROR: Failed to download agent binary
|
||||
echo Please ensure %REDFLAG_SERVER% is accessible
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
echo [OK] Agent binary downloaded
|
||||
echo.
|
||||
|
||||
REM Agent registration
|
||||
echo === Agent Registration ===
|
||||
echo.
|
||||
|
||||
REM Check if token was provided as command-line argument
|
||||
if not "%1"=="" (
|
||||
set TOKEN=%1
|
||||
echo Using provided registration token
|
||||
) else (
|
||||
echo IMPORTANT: You need a registration token to enroll this agent.
|
||||
echo.
|
||||
echo To get a token:
|
||||
echo 1. Visit: %REDFLAG_SERVER%/settings/tokens
|
||||
echo 2. Create a new registration token
|
||||
echo 3. Copy the token
|
||||
echo.
|
||||
set /p TOKEN="Enter registration token (or press Enter to skip): "
|
||||
)
|
||||
|
||||
REM Check if agent is already registered
|
||||
if exist "%CONFIG_DIR%\config.json" (
|
||||
echo.
|
||||
echo [INFO] Agent already registered - configuration file exists
|
||||
echo [INFO] Skipping registration to preserve agent history
|
||||
echo [INFO] If you need to re-register, delete: %CONFIG_DIR%\config.json
|
||||
echo.
|
||||
) else if not "%TOKEN%"=="" (
|
||||
echo.
|
||||
echo === Registering Agent ===
|
||||
echo.
|
||||
|
||||
REM Attempt registration
|
||||
"%AGENT_BINARY%" --server "%REDFLAG_SERVER%" --token "%TOKEN%" --register
|
||||
|
||||
REM Check exit code
|
||||
if %errorLevel% equ 0 (
|
||||
echo [OK] Agent registered successfully
|
||||
echo [OK] Configuration saved to: %CONFIG_DIR%\config.json
|
||||
echo.
|
||||
) else (
|
||||
echo.
|
||||
echo [ERROR] Registration failed
|
||||
echo.
|
||||
echo Please check:
|
||||
echo 1. Server is accessible: %REDFLAG_SERVER%
|
||||
echo 2. Registration token is valid and not expired
|
||||
echo 3. Token has available seats remaining
|
||||
echo.
|
||||
echo To try again:
|
||||
echo "%AGENT_BINARY%" --server "%REDFLAG_SERVER%" --token "%TOKEN%" --register
|
||||
echo.
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
) else (
|
||||
echo.
|
||||
echo [INFO] No registration token provided - skipping registration
|
||||
echo.
|
||||
echo To register later:
|
||||
echo "%AGENT_BINARY%" --server "%REDFLAG_SERVER%" --token YOUR_TOKEN --register
|
||||
)
|
||||
|
||||
REM Check if service already exists
|
||||
echo.
|
||||
echo === Configuring Windows Service ===
|
||||
echo.
|
||||
sc query RedFlagAgent >nul 2>&1
|
||||
if %errorLevel% equ 0 (
|
||||
echo [INFO] RedFlag Agent service already installed
|
||||
echo [INFO] Service will be restarted with updated binary
|
||||
echo.
|
||||
) else (
|
||||
echo Installing RedFlag Agent service...
|
||||
"%AGENT_BINARY%" -install-service
|
||||
if %errorLevel% equ 0 (
|
||||
echo [OK] Service installed successfully
|
||||
echo.
|
||||
|
||||
REM Give Windows SCM time to register the service
|
||||
timeout /t 2 /nobreak >nul
|
||||
) else (
|
||||
echo [ERROR] Failed to install service
|
||||
echo.
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
)
|
||||
|
||||
REM Start the service if agent is registered
|
||||
if exist "%CONFIG_DIR%\config.json" (
|
||||
echo Starting RedFlag Agent service...
|
||||
"%AGENT_BINARY%" -start-service
|
||||
if %errorLevel% equ 0 (
|
||||
echo [OK] RedFlag Agent service started
|
||||
echo.
|
||||
echo Agent is now running as a Windows service in the background.
|
||||
echo You can verify it is working by checking the agent status in the web UI.
|
||||
) else (
|
||||
echo [WARNING] Failed to start service. You can start it manually:
|
||||
echo "%AGENT_BINARY%" -start-service
|
||||
echo Or use Windows Services: services.msc
|
||||
)
|
||||
) else (
|
||||
echo [WARNING] Service not started (agent not registered)
|
||||
echo To register and start the service:
|
||||
echo 1. Register: "%AGENT_BINARY%" --server "%REDFLAG_SERVER%" --token YOUR_TOKEN --register
|
||||
echo 2. Start: "%AGENT_BINARY%" -start-service
|
||||
)
|
||||
|
||||
echo.
|
||||
echo === Installation Complete ===
|
||||
echo.
|
||||
echo The RedFlag agent has been installed as a Windows service.
|
||||
echo Configuration file: %CONFIG_DIR%\config.json
|
||||
echo Agent binary: %AGENT_BINARY%
|
||||
echo.
|
||||
echo Managing the RedFlag Agent service:
|
||||
echo Check status: "%AGENT_BINARY%" -service-status
|
||||
echo Start manually: "%AGENT_BINARY%" -start-service
|
||||
echo Stop service: "%AGENT_BINARY%" -stop-service
|
||||
echo Remove service: "%AGENT_BINARY%" -remove-service
|
||||
echo.
|
||||
echo Alternative management with Windows Services:
|
||||
echo Open services.msc and look for "RedFlag Update Agent"
|
||||
echo.
|
||||
echo To run the agent directly (for debugging):
|
||||
echo "%AGENT_BINARY%"
|
||||
echo.
|
||||
echo To verify the agent is working:
|
||||
echo 1. Check the web UI for the agent status
|
||||
echo 2. Look for recent check-ins from this machine
|
||||
echo.
|
||||
pause
|
||||
`
|
||||
|
||||
default:
|
||||
return "# Unsupported platform"
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"github.com/Fimeg/RedFlag/aggregator-server/internal/config"
|
||||
"github.com/Fimeg/RedFlag/aggregator-server/internal/database/queries"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
type RegistrationTokenHandler struct {
|
||||
@@ -29,6 +30,7 @@ func (h *RegistrationTokenHandler) GenerateRegistrationToken(c *gin.Context) {
|
||||
var request struct {
|
||||
Label string `json:"label" binding:"required"`
|
||||
ExpiresIn string `json:"expires_in"` // e.g., "24h", "7d", "168h"
|
||||
MaxSeats int `json:"max_seats"` // Number of agents that can use this token
|
||||
Metadata map[string]interface{} `json:"metadata"`
|
||||
}
|
||||
|
||||
@@ -86,8 +88,14 @@ func (h *RegistrationTokenHandler) GenerateRegistrationToken(c *gin.Context) {
|
||||
metadata["server_url"] = c.Request.Host
|
||||
metadata["expires_in"] = expiresIn
|
||||
|
||||
// Default max_seats to 1 if not provided or invalid
|
||||
maxSeats := request.MaxSeats
|
||||
if maxSeats < 1 {
|
||||
maxSeats = 1
|
||||
}
|
||||
|
||||
// Store token in database
|
||||
err = h.tokenQueries.CreateRegistrationToken(token, request.Label, expiresAt, metadata)
|
||||
err = h.tokenQueries.CreateRegistrationToken(token, request.Label, expiresAt, maxSeats, metadata)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create token"})
|
||||
return
|
||||
@@ -117,6 +125,7 @@ func (h *RegistrationTokenHandler) ListRegistrationTokens(c *gin.Context) {
|
||||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "50"))
|
||||
status := c.Query("status")
|
||||
isActive := c.Query("is_active") == "true"
|
||||
|
||||
// Validate pagination
|
||||
if limit > 100 {
|
||||
@@ -131,10 +140,26 @@ func (h *RegistrationTokenHandler) ListRegistrationTokens(c *gin.Context) {
|
||||
var tokens []queries.RegistrationToken
|
||||
var err error
|
||||
|
||||
if status != "" {
|
||||
// TODO: Add filtered queries by status
|
||||
tokens, err = h.tokenQueries.GetAllRegistrationTokens(limit, offset)
|
||||
// Handle filtering by active status
|
||||
if isActive || status == "active" {
|
||||
// Get only active tokens (no pagination for active-only queries)
|
||||
tokens, err = h.tokenQueries.GetActiveRegistrationTokens()
|
||||
|
||||
// Apply manual pagination to active tokens if needed
|
||||
if err == nil && len(tokens) > 0 {
|
||||
start := offset
|
||||
end := offset + limit
|
||||
if start >= len(tokens) {
|
||||
tokens = []queries.RegistrationToken{}
|
||||
} else {
|
||||
if end > len(tokens) {
|
||||
end = len(tokens)
|
||||
}
|
||||
tokens = tokens[start:end]
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Get all tokens with database-level pagination
|
||||
tokens, err = h.tokenQueries.GetAllRegistrationTokens(limit, offset)
|
||||
}
|
||||
|
||||
@@ -213,6 +238,34 @@ func (h *RegistrationTokenHandler) RevokeRegistrationToken(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"message": "Token revoked successfully"})
|
||||
}
|
||||
|
||||
// DeleteRegistrationToken permanently deletes a registration token
|
||||
func (h *RegistrationTokenHandler) DeleteRegistrationToken(c *gin.Context) {
|
||||
tokenID := c.Param("id")
|
||||
if tokenID == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Token ID is required"})
|
||||
return
|
||||
}
|
||||
|
||||
// Parse UUID
|
||||
id, err := uuid.Parse(tokenID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid token ID format"})
|
||||
return
|
||||
}
|
||||
|
||||
err = h.tokenQueries.DeleteRegistrationToken(id)
|
||||
if err != nil {
|
||||
if err.Error() == "token not found" {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Token not found"})
|
||||
} else {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete token"})
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"message": "Token deleted successfully"})
|
||||
}
|
||||
|
||||
// ValidateRegistrationToken checks if a token is valid (for testing/debugging)
|
||||
func (h *RegistrationTokenHandler) ValidateRegistrationToken(c *gin.Context) {
|
||||
token := c.Query("token")
|
||||
|
||||
@@ -2,16 +2,15 @@ package handlers
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"database/sql"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/lib/pq"
|
||||
_ "github.com/lib/pq"
|
||||
)
|
||||
|
||||
// SetupHandler handles server configuration
|
||||
@@ -25,8 +24,81 @@ func NewSetupHandler(configPath string) *SetupHandler {
|
||||
}
|
||||
}
|
||||
|
||||
// updatePostgresPassword updates the PostgreSQL user password
|
||||
func updatePostgresPassword(dbHost, dbPort, dbUser, currentPassword, newPassword string) error {
|
||||
// Connect to PostgreSQL with current credentials
|
||||
connStr := fmt.Sprintf("postgres://%s:%s@%s:%s/postgres?sslmode=disable", dbUser, currentPassword, dbHost, dbPort)
|
||||
|
||||
db, err := sql.Open("postgres", connStr)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to connect to PostgreSQL: %v", err)
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
// Test connection
|
||||
if err := db.Ping(); err != nil {
|
||||
return fmt.Errorf("failed to ping PostgreSQL: %v", err)
|
||||
}
|
||||
|
||||
// Update the password
|
||||
_, err = db.Exec("ALTER USER "+pq.QuoteIdentifier(dbUser)+" PASSWORD '"+newPassword+"'")
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to update PostgreSQL password: %v", err)
|
||||
}
|
||||
|
||||
fmt.Println("PostgreSQL password updated successfully")
|
||||
return nil
|
||||
}
|
||||
|
||||
// createSharedEnvContentForDisplay generates the .env file content for display
|
||||
func createSharedEnvContentForDisplay(req struct {
|
||||
AdminUser string `json:"adminUser"`
|
||||
AdminPass string `json:"adminPassword"`
|
||||
DBHost string `json:"dbHost"`
|
||||
DBPort string `json:"dbPort"`
|
||||
DBName string `json:"dbName"`
|
||||
DBUser string `json:"dbUser"`
|
||||
DBPassword string `json:"dbPassword"`
|
||||
ServerHost string `json:"serverHost"`
|
||||
ServerPort string `json:"serverPort"`
|
||||
MaxSeats string `json:"maxSeats"`
|
||||
}, jwtSecret string) (string, error) {
|
||||
// Generate .env file content for user to copy
|
||||
envContent := fmt.Sprintf(`# RedFlag Environment Configuration
|
||||
# Generated by web setup - Save this content to ./config/.env
|
||||
|
||||
# PostgreSQL Configuration (for PostgreSQL container)
|
||||
POSTGRES_DB=%s
|
||||
POSTGRES_USER=%s
|
||||
POSTGRES_PASSWORD=%s
|
||||
|
||||
# RedFlag Server Configuration
|
||||
REDFLAG_SERVER_HOST=%s
|
||||
REDFLAG_SERVER_PORT=%s
|
||||
REDFLAG_DB_HOST=%s
|
||||
REDFLAG_DB_PORT=%s
|
||||
REDFLAG_DB_NAME=%s
|
||||
REDFLAG_DB_USER=%s
|
||||
REDFLAG_DB_PASSWORD=%s
|
||||
REDFLAG_ADMIN_USER=%s
|
||||
REDFLAG_ADMIN_PASSWORD=%s
|
||||
REDFLAG_JWT_SECRET=%s
|
||||
REDFLAG_TOKEN_EXPIRY=24h
|
||||
REDFLAG_MAX_TOKENS=100
|
||||
REDFLAG_MAX_SEATS=%s`,
|
||||
req.DBName, req.DBUser, req.DBPassword,
|
||||
req.ServerHost, req.ServerPort,
|
||||
req.DBHost, req.DBPort, req.DBName, req.DBUser, req.DBPassword,
|
||||
req.AdminUser, req.AdminPass, jwtSecret, req.MaxSeats)
|
||||
|
||||
return envContent, nil
|
||||
}
|
||||
|
||||
// ShowSetupPage displays the web setup interface
|
||||
func (h *SetupHandler) ShowSetupPage(c *gin.Context) {
|
||||
// Display setup page - configuration will be generated via web interface
|
||||
fmt.Println("Showing setup page - configuration will be generated via web interface")
|
||||
|
||||
html := `
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
@@ -46,19 +118,16 @@ func (h *SetupHandler) ShowSetupPage(c *gin.Context) {
|
||||
.form-section h3 { color: #4f46e5; margin-bottom: 15px; font-size: 1.2rem; }
|
||||
.form-group { margin-bottom: 20px; }
|
||||
label { display: block; margin-bottom: 5px; font-weight: 500; color: #374151; }
|
||||
input, select { width: 100%; padding: 12px; border: 2px solid #e5e7eb; border-radius: 6px; font-size: 1rem; transition: border-color 0.3s; }
|
||||
input, select { width: 100%%; padding: 12px; border: 2px solid #e5e7eb; border-radius: 6px; font-size: 1rem; transition: border-color 0.3s; }
|
||||
input:focus, select:focus { outline: none; border-color: #4f46e5; box-shadow: 0 0 0 3px rgba(79, 70, 229, 0.1); }
|
||||
input[type="password"] { font-family: monospace; }
|
||||
.button { background: linear-gradient(135deg, #4f46e5 0%, #7c3aed 100%); color: white; border: none; padding: 14px 28px; border-radius: 6px; font-size: 1rem; font-weight: 600; cursor: pointer; transition: transform 0.2s; }
|
||||
.button:hover { transform: translateY(-1px); }
|
||||
.button:active { transform: translateY(0); }
|
||||
.progress { background: #f3f4f6; border-radius: 6px; height: 8px; overflow: hidden; margin: 20px 0; }
|
||||
.progress-bar { background: linear-gradient(90deg, #4f46e5, #7c3aed); height: 100%; width: 0%; transition: width 0.3s; }
|
||||
.status { text-align: center; padding: 20px; display: none; }
|
||||
.error { background: #fef2f2; color: #dc2626; padding: 15px; border-radius: 6px; margin: 20px 0; border: 1px solid #fecaca; }
|
||||
.success { background: #f0fdf4; color: #16a34a; padding: 15px; border-radius: 6px; margin: 20px 0; border: 1px solid #bbf7d0; }
|
||||
.grid { display: grid; grid-template-columns: 1fr 1fr; gap: 20px; }
|
||||
@media (max-width: 768px) { .grid { grid-template-columns: 1fr; } }
|
||||
.btn { background: linear-gradient(135deg, #4f46e5 0%%, #7c3aed 100%%); color: white; border: none; padding: 14px 28px; border-radius: 6px; font-size: 1rem; font-weight: 600; cursor: pointer; transition: transform 0.2s; }
|
||||
.btn:hover { transform: translateY(-2px); }
|
||||
.btn:disabled { opacity: 0.6; cursor: not-allowed; transform: none; }
|
||||
.success { color: #10b981; background: #ecfdf5; padding: 12px; border-radius: 6px; border: 1px solid #10b981; }
|
||||
.error { color: #ef4444; background: #fef2f2; padding: 12px; border-radius: 6px; border: 1px solid #ef4444; }
|
||||
.loading { display: none; text-align: center; margin: 20px 0; }
|
||||
.spinner { border: 3px solid #f3f3f3; border-top: 3px solid #4f46e5; border-radius: 50%%; width: 40px; height: 40px; animation: spin 1s linear infinite; margin: 0 auto; }
|
||||
@keyframes spin { 0%% { transform: rotate(0deg); } 100%% { transform: rotate(360deg); } }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
@@ -66,76 +135,78 @@ func (h *SetupHandler) ShowSetupPage(c *gin.Context) {
|
||||
<div class="card">
|
||||
<div class="header">
|
||||
<h1>🚀 RedFlag Server Setup</h1>
|
||||
<p class="subtitle">Configure your update management server</p>
|
||||
<p class="subtitle">Configure your RedFlag deployment</p>
|
||||
</div>
|
||||
<div class="content">
|
||||
<form id="setupForm">
|
||||
<div class="form-section">
|
||||
<h3>🔐 Admin Account</h3>
|
||||
<div class="grid">
|
||||
<div class="form-group">
|
||||
<label for="adminUser">Admin Username</label>
|
||||
<input type="text" id="adminUser" name="adminUser" value="admin" required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="adminPassword">Admin Password</label>
|
||||
<input type="password" id="adminPassword" name="adminPassword" required>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-section">
|
||||
<h3>💾 Database Configuration</h3>
|
||||
<div class="grid">
|
||||
<div class="form-group">
|
||||
<label for="dbHost">Database Host</label>
|
||||
<input type="text" id="dbHost" name="dbHost" value="postgres" required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="dbPort">Database Port</label>
|
||||
<input type="number" id="dbPort" name="dbPort" value="5432" required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="dbName">Database Name</label>
|
||||
<input type="text" id="dbName" name="dbName" value="redflag" required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="dbUser">Database User</label>
|
||||
<input type="text" id="dbUser" name="dbUser" value="redflag" required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="dbPassword">Database Password</label>
|
||||
<input type="password" id="dbPassword" name="dbPassword" value="redflag" required>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-section">
|
||||
<h3>🌐 Server Configuration</h3>
|
||||
<div class="grid">
|
||||
<div class="form-group">
|
||||
<label for="serverHost">Server Host</label>
|
||||
<input type="text" id="serverHost" name="serverHost" value="0.0.0.0" required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="serverPort">Server Port</label>
|
||||
<input type="number" id="serverPort" name="serverPort" value="8080" required>
|
||||
</div>
|
||||
<h3>📊 Server Configuration</h3>
|
||||
<div class="form-group">
|
||||
<label for="serverHost">Server Host</label>
|
||||
<input type="text" id="serverHost" name="serverHost" value="0.0.0.0" required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="maxSeats">Maximum Agent Seats</label>
|
||||
<input type="number" id="maxSeats" name="maxSeats" value="50" min="1" max="1000">
|
||||
<label for="serverPort">Server Port</label>
|
||||
<input type="number" id="serverPort" name="serverPort" value="8080" required>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="progress" id="progress" style="display: none;">
|
||||
<div class="progress-bar" id="progressBar"></div>
|
||||
<div class="form-section">
|
||||
<h3>🗄️ Database Configuration</h3>
|
||||
<div class="form-group">
|
||||
<label for="dbHost">Database Host</label>
|
||||
<input type="text" id="dbHost" name="dbHost" value="postgres" required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="dbPort">Database Port</label>
|
||||
<input type="number" id="dbPort" name="dbPort" value="5432" required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="dbName">Database Name</label>
|
||||
<input type="text" id="dbName" name="dbName" value="redflag" required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="dbUser">Database User</label>
|
||||
<input type="text" id="dbUser" name="dbUser" value="redflag" required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="dbPassword">Database Password</label>
|
||||
<input type="password" id="dbPassword" name="dbPassword" placeholder="Enter a secure database password" required>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="status" class="status"></div>
|
||||
<div class="form-section">
|
||||
<h3>👤 Administrator Account</h3>
|
||||
<div class="form-group">
|
||||
<label for="adminUser">Admin Username</label>
|
||||
<input type="text" id="adminUser" name="adminUser" value="admin" required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="adminPassword">Admin Password</label>
|
||||
<input type="password" id="adminPassword" name="adminPassword" placeholder="Enter a secure admin password" required>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="button">Configure Server</button>
|
||||
<div class="form-section">
|
||||
<h3>🔧 Agent Settings</h3>
|
||||
<div class="form-group">
|
||||
<label for="maxSeats">Maximum Agent Seats</label>
|
||||
<input type="number" id="maxSeats" name="maxSeats" value="50" min="1" max="1000" required>
|
||||
<small style="color: #6b7280; font-size: 0.875rem;">Maximum number of agents that can register</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="btn" id="submitBtn">
|
||||
🚀 Configure RedFlag Server
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div class="loading" id="loading">
|
||||
<div class="spinner"></div>
|
||||
<p>Configuring your RedFlag server...</p>
|
||||
</div>
|
||||
|
||||
<div id="result"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -144,56 +215,113 @@ func (h *SetupHandler) ShowSetupPage(c *gin.Context) {
|
||||
document.getElementById('setupForm').addEventListener('submit', async function(e) {
|
||||
e.preventDefault();
|
||||
|
||||
const formData = new FormData(e.target);
|
||||
const data = Object.fromEntries(formData.entries());
|
||||
const submitBtn = document.getElementById('submitBtn');
|
||||
const loading = document.getElementById('loading');
|
||||
const result = document.getElementById('result');
|
||||
|
||||
const progress = document.getElementById('progress');
|
||||
const progressBar = document.getElementById('progressBar');
|
||||
const status = document.getElementById('status');
|
||||
const submitButton = e.target.querySelector('button[type="submit"]');
|
||||
// Get form values
|
||||
const formData = {
|
||||
serverHost: document.getElementById('serverHost').value,
|
||||
serverPort: document.getElementById('serverPort').value,
|
||||
dbHost: document.getElementById('dbHost').value,
|
||||
dbPort: document.getElementById('dbPort').value,
|
||||
dbName: document.getElementById('dbName').value,
|
||||
dbUser: document.getElementById('dbUser').value,
|
||||
dbPassword: document.getElementById('dbPassword').value,
|
||||
adminUser: document.getElementById('adminUser').value,
|
||||
adminPassword: document.getElementById('adminPassword').value,
|
||||
maxSeats: document.getElementById('maxSeats').value
|
||||
};
|
||||
|
||||
// Show progress and disable button
|
||||
progress.style.display = 'block';
|
||||
submitButton.disabled = true;
|
||||
submitButton.textContent = 'Configuring...';
|
||||
// Validate inputs
|
||||
if (!formData.adminUser || !formData.adminPassword) {
|
||||
result.innerHTML = '<div class="error">❌ Admin username and password are required</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
if (!formData.dbHost || !formData.dbPort || !formData.dbName || !formData.dbUser || !formData.dbPassword) {
|
||||
result.innerHTML = '<div class="error">❌ All database fields are required</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
// Show loading
|
||||
submitBtn.disabled = true;
|
||||
loading.style.display = 'block';
|
||||
result.innerHTML = '';
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/v1/setup', {
|
||||
const response = await fetch('/api/setup/configure', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(data)
|
||||
body: JSON.stringify(formData)
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
const resultData = await response.json();
|
||||
|
||||
if (response.ok) {
|
||||
// Success
|
||||
progressBar.style.width = '100%';
|
||||
status.innerHTML = '<div class="success">✅ ' + result.message + '</div>';
|
||||
submitButton.textContent = 'Configuration Complete';
|
||||
let resultHtml = '<div class="success">';
|
||||
resultHtml += '<h3>✅ Configuration Generated Successfully!</h3>';
|
||||
resultHtml += '<p><strong>Your JWT Secret:</strong> <code style="background: #f3f4f6; padding: 2px 6px; border-radius: 3px;">' + resultData.jwtSecret + '</code> ';
|
||||
resultHtml += '<button onclick="copyJWT(\'' + resultData.jwtSecret + '\')" style="background: #4f46e5; color: white; border: none; padding: 4px 8px; border-radius: 3px; cursor: pointer; font-size: 0.8rem;">📋 Copy</button></p>';
|
||||
resultHtml += '<p><strong>⚠️ Important Next Steps:</strong></p>';
|
||||
resultHtml += '<div style="background: #fef3c7; border: 1px solid #f59e0b; border-radius: 6px; padding: 15px; margin: 15px 0;">';
|
||||
resultHtml += '<p style="margin: 0; color: #92400e;"><strong>🔧 Complete Setup Required:</strong></p>';
|
||||
resultHtml += '<ol style="margin: 10px 0 0 0; color: #92400e;">';
|
||||
resultHtml += '<li>Replace the bootstrap environment variables with the newly generated ones below</li>';
|
||||
resultHtml += '<li>Run: <code style="background: #fef3c7; padding: 2px 6px; border-radius: 3px;">' + resultData.manualRestartCommand + '</code></li>';
|
||||
resultHtml += '</ol>';
|
||||
resultHtml += '<p style="margin: 10px 0 0 0; color: #92400e; font-size: 0.9rem;"><strong>This step is required to apply your configuration and run database migrations.</strong></p>';
|
||||
resultHtml += '</div>';
|
||||
resultHtml += '</div>';
|
||||
|
||||
resultHtml += '<div style="margin-top: 20px;">';
|
||||
resultHtml += '<h4>📄 Configuration Content:</h4>';
|
||||
resultHtml += '<textarea readonly style="width: 100%%; height: 300px; font-family: monospace; font-size: 0.85rem; padding: 10px; border: 1px solid #d1d5db; border-radius: 6px; background: #f9fafb;">' + resultData.envContent + '</textarea>';
|
||||
resultHtml += '<button onclick="copyConfig()" style="background: #10b981; color: white; border: none; padding: 8px 16px; border-radius: 6px; cursor: pointer; margin-top: 10px;">📋 Copy All Configuration</button>';
|
||||
resultHtml += '</div>';
|
||||
|
||||
result.innerHTML = resultHtml;
|
||||
loading.style.display = 'none';
|
||||
|
||||
// Store JWT for copy function
|
||||
window.jwtSecret = resultData.jwtSecret;
|
||||
window.envContent = resultData.envContent;
|
||||
|
||||
// Redirect to admin interface after delay
|
||||
setTimeout(() => {
|
||||
window.location.href = '/admin';
|
||||
}, 3000);
|
||||
} else {
|
||||
// Error
|
||||
status.innerHTML = '<div class="error">❌ ' + result.error + '</div>';
|
||||
submitButton.disabled = false;
|
||||
submitButton.textContent = 'Configure Server';
|
||||
result.innerHTML = '<div class="error">❌ Error: ' + resultData.error + '</div>';
|
||||
submitBtn.disabled = false;
|
||||
loading.style.display = 'none';
|
||||
}
|
||||
} catch (error) {
|
||||
status.innerHTML = '<div class="error">❌ Network error: ' + error.message + '</div>';
|
||||
submitButton.disabled = false;
|
||||
submitButton.textContent = 'Configure Server';
|
||||
result.innerHTML = '<div class="error">❌ Network error: ' + error.message + '</div>';
|
||||
submitBtn.disabled = false;
|
||||
loading.style.display = 'none';
|
||||
}
|
||||
});
|
||||
|
||||
function copyJWT(jwt) {
|
||||
navigator.clipboard.writeText(jwt).then(() => {
|
||||
alert('JWT secret copied to clipboard!');
|
||||
}).catch(() => {
|
||||
prompt('Copy this JWT secret:', jwt);
|
||||
});
|
||||
}
|
||||
|
||||
function copyConfig() {
|
||||
if (window.envContent) {
|
||||
navigator.clipboard.writeText(window.envContent).then(() => {
|
||||
alert('Configuration copied to clipboard!');
|
||||
}).catch(() => {
|
||||
prompt('Copy this configuration:', window.envContent);
|
||||
});
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>`
|
||||
c.Data(200, "text/html; charset=utf-8", []byte(html))
|
||||
c.Data(http.StatusOK, "text/html; charset=utf-8", []byte(html))
|
||||
}
|
||||
|
||||
// ConfigureServer handles the configuration submission
|
||||
@@ -246,95 +374,36 @@ func (h *SetupHandler) ConfigureServer(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// Create configuration content
|
||||
envContent := fmt.Sprintf(`# RedFlag Server Configuration
|
||||
# Generated by web setup
|
||||
// Generate JWT secret for display (not logged for security)
|
||||
jwtSecret := deriveJWTSecret(req.AdminUser, req.AdminPass)
|
||||
|
||||
# Server Configuration
|
||||
REDFLAG_SERVER_HOST=%s
|
||||
REDFLAG_SERVER_PORT=%d
|
||||
REDFLAG_TLS_ENABLED=false
|
||||
# REDFLAG_TLS_CERT_FILE=
|
||||
# REDFLAG_TLS_KEY_FILE=
|
||||
|
||||
# Database Configuration
|
||||
REDFLAG_DB_HOST=%s
|
||||
REDFLAG_DB_PORT=%d
|
||||
REDFLAG_DB_NAME=%s
|
||||
REDFLAG_DB_USER=%s
|
||||
REDFLAG_DB_PASSWORD=%s
|
||||
|
||||
# Admin Configuration
|
||||
REDFLAG_ADMIN_USER=%s
|
||||
REDFLAG_ADMIN_PASSWORD=%s
|
||||
REDFLAG_JWT_SECRET=%s
|
||||
|
||||
# Agent Registration
|
||||
REDFLAG_TOKEN_EXPIRY=24h
|
||||
REDFLAG_MAX_TOKENS=100
|
||||
REDFLAG_MAX_SEATS=%d
|
||||
|
||||
# Legacy Configuration (for backwards compatibility)
|
||||
SERVER_PORT=%d
|
||||
DATABASE_URL=postgres://%s:%s@%s:%d/%s?sslmode=disable
|
||||
JWT_SECRET=%s
|
||||
CHECK_IN_INTERVAL=300
|
||||
OFFLINE_THRESHOLD=600
|
||||
TIMEZONE=UTC
|
||||
LATEST_AGENT_VERSION=0.1.16`,
|
||||
req.ServerHost, serverPort,
|
||||
req.DBHost, dbPort, req.DBName, req.DBUser, req.DBPassword,
|
||||
req.AdminUser, req.AdminPass, deriveJWTSecret(req.AdminUser, req.AdminPass),
|
||||
maxSeats,
|
||||
serverPort, req.DBUser, req.DBPassword, req.DBHost, dbPort, req.DBName, deriveJWTSecret(req.AdminUser, req.AdminPass))
|
||||
|
||||
// Write configuration to persistent location
|
||||
configDir := "/app/config"
|
||||
if err := os.MkdirAll(configDir, 0755); err != nil {
|
||||
fmt.Printf("Failed to create config directory: %v\n", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("Failed to create config directory: %v", err)})
|
||||
return
|
||||
// Step 1: Update PostgreSQL password from bootstrap to user password
|
||||
fmt.Println("Updating PostgreSQL password from bootstrap to user-provided password...")
|
||||
bootstrapPassword := "redflag_bootstrap" // This matches our bootstrap .env
|
||||
if err := updatePostgresPassword(req.DBHost, req.DBPort, req.DBUser, bootstrapPassword, req.DBPassword); err != nil {
|
||||
fmt.Printf("Warning: Failed to update PostgreSQL password: %v\n", err)
|
||||
fmt.Println("Will proceed with configuration anyway...")
|
||||
}
|
||||
|
||||
envPath := filepath.Join(configDir, ".env")
|
||||
if err := os.WriteFile(envPath, []byte(envContent), 0600); err != nil {
|
||||
fmt.Printf("Failed to save configuration: %v\n", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("Failed to save configuration: %v", err)})
|
||||
// Step 2: Generate configuration content for manual update
|
||||
fmt.Println("Generating configuration content for manual .env file update...")
|
||||
|
||||
// Generate the complete .env file content for the user to copy
|
||||
newEnvContent, err := createSharedEnvContentForDisplay(req, jwtSecret)
|
||||
if err != nil {
|
||||
fmt.Printf("Failed to generate .env content: %v\n", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to generate configuration content"})
|
||||
return
|
||||
}
|
||||
|
||||
// Trigger graceful server restart after configuration
|
||||
go func() {
|
||||
time.Sleep(2 * time.Second) // Give response time to reach client
|
||||
|
||||
// Get the current executable path
|
||||
execPath, err := os.Executable()
|
||||
if err != nil {
|
||||
fmt.Printf("Failed to get executable path: %v\n", err)
|
||||
return
|
||||
}
|
||||
|
||||
// Restart the server with the same executable
|
||||
cmd := exec.Command(execPath)
|
||||
cmd.Stdout = os.Stdout
|
||||
cmd.Stderr = os.Stderr
|
||||
cmd.Stdin = os.Stdin
|
||||
|
||||
// Start the new process
|
||||
if err := cmd.Start(); err != nil {
|
||||
fmt.Printf("Failed to start new server process: %v\n", err)
|
||||
return
|
||||
}
|
||||
|
||||
// Exit the current process gracefully
|
||||
fmt.Printf("Server restarting... PID: %d\n", cmd.Process.Pid)
|
||||
os.Exit(0)
|
||||
}()
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"message": "Configuration saved successfully! Server will restart automatically.",
|
||||
"configPath": envPath,
|
||||
"restart": true,
|
||||
"message": "Configuration generated successfully!",
|
||||
"jwtSecret": jwtSecret,
|
||||
"envContent": newEnvContent,
|
||||
"restartMessage": "Please replace the bootstrap environment variables with the newly generated ones, then run: docker-compose down && docker-compose up -d",
|
||||
"manualRestartRequired": true,
|
||||
"manualRestartCommand": "docker-compose down && docker-compose up -d",
|
||||
"configFilePath": "./config/.env",
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -6,24 +6,19 @@ import (
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/joho/godotenv"
|
||||
"golang.org/x/term"
|
||||
)
|
||||
|
||||
// Config holds the application configuration
|
||||
type Config struct {
|
||||
Server struct {
|
||||
Host string `env:"REDFLAG_SERVER_HOST" default:"0.0.0.0"`
|
||||
Port int `env:"REDFLAG_SERVER_PORT" default:"8080"`
|
||||
TLS struct {
|
||||
Enabled bool `env:"REDFLAG_TLS_ENABLED" default:"false"`
|
||||
CertFile string `env:"REDFLAG_TLS_CERT_FILE"`
|
||||
KeyFile string `env:"REDFLAG_TLS_KEY_FILE"`
|
||||
Host string `env:"REDFLAG_SERVER_HOST" default:"0.0.0.0"`
|
||||
Port int `env:"REDFLAG_SERVER_PORT" default:"8080"`
|
||||
PublicURL string `env:"REDFLAG_PUBLIC_URL"` // Optional: External URL for reverse proxy/load balancer
|
||||
TLS struct {
|
||||
Enabled bool `env:"REDFLAG_TLS_ENABLED" default:"false"`
|
||||
CertFile string `env:"REDFLAG_TLS_CERT_FILE"`
|
||||
KeyFile string `env:"REDFLAG_TLS_KEY_FILE"`
|
||||
}
|
||||
}
|
||||
Database struct {
|
||||
@@ -49,17 +44,9 @@ type Config struct {
|
||||
LatestAgentVersion string
|
||||
}
|
||||
|
||||
// Load reads configuration from environment variables
|
||||
// Load reads configuration from environment variables only (immutable configuration)
|
||||
func Load() (*Config, error) {
|
||||
// Load .env file from persistent config directory
|
||||
configPaths := []string{"/app/config/.env", ".env"}
|
||||
|
||||
for _, path := range configPaths {
|
||||
if _, err := os.Stat(path); err == nil {
|
||||
_ = godotenv.Load(path)
|
||||
break
|
||||
}
|
||||
}
|
||||
fmt.Printf("[CONFIG] Loading configuration from environment variables\n")
|
||||
|
||||
cfg := &Config{}
|
||||
|
||||
@@ -67,6 +54,7 @@ func Load() (*Config, error) {
|
||||
cfg.Server.Host = getEnv("REDFLAG_SERVER_HOST", "0.0.0.0")
|
||||
serverPort, _ := strconv.Atoi(getEnv("REDFLAG_SERVER_PORT", "8080"))
|
||||
cfg.Server.Port = serverPort
|
||||
cfg.Server.PublicURL = getEnv("REDFLAG_PUBLIC_URL", "") // Optional external URL
|
||||
cfg.Server.TLS.Enabled = getEnv("REDFLAG_TLS_ENABLED", "false") == "true"
|
||||
cfg.Server.TLS.CertFile = getEnv("REDFLAG_TLS_CERT_FILE", "")
|
||||
cfg.Server.TLS.KeyFile = getEnv("REDFLAG_TLS_KEY_FILE", "")
|
||||
@@ -106,6 +94,13 @@ func Load() (*Config, error) {
|
||||
return nil, fmt.Errorf("missing required configuration")
|
||||
}
|
||||
|
||||
// Check if we're using bootstrap defaults that need to be replaced
|
||||
if cfg.Admin.Password == "changeme" || cfg.Admin.JWTSecret == "bootstrap-jwt-secret-replace-in-setup" || cfg.Database.Password == "redflag_bootstrap" {
|
||||
fmt.Printf("[INFO] Server running with bootstrap configuration - setup required\n")
|
||||
fmt.Printf("[INFO] Configure via web interface at: http://localhost:8080/setup\n")
|
||||
return nil, fmt.Errorf("bootstrap configuration detected - setup required")
|
||||
}
|
||||
|
||||
// Validate JWT secret is not the development default
|
||||
if cfg.Admin.JWTSecret == "test-secret-for-development-only" {
|
||||
fmt.Printf("[SECURITY WARNING] Using development JWT secret\n")
|
||||
@@ -115,103 +110,9 @@ func Load() (*Config, error) {
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
// RunSetupWizard guides user through initial configuration
|
||||
// RunSetupWizard is deprecated - configuration is now handled via web interface
|
||||
func RunSetupWizard() error {
|
||||
fmt.Printf("RedFlag Server Setup Wizard\n")
|
||||
fmt.Printf("===========================\n\n")
|
||||
|
||||
// Admin credentials
|
||||
fmt.Printf("Admin Account Setup\n")
|
||||
fmt.Printf("--------------------\n")
|
||||
username := promptForInput("Admin username", "admin")
|
||||
password := promptForPassword("Admin password")
|
||||
|
||||
// Database configuration
|
||||
fmt.Printf("\nDatabase Configuration\n")
|
||||
fmt.Printf("----------------------\n")
|
||||
dbHost := promptForInput("Database host", "localhost")
|
||||
dbPort, _ := strconv.Atoi(promptForInput("Database port", "5432"))
|
||||
dbName := promptForInput("Database name", "redflag")
|
||||
dbUser := promptForInput("Database user", "redflag")
|
||||
dbPassword := promptForPassword("Database password")
|
||||
|
||||
// Server configuration
|
||||
fmt.Printf("\nServer Configuration\n")
|
||||
fmt.Printf("--------------------\n")
|
||||
serverHost := promptForInput("Server bind address", "0.0.0.0")
|
||||
serverPort, _ := strconv.Atoi(promptForInput("Server port", "8080"))
|
||||
|
||||
// Agent limits
|
||||
fmt.Printf("\nAgent Registration\n")
|
||||
fmt.Printf("------------------\n")
|
||||
maxSeats, _ := strconv.Atoi(promptForInput("Maximum agent seats (security limit)", "50"))
|
||||
|
||||
// Generate JWT secret from admin password
|
||||
jwtSecret := deriveJWTSecret(username, password)
|
||||
|
||||
// Create .env file
|
||||
envContent := fmt.Sprintf(`# RedFlag Server Configuration
|
||||
# Generated on %s
|
||||
|
||||
# Server Configuration
|
||||
REDFLAG_SERVER_HOST=%s
|
||||
REDFLAG_SERVER_PORT=%d
|
||||
REDFLAG_TLS_ENABLED=false
|
||||
# REDFLAG_TLS_CERT_FILE=
|
||||
# REDFLAG_TLS_KEY_FILE=
|
||||
|
||||
# Database Configuration
|
||||
REDFLAG_DB_HOST=%s
|
||||
REDFLAG_DB_PORT=%d
|
||||
REDFLAG_DB_NAME=%s
|
||||
REDFLAG_DB_USER=%s
|
||||
REDFLAG_DB_PASSWORD=%s
|
||||
|
||||
# Admin Configuration
|
||||
REDFLAG_ADMIN_USER=%s
|
||||
REDFLAG_ADMIN_PASSWORD=%s
|
||||
REDFLAG_JWT_SECRET=%s
|
||||
|
||||
# Agent Registration
|
||||
REDFLAG_TOKEN_EXPIRY=24h
|
||||
REDFLAG_MAX_TOKENS=100
|
||||
REDFLAG_MAX_SEATS=%d
|
||||
|
||||
# Legacy Configuration (for backwards compatibility)
|
||||
SERVER_PORT=%d
|
||||
DATABASE_URL=postgres://%s:%s@%s:%d/%s?sslmode=disable
|
||||
JWT_SECRET=%s
|
||||
CHECK_IN_INTERVAL=300
|
||||
OFFLINE_THRESHOLD=600
|
||||
TIMEZONE=UTC
|
||||
LATEST_AGENT_VERSION=0.1.8
|
||||
`, time.Now().Format("2006-01-02 15:04:05"), serverHost, serverPort,
|
||||
dbHost, dbPort, dbName, dbUser, dbPassword,
|
||||
username, password, jwtSecret, maxSeats,
|
||||
serverPort, dbUser, dbPassword, dbHost, dbPort, dbName, jwtSecret)
|
||||
|
||||
// Write .env file to persistent location
|
||||
configDir := "/app/config"
|
||||
if err := os.MkdirAll(configDir, 0755); err != nil {
|
||||
return fmt.Errorf("failed to create config directory: %w", err)
|
||||
}
|
||||
|
||||
envPath := filepath.Join(configDir, ".env")
|
||||
if err := os.WriteFile(envPath, []byte(envContent), 0600); err != nil {
|
||||
return fmt.Errorf("failed to write .env file: %w", err)
|
||||
}
|
||||
|
||||
fmt.Printf("\n[OK] Configuration saved to .env file\n")
|
||||
fmt.Printf("[SECURITY] File permissions set to 0600 (owner read/write only)\n")
|
||||
fmt.Printf("\nNext steps:\n")
|
||||
fmt.Printf(" 1. Start database: %s:%d\n", dbHost, dbPort)
|
||||
fmt.Printf(" 2. Create database: CREATE DATABASE %s;\n", dbName)
|
||||
fmt.Printf(" 3. Run migrations: ./redflag-server --migrate\n")
|
||||
fmt.Printf(" 4. Start server: ./redflag-server\n")
|
||||
fmt.Printf("\nServer will be available at: http://%s:%d\n", serverHost, serverPort)
|
||||
fmt.Printf("Admin interface: http://%s:%d/admin\n", serverHost, serverPort)
|
||||
|
||||
return nil
|
||||
return fmt.Errorf("CLI setup wizard is deprecated. Please use the web interface at http://localhost:8080/setup for configuration")
|
||||
}
|
||||
|
||||
func getEnv(key, defaultValue string) string {
|
||||
@@ -221,28 +122,6 @@ func getEnv(key, defaultValue string) string {
|
||||
return defaultValue
|
||||
}
|
||||
|
||||
func promptForInput(prompt, defaultValue string) string {
|
||||
fmt.Printf("%s [%s]: ", prompt, defaultValue)
|
||||
var input string
|
||||
fmt.Scanln(&input)
|
||||
if strings.TrimSpace(input) == "" {
|
||||
return defaultValue
|
||||
}
|
||||
return strings.TrimSpace(input)
|
||||
}
|
||||
|
||||
func promptForPassword(prompt string) string {
|
||||
fmt.Printf("%s: ", prompt)
|
||||
password, err := term.ReadPassword(int(os.Stdin.Fd()))
|
||||
if err != nil {
|
||||
// Fallback to non-hidden input
|
||||
var input string
|
||||
fmt.Scanln(&input)
|
||||
return strings.TrimSpace(input)
|
||||
}
|
||||
fmt.Printf("\n")
|
||||
return strings.TrimSpace(string(password))
|
||||
}
|
||||
|
||||
func deriveJWTSecret(username, password string) string {
|
||||
// Derive JWT secret from admin credentials
|
||||
|
||||
@@ -35,8 +35,18 @@ func Connect(databaseURL string) (*DB, error) {
|
||||
return &DB{db}, nil
|
||||
}
|
||||
|
||||
// Migrate runs database migrations
|
||||
// Migrate runs database migrations with proper tracking
|
||||
func (db *DB) Migrate(migrationsPath string) error {
|
||||
// Create migrations table if it doesn't exist
|
||||
createTableSQL := `
|
||||
CREATE TABLE IF NOT EXISTS schema_migrations (
|
||||
version VARCHAR(255) PRIMARY KEY,
|
||||
applied_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
|
||||
)`
|
||||
if _, err := db.Exec(createTableSQL); err != nil {
|
||||
return fmt.Errorf("failed to create migrations table: %w", err)
|
||||
}
|
||||
|
||||
// Read migration files
|
||||
files, err := os.ReadDir(migrationsPath)
|
||||
if err != nil {
|
||||
@@ -52,18 +62,67 @@ func (db *DB) Migrate(migrationsPath string) error {
|
||||
}
|
||||
sort.Strings(migrationFiles)
|
||||
|
||||
// Execute migrations
|
||||
// Execute migrations that haven't been applied yet
|
||||
for _, filename := range migrationFiles {
|
||||
// Check if migration has already been applied
|
||||
var count int
|
||||
err := db.Get(&count, "SELECT COUNT(*) FROM schema_migrations WHERE version = $1", filename)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to check migration status for %s: %w", filename, err)
|
||||
}
|
||||
|
||||
if count > 0 {
|
||||
fmt.Printf("→ Skipping migration (already applied): %s\n", filename)
|
||||
continue
|
||||
}
|
||||
|
||||
// Read migration file
|
||||
path := filepath.Join(migrationsPath, filename)
|
||||
content, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to read migration %s: %w", filename, err)
|
||||
}
|
||||
|
||||
if _, err := db.Exec(string(content)); err != nil {
|
||||
// Execute migration in a transaction
|
||||
tx, err := db.Beginx()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to begin transaction for migration %s: %w", filename, err)
|
||||
}
|
||||
|
||||
// Execute the migration SQL
|
||||
if _, err := tx.Exec(string(content)); err != nil {
|
||||
// Check if it's a "already exists" error - if so, handle gracefully
|
||||
if strings.Contains(err.Error(), "already exists") ||
|
||||
strings.Contains(err.Error(), "duplicate key") ||
|
||||
strings.Contains(err.Error(), "relation") && strings.Contains(err.Error(), "already exists") {
|
||||
fmt.Printf("⚠ Migration %s failed (objects already exist), marking as applied: %v\n", filename, err)
|
||||
// Rollback current transaction and start a new one for tracking
|
||||
tx.Rollback()
|
||||
// Start new transaction just for migration tracking
|
||||
if newTx, newTxErr := db.Beginx(); newTxErr == nil {
|
||||
if _, insertErr := newTx.Exec("INSERT INTO schema_migrations (version) VALUES ($1)", filename); insertErr == nil {
|
||||
newTx.Commit()
|
||||
} else {
|
||||
newTx.Rollback()
|
||||
}
|
||||
}
|
||||
continue
|
||||
}
|
||||
tx.Rollback()
|
||||
return fmt.Errorf("failed to execute migration %s: %w", filename, err)
|
||||
}
|
||||
|
||||
// Record the migration as applied
|
||||
if _, err := tx.Exec("INSERT INTO schema_migrations (version) VALUES ($1)", filename); err != nil {
|
||||
tx.Rollback()
|
||||
return fmt.Errorf("failed to record migration %s: %w", filename, err)
|
||||
}
|
||||
|
||||
// Commit the transaction
|
||||
if err := tx.Commit(); err != nil {
|
||||
return fmt.Errorf("failed to commit migration %s: %w", filename, err)
|
||||
}
|
||||
|
||||
fmt.Printf("✓ Executed migration: %s\n", filename)
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
-- Add seat tracking to registration tokens for multi-use support
|
||||
-- This allows tokens to be used multiple times up to a configured limit
|
||||
|
||||
-- Add seats columns
|
||||
ALTER TABLE registration_tokens
|
||||
ADD COLUMN max_seats INT NOT NULL DEFAULT 1,
|
||||
ADD COLUMN seats_used INT NOT NULL DEFAULT 0;
|
||||
|
||||
-- Backfill existing tokens
|
||||
-- Tokens with status='used' should have seats_used=1, max_seats=1
|
||||
UPDATE registration_tokens
|
||||
SET seats_used = 1,
|
||||
max_seats = 1
|
||||
WHERE status = 'used';
|
||||
|
||||
-- Active/expired/revoked tokens get max_seats=1, seats_used=0
|
||||
UPDATE registration_tokens
|
||||
SET seats_used = 0,
|
||||
max_seats = 1
|
||||
WHERE status IN ('active', 'expired', 'revoked');
|
||||
|
||||
-- Add constraint to ensure seats_used doesn't exceed max_seats
|
||||
ALTER TABLE registration_tokens
|
||||
ADD CONSTRAINT chk_seats_used_within_max
|
||||
CHECK (seats_used <= max_seats);
|
||||
|
||||
-- Add constraint to ensure positive seat values
|
||||
ALTER TABLE registration_tokens
|
||||
ADD CONSTRAINT chk_seats_positive
|
||||
CHECK (max_seats > 0 AND seats_used >= 0);
|
||||
|
||||
-- Create table to track all agents that used a token (for audit trail)
|
||||
CREATE TABLE IF NOT EXISTS registration_token_usage (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
token_id UUID NOT NULL REFERENCES registration_tokens(id) ON DELETE CASCADE,
|
||||
agent_id UUID NOT NULL REFERENCES agents(id) ON DELETE CASCADE,
|
||||
used_at TIMESTAMP DEFAULT NOW(),
|
||||
UNIQUE(token_id, agent_id)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_token_usage_token_id ON registration_token_usage(token_id);
|
||||
CREATE INDEX idx_token_usage_agent_id ON registration_token_usage(agent_id);
|
||||
|
||||
-- Backfill token usage table from existing used_by_agent_id
|
||||
INSERT INTO registration_token_usage (token_id, agent_id, used_at)
|
||||
SELECT id, used_by_agent_id, used_at
|
||||
FROM registration_tokens
|
||||
WHERE used_by_agent_id IS NOT NULL
|
||||
ON CONFLICT (token_id, agent_id) DO NOTHING;
|
||||
|
||||
-- Update is_registration_token_valid function to check seats
|
||||
CREATE OR REPLACE FUNCTION is_registration_token_valid(token_input VARCHAR)
|
||||
RETURNS BOOLEAN AS $$
|
||||
DECLARE
|
||||
token_valid BOOLEAN;
|
||||
BEGIN
|
||||
SELECT (status = 'active' AND expires_at > NOW() AND seats_used < max_seats) INTO token_valid
|
||||
FROM registration_tokens
|
||||
WHERE token = token_input;
|
||||
|
||||
RETURN COALESCE(token_valid, FALSE);
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
-- Update mark_registration_token_used function to increment seats
|
||||
CREATE OR REPLACE FUNCTION mark_registration_token_used(token_input VARCHAR, agent_id_param UUID)
|
||||
RETURNS BOOLEAN AS $$
|
||||
DECLARE
|
||||
rows_updated INTEGER; -- Fixed: Changed from BOOLEAN to INTEGER to match ROW_COUNT type
|
||||
token_id_val UUID;
|
||||
new_seats_used INT;
|
||||
token_max_seats INT;
|
||||
BEGIN
|
||||
-- Get token ID and current seat info
|
||||
SELECT id, seats_used + 1, max_seats INTO token_id_val, new_seats_used, token_max_seats
|
||||
FROM registration_tokens
|
||||
WHERE token = token_input
|
||||
AND status = 'active'
|
||||
AND expires_at > NOW()
|
||||
AND seats_used < max_seats;
|
||||
|
||||
-- If no token found or already full, return false
|
||||
IF token_id_val IS NULL THEN
|
||||
RETURN FALSE;
|
||||
END IF;
|
||||
|
||||
-- Increment seats_used
|
||||
UPDATE registration_tokens
|
||||
SET seats_used = new_seats_used,
|
||||
used_at = CASE
|
||||
WHEN used_at IS NULL THEN NOW() -- First use
|
||||
ELSE used_at -- Keep original first use time
|
||||
END,
|
||||
-- Only mark as 'used' if all seats are now taken
|
||||
status = CASE
|
||||
WHEN new_seats_used >= token_max_seats THEN 'used'
|
||||
ELSE 'active'
|
||||
END
|
||||
WHERE token = token_input
|
||||
AND status = 'active';
|
||||
|
||||
GET DIAGNOSTICS rows_updated = ROW_COUNT;
|
||||
|
||||
-- Record this usage in the audit table
|
||||
IF rows_updated > 0 THEN
|
||||
INSERT INTO registration_token_usage (token_id, agent_id, used_at)
|
||||
VALUES (token_id_val, agent_id_param, NOW())
|
||||
ON CONFLICT (token_id, agent_id) DO NOTHING;
|
||||
END IF;
|
||||
|
||||
RETURN rows_updated > 0;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
-- Add comment for documentation
|
||||
COMMENT ON COLUMN registration_tokens.max_seats IS 'Maximum number of agents that can register with this token';
|
||||
COMMENT ON COLUMN registration_tokens.seats_used IS 'Number of agents that have registered with this token';
|
||||
COMMENT ON TABLE registration_token_usage IS 'Audit trail of all agents registered with each token';
|
||||
@@ -0,0 +1,10 @@
|
||||
-- Create admin user from environment configuration
|
||||
-- This migration reads the admin credentials from environment variables
|
||||
-- and creates the initial admin user in the database
|
||||
|
||||
-- Note: This is a placeholder migration that will be executed by the application
|
||||
-- The actual user creation logic is handled in the main application startup
|
||||
-- to allow for proper password hashing and error handling
|
||||
|
||||
-- The admin user creation is handled by the application during startup
|
||||
-- This migration file exists for version tracking purposes
|
||||
@@ -27,12 +27,15 @@ type RegistrationToken struct {
|
||||
RevokedReason *string `json:"revoked_reason" db:"revoked_reason"`
|
||||
Status string `json:"status" db:"status"`
|
||||
CreatedBy string `json:"created_by" db:"created_by"`
|
||||
Metadata map[string]interface{} `json:"metadata" db:"metadata"`
|
||||
Metadata json.RawMessage `json:"metadata" db:"metadata"`
|
||||
MaxSeats int `json:"max_seats" db:"max_seats"`
|
||||
SeatsUsed int `json:"seats_used" db:"seats_used"`
|
||||
}
|
||||
|
||||
type TokenRequest struct {
|
||||
Label string `json:"label"`
|
||||
ExpiresIn string `json:"expires_in"` // e.g., "24h", "7d"
|
||||
MaxSeats int `json:"max_seats"` // Number of agents that can use this token (default: 1)
|
||||
Metadata map[string]interface{} `json:"metadata"`
|
||||
}
|
||||
|
||||
@@ -47,19 +50,24 @@ func NewRegistrationTokenQueries(db *sqlx.DB) *RegistrationTokenQueries {
|
||||
return &RegistrationTokenQueries{db: db}
|
||||
}
|
||||
|
||||
// CreateRegistrationToken creates a new one-time use registration token
|
||||
func (q *RegistrationTokenQueries) CreateRegistrationToken(token, label string, expiresAt time.Time, metadata map[string]interface{}) error {
|
||||
// CreateRegistrationToken creates a new registration token with seat tracking
|
||||
func (q *RegistrationTokenQueries) CreateRegistrationToken(token, label string, expiresAt time.Time, maxSeats int, metadata map[string]interface{}) error {
|
||||
metadataJSON, err := json.Marshal(metadata)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to marshal metadata: %w", err)
|
||||
}
|
||||
|
||||
// Ensure maxSeats is at least 1
|
||||
if maxSeats < 1 {
|
||||
maxSeats = 1
|
||||
}
|
||||
|
||||
query := `
|
||||
INSERT INTO registration_tokens (token, label, expires_at, metadata)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
INSERT INTO registration_tokens (token, label, expires_at, max_seats, metadata)
|
||||
VALUES ($1, $2, $3, $4, $5)
|
||||
`
|
||||
|
||||
_, err = q.db.Exec(query, token, label, expiresAt, metadataJSON)
|
||||
_, err = q.db.Exec(query, token, label, expiresAt, maxSeats, metadataJSON)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create registration token: %w", err)
|
||||
}
|
||||
@@ -67,20 +75,21 @@ func (q *RegistrationTokenQueries) CreateRegistrationToken(token, label string,
|
||||
return nil
|
||||
}
|
||||
|
||||
// ValidateRegistrationToken checks if a token is valid and unused
|
||||
// ValidateRegistrationToken checks if a token is valid and has available seats
|
||||
func (q *RegistrationTokenQueries) ValidateRegistrationToken(token string) (*RegistrationToken, error) {
|
||||
var regToken RegistrationToken
|
||||
query := `
|
||||
SELECT id, token, label, expires_at, created_at, used_at, used_by_agent_id,
|
||||
revoked, revoked_at, revoked_reason, status, created_by, metadata
|
||||
revoked, revoked_at, revoked_reason, status, created_by, metadata,
|
||||
max_seats, seats_used
|
||||
FROM registration_tokens
|
||||
WHERE token = $1 AND status = 'active' AND expires_at > NOW()
|
||||
WHERE token = $1 AND status = 'active' AND expires_at > NOW() AND seats_used < max_seats
|
||||
`
|
||||
|
||||
err := q.db.Get(®Token, query, token)
|
||||
if err != nil {
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, fmt.Errorf("invalid or expired token")
|
||||
return nil, fmt.Errorf("invalid, expired, or seats full")
|
||||
}
|
||||
return nil, fmt.Errorf("failed to validate token: %w", err)
|
||||
}
|
||||
@@ -89,27 +98,19 @@ func (q *RegistrationTokenQueries) ValidateRegistrationToken(token string) (*Reg
|
||||
}
|
||||
|
||||
// MarkTokenUsed marks a token as used by an agent
|
||||
// With seat tracking, this increments seats_used and only marks status='used' when all seats are taken
|
||||
func (q *RegistrationTokenQueries) MarkTokenUsed(token string, agentID uuid.UUID) error {
|
||||
query := `
|
||||
UPDATE registration_tokens
|
||||
SET status = 'used',
|
||||
used_at = NOW(),
|
||||
used_by_agent_id = $1
|
||||
WHERE token = $2 AND status = 'active' AND expires_at > NOW()
|
||||
`
|
||||
// Call the PostgreSQL function that handles seat tracking logic
|
||||
query := `SELECT mark_registration_token_used($1, $2)`
|
||||
|
||||
result, err := q.db.Exec(query, agentID, token)
|
||||
var success bool
|
||||
err := q.db.QueryRow(query, token, agentID).Scan(&success)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to mark token as used: %w", err)
|
||||
}
|
||||
|
||||
rowsAffected, err := result.RowsAffected()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get rows affected: %w", err)
|
||||
}
|
||||
|
||||
if rowsAffected == 0 {
|
||||
return fmt.Errorf("token not found or already used")
|
||||
if !success {
|
||||
return fmt.Errorf("token not found, already used, expired, or seats full")
|
||||
}
|
||||
|
||||
return nil
|
||||
@@ -120,7 +121,8 @@ func (q *RegistrationTokenQueries) GetActiveRegistrationTokens() ([]Registration
|
||||
var tokens []RegistrationToken
|
||||
query := `
|
||||
SELECT id, token, label, expires_at, created_at, used_at, used_by_agent_id,
|
||||
revoked, revoked_at, revoked_reason, status, created_by, metadata
|
||||
revoked, revoked_at, revoked_reason, status, created_by, metadata,
|
||||
max_seats, seats_used
|
||||
FROM registration_tokens
|
||||
WHERE status = 'active'
|
||||
ORDER BY created_at DESC
|
||||
@@ -139,7 +141,8 @@ func (q *RegistrationTokenQueries) GetAllRegistrationTokens(limit, offset int) (
|
||||
var tokens []RegistrationToken
|
||||
query := `
|
||||
SELECT id, token, label, expires_at, created_at, used_at, used_by_agent_id,
|
||||
revoked, revoked_at, revoked_reason, status, created_by, metadata
|
||||
revoked, revoked_at, revoked_reason, status, created_by, metadata,
|
||||
max_seats, seats_used
|
||||
FROM registration_tokens
|
||||
ORDER BY created_at DESC
|
||||
LIMIT $1 OFFSET $2
|
||||
@@ -153,7 +156,7 @@ func (q *RegistrationTokenQueries) GetAllRegistrationTokens(limit, offset int) (
|
||||
return tokens, nil
|
||||
}
|
||||
|
||||
// RevokeRegistrationToken revokes a token
|
||||
// RevokeRegistrationToken revokes a token (can revoke tokens in any status)
|
||||
func (q *RegistrationTokenQueries) RevokeRegistrationToken(token, reason string) error {
|
||||
query := `
|
||||
UPDATE registration_tokens
|
||||
@@ -161,7 +164,7 @@ func (q *RegistrationTokenQueries) RevokeRegistrationToken(token, reason string)
|
||||
revoked = true,
|
||||
revoked_at = NOW(),
|
||||
revoked_reason = $1
|
||||
WHERE token = $2 AND status = 'active'
|
||||
WHERE token = $2
|
||||
`
|
||||
|
||||
result, err := q.db.Exec(query, reason, token)
|
||||
@@ -175,7 +178,28 @@ func (q *RegistrationTokenQueries) RevokeRegistrationToken(token, reason string)
|
||||
}
|
||||
|
||||
if rowsAffected == 0 {
|
||||
return fmt.Errorf("token not found or already used/revoked")
|
||||
return fmt.Errorf("token not found")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeleteRegistrationToken permanently deletes a token from the database
|
||||
func (q *RegistrationTokenQueries) DeleteRegistrationToken(tokenID uuid.UUID) error {
|
||||
query := `DELETE FROM registration_tokens WHERE id = $1`
|
||||
|
||||
result, err := q.db.Exec(query, tokenID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to delete token: %w", err)
|
||||
}
|
||||
|
||||
rowsAffected, err := result.RowsAffected()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get rows affected: %w", err)
|
||||
}
|
||||
|
||||
if rowsAffected == 0 {
|
||||
return fmt.Errorf("token not found")
|
||||
}
|
||||
|
||||
return nil
|
||||
|
||||
123
aggregator-server/internal/database/queries/users.go
Normal file
123
aggregator-server/internal/database/queries/users.go
Normal file
@@ -0,0 +1,123 @@
|
||||
package queries
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/Fimeg/RedFlag/aggregator-server/internal/models"
|
||||
"github.com/google/uuid"
|
||||
"github.com/jmoiron/sqlx"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
type UserQueries struct {
|
||||
db *sqlx.DB
|
||||
}
|
||||
|
||||
func NewUserQueries(db *sqlx.DB) *UserQueries {
|
||||
return &UserQueries{db: db}
|
||||
}
|
||||
|
||||
// CreateUser inserts a new user into the database with password hashing
|
||||
func (q *UserQueries) CreateUser(username, email, password, role string) (*models.User, error) {
|
||||
// Hash the password
|
||||
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
user := &models.User{
|
||||
ID: uuid.New(),
|
||||
Username: username,
|
||||
Email: email,
|
||||
PasswordHash: string(hashedPassword),
|
||||
Role: role,
|
||||
CreatedAt: time.Now().UTC(),
|
||||
}
|
||||
|
||||
query := `
|
||||
INSERT INTO users (
|
||||
id, username, email, password_hash, role, created_at
|
||||
) VALUES (
|
||||
:id, :username, :email, :password_hash, :role, :created_at
|
||||
)
|
||||
RETURNING *
|
||||
`
|
||||
|
||||
rows, err := q.db.NamedQuery(query, user)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
if rows.Next() {
|
||||
if err := rows.StructScan(user); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return user, nil
|
||||
}
|
||||
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// GetUserByUsername retrieves a user by username
|
||||
func (q *UserQueries) GetUserByUsername(username string) (*models.User, error) {
|
||||
var user models.User
|
||||
query := `SELECT * FROM users WHERE username = $1`
|
||||
err := q.db.Get(&user, query, username)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &user, nil
|
||||
}
|
||||
|
||||
// VerifyCredentials checks if the provided username and password are valid
|
||||
func (q *UserQueries) VerifyCredentials(username, password string) (*models.User, error) {
|
||||
user, err := q.GetUserByUsername(username)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Compare the provided password with the stored hash
|
||||
err = bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(password))
|
||||
if err != nil {
|
||||
return nil, err // Invalid password
|
||||
}
|
||||
|
||||
// Update last login time
|
||||
q.UpdateLastLogin(user.ID)
|
||||
|
||||
// Don't return password hash
|
||||
user.PasswordHash = ""
|
||||
return user, nil
|
||||
}
|
||||
|
||||
// UpdateLastLogin updates the user's last login timestamp
|
||||
func (q *UserQueries) UpdateLastLogin(id uuid.UUID) error {
|
||||
query := `UPDATE users SET last_login = $1 WHERE id = $2`
|
||||
_, err := q.db.Exec(query, time.Now().UTC(), id)
|
||||
return err
|
||||
}
|
||||
|
||||
// GetUserByID retrieves a user by ID
|
||||
func (q *UserQueries) GetUserByID(id uuid.UUID) (*models.User, error) {
|
||||
var user models.User
|
||||
query := `SELECT id, username, email, role, created_at, last_login FROM users WHERE id = $1`
|
||||
err := q.db.Get(&user, query, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &user, nil
|
||||
}
|
||||
|
||||
// EnsureAdminUser creates an admin user if one doesn't exist
|
||||
func (q *UserQueries) EnsureAdminUser(username, email, password string) error {
|
||||
// Check if admin user already exists
|
||||
existingUser, err := q.GetUserByUsername(username)
|
||||
if err == nil && existingUser != nil {
|
||||
return nil // Admin user already exists
|
||||
}
|
||||
|
||||
// Create admin user
|
||||
_, err = q.CreateUser(username, email, password, "admin")
|
||||
return err
|
||||
}
|
||||
@@ -63,12 +63,13 @@ type AgentSpecs struct {
|
||||
|
||||
// AgentRegistrationRequest is the payload for agent registration
|
||||
type AgentRegistrationRequest struct {
|
||||
Hostname string `json:"hostname" binding:"required"`
|
||||
OSType string `json:"os_type" binding:"required"`
|
||||
OSVersion string `json:"os_version"`
|
||||
OSArchitecture string `json:"os_architecture"`
|
||||
AgentVersion string `json:"agent_version" binding:"required"`
|
||||
Metadata map[string]string `json:"metadata"`
|
||||
Hostname string `json:"hostname" binding:"required"`
|
||||
OSType string `json:"os_type" binding:"required"`
|
||||
OSVersion string `json:"os_version"`
|
||||
OSArchitecture string `json:"os_architecture"`
|
||||
AgentVersion string `json:"agent_version" binding:"required"`
|
||||
RegistrationToken string `json:"registration_token"` // Optional, for fallback method
|
||||
Metadata map[string]string `json:"metadata"`
|
||||
}
|
||||
|
||||
// AgentRegistrationResponse is returned after successful registration
|
||||
|
||||
22
aggregator-server/internal/models/user.go
Normal file
22
aggregator-server/internal/models/user.go
Normal file
@@ -0,0 +1,22 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
type User struct {
|
||||
ID uuid.UUID `json:"id" db:"id"`
|
||||
Username string `json:"username" db:"username"`
|
||||
Email string `json:"email" db:"email"`
|
||||
PasswordHash string `json:"-" db:"password_hash"` // Don't include in JSON
|
||||
Role string `json:"role" db:"role"`
|
||||
CreatedAt time.Time `json:"created_at" db:"created_at"`
|
||||
LastLogin *time.Time `json:"last_login" db:"last_login"`
|
||||
}
|
||||
|
||||
type UserCredentials struct {
|
||||
Username string `json:"username" binding:"required"`
|
||||
Password string `json:"password" binding:"required"`
|
||||
}
|
||||
Reference in New Issue
Block a user