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:
Fimeg
2025-10-30 22:17:48 -04:00
parent 3940877fb2
commit a92ac0ed78
60 changed files with 4301 additions and 1258 deletions

13
aggregator-agent/NOTICE Normal file
View File

@@ -0,0 +1,13 @@
RedFlag Agent
Copyright 2024-2025
This software includes code from the following third-party projects:
---
windowsupdate
Copyright 2022 Zheng Dayu
Licensed under the Apache License, Version 2.0
https://github.com/ceshihao/windowsupdate
Included in: aggregator-agent/pkg/windowsupdate/

View File

@@ -16,12 +16,13 @@ import (
"github.com/Fimeg/RedFlag/aggregator-agent/internal/display"
"github.com/Fimeg/RedFlag/aggregator-agent/internal/installer"
"github.com/Fimeg/RedFlag/aggregator-agent/internal/scanner"
"github.com/Fimeg/RedFlag/aggregator-agent/internal/service"
"github.com/Fimeg/RedFlag/aggregator-agent/internal/system"
"github.com/google/uuid"
)
const (
AgentVersion = "0.1.16" // Enhanced configuration system with proxy support and registration tokens
AgentVersion = "0.1.17" // Fixed Linux disk detection to show all physical mount points (/, /home, etc.)
)
// getConfigPath returns the platform-specific config path
@@ -86,6 +87,13 @@ func main() {
displayName := flag.String("name", "", "Display name for agent")
insecureTLS := flag.Bool("insecure-tls", false, "Skip TLS certificate verification")
exportFormat := flag.String("export", "", "Export format: json, csv")
// Windows service management commands
installServiceCmd := flag.Bool("install-service", false, "Install as Windows service")
removeServiceCmd := flag.Bool("remove-service", false, "Remove Windows service")
startServiceCmd := flag.Bool("start-service", false, "Start Windows service")
stopServiceCmd := flag.Bool("stop-service", false, "Stop Windows service")
serviceStatusCmd := flag.Bool("service-status", false, "Show Windows service status")
flag.Parse()
// Handle version command
@@ -95,6 +103,48 @@ func main() {
os.Exit(0)
}
// Handle Windows service management commands (only on Windows)
if runtime.GOOS == "windows" {
if *installServiceCmd {
if err := service.InstallService(); err != nil {
log.Fatalf("Failed to install service: %v", err)
}
fmt.Println("RedFlag service installed successfully")
os.Exit(0)
}
if *removeServiceCmd {
if err := service.RemoveService(); err != nil {
log.Fatalf("Failed to remove service: %v", err)
}
fmt.Println("RedFlag service removed successfully")
os.Exit(0)
}
if *startServiceCmd {
if err := service.StartService(); err != nil {
log.Fatalf("Failed to start service: %v", err)
}
fmt.Println("RedFlag service started successfully")
os.Exit(0)
}
if *stopServiceCmd {
if err := service.StopService(); err != nil {
log.Fatalf("Failed to stop service: %v", err)
}
fmt.Println("RedFlag service stopped successfully")
os.Exit(0)
}
if *serviceStatusCmd {
if err := service.ServiceStatus(); err != nil {
log.Fatalf("Failed to get service status: %v", err)
}
os.Exit(0)
}
}
// Parse tags from comma-separated string
var tags []string
if *tagsFlag != "" {
@@ -197,7 +247,16 @@ func main() {
log.Fatal("Agent not registered. Run with -register flag first.")
}
// Start agent service
// Check if running as Windows service
if runtime.GOOS == "windows" && service.IsService() {
// Run as Windows service
if err := service.RunService(cfg); err != nil {
log.Fatal("Service failed:", err)
}
return
}
// Start agent service (console mode)
if err := runAgent(cfg); err != nil {
log.Fatal("Agent failed:", err)
}
@@ -221,7 +280,8 @@ func registerAgent(cfg *config.Config, serverURL string) error {
}
}
apiClient := client.NewClient(serverURL, "")
// Use registration token from config if available
apiClient := client.NewClient(serverURL, cfg.RegistrationToken)
// Create metadata with system information
metadata := map[string]string{

View File

@@ -1,10 +1,12 @@
module github.com/Fimeg/RedFlag/aggregator-agent
go 1.21
go 1.23.0
require (
github.com/docker/docker v27.4.1+incompatible
github.com/go-ole/go-ole v1.3.0
github.com/google/uuid v1.6.0
github.com/scjalliance/comshim v0.0.0-20250111221056-b2ef9d8d7e0f
)
require (
@@ -16,7 +18,6 @@ require (
github.com/felixge/httpsnoop v1.0.4 // indirect
github.com/go-logr/logr v1.4.3 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
github.com/go-ole/go-ole v1.3.0 // indirect
github.com/gogo/protobuf v1.3.2 // indirect
github.com/moby/docker-image-spec v1.3.1 // indirect
github.com/moby/term v0.5.2 // indirect
@@ -24,7 +25,6 @@ require (
github.com/opencontainers/go-digest v1.0.0 // indirect
github.com/opencontainers/image-spec v1.1.1 // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/scjalliance/comshim v0.0.0-20250111221056-b2ef9d8d7e0f // indirect
go.opentelemetry.io/auto/sdk v1.1.0 // indirect
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.63.0 // indirect
go.opentelemetry.io/otel v1.38.0 // indirect
@@ -32,6 +32,6 @@ require (
go.opentelemetry.io/otel/metric v1.38.0 // indirect
go.opentelemetry.io/otel/trace v1.38.0 // indirect
golang.org/x/sys v0.35.0 // indirect
golang.org/x/time v0.14.0 // indirect
golang.org/x/time v0.5.0 // indirect
gotest.tools/v3 v3.5.2 // indirect
)

View File

@@ -105,8 +105,8 @@ golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng=
golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU=
golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI=
golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4=
golang.org/x/time v0.5.0 h1:o7cqy6amK/52YcAKIPlM3a+Fpj35zvRj2TP+e1xFSfk=
golang.org/x/time v0.5.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=

View File

@@ -56,6 +56,13 @@ install_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
@@ -167,6 +174,13 @@ register_agent() {
# Create config directory
mkdir -p /etc/aggregator
# 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"

View File

@@ -46,12 +46,13 @@ func (c *Client) SetToken(token string) {
// RegisterRequest is the payload for agent registration
type RegisterRequest struct {
Hostname string `json:"hostname"`
OSType string `json:"os_type"`
OSVersion string `json:"os_version"`
OSArchitecture string `json:"os_architecture"`
AgentVersion string `json:"agent_version"`
Metadata map[string]string `json:"metadata"`
Hostname string `json:"hostname"`
OSType string `json:"os_type"`
OSVersion string `json:"os_version"`
OSArchitecture string `json:"os_architecture"`
AgentVersion string `json:"agent_version"`
RegistrationToken string `json:"registration_token,omitempty"` // Fallback method
Metadata map[string]string `json:"metadata"`
}
// RegisterResponse is returned after successful registration
@@ -66,6 +67,12 @@ type RegisterResponse struct {
func (c *Client) Register(req RegisterRequest) (*RegisterResponse, error) {
url := fmt.Sprintf("%s/api/v1/agents/register", c.baseURL)
// If we have a registration token, include it in the request
// Registration tokens are longer than regular JWT tokens (usually 64 chars vs JWT ~400 chars)
if c.token != "" && len(c.token) > 40 {
req.RegistrationToken = c.token
}
body, err := json.Marshal(req)
if err != nil {
return nil, err
@@ -77,6 +84,12 @@ func (c *Client) Register(req RegisterRequest) (*RegisterResponse, error) {
}
httpReq.Header.Set("Content-Type", "application/json")
// Add Authorization header if we have a registration token (preferred method)
// Registration tokens are longer than regular JWT tokens (usually 64 chars vs JWT ~400 chars)
if c.token != "" && len(c.token) > 40 {
httpReq.Header.Set("Authorization", "Bearer "+c.token)
}
resp, err := c.http.Do(httpReq)
if err != nil {
return nil, err

View File

@@ -349,6 +349,12 @@ func (c *Config) Save(configPath string) error {
return fmt.Errorf("failed to marshal config: %w", err)
}
// Create parent directory if it doesn't exist
dir := filepath.Dir(configPath)
if err := os.MkdirAll(dir, 0755); err != nil {
return fmt.Errorf("failed to create config directory: %w", err)
}
if err := os.WriteFile(configPath, data, 0600); err != nil {
return fmt.Errorf("failed to write config: %w", err)
}

View File

@@ -0,0 +1,52 @@
//go:build !windows
package service
import (
"fmt"
"runtime"
"github.com/Fimeg/RedFlag/aggregator-agent/internal/config"
)
// Stub implementations for non-Windows platforms
// RunService executes the agent as a Windows service (stub for non-Windows)
func RunService(cfg *config.Config) error {
return fmt.Errorf("Windows service mode is only available on Windows, current OS: %s", runtime.GOOS)
}
// IsService returns true if running as Windows service (stub for non-Windows)
func IsService() bool {
return false
}
// InstallService installs the agent as a Windows service (stub for non-Windows)
func InstallService() error {
return fmt.Errorf("Windows service installation is only available on Windows, current OS: %s", runtime.GOOS)
}
// RemoveService removes the Windows service (stub for non-Windows)
func RemoveService() error {
return fmt.Errorf("Windows service removal is only available on Windows, current OS: %s", runtime.GOOS)
}
// StartService starts the Windows service (stub for non-Windows)
func StartService() error {
return fmt.Errorf("Windows service management is only available on Windows, current OS: %s", runtime.GOOS)
}
// StopService stops the Windows service (stub for non-Windows)
func StopService() error {
return fmt.Errorf("Windows service management is only available on Windows, current OS: %s", runtime.GOOS)
}
// ServiceStatus returns the current status of the Windows service (stub for non-Windows)
func ServiceStatus() error {
return fmt.Errorf("Windows service management is only available on Windows, current OS: %s", runtime.GOOS)
}
// RunConsole runs the agent in console mode with signal handling
func RunConsole(cfg *config.Config) error {
// For non-Windows, just run normally
return fmt.Errorf("Console mode is handled by main application logic on %s", runtime.GOOS)
}

File diff suppressed because it is too large Load Diff

View File

@@ -262,9 +262,42 @@ func getDiskInfo() ([]DiskInfo, error) {
fields := strings.Fields(line)
if len(fields) >= 6 {
mountpoint := fields[0]
filesystem := fields[5]
// Filter out pseudo-filesystems and only show physical/important mounts
// Skip tmpfs, devtmpfs, overlay, squashfs, etc.
if strings.HasPrefix(filesystem, "tmpfs") ||
strings.HasPrefix(filesystem, "devtmpfs") ||
strings.HasPrefix(filesystem, "overlay") ||
strings.HasPrefix(filesystem, "squashfs") ||
strings.HasPrefix(filesystem, "udev") ||
strings.HasPrefix(filesystem, "proc") ||
strings.HasPrefix(filesystem, "sysfs") ||
strings.HasPrefix(filesystem, "cgroup") ||
strings.HasPrefix(filesystem, "devpts") ||
strings.HasPrefix(filesystem, "securityfs") ||
strings.HasPrefix(filesystem, "pstore") ||
strings.HasPrefix(filesystem, "bpf") ||
strings.HasPrefix(filesystem, "configfs") ||
strings.HasPrefix(filesystem, "fusectl") ||
strings.HasPrefix(filesystem, "hugetlbfs") ||
strings.HasPrefix(filesystem, "mqueue") ||
strings.HasPrefix(filesystem, "debugfs") ||
strings.HasPrefix(filesystem, "tracefs") {
continue // Skip virtual/pseudo filesystems
}
// Skip container/snap mounts unless they're important
if strings.Contains(mountpoint, "/snap/") ||
strings.Contains(mountpoint, "/var/lib/docker") ||
strings.Contains(mountpoint, "/run") {
continue
}
disk := DiskInfo{
Mountpoint: fields[0],
Filesystem: fields[5],
Mountpoint: mountpoint,
Filesystem: filesystem,
}
// Parse sizes (df outputs in human readable format, we'll parse the numeric part)

View File

@@ -14,20 +14,34 @@ import (
func getWindowsInfo() string {
// Try using wmic for detailed Windows version info
if cmd, err := exec.LookPath("wmic"); err == nil {
if data, err := exec.Command(cmd, "os", "get", "Caption,Version,BuildNumber,SKU").Output(); err == nil {
lines := strings.Split(string(data), "\n")
for _, line := range lines {
if strings.Contains(line, "Microsoft Windows") {
// Clean up the output
line = strings.TrimSpace(line)
// Remove extra spaces
for strings.Contains(line, " ") {
line = strings.ReplaceAll(line, " ", " ")
}
return line
}
// Get Caption (e.g., "Microsoft Windows 10 Pro")
caption := ""
if data, err := exec.Command(cmd, "os", "get", "Caption", "/value").Output(); err == nil {
output := strings.TrimSpace(string(data))
if strings.HasPrefix(output, "Caption=") {
caption = strings.TrimPrefix(output, "Caption=")
caption = strings.TrimSpace(caption)
}
}
// Get Version and Build Number
version := ""
if data, err := exec.Command(cmd, "os", "get", "Version", "/value").Output(); err == nil {
output := strings.TrimSpace(string(data))
if strings.HasPrefix(output, "Version=") {
version = strings.TrimPrefix(output, "Version=")
version = strings.TrimSpace(version)
}
}
// Combine caption and version for clean output
if caption != "" && version != "" {
return fmt.Sprintf("%s (Build %s)", caption, version)
} else if caption != "" {
return caption
} else if version != "" {
return fmt.Sprintf("Windows %s", version)
}
}
// Fallback to basic version detection
@@ -180,31 +194,50 @@ func getWindowsDiskInfo() ([]DiskInfo, error) {
var disks []DiskInfo
if cmd, err := exec.LookPath("wmic"); err == nil {
// Get logical disk information
if data, err := exec.Command(cmd, "logicaldisk", "get", "DeviceID,Size,FreeSpace,FileSystem").Output(); err == nil {
// Get logical disk information - use /value format for reliable parsing
if data, err := exec.Command(cmd, "logicaldisk", "get", "DeviceID,Size,FreeSpace,FileSystem", "/format:csv").Output(); err == nil {
lines := strings.Split(string(data), "\n")
for _, line := range lines {
if strings.TrimSpace(line) != "" && !strings.Contains(line, "DeviceID") {
fields := strings.Fields(line)
if len(fields) >= 4 {
disk := DiskInfo{
Mountpoint: strings.TrimSpace(fields[0]),
Filesystem: strings.TrimSpace(fields[3]),
}
for i, line := range lines {
line = strings.TrimSpace(line)
// Skip header and empty lines
if i == 0 || line == "" || !strings.Contains(line, ",") {
continue
}
// Parse sizes (wmic outputs in bytes)
if total, err := strconv.ParseUint(strings.TrimSpace(fields[1]), 10, 64); err == nil {
disk.Total = total
}
if available, err := strconv.ParseUint(strings.TrimSpace(fields[2]), 10, 64); err == nil {
disk.Available = available
}
// CSV format: Node,DeviceID,FileSystem,FreeSpace,Size
fields := strings.Split(line, ",")
if len(fields) >= 5 {
deviceID := strings.TrimSpace(fields[1])
filesystem := strings.TrimSpace(fields[2])
freeSpaceStr := strings.TrimSpace(fields[3])
sizeStr := strings.TrimSpace(fields[4])
// Skip if no size info (e.g., CD-ROM drives)
if sizeStr == "" || freeSpaceStr == "" {
continue
}
disk := DiskInfo{
Mountpoint: deviceID,
Filesystem: filesystem,
}
// Parse sizes (wmic outputs in bytes)
if total, err := strconv.ParseUint(sizeStr, 10, 64); err == nil {
disk.Total = total
}
if available, err := strconv.ParseUint(freeSpaceStr, 10, 64); err == nil {
disk.Available = available
}
// Calculate used space
if disk.Total > 0 && disk.Available <= disk.Total {
disk.Used = disk.Total - disk.Available
if disk.Total > 0 {
disk.UsedPercent = float64(disk.Used) / float64(disk.Total) * 100
}
disk.UsedPercent = float64(disk.Used) / float64(disk.Total) * 100
}
// Only add disks with valid size info
if disk.Total > 0 {
disks = append(disks, disk)
}
}
@@ -238,36 +271,35 @@ func getWindowsProcessCount() (int, error) {
func getWindowsUptime() (string, error) {
// Try PowerShell first for more accurate uptime
if cmd, err := exec.LookPath("powershell"); err == nil {
// Get uptime in seconds for precise calculation
if data, err := exec.Command(cmd, "-Command",
"(Get-Date) - (Get-CimInstance Win32_OperatingSystem).LastBootUpTime | Select-Object TotalDays").Output(); err == nil {
// Parse the output to get days
lines := strings.Split(string(data), "\n")
for _, line := range lines {
if strings.Contains(line, "TotalDays") {
fields := strings.Fields(line)
if len(fields) >= 2 {
if days, err := strconv.ParseFloat(fields[len(fields)-1], 64); err == nil {
return formatUptimeFromDays(days), nil
}
}
}
"(New-TimeSpan -Start (Get-CimInstance Win32_OperatingSystem).LastBootUpTime -End (Get-Date)).TotalSeconds").Output(); err == nil {
secondsStr := strings.TrimSpace(string(data))
if seconds, err := strconv.ParseFloat(secondsStr, 64); err == nil {
return formatUptimeFromSeconds(seconds), nil
}
}
}
// Fallback to wmic
// Fallback to wmic with manual parsing
if cmd, err := exec.LookPath("wmic"); err == nil {
if data, err := exec.Command(cmd, "os", "get", "LastBootUpTime").Output(); err == nil {
lines := strings.Split(string(data), "\n")
for _, line := range lines {
if strings.TrimSpace(line) != "" && !strings.Contains(line, "LastBootUpTime") {
// Parse WMI datetime format: 20231201123045.123456-300
wmiTime := strings.TrimSpace(line)
if len(wmiTime) >= 14 {
// Extract just the date part for basic calculation
// This is a simplified approach - in production you'd want proper datetime parsing
return fmt.Sprintf("Since %s", wmiTime[:8]), nil
}
if data, err := exec.Command(cmd, "os", "get", "LastBootUpTime", "/value").Output(); err == nil {
output := strings.TrimSpace(string(data))
if strings.HasPrefix(output, "LastBootUpTime=") {
wmiTime := strings.TrimPrefix(output, "LastBootUpTime=")
wmiTime = strings.TrimSpace(wmiTime)
// Parse WMI datetime format: 20251025123045.123456-300
if len(wmiTime) >= 14 {
// Extract date/time components: YYYYMMDDHHmmss
year := wmiTime[0:4]
month := wmiTime[4:6]
day := wmiTime[6:8]
hour := wmiTime[8:10]
minute := wmiTime[10:12]
second := wmiTime[12:14]
bootTimeStr := fmt.Sprintf("%s-%s-%s %s:%s:%s", year, month, day, hour, minute, second)
return fmt.Sprintf("Since %s", bootTimeStr), nil
}
}
}
@@ -276,6 +308,27 @@ func getWindowsUptime() (string, error) {
return "Unknown", nil
}
// formatUptimeFromSeconds formats uptime from seconds into human readable format
func formatUptimeFromSeconds(seconds float64) string {
days := int(seconds / 86400)
hours := int((seconds - float64(days*86400)) / 3600)
minutes := int((seconds - float64(days*86400) - float64(hours*3600)) / 60)
if days > 0 {
if hours > 0 {
return fmt.Sprintf("%d days, %d hours", days, hours)
}
return fmt.Sprintf("%d days", days)
} else if hours > 0 {
if minutes > 0 {
return fmt.Sprintf("%d hours, %d minutes", hours, minutes)
}
return fmt.Sprintf("%d hours", hours)
} else {
return fmt.Sprintf("%d minutes", minutes)
}
}
// formatUptimeFromDays formats uptime from days into human readable format
func formatUptimeFromDays(days float64) string {
if days < 1 {