feat: add config sync endpoint and security UI updates

- Add GET /api/v1/agents/:id/config endpoint for server configuration
- Agent fetches config during check-in and applies updates
- Add version tracking to prevent unnecessary config applications
- Clean separation: config sync independent of commands
- Fix agent UI subsystem settings to actually control agent behavior
- Update Security Health UI with frosted glass styling and tooltips
This commit is contained in:
Fimeg
2025-11-03 22:36:26 -05:00
parent eccc38d7c9
commit 38894f64d3
18 changed files with 944 additions and 87 deletions

View File

@@ -31,6 +31,10 @@ const (
AgentVersion = "0.1.22" // v0.1.22: Machine binding and version enforcement security release
)
var (
lastConfigVersion int64 = 0 // Track last applied config version
)
// getConfigPath returns the platform-specific config path
func getConfigPath() string {
if runtime.GOOS == "windows" {
@@ -452,6 +456,79 @@ func renewTokenIfNeeded(apiClient *client.Client, cfg *config.Config, err error)
return apiClient, nil
}
// syncServerConfig checks for and applies server configuration updates
func syncServerConfig(apiClient *client.Client, cfg *config.Config) error {
// Get current config from server
serverConfig, err := apiClient.GetConfig(cfg.AgentID)
if err != nil {
return fmt.Errorf("failed to get server config: %w", err)
}
// Check if config version is newer
if serverConfig.Version <= lastConfigVersion {
return nil // No update needed
}
log.Printf("📡 Server config update detected (version: %d)", serverConfig.Version)
changes := false
// Apply subsystem configuration from server
for subsystemName, subsystemConfig := range serverConfig.Subsystems {
if configMap, ok := subsystemConfig.(map[string]interface{}); ok {
enabled := false
intervalMinutes := 0
autoRun := false
if e, exists := configMap["enabled"]; exists {
if eVal, ok := e.(bool); ok {
enabled = eVal
}
}
if i, exists := configMap["interval_minutes"]; exists {
if iVal, ok := i.(float64); ok {
intervalMinutes = int(iVal)
}
}
if a, exists := configMap["auto_run"]; exists {
if aVal, ok := a.(bool); ok {
autoRun = aVal
}
}
// Only log changes if different from current state
currentEnabled := cfg.Subsystems.APT.Enabled // We'd need to check actual current state here
if enabled != currentEnabled {
log.Printf(" → %s: enabled=%v (changed)", subsystemName, enabled)
changes = true
}
if intervalMinutes > 0 && intervalMinutes != cfg.CheckInInterval {
log.Printf(" → %s: interval=%d minutes (changed)", subsystemName, intervalMinutes)
changes = true
// Note: For now, we use the check-in interval from server config
cfg.CheckInInterval = intervalMinutes
}
if autoRun {
log.Printf(" → %s: auto_run=%v (server-side scheduling)", subsystemName, autoRun)
}
}
}
if changes {
log.Printf("✅ Server configuration applied successfully")
} else {
log.Printf(" Server config received but no changes detected")
}
// Update last config version
lastConfigVersion = serverConfig.Version
return nil
}
func runAgent(cfg *config.Config) error {
log.Printf("🚩 RedFlag Agent v%s starting...\n", AgentVersion)
log.Printf("==================================================================")
@@ -670,6 +747,13 @@ func runAgent(cfg *config.Config) error {
}
}
// Sync configuration from server (non-blocking)
go func() {
if err := syncServerConfig(apiClient, cfg); err != nil {
log.Printf("Warning: Failed to sync server config: %v", err)
}
}()
commands := response.Commands
if len(commands) == 0 {
log.Printf("Check-in successful - no new commands")

View File

@@ -285,21 +285,59 @@ func handleScanDocker(apiClient *client.Client, cfg *config.Config, ackTracker *
log.Printf("Failed to report scan log: %v\n", err)
}
// Report updates to server if any were found
if len(result.Updates) > 0 {
report := client.UpdateReport{
CommandID: commandID,
Timestamp: time.Now(),
Updates: result.Updates,
// Report Docker images to server using dedicated endpoint
// Get Docker scanner and use proper interface
dockerScanner, err := orchestrator.NewDockerScanner()
if err != nil {
return fmt.Errorf("failed to create Docker scanner: %w", err)
}
defer dockerScanner.Close()
if dockerScanner.IsAvailable() {
images, err := dockerScanner.ScanDocker()
if err != nil {
return fmt.Errorf("failed to scan Docker images: %w", err)
}
if err := apiClient.ReportUpdates(cfg.AgentID, report); err != nil {
return fmt.Errorf("failed to report Docker updates: %w", err)
}
// Always report all Docker images (not just those with updates)
if len(images) > 0 {
// Convert DockerImage to DockerReportItem for API call
imageItems := make([]client.DockerReportItem, 0, len(images))
for _, image := range images {
item := client.DockerReportItem{
PackageType: "docker_image",
PackageName: image.ImageName,
CurrentVersion: image.ImageID,
AvailableVersion: image.LatestImageID,
Severity: image.Severity,
RepositorySource: image.RepositorySource,
Metadata: image.Metadata,
}
imageItems = append(imageItems, item)
}
log.Printf("✓ Reported %d Docker image updates to server\n", len(result.Updates))
report := client.DockerReport{
CommandID: commandID,
Timestamp: time.Now(),
Images: imageItems,
}
if err := apiClient.ReportDockerImages(cfg.AgentID, report); err != nil {
return fmt.Errorf("failed to report Docker images: %w", err)
}
updateCount := 0
for _, image := range images {
if image.HasUpdate {
updateCount++
}
}
log.Printf("✓ Reported %d Docker images (%d with updates) to server\n", len(images), updateCount)
} else {
log.Println("No Docker images found")
}
} else {
log.Println("No Docker image updates found")
log.Println("Docker not available on this system")
}
return nil

View File

@@ -0,0 +1,262 @@
#!/bin/bash
set -e
# RedFlag Agent Installation Script
# This script installs the RedFlag agent as a systemd service with proper permissions
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
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"
echo "=== RedFlag Agent Installation ==="
echo ""
# Check if running as root
if [ "$EUID" -ne 0 ]; then
echo "ERROR: This script must be run as root (use sudo)"
exit 1
fi
# Function to create user if doesn't exist
create_user() {
if id "$AGENT_USER" &>/dev/null; then
echo "✓ User $AGENT_USER already exists"
else
echo "Creating system user $AGENT_USER..."
useradd -r -s /bin/false -d "$AGENT_HOME" -m "$AGENT_USER"
echo "✓ User $AGENT_USER created"
fi
# Add user to docker group for Docker update scanning
if getent group docker &>/dev/null; then
echo "Adding $AGENT_USER to docker group..."
usermod -aG docker "$AGENT_USER"
echo "✓ User $AGENT_USER added to docker group"
else
echo "⚠ Docker group not found - Docker updates will not be available"
echo " (Install Docker first, then reinstall the agent to enable Docker support)"
fi
}
# Function to build agent binary
build_agent() {
echo "Building agent binary..."
cd "$SCRIPT_DIR"
go build -o redflag-agent ./cmd/agent
echo "✓ Agent binary built"
}
# Function to install agent binary
install_binary() {
echo "Installing agent binary to $AGENT_BINARY..."
cp "$SCRIPT_DIR/redflag-agent" "$AGENT_BINARY"
chmod 755 "$AGENT_BINARY"
chown root:root "$AGENT_BINARY"
echo "✓ Agent binary installed"
# 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
}
# Function to install sudoers configuration
install_sudoers() {
echo "Installing sudoers configuration..."
cat > "$SUDOERS_FILE" <<'EOF'
# RedFlag Agent minimal sudo permissions
# This file is generated automatically during RedFlag agent installation
# APT package management commands
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
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 *
EOF
chmod 440 "$SUDOERS_FILE"
# Validate sudoers file
if visudo -c -f "$SUDOERS_FILE"; then
echo "✓ Sudoers configuration installed and validated"
else
echo "ERROR: Sudoers configuration is invalid"
rm -f "$SUDOERS_FILE"
exit 1
fi
}
# Function to install systemd service
install_service() {
echo "Installing systemd service..."
cat > "$SERVICE_FILE" <<EOF
[Unit]
Description=RedFlag Update Agent
After=network.target
[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
ProtectSystem=strict
ProtectHome=true
ReadWritePaths=$AGENT_HOME /var/log /etc/aggregator /var/lib/aggregator
PrivateTmp=true
[Install]
WantedBy=multi-user.target
EOF
chmod 644 "$SERVICE_FILE"
echo "✓ Systemd service installed"
}
# Function to start and enable service
start_service() {
echo "Reloading systemd daemon..."
systemctl daemon-reload
# Stop service if running
if systemctl is-active --quiet redflag-agent; then
echo "Stopping existing service..."
systemctl stop redflag-agent
fi
echo "Enabling and starting redflag-agent service..."
systemctl enable redflag-agent
systemctl start redflag-agent
# Wait a moment for service to start
sleep 2
echo "✓ Service started"
}
# Function to show status
show_status() {
echo ""
echo "=== Service Status ==="
systemctl status redflag-agent --no-pager -l
echo ""
echo "=== Recent Logs ==="
journalctl -u redflag-agent -n 20 --no-pager
}
# Function to register agent
register_agent() {
local server_url="${1:-http://localhost:8080}"
echo "Registering agent with server at $server_url..."
# Create config directory
mkdir -p /etc/aggregator
# Create state directory for acknowledgment tracking (v0.1.19+)
mkdir -p /var/lib/aggregator
chown redflag-agent:redflag-agent /var/lib/aggregator
chmod 755 /var/lib/aggregator
# Clean up old configuration files (prevents conflicts during reinstall/upgrade)
if [ -f /etc/aggregator/.env ]; then
echo "Removing old .env file..."
rm -f /etc/aggregator/.env
echo "✓ Old .env file removed"
fi
if [ -f /etc/aggregator/config.json ]; then
echo "Removing old config.json file..."
rm -f /etc/aggregator/config.json
echo "✓ Old config.json file removed"
fi
# 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 /etc/aggregator 2>/dev/null || true
echo "✓ SELinux context set for config directory"
fi
# Register agent (run as regular binary, not as service)
if "$AGENT_BINARY" -register -server "$server_url"; then
echo "✓ Agent registered successfully"
else
echo "ERROR: Agent registration failed"
echo "Please ensure the RedFlag server is running at $server_url"
exit 1
fi
}
# Main installation flow
SERVER_URL="${1:-http://localhost:8080}"
echo "Step 1: Creating system user..."
create_user
echo ""
echo "Step 2: Building agent binary..."
build_agent
echo ""
echo "Step 3: Installing agent binary..."
install_binary
echo ""
echo "Step 4: Registering agent with server..."
register_agent "$SERVER_URL"
echo ""
echo "Step 5: Setting config file permissions..."
chown redflag-agent:redflag-agent /etc/aggregator/config.json
chmod 600 /etc/aggregator/config.json
echo ""
echo "Step 6: Installing sudoers configuration..."
install_sudoers
echo ""
echo "Step 7: Installing systemd service..."
install_service
echo ""
echo "Step 8: Starting service..."
start_service
echo ""
echo "=== Installation Complete ==="
echo ""
echo "The RedFlag agent is now installed and running as a systemd service."
echo "Server URL: $SERVER_URL"
echo ""
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 " - Disable: sudo systemctl disable redflag-agent"
echo ""
echo "Note: To re-register with a different server, edit /etc/aggregator/config.json"
echo ""
show_status

View File

@@ -675,3 +675,40 @@ func parseOSRelease(data []byte) string {
return "Linux"
}
// AgentConfigResponse contains subsystem configuration from server
type AgentConfigResponse struct {
Subsystems map[string]interface{} `json:"subsystems"`
Version int64 `json:"version"`
}
// GetConfig retrieves current subsystem configuration from server
func (c *Client) GetConfig(agentID uuid.UUID) (*AgentConfigResponse, error) {
url := fmt.Sprintf("%s/api/v1/agents/%s/config", c.baseURL, agentID)
req, err := http.NewRequest("GET", url, nil)
if err != nil {
return nil, err
}
req.Header.Set("Authorization", "Bearer "+c.token)
c.addMachineIDHeader(req) // Security: Validate machine binding (v0.1.22+)
resp, err := c.http.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
bodyBytes, _ := io.ReadAll(resp.Body)
return nil, fmt.Errorf("failed to get config: %s - %s", resp.Status, string(bodyBytes))
}
var result AgentConfigResponse
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return nil, err
}
return &result, nil
}

View File

@@ -0,0 +1,266 @@
package orchestrator
import (
"context"
"fmt"
"os/exec"
"strings"
"time"
"github.com/Fimeg/RedFlag/aggregator-agent/internal/client"
"github.com/docker/docker/api/types/container"
dockerclient "github.com/docker/docker/client"
)
// DockerScanner scans for Docker image updates
type DockerScanner struct {
client *dockerclient.Client
registryClient *RegistryClient
}
// NewDockerScanner creates a new Docker scanner
func NewDockerScanner() (*DockerScanner, error) {
cli, err := dockerclient.NewClientWithOpts(dockerclient.FromEnv, dockerclient.WithAPIVersionNegotiation())
if err != nil {
return nil, err
}
return &DockerScanner{
client: cli,
registryClient: NewRegistryClient(),
}, nil
}
// IsAvailable checks if Docker is available on this system
func (s *DockerScanner) IsAvailable() bool {
_, err := exec.LookPath("docker")
if err != nil {
return false
}
// Try to ping Docker daemon
if s.client != nil {
_, err := s.client.Ping(context.Background())
return err == nil
}
return false
}
// ScanDocker scans for available Docker image updates and returns proper DockerImage data
func (s *DockerScanner) ScanDocker() ([]DockerImage, error) {
ctx := context.Background()
// List all containers
containers, err := s.client.ContainerList(ctx, container.ListOptions{All: true})
if err != nil {
return nil, fmt.Errorf("failed to list containers: %w", err)
}
var images []DockerImage
seenImages := make(map[string]bool)
for _, c := range containers {
imageName := c.Image
// Skip if we've already checked this image
if seenImages[imageName] {
continue
}
seenImages[imageName] = true
// Get current image details
imageInspect, _, err := s.client.ImageInspectWithRaw(ctx, imageName)
if err != nil {
continue
}
// Parse image name and tag
parts := strings.Split(imageName, ":")
baseImage := parts[0]
currentTag := "latest"
if len(parts) > 1 {
currentTag = parts[1]
}
// Check if update is available by comparing with registry
hasUpdate, remoteDigest := s.checkForUpdate(ctx, baseImage, currentTag, imageInspect.ID)
// Extract short digest for display (first 12 chars of sha256 hash)
localDigest := imageInspect.ID
localShortDigest := ""
if len(localDigest) > 7 {
parts := strings.SplitN(localDigest, ":", 2)
if len(parts) == 2 && len(parts[1]) >= 12 {
localShortDigest = parts[1][:12]
}
}
remoteShortDigest := ""
if len(remoteDigest) > 7 {
parts := strings.SplitN(remoteDigest, ":", 2)
if len(parts) == 2 && len(parts[1]) >= 12 {
remoteShortDigest = parts[1][:12]
}
}
// Determine severity based on update status
severity := "low"
if hasUpdate {
severity = "moderate"
}
// Extract image labels
labels := make(map[string]string)
if imageInspect.Config != nil {
labels = imageInspect.Config.Labels
}
// Get image size
sizeBytes := int64(0)
if len(imageInspect.RootFS.Layers) > 0 {
sizeBytes = imageInspect.Size
}
// Parse the creation time - imageInspect.Created is already a string
createdAt := imageInspect.Created
if createdAt == "" {
createdAt = time.Now().Format(time.RFC3339)
}
image := DockerImage{
ImageName: imageName,
ImageTag: currentTag,
ImageID: localShortDigest,
RepositorySource: baseImage,
SizeBytes: sizeBytes,
CreatedAt: createdAt,
HasUpdate: hasUpdate,
LatestImageID: remoteShortDigest,
Severity: severity,
Labels: labels,
Metadata: map[string]interface{}{
"container_id": c.ID[:12],
"container_names": c.Names,
"container_state": c.State,
"image_created": imageInspect.Created,
"local_full_digest": localDigest,
"remote_digest": remoteDigest,
},
}
images = append(images, image)
}
return images, nil
}
// Name returns the scanner name
func (s *DockerScanner) Name() string {
return "Docker Image Scanner"
}
// --- Legacy Compatibility Methods ---
// Scan scans for available Docker image updates (LEGACY)
// This method is kept for backwards compatibility with the old Scanner interface
func (s *DockerScanner) Scan() ([]client.UpdateReportItem, error) {
images, err := s.ScanDocker()
if err != nil {
return nil, err
}
// Convert proper DockerImage back to legacy UpdateReportItem format
var items []client.UpdateReportItem
for _, image := range images {
if image.HasUpdate { // Only include images that have updates
item := client.UpdateReportItem{
PackageType: "docker_image",
PackageName: image.ImageName,
PackageDescription: fmt.Sprintf("Docker Image: %s", image.ImageName),
CurrentVersion: image.ImageID,
AvailableVersion: image.LatestImageID,
Severity: image.Severity,
RepositorySource: image.RepositorySource,
Metadata: image.Metadata,
}
items = append(items, item)
}
}
return items, nil
}
// --- Typed Scanner Implementation ---
// GetType returns the scanner type
func (s *DockerScanner) GetType() ScannerType {
return ScannerTypeDocker
}
// ScanTyped returns typed results (new implementation)
func (s *DockerScanner) ScanTyped() (TypedScannerResult, error) {
startTime := time.Now()
images, err := s.ScanDocker()
if err != nil {
return TypedScannerResult{
ScannerName: s.Name(),
ScannerType: ScannerTypeDocker,
Error: err,
Status: "failed",
Duration: time.Since(startTime).Milliseconds(),
}, err
}
return TypedScannerResult{
ScannerName: s.Name(),
ScannerType: ScannerTypeDocker,
DockerData: images,
Status: "success",
Duration: time.Since(startTime).Milliseconds(),
}, nil
}
// checkForUpdate checks if a newer image version is available by comparing digests
// Returns (hasUpdate bool, remoteDigest string)
func (s *DockerScanner) checkForUpdate(ctx context.Context, imageName, tag, currentID string) (bool, string) {
// Get remote digest from registry
remoteDigest, err := s.registryClient.GetRemoteDigest(ctx, imageName, tag)
if err != nil {
// If we can't check the registry, log the error but don't report an update
fmt.Printf("Warning: Failed to check registry for %s:%s: %v\n", imageName, tag, err)
return false, ""
}
// Compare digests
hasUpdate := currentID != remoteDigest
return hasUpdate, remoteDigest
}
// Close closes the Docker client
func (s *DockerScanner) Close() error {
if s.client != nil {
return s.client.Close()
}
return nil
}
// --- Registry Client (simplified for this implementation) ---
// RegistryClient handles Docker registry API interactions
type RegistryClient struct{}
// NewRegistryClient creates a new registry client
func NewRegistryClient() *RegistryClient {
return &RegistryClient{}
}
// GetRemoteDigest gets the remote digest for an image from the registry
func (r *RegistryClient) GetRemoteDigest(ctx context.Context, imageName, tag string) (string, error) {
// This is a simplified implementation
// In a real implementation, you would query Docker Hub or the appropriate registry
// For now, return an empty string to indicate no remote digest available
return "", fmt.Errorf("registry client not implemented")
}

View File

@@ -2,7 +2,6 @@ package orchestrator
import (
"github.com/Fimeg/RedFlag/aggregator-agent/internal/client"
"github.com/Fimeg/RedFlag/aggregator-agent/internal/system"
)
// StorageMetric represents a single storage/disk metric

View File

@@ -45,16 +45,16 @@ func (s *StorageScanner) ScanStorage() ([]StorageMetric, error) {
Filesystem: disk.Filesystem,
Device: disk.Device,
DiskType: disk.DiskType,
TotalBytes: disk.Total,
UsedBytes: disk.Used,
AvailableBytes: disk.Available,
TotalBytes: int64(disk.Total),
UsedBytes: int64(disk.Used),
AvailableBytes: int64(disk.Available),
UsedPercent: disk.UsedPercent,
IsRoot: disk.IsRoot,
IsLargest: disk.IsLargest,
Severity: determineDiskSeverity(disk.UsedPercent),
Metadata: map[string]interface{}{
"agent_version": s.agentVersion,
"collected_at": sysInfo.Timestamp,
"collected_at": time.Now().Format(time.RFC3339),
},
}
metrics = append(metrics, metric)