Skip to content
This repository was archived by the owner on Feb 23, 2026. It is now read-only.
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 9 additions & 7 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,12 @@
# Build variables
BINARY_NAME=patchmon-agent
BUILD_DIR=build
# Get version from git tags, fallback to "dev" if not available
VERSION=$(shell git describe --tags --always --dirty 2>/dev/null || echo "dev")
# Use hardcoded version instead of git tags
VERSION=1.3.0
# Strip debug info and set version variable
LDFLAGS=-ldflags "-s -w -X patchmon-agent/internal/version.Version=$(VERSION)"
# Disable VCS stamping
BUILD_FLAGS=-buildvcs=false

# Go variables
GOBASE=$(shell pwd)
Expand All @@ -21,17 +23,17 @@ all: build
build:
@echo "Building $(BINARY_NAME)..."
@mkdir -p $(BUILD_DIR)
@go build $(LDFLAGS) -o $(GOBIN)/$(BINARY_NAME) ./cmd/patchmon-agent
@go build $(BUILD_FLAGS) $(LDFLAGS) -o $(GOBIN)/$(BINARY_NAME) ./cmd/patchmon-agent

# Build for multiple architectures
.PHONY: build-all
build-all:
@echo "Building for multiple architectures..."
@mkdir -p $(BUILD_DIR)
@GOOS=linux GOARCH=amd64 go build $(LDFLAGS) -o $(GOBIN)/$(BINARY_NAME)-linux-amd64 ./cmd/patchmon-agent
@GOOS=linux GOARCH=386 go build $(LDFLAGS) -o $(GOBIN)/$(BINARY_NAME)-linux-386 ./cmd/patchmon-agent
@GOOS=linux GOARCH=arm64 go build $(LDFLAGS) -o $(GOBIN)/$(BINARY_NAME)-linux-arm64 ./cmd/patchmon-agent
@GOOS=linux GOARCH=arm go build $(LDFLAGS) -o $(GOBIN)/$(BINARY_NAME)-linux-arm64 ./cmd/patchmon-agent
@GOOS=linux GOARCH=amd64 go build $(BUILD_FLAGS) $(LDFLAGS) -o $(GOBIN)/$(BINARY_NAME)-linux-amd64 ./cmd/patchmon-agent
@GOOS=linux GOARCH=386 go build $(BUILD_FLAGS) $(LDFLAGS) -o $(GOBIN)/$(BINARY_NAME)-linux-386 ./cmd/patchmon-agent
@GOOS=linux GOARCH=arm64 go build $(BUILD_FLAGS) $(LDFLAGS) -o $(GOBIN)/$(BINARY_NAME)-linux-arm64 ./cmd/patchmon-agent
@GOOS=linux GOARCH=arm go build $(BUILD_FLAGS) $(LDFLAGS) -o $(GOBIN)/$(BINARY_NAME)-linux-arm ./cmd/patchmon-agent

# Install dependencies
.PHONY: deps
Expand Down
13 changes: 0 additions & 13 deletions cmd/patchmon-agent/commands/diagnostics.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@ import (
"runtime"
"strings"

"patchmon-agent/internal/crontab"
"patchmon-agent/internal/system"
"patchmon-agent/internal/utils"
"patchmon-agent/internal/version"
Expand Down Expand Up @@ -39,8 +38,6 @@ func showDiagnostics() error {
osType, osVersion, err := systemDetector.DetectOS()
if err != nil {
fmt.Printf(" OS: %s (detection failed: %v)\n", runtime.GOOS, err)
osType = runtime.GOOS
osVersion = "unknown"
} else {
fmt.Printf(" OS: %s %s\n", osType, osVersion)
}
Expand Down Expand Up @@ -84,16 +81,6 @@ func showDiagnostics() error {
}
fmt.Printf("\n")

// Crontab Status
fmt.Printf("Crontab Status:\n")
cronManager := crontab.New(logger)
if crontabSchedule := cronManager.GetSchedule(); crontabSchedule != "" {
fmt.Printf(" ✅ Schedule installed: %s\n", crontabSchedule)
} else {
fmt.Printf(" ❌ Schedule not installed\n")
}
fmt.Printf("\n")

// Network Connectivity & API Credentials
fmt.Printf("Network Connectivity & API Credentials:\n")
fmt.Printf(" Server URL: %s\n", cfg.PatchmonServer)
Expand Down
12 changes: 10 additions & 2 deletions cmd/patchmon-agent/commands/report.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package commands
import (
"context"
"fmt"
"time"

"patchmon-agent/internal/client"
"patchmon-agent/internal/hardware"
Expand Down Expand Up @@ -32,6 +33,8 @@ var reportCmd = &cobra.Command{
}

func sendReport() error {
// Start tracking execution time
startTime := time.Now()
logger.Debug("Starting report process")

// Load API credentials to send report
Expand Down Expand Up @@ -80,7 +83,7 @@ func sendReport() error {

// Get package information
logger.Info("Collecting package information...")
packageList, err := packageMgr.GetPackages(osType)
packageList, err := packageMgr.GetPackages()
if err != nil {
return fmt.Errorf("failed to get packages: %w", err)
}
Expand Down Expand Up @@ -117,7 +120,7 @@ func sendReport() error {

// Get repository information
logger.Info("Collecting repository information...")
repoList, err := repoMgr.GetRepositories(osType)
repoList, err := repoMgr.GetRepositories()
if err != nil {
logger.WithError(err).Warn("Failed to get repositories")
repoList = []models.Repository{}
Expand All @@ -132,6 +135,10 @@ func sendReport() error {
}).Debug("Repository info")
}

// Calculate execution time (in seconds, with millisecond precision)
executionTime := time.Since(startTime).Seconds()
logger.WithField("execution_time_seconds", executionTime).Debug("Data collection completed")

// Create payload
payload := &models.ReportPayload{
Packages: packageList,
Expand All @@ -155,6 +162,7 @@ func sendReport() error {
GatewayIP: networkInfo.GatewayIP,
DNSServers: networkInfo.DNSServers,
NetworkInterfaces: networkInfo.NetworkInterfaces,
ExecutionTime: executionTime,
}

// Send report
Expand Down
34 changes: 22 additions & 12 deletions cmd/patchmon-agent/commands/root.go
Original file line number Diff line number Diff line change
@@ -1,15 +1,17 @@
package commands

import (
"fmt"
"os"
"fmt"
"os"
"path/filepath"

"patchmon-agent/internal/config"
"patchmon-agent/internal/constants"
"patchmon-agent/internal/version"
"patchmon-agent/internal/config"
"patchmon-agent/internal/constants"
"patchmon-agent/internal/version"

"github.com/sirupsen/logrus"
"github.com/spf13/cobra"
"github.com/sirupsen/logrus"
"github.com/spf13/cobra"
lumberjack "gopkg.in/natefinch/lumberjack.v2"
)

var (
Expand Down Expand Up @@ -51,8 +53,7 @@ func init() {
rootCmd.AddCommand(pingCmd)
rootCmd.AddCommand(configCmd)
rootCmd.AddCommand(checkVersionCmd)
rootCmd.AddCommand(updateAgentCmd)
rootCmd.AddCommand(updateCrontabCmd)
rootCmd.AddCommand(updateAgentCmd)
rootCmd.AddCommand(diagnosticsCmd)
rootCmd.AddCommand(uninstallCmd)
}
Expand All @@ -67,9 +68,18 @@ func initialiseAgent() {
TimestampFormat: "2006-01-02T15:04:05",
})

// Initialise configuration manager
cfgManager = config.New()
cfgManager.SetConfigFile(configFile)
// Initialise configuration manager
cfgManager = config.New()
cfgManager.SetConfigFile(configFile)

// Load config early to determine log file path
_ = cfgManager.LoadConfig()
logFile := cfgManager.GetConfig().LogFile
if logFile == "" {
logFile = config.DefaultLogFile
}
_ = os.MkdirAll(filepath.Dir(logFile), 0755)
logger.SetOutput(&lumberjack.Logger{Filename: logFile, MaxSize: 10, MaxBackups: 5, MaxAge: 14, Compress: true})
}

// updateLogLevel sets the logger level based on the flag value
Expand Down
197 changes: 197 additions & 0 deletions cmd/patchmon-agent/commands/serve.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,197 @@
package commands

import (
"context"
"encoding/json"
"net/http"
"strings"
"time"

"patchmon-agent/internal/client"

"github.com/gorilla/websocket"
"github.com/spf13/cobra"
)

// serveCmd runs the agent as a long-lived service
var serveCmd = &cobra.Command{
Use: "serve",
Short: "Run the agent as a service with async updates",
RunE: func(cmd *cobra.Command, args []string) error {
if err := checkRoot(); err != nil {
return err
}
return runService()
},
}

func init() {
rootCmd.AddCommand(serveCmd)
}

func runService() error {
if err := cfgManager.LoadCredentials(); err != nil {
return err
}

httpClient := client.New(cfgManager, logger)
ctx := context.Background()

// obtain initial interval
intervalMinutes := 60
if resp, err := httpClient.GetUpdateInterval(ctx); err == nil && resp.UpdateInterval > 0 {
intervalMinutes = resp.UpdateInterval
}

ticker := time.NewTicker(time.Duration(intervalMinutes) * time.Minute)
defer ticker.Stop()

// initial report on boot
if err := sendReport(); err != nil {
logger.WithError(err).Warn("initial report failed")
}

// start websocket loop
messages := make(chan wsMsg, 10)
go wsLoop(messages)

for {
select {
case <-ticker.C:
if err := sendReport(); err != nil {
logger.WithError(err).Warn("periodic report failed")
}
case m := <-messages:
switch m.kind {
case "settings_update":
if m.interval > 0 {
ticker.Stop()
ticker = time.NewTicker(time.Duration(m.interval) * time.Minute)
logger.WithField("new_interval", m.interval).Info("interval updated, no report sent")
}
case "report_now":
if err := sendReport(); err != nil {
logger.WithError(err).Warn("report_now failed")
}
case "update_agent":
if err := updateAgent(); err != nil {
logger.WithError(err).Warn("update_agent failed")
}
case "update_notification":
logger.WithField("version", m.version).Info("Update notification received from server")
if m.force {
logger.Info("Force update requested, updating agent now")
if err := updateAgent(); err != nil {
logger.WithError(err).Warn("forced update failed")
}
} else {
logger.Info("Update available, run 'patchmon-agent update-agent' to update")
}
}
}
}
}

type wsMsg struct {
kind string
interval int
version string
force bool
}

func wsLoop(out chan<- wsMsg) {
backoff := time.Second
for {
if err := connectOnce(out); err != nil {
logger.WithError(err).Warn("ws disconnected; retrying")
}
time.Sleep(backoff)
if backoff < 30*time.Second {
backoff *= 2
}
}
}

func connectOnce(out chan<- wsMsg) error {
server := cfgManager.GetConfig().PatchmonServer
if server == "" {
return nil
}
apiID := cfgManager.GetCredentials().APIID
apiKey := cfgManager.GetCredentials().APIKey

// Convert http(s) -> ws(s)
wsURL := server
if strings.HasPrefix(wsURL, "https://") {
wsURL = "wss://" + strings.TrimPrefix(wsURL, "https://")
} else if strings.HasPrefix(wsURL, "http://") {
wsURL = "ws://" + strings.TrimPrefix(wsURL, "http://")
}
if strings.HasSuffix(wsURL, "/") {
wsURL = strings.TrimRight(wsURL, "/")
}
wsURL = wsURL + "/api/" + cfgManager.GetConfig().APIVersion + "/agents/ws"
header := http.Header{}
header.Set("X-API-ID", apiID)
header.Set("X-API-KEY", apiKey)

conn, _, err := websocket.DefaultDialer.Dial(wsURL, header)
if err != nil {
return err
}
defer func() { _ = conn.Close() }()

// ping loop
go func() {
t := time.NewTicker(30 * time.Second)
defer t.Stop()
for range t.C {
_ = conn.WriteControl(websocket.PingMessage, nil, time.Now().Add(5*time.Second))
}
}()

// Set read deadlines and extend them on pong frames to avoid idle timeouts
_ = conn.SetReadDeadline(time.Now().Add(90 * time.Second))
conn.SetPongHandler(func(string) error {
return conn.SetReadDeadline(time.Now().Add(90 * time.Second))
})

logger.WithField("url", wsURL).Info("WebSocket connected")
for {
_, data, err := conn.ReadMessage()
if err != nil {
return err
}
var payload struct {
Type string `json:"type"`
UpdateInterval int `json:"update_interval"`
Version string `json:"version"`
Force bool `json:"force"`
Message string `json:"message"`
}
if json.Unmarshal(data, &payload) == nil {
switch payload.Type {
case "settings_update":
logger.WithField("interval", payload.UpdateInterval).Info("settings_update received")
out <- wsMsg{kind: "settings_update", interval: payload.UpdateInterval}
case "report_now":
logger.Info("report_now received")
out <- wsMsg{kind: "report_now"}
case "update_agent":
logger.Info("update_agent received")
out <- wsMsg{kind: "update_agent"}
case "update_notification":
logger.WithFields(map[string]interface{}{
"version": payload.Version,
"force": payload.Force,
"message": payload.Message,
}).Info("update_notification received")
out <- wsMsg{
kind: "update_notification",
version: payload.Version,
force: payload.Force,
}
}
}
}
}
Loading