v0.1.16: Security overhaul and systematic deployment preparation

Breaking changes for clean alpha releases:
- JWT authentication with user-provided secrets (no more development defaults)
- Registration token system for secure agent enrollment
- Rate limiting with user-adjustable settings
- Enhanced agent configuration with proxy support
- Interactive server setup wizard (--setup flag)
- Heartbeat architecture separation for better UX
- Package status synchronization fixes
- Accurate timestamp tracking for RMM features

Setup process for new installations:
1. docker-compose up -d postgres
2. ./redflag-server --setup
3. ./redflag-server --migrate
4. ./redflag-server
5. Generate tokens via admin UI
6. Deploy agents with registration tokens
This commit is contained in:
Fimeg
2025-10-29 10:38:18 -04:00
parent b3e1b9e52f
commit 03fee29760
50 changed files with 5807 additions and 466 deletions

View File

@@ -16,9 +16,11 @@ import (
// Client handles API communication with the server
type Client struct {
baseURL string
token string
http *http.Client
baseURL string
token string
http *http.Client
RapidPollingEnabled bool
RapidPollingUntil time.Time
}
// NewClient creates a new API client
@@ -159,20 +161,28 @@ type Command struct {
// CommandsResponse contains pending commands
type CommandsResponse struct {
Commands []Command `json:"commands"`
Commands []Command `json:"commands"`
RapidPolling *RapidPollingConfig `json:"rapid_polling,omitempty"`
}
// RapidPollingConfig contains rapid polling configuration from server
type RapidPollingConfig struct {
Enabled bool `json:"enabled"`
Until string `json:"until"` // ISO 8601 timestamp
}
// SystemMetrics represents lightweight system metrics sent with check-ins
type SystemMetrics struct {
CPUPercent float64 `json:"cpu_percent,omitempty"`
MemoryPercent float64 `json:"memory_percent,omitempty"`
MemoryUsedGB float64 `json:"memory_used_gb,omitempty"`
MemoryTotalGB float64 `json:"memory_total_gb,omitempty"`
DiskUsedGB float64 `json:"disk_used_gb,omitempty"`
DiskTotalGB float64 `json:"disk_total_gb,omitempty"`
DiskPercent float64 `json:"disk_percent,omitempty"`
Uptime string `json:"uptime,omitempty"`
Version string `json:"version,omitempty"` // Agent version
CPUPercent float64 `json:"cpu_percent,omitempty"`
MemoryPercent float64 `json:"memory_percent,omitempty"`
MemoryUsedGB float64 `json:"memory_used_gb,omitempty"`
MemoryTotalGB float64 `json:"memory_total_gb,omitempty"`
DiskUsedGB float64 `json:"disk_used_gb,omitempty"`
DiskTotalGB float64 `json:"disk_total_gb,omitempty"`
DiskPercent float64 `json:"disk_percent,omitempty"`
Uptime string `json:"uptime,omitempty"`
Version string `json:"version,omitempty"` // Agent version
Metadata map[string]interface{} `json:"metadata,omitempty"` // Additional metadata
}
// GetCommands retrieves pending commands from the server
@@ -219,6 +229,16 @@ func (c *Client) GetCommands(agentID uuid.UUID, metrics *SystemMetrics) ([]Comma
return nil, err
}
// Handle rapid polling configuration if provided
if result.RapidPolling != nil {
// Parse the timestamp
if until, err := time.Parse(time.RFC3339, result.RapidPolling.Until); err == nil {
// Update client's rapid polling configuration
c.RapidPollingEnabled = result.RapidPolling.Enabled
c.RapidPollingUntil = until
}
}
return result.Commands, nil
}

View File

@@ -5,21 +5,152 @@ import (
"fmt"
"os"
"path/filepath"
"time"
"github.com/google/uuid"
)
// Config holds agent configuration
type Config struct {
ServerURL string `json:"server_url"`
AgentID uuid.UUID `json:"agent_id"`
Token string `json:"token"` // Short-lived access token (24h)
RefreshToken string `json:"refresh_token"` // Long-lived refresh token (90d)
CheckInInterval int `json:"check_in_interval"`
// ProxyConfig holds proxy configuration
type ProxyConfig struct {
Enabled bool `json:"enabled"`
HTTP string `json:"http,omitempty"` // HTTP proxy URL
HTTPS string `json:"https,omitempty"` // HTTPS proxy URL
NoProxy string `json:"no_proxy,omitempty"` // Comma-separated hosts to bypass proxy
Username string `json:"username,omitempty"` // Proxy username (optional)
Password string `json:"password,omitempty"` // Proxy password (optional)
}
// Load reads configuration from file
func Load(configPath string) (*Config, error) {
// TLSConfig holds TLS/security configuration
type TLSConfig struct {
InsecureSkipVerify bool `json:"insecure_skip_verify"` // Skip TLS certificate verification
CertFile string `json:"cert_file,omitempty"` // Client certificate file
KeyFile string `json:"key_file,omitempty"` // Client key file
CAFile string `json:"ca_file,omitempty"` // CA certificate file
}
// NetworkConfig holds network-related configuration
type NetworkConfig struct {
Timeout time.Duration `json:"timeout"` // Request timeout
RetryCount int `json:"retry_count"` // Number of retries
RetryDelay time.Duration `json:"retry_delay"` // Delay between retries
MaxIdleConn int `json:"max_idle_conn"` // Maximum idle connections
}
// LoggingConfig holds logging configuration
type LoggingConfig struct {
Level string `json:"level"` // Log level (debug, info, warn, error)
File string `json:"file,omitempty"` // Log file path (optional)
MaxSize int `json:"max_size"` // Max log file size in MB
MaxBackups int `json:"max_backups"` // Max number of log file backups
MaxAge int `json:"max_age"` // Max age of log files in days
}
// Config holds agent configuration
type Config struct {
// Server Configuration
ServerURL string `json:"server_url"`
RegistrationToken string `json:"registration_token,omitempty"` // One-time registration token
// Agent Authentication
AgentID uuid.UUID `json:"agent_id"`
Token string `json:"token"` // Short-lived access token (24h)
RefreshToken string `json:"refresh_token"` // Long-lived refresh token (90d)
// Agent Behavior
CheckInInterval int `json:"check_in_interval"`
// Rapid polling mode for faster response during operations
RapidPollingEnabled bool `json:"rapid_polling_enabled"`
RapidPollingUntil time.Time `json:"rapid_polling_until"`
// Network Configuration
Network NetworkConfig `json:"network,omitempty"`
// Proxy Configuration
Proxy ProxyConfig `json:"proxy,omitempty"`
// Security Configuration
TLS TLSConfig `json:"tls,omitempty"`
// Logging Configuration
Logging LoggingConfig `json:"logging,omitempty"`
// Agent Metadata
Tags []string `json:"tags,omitempty"` // User-defined tags
Metadata map[string]string `json:"metadata,omitempty"` // Custom metadata
DisplayName string `json:"display_name,omitempty"` // Human-readable name
Organization string `json:"organization,omitempty"` // Organization/group
}
// Load reads configuration from multiple sources with priority order:
// 1. CLI flags
// 2. Environment variables
// 3. Configuration file
// 4. Default values
func Load(configPath string, cliFlags *CLIFlags) (*Config, error) {
// Start with defaults
config := getDefaultConfig()
// Load from config file if it exists
if fileConfig, err := loadFromFile(configPath); err == nil {
mergeConfig(config, fileConfig)
}
// Override with environment variables
mergeConfig(config, loadFromEnv())
// Override with CLI flags (highest priority)
if cliFlags != nil {
mergeConfig(config, loadFromFlags(cliFlags))
}
// Validate configuration
if err := validateConfig(config); err != nil {
return nil, fmt.Errorf("invalid configuration: %w", err)
}
return config, nil
}
// CLIFlags holds command line flag values
type CLIFlags struct {
ServerURL string
RegistrationToken string
ProxyHTTP string
ProxyHTTPS string
ProxyNoProxy string
LogLevel string
ConfigFile string
Tags []string
Organization string
DisplayName string
InsecureTLS bool
}
// getDefaultConfig returns default configuration values
func getDefaultConfig() *Config {
return &Config{
ServerURL: "http://localhost:8080",
CheckInInterval: 300, // 5 minutes
Network: NetworkConfig{
Timeout: 30 * time.Second,
RetryCount: 3,
RetryDelay: 5 * time.Second,
MaxIdleConn: 10,
},
Logging: LoggingConfig{
Level: "info",
MaxSize: 100, // 100MB
MaxBackups: 3,
MaxAge: 28, // 28 days
},
Tags: []string{},
Metadata: make(map[string]string),
}
}
// loadFromFile reads configuration from file
func loadFromFile(configPath string) (*Config, error) {
// Ensure directory exists
dir := filepath.Dir(configPath)
if err := os.MkdirAll(dir, 0755); err != nil {
@@ -30,8 +161,7 @@ func Load(configPath string) (*Config, error) {
data, err := os.ReadFile(configPath)
if err != nil {
if os.IsNotExist(err) {
// Return empty config if file doesn't exist
return &Config{}, nil
return getDefaultConfig(), nil // Return defaults if file doesn't exist
}
return nil, fmt.Errorf("failed to read config: %w", err)
}
@@ -44,6 +174,174 @@ func Load(configPath string) (*Config, error) {
return &config, nil
}
// loadFromEnv loads configuration from environment variables
func loadFromEnv() *Config {
config := &Config{}
if serverURL := os.Getenv("REDFLAG_SERVER_URL"); serverURL != "" {
config.ServerURL = serverURL
}
if token := os.Getenv("REDFLAG_REGISTRATION_TOKEN"); token != "" {
config.RegistrationToken = token
}
if proxyHTTP := os.Getenv("REDFLAG_HTTP_PROXY"); proxyHTTP != "" {
config.Proxy.Enabled = true
config.Proxy.HTTP = proxyHTTP
}
if proxyHTTPS := os.Getenv("REDFLAG_HTTPS_PROXY"); proxyHTTPS != "" {
config.Proxy.Enabled = true
config.Proxy.HTTPS = proxyHTTPS
}
if noProxy := os.Getenv("REDFLAG_NO_PROXY"); noProxy != "" {
config.Proxy.NoProxy = noProxy
}
if logLevel := os.Getenv("REDFLAG_LOG_LEVEL"); logLevel != "" {
if config.Logging == (LoggingConfig{}) {
config.Logging = LoggingConfig{}
}
config.Logging.Level = logLevel
}
if org := os.Getenv("REDFLAG_ORGANIZATION"); org != "" {
config.Organization = org
}
if displayName := os.Getenv("REDFLAG_DISPLAY_NAME"); displayName != "" {
config.DisplayName = displayName
}
return config
}
// loadFromFlags loads configuration from CLI flags
func loadFromFlags(flags *CLIFlags) *Config {
config := &Config{}
if flags.ServerURL != "" {
config.ServerURL = flags.ServerURL
}
if flags.RegistrationToken != "" {
config.RegistrationToken = flags.RegistrationToken
}
if flags.ProxyHTTP != "" || flags.ProxyHTTPS != "" {
config.Proxy = ProxyConfig{
Enabled: true,
HTTP: flags.ProxyHTTP,
HTTPS: flags.ProxyHTTPS,
NoProxy: flags.ProxyNoProxy,
}
}
if flags.LogLevel != "" {
config.Logging = LoggingConfig{
Level: flags.LogLevel,
}
}
if len(flags.Tags) > 0 {
config.Tags = flags.Tags
}
if flags.Organization != "" {
config.Organization = flags.Organization
}
if flags.DisplayName != "" {
config.DisplayName = flags.DisplayName
}
if flags.InsecureTLS {
config.TLS = TLSConfig{
InsecureSkipVerify: true,
}
}
return config
}
// mergeConfig merges source config into target config (non-zero values only)
func mergeConfig(target, source *Config) {
if source.ServerURL != "" {
target.ServerURL = source.ServerURL
}
if source.RegistrationToken != "" {
target.RegistrationToken = source.RegistrationToken
}
if source.CheckInInterval != 0 {
target.CheckInInterval = source.CheckInInterval
}
if source.AgentID != uuid.Nil {
target.AgentID = source.AgentID
}
if source.Token != "" {
target.Token = source.Token
}
if source.RefreshToken != "" {
target.RefreshToken = source.RefreshToken
}
// Merge nested configs
if source.Network != (NetworkConfig{}) {
target.Network = source.Network
}
if source.Proxy != (ProxyConfig{}) {
target.Proxy = source.Proxy
}
if source.TLS != (TLSConfig{}) {
target.TLS = source.TLS
}
if source.Logging != (LoggingConfig{}) {
target.Logging = source.Logging
}
// Merge metadata
if source.Tags != nil {
target.Tags = source.Tags
}
if source.Metadata != nil {
if target.Metadata == nil {
target.Metadata = make(map[string]string)
}
for k, v := range source.Metadata {
target.Metadata[k] = v
}
}
if source.DisplayName != "" {
target.DisplayName = source.DisplayName
}
if source.Organization != "" {
target.Organization = source.Organization
}
// Merge rapid polling settings
target.RapidPollingEnabled = source.RapidPollingEnabled
if !source.RapidPollingUntil.IsZero() {
target.RapidPollingUntil = source.RapidPollingUntil
}
}
// validateConfig validates configuration values
func validateConfig(config *Config) error {
if config.ServerURL == "" {
return fmt.Errorf("server_url is required")
}
if config.CheckInInterval < 30 {
return fmt.Errorf("check_in_interval must be at least 30 seconds")
}
if config.CheckInInterval > 3600 {
return fmt.Errorf("check_in_interval cannot exceed 3600 seconds (1 hour)")
}
if config.Network.Timeout <= 0 {
return fmt.Errorf("network timeout must be positive")
}
if config.Network.RetryCount < 0 || config.Network.RetryCount > 10 {
return fmt.Errorf("retry_count must be between 0 and 10")
}
// Validate log level
validLogLevels := map[string]bool{
"debug": true, "info": true, "warn": true, "error": true,
}
if config.Logging.Level != "" && !validLogLevels[config.Logging.Level] {
return fmt.Errorf("invalid log level: %s", config.Logging.Level)
}
return nil
}
// Save writes configuration to file
func (c *Config) Save(configPath string) error {
data, err := json.MarshalIndent(c, "", " ")
@@ -62,3 +360,13 @@ func (c *Config) Save(configPath string) error {
func (c *Config) IsRegistered() bool {
return c.AgentID != uuid.Nil && c.Token != ""
}
// NeedsRegistration checks if the agent needs to register with a token
func (c *Config) NeedsRegistration() bool {
return c.RegistrationToken != "" && c.AgentID == uuid.Nil
}
// HasRegistrationToken checks if the agent has a registration token
func (c *Config) HasRegistrationToken() bool {
return c.RegistrationToken != ""
}

View File

@@ -146,6 +146,36 @@ func (i *APTInstaller) Upgrade() (*InstallResult, error) {
}, nil
}
// UpdatePackage updates a specific package using APT
func (i *APTInstaller) UpdatePackage(packageName string) (*InstallResult, error) {
startTime := time.Now()
// Update specific package using secure executor
updateResult, err := i.executor.ExecuteCommand("apt-get", []string{"install", "--only-upgrade", "-y", packageName})
duration := int(time.Since(startTime).Seconds())
if err != nil {
return &InstallResult{
Success: false,
ErrorMessage: fmt.Sprintf("APT update failed: %v", err),
Stdout: updateResult.Stdout,
Stderr: updateResult.Stderr,
ExitCode: updateResult.ExitCode,
DurationSeconds: duration,
}, err
}
return &InstallResult{
Success: true,
Stdout: updateResult.Stdout,
Stderr: updateResult.Stderr,
ExitCode: updateResult.ExitCode,
DurationSeconds: duration,
PackagesInstalled: []string{packageName},
Action: "update",
}, nil
}
// DryRun performs a dry run installation to check dependencies
func (i *APTInstaller) DryRun(packageName string) (*InstallResult, error) {
startTime := time.Now()

View File

@@ -31,15 +31,8 @@ func (i *DNFInstaller) IsAvailable() bool {
func (i *DNFInstaller) Install(packageName string) (*InstallResult, error) {
startTime := time.Now()
// Refresh package cache first using secure executor
refreshResult, err := i.executor.ExecuteCommand("dnf", []string{"makecache"})
if err != nil {
refreshResult.DurationSeconds = int(time.Since(startTime).Seconds())
refreshResult.ErrorMessage = fmt.Sprintf("Failed to refresh DNF cache: %v", err)
return refreshResult, fmt.Errorf("dnf refresh failed: %w", err)
}
// Install package using secure executor
// For single package installs, skip makecache to avoid repository conflicts
// Only run makecache when installing multiple packages (InstallMultiple method)
installResult, err := i.executor.ExecuteCommand("dnf", []string{"install", "-y", packageName})
duration := int(time.Since(startTime).Seconds())
@@ -75,17 +68,11 @@ func (i *DNFInstaller) InstallMultiple(packageNames []string) (*InstallResult, e
startTime := time.Now()
// Refresh package cache first using secure executor
refreshResult, err := i.executor.ExecuteCommand("dnf", []string{"makecache"})
if err != nil {
refreshResult.DurationSeconds = int(time.Since(startTime).Seconds())
refreshResult.ErrorMessage = fmt.Sprintf("Failed to refresh DNF cache: %v", err)
return refreshResult, fmt.Errorf("dnf refresh failed: %w", err)
}
// Install all packages in one command using secure executor
args := []string{"install", "-y"}
args = append(args, packageNames...)
// Install all packages in one command using secure executor
installResult, err := i.executor.ExecuteCommand("dnf", args)
duration := int(time.Since(startTime).Seconds())
@@ -299,6 +286,37 @@ func (i *DNFInstaller) extractPackageNameFromDNFLine(line string) string {
return ""
}
// UpdatePackage updates a specific package using DNF
func (i *DNFInstaller) UpdatePackage(packageName string) (*InstallResult, error) {
startTime := time.Now()
// Update specific package using secure executor
// Use 'dnf upgrade' instead of 'dnf install' for existing packages
updateResult, err := i.executor.ExecuteCommand("dnf", []string{"upgrade", "-y", packageName})
duration := int(time.Since(startTime).Seconds())
if err != nil {
return &InstallResult{
Success: false,
ErrorMessage: fmt.Sprintf("DNF upgrade failed: %v", err),
Stdout: updateResult.Stdout,
Stderr: updateResult.Stderr,
ExitCode: updateResult.ExitCode,
DurationSeconds: duration,
}, err
}
return &InstallResult{
Success: true,
Stdout: updateResult.Stdout,
Stderr: updateResult.Stderr,
ExitCode: updateResult.ExitCode,
DurationSeconds: duration,
PackagesInstalled: []string{packageName},
Action: "upgrade",
}, nil
}
// GetPackageType returns type of packages this installer handles
func (i *DNFInstaller) GetPackageType() string {
return "dnf"

View File

@@ -60,6 +60,12 @@ func (i *DockerInstaller) Update(imageName, targetVersion string) (*InstallResul
}, nil
}
// UpdatePackage updates a specific Docker image (alias for Update method)
func (i *DockerInstaller) UpdatePackage(imageName string) (*InstallResult, error) {
// Docker uses same logic for updating as installing
return i.Update(imageName, "")
}
// Install installs a Docker image (alias for Update)
func (i *DockerInstaller) Install(imageName string) (*InstallResult, error) {
return i.Update(imageName, "")

View File

@@ -8,6 +8,7 @@ type Installer interface {
Install(packageName string) (*InstallResult, error)
InstallMultiple(packageNames []string) (*InstallResult, error)
Upgrade() (*InstallResult, error)
UpdatePackage(packageName string) (*InstallResult, error) // New: Update specific package
GetPackageType() string
DryRun(packageName string) (*InstallResult, error) // New: Perform dry run to check dependencies
}

View File

@@ -23,6 +23,7 @@ var AllowedCommands = map[string][]string{
},
"dnf": {
"refresh",
"makecache",
"install",
"upgrade",
},
@@ -93,6 +94,9 @@ func (e *SecureCommandExecutor) validateDNFCommand(args []string) error {
if !contains(args, "-y") {
return fmt.Errorf("dnf refresh must include -y flag")
}
case "makecache":
// makecache doesn't require -y flag as it's read-only
return nil
case "install":
// Allow dry-run flags for dependency checking
dryRunFlags := []string{"--assumeno", "--downloadonly"}
@@ -165,12 +169,22 @@ func (e *SecureCommandExecutor) ExecuteCommand(baseCmd string, args []string) (*
}, fmt.Errorf("command validation failed: %w", err)
}
// Log the command for audit purposes (in a real implementation, this would go to a secure log)
fmt.Printf("[AUDIT] Executing command: %s %s\n", baseCmd, strings.Join(args, " "))
// Resolve the full path to the command (required for sudo to match sudoers rules)
fullPath, err := exec.LookPath(baseCmd)
if err != nil {
return &InstallResult{
Success: false,
ErrorMessage: fmt.Sprintf("Command not found: %s", baseCmd),
}, fmt.Errorf("command not found: %w", err)
}
// Execute the command without sudo - it will be handled by sudoers
fullArgs := append([]string{baseCmd}, args...)
cmd := exec.Command(fullArgs[0], fullArgs[1:]...)
// Log the command for audit purposes (in a real implementation, this would go to a secure log)
fmt.Printf("[AUDIT] Executing command: sudo %s %s\n", fullPath, strings.Join(args, " "))
// Execute the command with sudo - requires sudoers configuration
// Use full path to match sudoers rules exactly
fullArgs := append([]string{fullPath}, args...)
cmd := exec.Command("sudo", fullArgs...)
output, err := cmd.CombinedOutput()

View File

@@ -117,6 +117,12 @@ func (i *WindowsUpdateInstaller) installViaPowerShell(packageNames []string) (st
return "Windows Updates installed via PowerShell", nil
}
// UpdatePackage updates a specific Windows update (alias for Install method)
func (i *WindowsUpdateInstaller) UpdatePackage(packageName string) (*InstallResult, error) {
// Windows uses same logic for updating as installing
return i.Install(packageName)
}
// installViaWuauclt uses traditional Windows Update client
func (i *WindowsUpdateInstaller) installViaWuauclt(packageNames []string) (string, error) {
// Force detection of updates

View File

@@ -371,4 +371,10 @@ type WingetPackage struct {
Source string `json:"Source"`
IsPinned bool `json:"IsPinned"`
PinReason string `json:"PinReason,omitempty"`
}
// UpdatePackage updates a specific winget package (alias for Install method)
func (i *WingetInstaller) UpdatePackage(packageName string) (*InstallResult, error) {
// Winget uses same logic for updating as installing
return i.Install(packageName)
}