diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..8356aee --- /dev/null +++ b/.gitattributes @@ -0,0 +1,40 @@ +# Set default behavior to automatically normalize line endings +* text=auto + +# Force LF line endings for source code files +*.go text eol=lf +*.mod text eol=lf +*.sum text eol=lf +*.yml text eol=lf +*.yaml text eol=lf +*.json text eol=lf +*.md text eol=lf +*.txt text eol=lf +*.sh text eol=lf +*.bash text eol=lf +Makefile text eol=lf + +# Force CRLF for Windows-specific files +*.bat text eol=crlf +*.cmd text eol=crlf +*.ps1 text eol=crlf + +# Binary files (don't modify line endings) +*.png binary +*.jpg binary +*.jpeg binary +*.gif binary +*.ico binary +*.pdf binary +*.zip binary +*.tar binary +*.gz binary +*.exe binary +*.dll binary +*.so binary +*.dylib binary + +# Git files +*.gitignore text eol=lf +*.gitattributes text eol=lf + diff --git a/Makefile b/Makefile index 8c30503..1fafb5b 100644 --- a/Makefile +++ b/Makefile @@ -3,10 +3,8 @@ # Build variables BINARY_NAME=patchmon-agent BUILD_DIR=build -# Use hardcoded version instead of git tags -VERSION=1.3.6 -# Strip debug info and set version variable -LDFLAGS=-ldflags "-s -w -X patchmon-agent/internal/version.Version=$(VERSION)" +# Strip debug info (version comes from internal/version/version.go) +LDFLAGS=-ldflags "-s -w" # Disable VCS stamping BUILD_FLAGS=-buildvcs=false @@ -14,9 +12,9 @@ BUILD_FLAGS=-buildvcs=false GOBASE=$(shell pwd) GOBIN=$(GOBASE)/$(BUILD_DIR) # Use full path to go binary to avoid PATH issues when running as root -GO_CMD=/usr/local/go/bin/go +GO_CMD=/usr/bin/go # Use full path to golangci-lint binary to avoid PATH issues when running as root -GOLANGCI_LINT_CMD=/usr/local/go/bin/golangci-lint +GOLANGCI_LINT_CMD=/root/go/bin/golangci-lint # Default target .PHONY: all diff --git a/cmd/patchmon-agent/commands/report.go b/cmd/patchmon-agent/commands/report.go index 4526158..65453cc 100644 --- a/cmd/patchmon-agent/commands/report.go +++ b/cmd/patchmon-agent/commands/report.go @@ -2,7 +2,9 @@ package commands import ( "context" + "encoding/json" "fmt" + "os" "time" "patchmon-agent/internal/client" @@ -20,6 +22,8 @@ import ( "github.com/spf13/cobra" ) +var reportJson bool + // reportCmd represents the report command var reportCmd = &cobra.Command{ Use: "report", @@ -30,20 +34,26 @@ var reportCmd = &cobra.Command{ return err } - return sendReport() + return sendReport(reportJson) }, } -func sendReport() error { +func init() { + reportCmd.Flags().BoolVar(&reportJson, "json", false, "Output the JSON report payload to stdout instead of sending to server") +} + +func sendReport(outputJson bool) error { // Start tracking execution time startTime := time.Now() logger.Debug("Starting report process") - // Load API credentials to send report - logger.Debug("Loading API credentials") - if err := cfgManager.LoadCredentials(); err != nil { - logger.WithError(err).Debug("Failed to load credentials") - return err + // Load API credentials only if we're sending the report (not just outputting JSON) + if !outputJson { + logger.Debug("Loading API credentials") + if err := cfgManager.LoadCredentials(); err != nil { + logger.WithError(err).Debug("Failed to load credentials") + return err + } } // Initialise managers @@ -189,6 +199,18 @@ func sendReport() error { RebootReason: rebootReason, } + // If --report-json flag is set, output JSON and exit + if outputJson { + jsonData, err := json.MarshalIndent(payload, "", " ") + if err != nil { + return fmt.Errorf("failed to marshal JSON: %w", err) + } + if _, err := fmt.Fprintf(os.Stdout, "%s\n", jsonData); err != nil { + return fmt.Errorf("failed to write JSON output: %w", err) + } + return nil + } + // Send report logger.Info("Sending report to PatchMon server...") httpClient := client.New(cfgManager, logger) @@ -244,6 +266,13 @@ func sendReport() error { logger.Info("PatchMon agent update completed successfully") // updateAgent() will exit after restart, so this won't be reached } + } else if versionInfo.AutoUpdateDisabled && versionInfo.LatestVersion != versionInfo.CurrentVersion { + // Update is available but auto-update is disabled + logger.WithFields(logrus.Fields{ + "current": versionInfo.CurrentVersion, + "latest": versionInfo.LatestVersion, + "reason": versionInfo.AutoUpdateDisabledReason, + }).Info("New update available but auto-update is disabled") } else { logger.WithField("version", versionInfo.CurrentVersion).Info("Agent is up to date") } diff --git a/cmd/patchmon-agent/commands/serve.go b/cmd/patchmon-agent/commands/serve.go index 9601926..1d163af 100644 --- a/cmd/patchmon-agent/commands/serve.go +++ b/cmd/patchmon-agent/commands/serve.go @@ -14,6 +14,7 @@ import ( "patchmon-agent/internal/client" "patchmon-agent/internal/integrations" "patchmon-agent/internal/integrations/docker" + "patchmon-agent/internal/utils" "patchmon-agent/pkg/models" "github.com/gorilla/websocket" @@ -44,14 +45,106 @@ func runService() error { httpClient := client.New(cfgManager, logger) ctx := context.Background() - // obtain initial interval - intervalMinutes := 60 + // Get api_id for offset calculation + apiId := cfgManager.GetCredentials().APIID + + // Load interval from config.yml (with default fallback) + intervalMinutes := cfgManager.GetConfig().UpdateInterval + if intervalMinutes <= 0 { + // Default to 60 if not set or invalid + intervalMinutes = 60 + logger.WithField("interval", intervalMinutes).Info("Using default interval (not set in config)") + } else { + logger.WithField("interval", intervalMinutes).Info("Loaded interval from config.yml") + } + + // Fetch interval from server and update config if different if resp, err := httpClient.GetUpdateInterval(ctx); err == nil && resp.UpdateInterval > 0 { - intervalMinutes = resp.UpdateInterval + if resp.UpdateInterval != intervalMinutes { + logger.WithFields(map[string]interface{}{ + "config_interval": intervalMinutes, + "server_interval": resp.UpdateInterval, + }).Info("Server interval differs from config, updating config.yml") + + if err := cfgManager.SetUpdateInterval(resp.UpdateInterval); err != nil { + logger.WithError(err).Warn("Failed to save interval to config.yml") + } else { + intervalMinutes = resp.UpdateInterval + logger.WithField("interval", intervalMinutes).Info("Updated interval in config.yml") + } + } + } else if err != nil { + logger.WithError(err).Warn("Failed to fetch interval from server, using config value") } - ticker := time.NewTicker(time.Duration(intervalMinutes) * time.Minute) - defer ticker.Stop() + // Fetch integration status from server and sync with config.yml + logger.Info("Syncing integration status from server...") + if integrationResp, err := httpClient.GetIntegrationStatus(ctx); err == nil && integrationResp.Success { + configUpdated := false + for integrationName, serverEnabled := range integrationResp.Integrations { + configEnabled := cfgManager.IsIntegrationEnabled(integrationName) + if serverEnabled != configEnabled { + logger.WithFields(map[string]interface{}{ + "integration": integrationName, + "config_value": configEnabled, + "server_value": serverEnabled, + }).Info("Integration status differs, updating config.yml") + + if err := cfgManager.SetIntegrationEnabled(integrationName, serverEnabled); err != nil { + logger.WithError(err).Warn("Failed to save integration status to config.yml") + } else { + configUpdated = true + logger.WithFields(map[string]interface{}{ + "integration": integrationName, + "enabled": serverEnabled, + }).Info("Updated integration status in config.yml") + } + } + } + + if configUpdated { + // Reload config so in-memory state matches the updated file + if err := cfgManager.LoadConfig(); err != nil { + logger.WithError(err).Warn("Failed to reload config after integration update") + } else { + logger.Info("Config reloaded, integration settings will be applied") + } + } else { + logger.Debug("Integration status matches config, no update needed") + } + } else if err != nil { + logger.WithError(err).Warn("Failed to fetch integration status from server, using config values") + } + + // Load or calculate offset based on api_id to stagger reporting times + var offset time.Duration + configOffsetSeconds := cfgManager.GetConfig().ReportOffset + + // Calculate what the offset should be based on current api_id and interval + calculatedOffset := utils.CalculateReportOffset(apiId, intervalMinutes) + calculatedOffsetSeconds := int(calculatedOffset.Seconds()) + + // Use config offset if it exists and matches calculated value, otherwise recalculate and save + if configOffsetSeconds > 0 && configOffsetSeconds == calculatedOffsetSeconds { + offset = time.Duration(configOffsetSeconds) * time.Second + logger.WithFields(map[string]interface{}{ + "api_id": apiId, + "interval_minutes": intervalMinutes, + "offset_seconds": offset.Seconds(), + }).Info("Loaded report offset from config.yml") + } else { + // Offset not in config or doesn't match, calculate and save it + offset = calculatedOffset + if err := cfgManager.SetReportOffset(calculatedOffsetSeconds); err != nil { + logger.WithError(err).Warn("Failed to save offset to config.yml") + } else { + logger.WithFields(map[string]interface{}{ + "api_id": apiId, + "interval_minutes": intervalMinutes, + "offset_seconds": offset.Seconds(), + }).Info("Calculated and saved report offset to config.yml") + } + } // Send startup ping to notify server that agent has started logger.Info("🚀 Agent starting up, notifying server...") @@ -63,7 +156,7 @@ func runService() error { // initial report on boot logger.Info("Sending initial report on startup...") - if err := sendReport(); err != nil { + if err := sendReport(false); err != nil { logger.WithError(err).Warn("initial report failed") } else { logger.Info("✅ Initial report sent successfully") @@ -78,22 +171,74 @@ func runService() error { // Start integration monitoring (Docker real-time events, etc.) startIntegrationMonitoring(ctx, dockerEvents) + // Create ticker with initial interval + ticker := time.NewTicker(time.Duration(intervalMinutes) * time.Minute) + defer ticker.Stop() + + // Wait for offset before starting periodic reports + // This staggers the reporting times across different agents + offsetTimer := time.NewTimer(offset) + defer offsetTimer.Stop() + + // Track whether offset period has passed + offsetPassed := false + + // Track current interval for offset recalculation on updates + currentInterval := intervalMinutes + for { select { + case <-offsetTimer.C: + // Offset period completed, start consuming from ticker normally + offsetPassed = true + logger.Debug("Offset period completed, periodic reports will now start") case <-ticker.C: - if err := sendReport(); err != nil { - logger.WithError(err).Warn("periodic report failed") + // Only process ticker events after offset has passed + if offsetPassed { + if err := sendReport(false); err != nil { + logger.WithError(err).Warn("periodic report failed") + } } case m := <-messages: switch m.kind { case "settings_update": - if m.interval > 0 { + if m.interval > 0 && m.interval != currentInterval { + // Save new interval to config.yml + if err := cfgManager.SetUpdateInterval(m.interval); err != nil { + logger.WithError(err).Warn("Failed to save interval to config.yml") + } else { + logger.WithField("interval", m.interval).Info("Saved new interval to config.yml") + } + + // Recalculate offset for new interval and save to config.yml + newOffset := utils.CalculateReportOffset(apiId, m.interval) + newOffsetSeconds := int(newOffset.Seconds()) + if err := cfgManager.SetReportOffset(newOffsetSeconds); err != nil { + logger.WithError(err).Warn("Failed to save offset to config.yml") + } + + logger.WithFields(map[string]interface{}{ + "old_interval": currentInterval, + "new_interval": m.interval, + "new_offset_seconds": newOffset.Seconds(), + }).Info("Recalculated and saved offset for new interval") + + // Stop old ticker ticker.Stop() + + // Create new ticker with updated interval ticker = time.NewTicker(time.Duration(m.interval) * time.Minute) + currentInterval = m.interval + + // Reset offset timer for new interval + offsetTimer.Stop() + offsetTimer = time.NewTimer(newOffset) + offsetPassed = false // Reset flag for new interval + logger.WithField("new_interval", m.interval).Info("interval updated, no report sent") } case "report_now": - if err := sendReport(); err != nil { + if err := sendReport(false); err != nil { logger.WithError(err).Warn("report_now failed") } case "update_agent": @@ -352,7 +497,7 @@ func toggleIntegration(integrationName string, enabled bool) error { // Since we're running inside the service, we can't stop ourselves directly // Instead, we'll create a helper script that runs after we exit logger.Debug("Detected OpenRC, scheduling service restart via helper script") - + // Create a helper script that will restart the service after we exit helperScript := `#!/bin/sh # Wait a moment for the current process to exit @@ -385,7 +530,7 @@ rm -f "$0" os.Exit(0) } } - + // Fallback: If helper script approach failed, just exit and let OpenRC handle it // OpenRC with command_background="yes" should restart on exit logger.Info("Exiting to allow OpenRC to restart service with updated config...") diff --git a/cmd/patchmon-agent/commands/version_update.go b/cmd/patchmon-agent/commands/version_update.go index c38f325..9eb59c4 100644 --- a/cmd/patchmon-agent/commands/version_update.go +++ b/cmd/patchmon-agent/commands/version_update.go @@ -36,11 +36,13 @@ type ServerVersionResponse struct { } type ServerVersionInfo struct { - CurrentVersion string `json:"currentVersion"` - LatestVersion string `json:"latestVersion"` - HasUpdate bool `json:"hasUpdate"` - LastChecked string `json:"lastChecked"` - SupportedArchitectures []string `json:"supportedArchitectures"` + CurrentVersion string `json:"currentVersion"` + LatestVersion string `json:"latestVersion"` + HasUpdate bool `json:"hasUpdate"` + AutoUpdateDisabled bool `json:"autoUpdateDisabled"` + AutoUpdateDisabledReason string `json:"autoUpdateDisabledReason"` + LastChecked string `json:"lastChecked"` + SupportedArchitectures []string `json:"supportedArchitectures"` } // checkVersionCmd represents the check-version command @@ -87,6 +89,16 @@ func checkVersion() error { fmt.Printf(" Current version: %s\n", currentVersion) fmt.Printf(" Latest version: %s\n", latestVersion) fmt.Printf("\nTo update, run: patchmon-agent update-agent\n") + } else if versionInfo.AutoUpdateDisabled && latestVersion != currentVersion { + logger.WithFields(map[string]interface{}{ + "current": currentVersion, + "latest": latestVersion, + "reason": versionInfo.AutoUpdateDisabledReason, + }).Info("New update available but auto-update is disabled") + fmt.Printf("Current version: %s\n", currentVersion) + fmt.Printf("Latest version: %s\n", latestVersion) + fmt.Printf("Status: %s\n", versionInfo.AutoUpdateDisabledReason) + fmt.Printf("\nTo update manually, run: patchmon-agent update-agent\n") } else { logger.WithField("version", currentVersion).Info("Agent is up to date") fmt.Printf("Agent is up to date (version %s)\n", currentVersion) @@ -482,58 +494,6 @@ func cleanupOldBackups(executablePath string) { } } -// verifyRunningBinaryVersion checks if the running process is using the expected binary version -func verifyRunningBinaryVersion(executablePath, expectedVersion string) error { - // Get the process ID of the running patchmon-agent - // We'll check the binary path of the running process - pidCmd := exec.Command("pgrep", "-f", "patchmon-agent") - pidOutput, err := pidCmd.Output() - if err != nil { - return fmt.Errorf("could not find running process: %w", err) - } - - pids := strings.Fields(strings.TrimSpace(string(pidOutput))) - if len(pids) == 0 { - return fmt.Errorf("no patchmon-agent process found") - } - - // Check the first PID (main process) - pid := pids[0] - - // On Linux, check /proc/PID/exe to see what binary is actually running - procExe := fmt.Sprintf("/proc/%s/exe", pid) - actualPath, err := os.Readlink(procExe) - if err != nil { - // Fallback: try to get version from running process - versionCmd := exec.Command("sh", "-c", fmt.Sprintf("cat /proc/%s/cmdline | tr '\\0' ' '", pid)) - cmdline, _ := versionCmd.Output() - logger.WithField("cmdline", string(cmdline)).Debug("Could not read process exe link, using cmdline") - return nil // Non-critical, don't fail - } - - // Resolve symlinks to compare actual paths - resolvedActual, err := filepath.EvalSymlinks(actualPath) - if err != nil { - resolvedActual = actualPath - } - - resolvedExpected, err := filepath.EvalSymlinks(executablePath) - if err != nil { - resolvedExpected = executablePath - } - - if resolvedActual != resolvedExpected { - logger.WithFields(map[string]interface{}{ - "expected": resolvedExpected, - "actual": resolvedActual, - }).Warn("Running process binary path does not match expected path") - return fmt.Errorf("binary path mismatch: expected %s, got %s", resolvedExpected, resolvedActual) - } - - logger.Debug("Verified running process is using correct binary") - return nil -} - // checkRecentUpdate checks if we updated recently to prevent update loops func checkRecentUpdate() error { updateMarkerPath := "/etc/patchmon/.last_update_timestamp" @@ -594,50 +554,53 @@ func restartService(executablePath, expectedVersion string) error { // Detect init system and use appropriate restart command if _, err := exec.LookPath("systemctl"); err == nil { // Systemd is available - logger.Debug("Detected systemd, using systemctl stop+start") - - // First, stop the service (this will send SIGTERM to current process) - // We use stop+start instead of restart to ensure clean shutdown - logger.Debug("Stopping patchmon-agent service...") - stopCmd := exec.CommandContext(ctx, "systemctl", "stop", "patchmon-agent") - stopOutput, err := stopCmd.CombinedOutput() - if err != nil { - logger.WithError(err).WithField("output", string(stopOutput)).Warn("Failed to stop service, trying start anyway") - } else { - // Give systemd a moment to stop the service - time.Sleep(1 * time.Second) - } - - // Now start the service (this will use the new binary) - logger.Debug("Starting patchmon-agent service...") - startCmd := exec.CommandContext(ctx, "systemctl", "start", "patchmon-agent") - startOutput, err := startCmd.CombinedOutput() - if err != nil { - return fmt.Errorf("failed to start service: %w, output: %s", err, string(startOutput)) - } + // Since we're running inside the service, we can't stop ourselves directly + // Instead, we'll create a helper script that runs after we exit + logger.Debug("Detected systemd, scheduling service restart via helper script") - // Verify the service is actually running - logger.Debug("Verifying service is running...") - // Longer wait for ARM systems which may need more time - time.Sleep(3 * time.Second) - statusCmd := exec.CommandContext(ctx, "systemctl", "is-active", "patchmon-agent") - statusOutput, err := statusCmd.CombinedOutput() - if err != nil || strings.TrimSpace(string(statusOutput)) != "active" { - return fmt.Errorf("service restart verification failed: status=%s, error=%v", string(statusOutput), err) + // Ensure /etc/patchmon directory exists + if err := os.MkdirAll("/etc/patchmon", 0755); err != nil { + logger.WithError(err).Warn("Failed to create /etc/patchmon directory, will try anyway") } - // Additional verification: Check if the running process is using the new binary - // This is critical for ARM systems where restart might not work properly - logger.Debug("Verifying running process is using new binary...") - time.Sleep(2 * time.Second) // Give process time to fully start - if err := verifyRunningBinaryVersion(executablePath, expectedVersion); err != nil { - logger.WithError(err).Warn("Could not verify running binary version - service restarted but version check failed") - // Don't fail the update, but log the warning - // This helps identify cases where restart didn't actually load new binary + // Create a helper script that will restart the service after we exit + helperScript := `#!/bin/sh +# Wait a moment for the current process to exit +sleep 2 +# Restart the service using systemctl +systemctl restart patchmon-agent 2>&1 || systemctl start patchmon-agent 2>&1 +# Clean up this script +rm -f "$0" +` + helperPath := "/etc/patchmon/patchmon-restart-helper.sh" + if err := os.WriteFile(helperPath, []byte(helperScript), 0755); err != nil { + logger.WithError(err).Warn("Failed to create restart helper script, will exit and rely on systemd auto-restart") + // Fall through to exit approach + } else { + // Execute the helper script in background (detached from current process) + // Use 'sh -c' with nohup to ensure it runs after we exit + cmd := exec.Command("sh", "-c", fmt.Sprintf("nohup %s > /dev/null 2>&1 &", helperPath)) + if err := cmd.Start(); err != nil { + logger.WithError(err).Warn("Failed to start restart helper script, will exit and rely on systemd auto-restart") + // Clean up script + if removeErr := os.Remove(helperPath); removeErr != nil { + logger.WithError(removeErr).Debug("Failed to remove helper script") + } + // Fall through to exit approach + } else { + logger.Info("Scheduled service restart via helper script, exiting now...") + // Give the helper script a moment to start + time.Sleep(500 * time.Millisecond) + // Exit gracefully - the helper script will restart the service + os.Exit(0) + } } - logger.WithField("output", string(startOutput)).Debug("Service restart command completed") - logger.Info("Service restarted successfully - new binary is now active") + // Fallback: If helper script approach failed, just exit and let systemd handle it + // Systemd with Restart=always should restart on exit + logger.Info("Exiting to allow systemd to restart service with new binary...") + os.Exit(0) + // os.Exit never returns, but we need this for code flow return nil } else if _, err := exec.LookPath("rc-service"); err == nil { // OpenRC is available (Alpine Linux) @@ -645,6 +608,11 @@ func restartService(executablePath, expectedVersion string) error { // Instead, we'll create a helper script that runs after we exit logger.Debug("Detected OpenRC, scheduling service restart via helper script") + // Ensure /etc/patchmon directory exists + if err := os.MkdirAll("/etc/patchmon", 0755); err != nil { + logger.WithError(err).Warn("Failed to create /etc/patchmon directory, will try anyway") + } + // Create a helper script that will restart the service after we exit helperScript := `#!/bin/sh # Wait a moment for the current process to exit diff --git a/internal/client/client.go b/internal/client/client.go index 20f32f3..ee7ed8a 100644 --- a/internal/client/client.go +++ b/internal/client/client.go @@ -179,6 +179,36 @@ func (c *Client) SendDockerData(ctx context.Context, payload *models.DockerPaylo return result, nil } +// GetIntegrationStatus gets the current integration status from server +func (c *Client) GetIntegrationStatus(ctx context.Context) (*models.IntegrationStatusResponse, error) { + url := fmt.Sprintf("%s/api/%s/hosts/integrations", c.config.PatchmonServer, c.config.APIVersion) + + c.logger.Debug("Getting integration status from server") + + resp, err := c.client.R(). + SetContext(ctx). + SetHeader("Content-Type", "application/json"). + SetHeader("X-API-ID", c.credentials.APIID). + SetHeader("X-API-KEY", c.credentials.APIKey). + SetResult(&models.IntegrationStatusResponse{}). + Get(url) + + if err != nil { + return nil, fmt.Errorf("integration status request failed: %w", err) + } + + if resp.StatusCode() != 200 { + return nil, fmt.Errorf("integration status request failed with status %d: %s", resp.StatusCode(), resp.String()) + } + + result, ok := resp.Result().(*models.IntegrationStatusResponse) + if !ok { + return nil, fmt.Errorf("invalid response format") + } + + return result, nil +} + // SendDockerStatusEvent sends a real-time Docker container status event via WebSocket func (c *Client) SendDockerStatusEvent(event *models.DockerStatusEvent) error { // This will be called by the WebSocket connection in the serve command diff --git a/internal/config/config.go b/internal/config/config.go index 002614b..f9d2161 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -21,6 +21,13 @@ const ( CronFilePath = "/etc/cron.d/patchmon-agent" ) +// AvailableIntegrations lists all integrations that can be enabled/disabled +// Add new integrations here as they are implemented +var AvailableIntegrations = []string{ + "docker", + // Future: "proxmox", "kubernetes", etc. +} + // Manager handles configuration management type Manager struct { config *models.Config @@ -37,6 +44,7 @@ func New() *Manager { CredentialsFile: DefaultCredentialsFile, LogFile: DefaultLogFile, LogLevel: DefaultLogLevel, + UpdateInterval: 60, // Default to 60 minutes Integrations: make(map[string]bool), }, configFile: DefaultConfigFile, @@ -82,6 +90,28 @@ func (m *Manager) LoadConfig() error { return fmt.Errorf("error unmarshaling config: %w", err) } + // Handle backward compatibility: set defaults for fields that may not exist in older configs + // If UpdateInterval is 0 or not set, use default of 60 minutes + if m.config.UpdateInterval <= 0 { + m.config.UpdateInterval = 60 + } + + // If Integrations map is nil (not set in old configs), initialize it + if m.config.Integrations == nil { + m.config.Integrations = make(map[string]bool) + } + + // Ensure all available integrations are present in the map with default value (false) + // This ensures config.yml always shows all integrations, even if they're disabled + for _, integrationName := range AvailableIntegrations { + if _, exists := m.config.Integrations[integrationName]; !exists { + m.config.Integrations[integrationName] = false + } + } + + // ReportOffset can be 0 - it will be recalculated if missing + // No need to set a default here as it's calculated dynamically + return nil } @@ -152,11 +182,21 @@ func (m *Manager) SaveConfig() error { configViper.Set("log_file", m.config.LogFile) configViper.Set("log_level", m.config.LogLevel) configViper.Set("skip_ssl_verify", m.config.SkipSSLVerify) - - // Save integrations if they exist - if len(m.config.Integrations) > 0 { - configViper.Set("integrations", m.config.Integrations) + configViper.Set("update_interval", m.config.UpdateInterval) + configViper.Set("report_offset", m.config.ReportOffset) + + // Always save integrations map with all available integrations + // This ensures config.yml always shows all integrations with their current state + // Ensure all available integrations are present before saving + if m.config.Integrations == nil { + m.config.Integrations = make(map[string]bool) + } + for _, integrationName := range AvailableIntegrations { + if _, exists := m.config.Integrations[integrationName]; !exists { + m.config.Integrations[integrationName] = false + } } + configViper.Set("integrations", m.config.Integrations) if err := configViper.WriteConfigAs(m.configFile); err != nil { return fmt.Errorf("error writing config file: %w", err) @@ -165,6 +205,24 @@ func (m *Manager) SaveConfig() error { return nil } +// SetUpdateInterval sets the update interval and saves it to config file +func (m *Manager) SetUpdateInterval(interval int) error { + if interval <= 0 { + return fmt.Errorf("invalid update interval: %d (must be > 0)", interval) + } + m.config.UpdateInterval = interval + return m.SaveConfig() +} + +// SetReportOffset sets the report offset (in seconds) and saves it to config file +func (m *Manager) SetReportOffset(offsetSeconds int) error { + if offsetSeconds < 0 { + return fmt.Errorf("invalid report offset: %d (must be >= 0)", offsetSeconds) + } + m.config.ReportOffset = offsetSeconds + return m.SaveConfig() +} + // IsIntegrationEnabled checks if an integration is enabled // Returns false if not specified (default behavior - integrations are disabled by default) func (m *Manager) IsIntegrationEnabled(name string) bool { diff --git a/internal/network/network.go b/internal/network/network.go index 0bd2115..63c15ba 100644 --- a/internal/network/network.go +++ b/internal/network/network.go @@ -4,6 +4,8 @@ import ( "fmt" "net" "os" + "os/exec" + "strconv" "strings" "github.com/sirupsen/logrus" @@ -150,24 +152,45 @@ func (m *Manager) getNetworkInterfaces() []models.NetworkInterface { continue } + // Get gateways for this interface (separate for IPv4 and IPv6) + ipv4Gateway := m.getInterfaceGateway(iface.Name, false) + ipv6Gateway := m.getInterfaceGateway(iface.Name, true) + for _, addr := range addrs { if ipnet, ok := addr.(*net.IPNet); ok { var family string + var gateway string + if ipnet.IP.To4() != nil { family = constants.IPFamilyIPv4 + gateway = ipv4Gateway } else { family = constants.IPFamilyIPv6 + // Check if this is a link-local address (fe80::/64) + // Link-local addresses don't have gateways + if ipnet.IP.IsLinkLocalUnicast() { + gateway = "" // No gateway for link-local addresses + } else { + gateway = ipv6Gateway + } } + // Calculate netmask in CIDR notation + ones, _ := ipnet.Mask.Size() + netmask := fmt.Sprintf("/%d", ones) + addresses = append(addresses, models.NetworkAddress{ Address: ipnet.IP.String(), Family: family, + Netmask: netmask, + Gateway: gateway, }) } } - // Only include interfaces that have addresses - if len(addresses) > 0 { + // Include interface even if it has no addresses (to show MAC, status, etc.) + // But prefer interfaces with addresses + if len(addresses) > 0 || iface.Flags&net.FlagUp != 0 { // Determine interface type interfaceType := constants.NetTypeEthernet if strings.HasPrefix(iface.Name, "wl") || strings.HasPrefix(iface.Name, "wifi") { @@ -176,13 +199,122 @@ func (m *Manager) getNetworkInterfaces() []models.NetworkInterface { interfaceType = constants.NetTypeBridge } + // Get MAC address + macAddress := "" + if len(iface.HardwareAddr) > 0 { + macAddress = iface.HardwareAddr.String() + } + + // Get status + status := "down" + if iface.Flags&net.FlagUp != 0 { + status = "up" + } + + // Get link speed and duplex + linkSpeed, duplex := m.getLinkSpeedAndDuplex(iface.Name) + result = append(result, models.NetworkInterface{ - Name: iface.Name, - Type: interfaceType, - Addresses: addresses, + Name: iface.Name, + Type: interfaceType, + MACAddress: macAddress, + MTU: iface.MTU, + Status: status, + LinkSpeed: linkSpeed, + Duplex: duplex, + Addresses: addresses, }) } } return result } + +// getInterfaceGateway gets the gateway IP for a specific interface +// ipv6 specifies whether to get IPv6 gateway (true) or IPv4 gateway (false) +func (m *Manager) getInterfaceGateway(interfaceName string, ipv6 bool) string { + // Try using 'ip route' command first (more reliable) + if _, err := exec.LookPath("ip"); err == nil { + var cmd *exec.Cmd + if ipv6 { + // Use ip -6 route for IPv6 + cmd = exec.Command("ip", "-6", "route", "show", "dev", interfaceName) + } else { + // Use ip route (defaults to IPv4) + cmd = exec.Command("ip", "route", "show", "dev", interfaceName) + } + + output, err := cmd.Output() + if err == nil { + lines := strings.Split(string(output), "\n") + for _, line := range lines { + fields := strings.Fields(line) + // Look for default route: "default via dev " + if len(fields) >= 3 && fields[0] == "default" && fields[1] == "via" { + return fields[2] + } + // Look for route with gateway: "0.0.0.0/0 via " (IPv4) or "::/0 via " (IPv6) + if len(fields) >= 4 { + if !ipv6 && fields[0] == "0.0.0.0/0" && fields[1] == "via" { + return fields[2] + } + if ipv6 && fields[0] == "::/0" && fields[1] == "via" { + return fields[2] + } + } + } + } + } + + // Fallback: parse /proc/net/route for IPv4 (IPv6 routing is more complex) + if !ipv6 { + data, err := os.ReadFile("/proc/net/route") + if err != nil { + return "" + } + + for line := range strings.SplitSeq(string(data), "\n") { + fields := strings.Fields(line) + if len(fields) >= 3 && fields[0] == interfaceName && fields[1] == "00000000" { + // Default route for this interface + if gateway := m.hexToIP(fields[2]); gateway != "" { + return gateway + } + } + } + } + + return "" +} + +// getLinkSpeedAndDuplex gets the link speed (in Mbps) and duplex mode for an interface +func (m *Manager) getLinkSpeedAndDuplex(interfaceName string) (int, string) { + // Read speed from /sys/class/net//speed + speedPath := fmt.Sprintf("/sys/class/net/%s/speed", interfaceName) + speedData, err := os.ReadFile(speedPath) + if err != nil { + // Speed not available (common for virtual interfaces) + return -1, "" + } + + speedStr := strings.TrimSpace(string(speedData)) + speed, err := strconv.Atoi(speedStr) + if err != nil { + return -1, "" + } + + // Read duplex from /sys/class/net//duplex + duplexPath := fmt.Sprintf("/sys/class/net/%s/duplex", interfaceName) + duplexData, err := os.ReadFile(duplexPath) + if err != nil { + return speed, "" + } + + duplex := strings.TrimSpace(string(duplexData)) + // Normalize duplex values + if duplex == "full" || duplex == "half" { + return speed, duplex + } + + return speed, "" +} diff --git a/internal/system/reboot.go b/internal/system/reboot.go index 36b62c9..f613849 100644 --- a/internal/system/reboot.go +++ b/internal/system/reboot.go @@ -1,6 +1,7 @@ package system import ( + "fmt" "os" "os/exec" "sort" @@ -11,32 +12,40 @@ import ( // CheckRebootRequired checks if the system requires a reboot // Returns (needsReboot bool, reason string) func (d *Detector) CheckRebootRequired() (bool, string) { + runningKernel := d.getRunningKernel() + latestKernel := d.getLatestInstalledKernel() + // Check Debian/Ubuntu - reboot-required flag file if _, err := os.Stat("/var/run/reboot-required"); err == nil { d.logger.Debug("Reboot required: /var/run/reboot-required file exists") - return true, "Reboot flag file exists" + reason := "Reboot flag file exists (/var/run/reboot-required)" + if runningKernel != latestKernel && latestKernel != "" { + reason += fmt.Sprintf(" | Running kernel: %s, Installed kernel: %s", runningKernel, latestKernel) + } + return true, reason } // Check RHEL/Fedora - needs-restarting utility if needsRestart, reason := d.checkNeedsRestarting(); needsRestart { d.logger.WithField("reason", reason).Debug("Reboot required: needs-restarting check") + if runningKernel != latestKernel && latestKernel != "" { + reason += fmt.Sprintf(" | Running kernel: %s, Installed kernel: %s", runningKernel, latestKernel) + } return true, reason } // Universal kernel check - compare running vs latest installed - runningKernel := d.getRunningKernel() - latestKernel := d.getLatestInstalledKernel() - if runningKernel != latestKernel && latestKernel != "" { d.logger.WithFields(map[string]interface{}{ "running": runningKernel, "latest": latestKernel, }).Debug("Reboot required: kernel version mismatch") - return true, "Kernel version mismatch" + reason := fmt.Sprintf("Kernel version mismatch | Running kernel: %s, Installed kernel: %s", runningKernel, latestKernel) + return true, reason } d.logger.Debug("No reboot required") - return false, "No reboot required" + return false, "" } // checkNeedsRestarting checks using needs-restarting command (RHEL/Fedora) diff --git a/internal/utils/offset.go b/internal/utils/offset.go new file mode 100644 index 0000000..c9efd7e --- /dev/null +++ b/internal/utils/offset.go @@ -0,0 +1,43 @@ +package utils + +import ( + "hash/fnv" + "time" +) + +// CalculateReportOffset calculates a unique, deterministic offset for report timing +// based on the agent's api_id and the reporting interval. This ensures different +// agents report at staggered times to prevent overwhelming the server. +// +// For intervals >= 60 minutes: returns offset in minutes (0-59) +// For intervals < 60 minutes: returns offset in seconds (0 to interval*60-1) +// +// The same api_id will always produce the same offset, ensuring consistency +// across service restarts. +func CalculateReportOffset(apiId string, intervalMinutes int) time.Duration { + // Hash the api_id to get a consistent numeric value + hash := hashString(apiId) + + if intervalMinutes >= 60 { + // For hourly or longer intervals, offset in minutes (0-59) + // Example: api_id hash % 60 = 10 → reports at :10 past each hour + offsetMinutes := hash % 60 + return time.Duration(offsetMinutes) * time.Minute + } else { + // For sub-hourly intervals, offset in seconds + // Example: 5-minute interval, hash % 300 = 7 → reports at :07, :12, :17, etc. + maxOffsetSeconds := intervalMinutes * 60 + offsetSeconds := hash % uint64(maxOffsetSeconds) + return time.Duration(offsetSeconds) * time.Second + } +} + +// hashString creates a deterministic hash from a string using FNV-1a algorithm +// This ensures the same input always produces the same hash value +func hashString(s string) uint64 { + h := fnv.New64a() + h.Write([]byte(s)) + return h.Sum64() +} + + diff --git a/internal/version/version.go b/internal/version/version.go index 1b9fc23..847c375 100644 --- a/internal/version/version.go +++ b/internal/version/version.go @@ -1,4 +1,4 @@ package version // Version represents the current version of the patchmon-agent -const Version = "1.3.6" +const Version = "1.3.7" diff --git a/pkg/models/models.go b/pkg/models/models.go index 815ab45..37bf61b 100644 --- a/pkg/models/models.go +++ b/pkg/models/models.go @@ -53,44 +53,51 @@ type NetworkInfo struct { // NetworkInterface represents a network interface type NetworkInterface struct { - Name string `json:"name"` - Type string `json:"type"` - Addresses []NetworkAddress `json:"addresses"` + Name string `json:"name"` + Type string `json:"type"` + MACAddress string `json:"macAddress,omitempty"` + MTU int `json:"mtu,omitempty"` + Status string `json:"status,omitempty"` // "up" or "down" + LinkSpeed int `json:"linkSpeed,omitempty"` // Speed in Mbps, -1 if unknown + Duplex string `json:"duplex,omitempty"` // "full", "half", or "" + Addresses []NetworkAddress `json:"addresses"` } // NetworkAddress represents an IP address type NetworkAddress struct { Address string `json:"address"` - Family string `json:"family"` + Family string `json:"family"` // "inet" or "inet6" + Netmask string `json:"netmask,omitempty"` // CIDR notation (e.g., "/24" or "/64") + Gateway string `json:"gateway,omitempty"` // Gateway for this specific address/interface } // ReportPayload represents the data sent to the server type ReportPayload struct { - Packages []Package `json:"packages"` - Repositories []Repository `json:"repositories"` - OSType string `json:"osType"` - OSVersion string `json:"osVersion"` - Hostname string `json:"hostname"` - IP string `json:"ip"` - Architecture string `json:"architecture"` - AgentVersion string `json:"agentVersion"` - MachineID string `json:"machineId"` - KernelVersion string `json:"kernelVersion"` + Packages []Package `json:"packages"` + Repositories []Repository `json:"repositories"` + OSType string `json:"osType"` + OSVersion string `json:"osVersion"` + Hostname string `json:"hostname"` + IP string `json:"ip"` + Architecture string `json:"architecture"` + AgentVersion string `json:"agentVersion"` + MachineID string `json:"machineId"` + KernelVersion string `json:"kernelVersion"` InstalledKernelVersion string `json:"installedKernelVersion,omitempty"` - SELinuxStatus string `json:"selinuxStatus"` - SystemUptime string `json:"systemUptime"` - LoadAverage []float64 `json:"loadAverage"` - CPUModel string `json:"cpuModel"` - CPUCores int `json:"cpuCores"` - RAMInstalled float64 `json:"ramInstalled"` - SwapSize float64 `json:"swapSize"` - DiskDetails []DiskInfo `json:"diskDetails"` - GatewayIP string `json:"gatewayIp"` - DNSServers []string `json:"dnsServers"` - NetworkInterfaces []NetworkInterface `json:"networkInterfaces"` - ExecutionTime float64 `json:"executionTime"` // Collection time in seconds - NeedsReboot bool `json:"needsReboot"` - RebootReason string `json:"rebootReason,omitempty"` + SELinuxStatus string `json:"selinuxStatus"` + SystemUptime string `json:"systemUptime"` + LoadAverage []float64 `json:"loadAverage"` + CPUModel string `json:"cpuModel"` + CPUCores int `json:"cpuCores"` + RAMInstalled float64 `json:"ramInstalled"` + SwapSize float64 `json:"swapSize"` + DiskDetails []DiskInfo `json:"diskDetails"` + GatewayIP string `json:"gatewayIp"` + DNSServers []string `json:"dnsServers"` + NetworkInterfaces []NetworkInterface `json:"networkInterfaces"` + ExecutionTime float64 `json:"executionTime"` // Collection time in seconds + NeedsReboot bool `json:"needsReboot"` + RebootReason string `json:"rebootReason,omitempty"` } // PingResponse represents server ping response @@ -151,6 +158,12 @@ type HostSettingsResponse struct { HostAutoUpdate bool `json:"host_auto_update"` } +// IntegrationStatusResponse represents integration status response from server +type IntegrationStatusResponse struct { + Success bool `json:"success"` + Integrations map[string]bool `json:"integrations"` +} + // Credentials holds API authentication information type Credentials struct { APIID string `yaml:"api_id" mapstructure:"api_id"` @@ -159,11 +172,13 @@ type Credentials struct { // Config represents agent configuration type Config struct { - PatchmonServer string `yaml:"patchmon_server" mapstructure:"patchmon_server"` - APIVersion string `yaml:"api_version" mapstructure:"api_version"` - CredentialsFile string `yaml:"credentials_file" mapstructure:"credentials_file"` - LogFile string `yaml:"log_file" mapstructure:"log_file"` - LogLevel string `yaml:"log_level" mapstructure:"log_level"` - SkipSSLVerify bool `yaml:"skip_ssl_verify" mapstructure:"skip_ssl_verify"` - Integrations map[string]bool `yaml:"integrations" mapstructure:"integrations"` + PatchmonServer string `yaml:"patchmon_server" mapstructure:"patchmon_server"` + APIVersion string `yaml:"api_version" mapstructure:"api_version"` + CredentialsFile string `yaml:"credentials_file" mapstructure:"credentials_file"` + LogFile string `yaml:"log_file" mapstructure:"log_file"` + LogLevel string `yaml:"log_level" mapstructure:"log_level"` + SkipSSLVerify bool `yaml:"skip_ssl_verify" mapstructure:"skip_ssl_verify"` + UpdateInterval int `yaml:"update_interval" mapstructure:"update_interval"` // Interval in minutes + ReportOffset int `yaml:"report_offset" mapstructure:"report_offset"` // Offset in seconds + Integrations map[string]bool `yaml:"integrations" mapstructure:"integrations"` }