Files
Redflag/aggregator-agent/internal/client/client.go
Fimeg 6b3ab6d6fc fix: Remove duplicate scan logging to prevent storage/system scans on Updates page
BREAKING CHANGE: Storage and system scans no longer create entries in update_logs

**Problem**
- Storage scans were appearing on Updates page (mixed with package updates)
- System scans were appearing on Updates page (mixed with package updates)
- Duplicate "Scan All" entries from collective + individual logging

**Root Cause**
Scan handlers were calling both ReportLog() and dedicated endpoints:
- reportLogWithAck → POST /api/v1/agents/:id/logs → update_logs table
- This caused storage/system metrics to appear alongside package updates

**Fix**
Removed ALL ReportLog() calls from scan handlers:
1. handleScanUpdatesV2 (lines 44-46): Removed collective logging
2. handleScanStorage (lines 103-105): Use only ReportStorageMetrics
3. handleScanSystem (lines 189-191): Use only ReportMetrics
4. handleScanDocker (lines 269-271): Use only ReportDockerImages

**Verification**
- All 4 handlers have working dedicated endpoints (verified via subagent)
- Routes already registered: POST /storage-metrics, POST /metrics, etc.
- Frontend queries correct endpoints (verified)
- No data loss: dedicated endpoints store in proper tables

**Result**
- Storage scans → storage_metrics table → Storage page only 
- System scans → system reporting → System page only 
- Package updates → update_logs table → Updates page only 
- No duplicate "Scan All" entries 

**Files Changed**
- aggregator-agent/cmd/agent/subsystem_handlers.go: Removed 20 lines of ReportLog calls
- internal/api/handlers/agents.go: Command recovery enhancements
- internal/api/handlers/updates.go: Subsystem extraction logic
- internal/database/queries/commands.go: GetStuckCommands query
2025-12-19 15:11:32 -05:00

917 lines
29 KiB
Go

package client
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"runtime"
"strings"
"time"
"github.com/Fimeg/RedFlag/aggregator-agent/internal/event"
"github.com/Fimeg/RedFlag/aggregator-agent/internal/models"
"github.com/Fimeg/RedFlag/aggregator-agent/internal/system"
"github.com/google/uuid"
)
// Client handles API communication with the server
type Client struct {
baseURL string
token string
http *http.Client
RapidPollingEnabled bool
RapidPollingUntil time.Time
machineID string // Cached machine ID for security binding
eventBuffer *event.Buffer
agentID uuid.UUID
}
// NewClient creates a new API client
func NewClient(baseURL, token string) *Client {
// Get machine ID for security binding (v0.1.22+)
machineID, err := system.GetMachineID()
if err != nil {
// Log warning but don't fail - older servers may not require it
fmt.Printf("Warning: Failed to get machine ID: %v\n", err)
machineID = "" // Will be handled by server validation
}
return &Client{
baseURL: baseURL,
token: token,
machineID: machineID,
http: &http.Client{
Timeout: 30 * time.Second,
},
}
}
// NewClientWithEventBuffer creates a new API client with event buffering capability
func NewClientWithEventBuffer(baseURL, token string, statePath string, agentID uuid.UUID) *Client {
client := NewClient(baseURL, token)
client.agentID = agentID
// Initialize event buffer if state path is provided
if statePath != "" {
eventBufferPath := filepath.Join(statePath, "events_buffer.json")
client.eventBuffer = event.NewBuffer(eventBufferPath)
}
return client
}
// bufferEvent buffers a system event for later reporting
func (c *Client) bufferEvent(eventType, eventSubtype, severity, component, message string, metadata map[string]interface{}) {
if c.eventBuffer == nil {
return // Event buffering not enabled
}
// Use agent ID if available, otherwise create event with nil agent ID
var agentIDPtr *uuid.UUID
if c.agentID != uuid.Nil {
agentIDPtr = &c.agentID
}
event := &models.SystemEvent{
ID: uuid.New(),
AgentID: agentIDPtr,
EventType: eventType,
EventSubtype: eventSubtype,
Severity: severity,
Component: component,
Message: message,
Metadata: metadata,
CreatedAt: time.Now(),
}
// Buffer the event (best effort - don't fail if buffering fails)
if err := c.eventBuffer.BufferEvent(event); err != nil {
fmt.Printf("Warning: Failed to buffer event: %v\n", err)
}
}
// GetBufferedEvents returns all buffered events and clears the buffer
func (c *Client) GetBufferedEvents() ([]*models.SystemEvent, error) {
if c.eventBuffer == nil {
return nil, nil // Event buffering not enabled
}
return c.eventBuffer.GetBufferedEvents()
}
// addMachineIDHeader adds X-Machine-ID header to authenticated requests (v0.1.22+)
func (c *Client) addMachineIDHeader(req *http.Request) {
if c.machineID != "" {
req.Header.Set("X-Machine-ID", c.machineID)
}
}
// GetToken returns the current JWT token
func (c *Client) GetToken() string {
return c.token
}
// SetToken updates the JWT token
func (c *Client) SetToken(token string) {
c.token = token
}
// 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"`
RegistrationToken string `json:"registration_token,omitempty"` // Fallback method
MachineID string `json:"machine_id"`
PublicKeyFingerprint string `json:"public_key_fingerprint"`
Metadata map[string]string `json:"metadata"`
}
// RegisterResponse is returned after successful registration
type RegisterResponse struct {
AgentID uuid.UUID `json:"agent_id"`
Token string `json:"token"` // Short-lived access token (24h)
RefreshToken string `json:"refresh_token"` // Long-lived refresh token (90d)
Config map[string]interface{} `json:"config"`
}
// Register registers the agent with the server
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 {
// Buffer registration failure event
c.bufferEvent("registration_failure", "marshal_error", "error", "client",
fmt.Sprintf("Failed to marshal registration request: %v", err),
map[string]interface{}{
"error": err.Error(),
"hostname": req.Hostname,
})
return nil, err
}
httpReq, err := http.NewRequest("POST", url, bytes.NewBuffer(body))
if err != nil {
// Buffer registration failure event
c.bufferEvent("registration_failure", "request_creation_error", "error", "client",
fmt.Sprintf("Failed to create registration request: %v", err),
map[string]interface{}{
"error": err.Error(),
"hostname": req.Hostname,
})
return nil, err
}
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 {
// Buffer registration failure event
c.bufferEvent("registration_failure", "network_error", "error", "client",
fmt.Sprintf("Registration request failed: %v", err),
map[string]interface{}{
"error": err.Error(),
"hostname": req.Hostname,
"server_url": c.baseURL,
})
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
bodyBytes, _ := io.ReadAll(resp.Body)
errorMsg := fmt.Sprintf("registration failed: %s - %s", resp.Status, string(bodyBytes))
// Buffer registration failure event
c.bufferEvent("registration_failure", "api_error", "error", "client",
errorMsg,
map[string]interface{}{
"status_code": resp.StatusCode,
"response_body": string(bodyBytes),
"hostname": req.Hostname,
"server_url": c.baseURL,
})
return nil, fmt.Errorf(errorMsg)
}
var result RegisterResponse
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
// Buffer registration failure event
c.bufferEvent("registration_failure", "decode_error", "error", "client",
fmt.Sprintf("Failed to decode registration response: %v", err),
map[string]interface{}{
"error": err.Error(),
"hostname": req.Hostname,
})
return nil, err
}
// Update client token and agent ID
c.token = result.Token
c.agentID = result.AgentID
return &result, nil
}
// TokenRenewalRequest is the payload for token renewal using refresh token
type TokenRenewalRequest struct {
AgentID uuid.UUID `json:"agent_id"`
RefreshToken string `json:"refresh_token"`
AgentVersion string `json:"agent_version,omitempty"` // Agent's current version for upgrade tracking
}
// TokenRenewalResponse is returned after successful token renewal
type TokenRenewalResponse struct {
Token string `json:"token"` // New short-lived access token (24h)
}
// RenewToken uses refresh token to get a new access token (proper implementation)
func (c *Client) RenewToken(agentID uuid.UUID, refreshToken string, agentVersion string) error {
url := fmt.Sprintf("%s/api/v1/agents/renew", c.baseURL)
renewalReq := TokenRenewalRequest{
AgentID: agentID,
RefreshToken: refreshToken,
AgentVersion: agentVersion,
}
body, err := json.Marshal(renewalReq)
if err != nil {
// Buffer token renewal failure event
c.bufferEvent("token_renewal_failure", "marshal_error", "error", "client",
fmt.Sprintf("Failed to marshal token renewal request: %v", err),
map[string]interface{}{
"error": err.Error(),
"agent_id": agentID.String(),
})
return err
}
httpReq, err := http.NewRequest("POST", url, bytes.NewBuffer(body))
if err != nil {
// Buffer token renewal failure event
c.bufferEvent("token_renewal_failure", "request_creation_error", "error", "client",
fmt.Sprintf("Failed to create token renewal request: %v", err),
map[string]interface{}{
"error": err.Error(),
"agent_id": agentID.String(),
})
return err
}
httpReq.Header.Set("Content-Type", "application/json")
resp, err := c.http.Do(httpReq)
if err != nil {
// Buffer token renewal failure event
c.bufferEvent("token_renewal_failure", "network_error", "error", "client",
fmt.Sprintf("Token renewal request failed: %v", err),
map[string]interface{}{
"error": err.Error(),
"agent_id": agentID.String(),
"server_url": c.baseURL,
})
return err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
bodyBytes, _ := io.ReadAll(resp.Body)
errorMsg := fmt.Sprintf("token renewal failed: %s - %s", resp.Status, string(bodyBytes))
// Buffer token renewal failure event
c.bufferEvent("token_renewal_failure", "api_error", "error", "client",
errorMsg,
map[string]interface{}{
"status_code": resp.StatusCode,
"response_body": string(bodyBytes),
"agent_id": agentID.String(),
"server_url": c.baseURL,
})
return fmt.Errorf(errorMsg)
}
var result TokenRenewalResponse
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
// Buffer token renewal failure event
c.bufferEvent("token_renewal_failure", "decode_error", "error", "client",
fmt.Sprintf("Failed to decode token renewal response: %v", err),
map[string]interface{}{
"error": err.Error(),
"agent_id": agentID.String(),
})
return err
}
// Update client token
c.token = result.Token
return nil
}
// Command represents a command from the server
type Command struct {
ID string `json:"id"`
Type string `json:"type"`
Params map[string]interface{} `json:"params"`
Signature string `json:"signature,omitempty"` // Ed25519 signature of the command
}
// CommandItem is an alias for Command for consistency with server models
type CommandItem = Command
// CommandsResponse contains pending commands
type CommandsResponse struct {
Commands []Command `json:"commands"`
RapidPolling *RapidPollingConfig `json:"rapid_polling,omitempty"`
AcknowledgedIDs []string `json:"acknowledged_ids,omitempty"` // IDs server has received
}
// RapidPollingConfig contains rapid polling configuration from server
type RapidPollingConfig struct {
Enabled bool `json:"enabled"`
Until string `json:"until"` // ISO 8601 timestamp
}
// SystemMetrics represents lightweight system metrics sent with check-ins
type SystemMetrics struct {
CPUPercent float64 `json:"cpu_percent,omitempty"`
MemoryPercent float64 `json:"memory_percent,omitempty"`
MemoryUsedGB float64 `json:"memory_used_gb,omitempty"`
MemoryTotalGB float64 `json:"memory_total_gb,omitempty"`
DiskUsedGB float64 `json:"disk_used_gb,omitempty"`
DiskTotalGB float64 `json:"disk_total_gb,omitempty"`
DiskPercent float64 `json:"disk_percent,omitempty"`
Uptime string `json:"uptime,omitempty"`
Version string `json:"version,omitempty"` // Agent version
Metadata map[string]interface{} `json:"metadata,omitempty"` // Additional metadata
// Command acknowledgment tracking
PendingAcknowledgments []string `json:"pending_acknowledgments,omitempty"` // Command IDs awaiting ACK
}
// GetCommands retrieves pending commands from the server
// Optionally sends lightweight system metrics in the request
// Returns the full response including commands and acknowledged IDs
func (c *Client) GetCommands(agentID uuid.UUID, metrics *SystemMetrics) (*CommandsResponse, error) {
url := fmt.Sprintf("%s/api/v1/agents/%s/commands", c.baseURL, agentID)
var req *http.Request
var err error
// If metrics provided, send them in request body
if metrics != nil {
body, err := json.Marshal(metrics)
if err != nil {
return nil, err
}
req, err = http.NewRequest("GET", url, bytes.NewBuffer(body))
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", "application/json")
} else {
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 commands: %s - %s", resp.Status, string(bodyBytes))
}
var result CommandsResponse
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return nil, err
}
// Handle rapid polling configuration if provided
if result.RapidPolling != nil {
// Parse the timestamp
if until, err := time.Parse(time.RFC3339, result.RapidPolling.Until); err == nil {
// Update client's rapid polling configuration
c.RapidPollingEnabled = result.RapidPolling.Enabled
c.RapidPollingUntil = until
}
}
return &result, nil
}
// UpdateReport represents discovered updates
type UpdateReport struct {
CommandID string `json:"command_id"`
Timestamp time.Time `json:"timestamp"`
Updates []UpdateReportItem `json:"updates"`
}
// UpdateReportItem represents a single update
type UpdateReportItem struct {
PackageType string `json:"package_type"`
PackageName string `json:"package_name"`
PackageDescription string `json:"package_description"`
CurrentVersion string `json:"current_version"`
AvailableVersion string `json:"available_version"`
Severity string `json:"severity"`
CVEList []string `json:"cve_list"`
KBID string `json:"kb_id"`
RepositorySource string `json:"repository_source"`
SizeBytes int64 `json:"size_bytes"`
Metadata map[string]interface{} `json:"metadata"`
}
// ReportUpdates sends discovered updates to the server
func (c *Client) ReportUpdates(agentID uuid.UUID, report UpdateReport) error {
url := fmt.Sprintf("%s/api/v1/agents/%s/updates", c.baseURL, agentID)
body, err := json.Marshal(report)
if err != nil {
return err
}
req, err := http.NewRequest("POST", url, bytes.NewBuffer(body))
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/json")
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 err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
bodyBytes, _ := io.ReadAll(resp.Body)
return fmt.Errorf("failed to report updates: %s - %s", resp.Status, string(bodyBytes))
}
return nil
}
// MetricsReport represents metrics data (storage, system, CPU, memory)
type MetricsReport struct {
CommandID string `json:"command_id"`
Timestamp time.Time `json:"timestamp"`
Metrics []MetricsReportItem `json:"metrics"`
}
// MetricsReportItem represents a single metric
type MetricsReportItem struct {
PackageType string `json:"package_type"`
PackageName string `json:"package_name"`
CurrentVersion string `json:"current_version"`
AvailableVersion string `json:"available_version"`
Severity string `json:"severity"`
RepositorySource string `json:"repository_source"`
Metadata map[string]interface{} `json:"metadata"`
}
// ReportMetrics sends metrics data to the server
func (c *Client) ReportMetrics(agentID uuid.UUID, report MetricsReport) error {
url := fmt.Sprintf("%s/api/v1/agents/%s/metrics", c.baseURL, agentID)
body, err := json.Marshal(report)
if err != nil {
return err
}
req, err := http.NewRequest("POST", url, bytes.NewBuffer(body))
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/json")
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 err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
bodyBytes, _ := io.ReadAll(resp.Body)
return fmt.Errorf("failed to report metrics: %s - %s", resp.Status, string(bodyBytes))
}
return nil
}
// DockerReport represents Docker image information
type DockerReport struct {
CommandID string `json:"command_id"`
Timestamp time.Time `json:"timestamp"`
Images []DockerReportItem `json:"images"`
}
// DockerReportItem represents a single Docker image
type DockerReportItem struct {
PackageType string `json:"package_type"`
PackageName string `json:"package_name"`
CurrentVersion string `json:"current_version"`
AvailableVersion string `json:"available_version"`
Severity string `json:"severity"`
RepositorySource string `json:"repository_source"`
Metadata map[string]interface{} `json:"metadata"`
}
// ReportDockerImages sends Docker image information to the server
func (c *Client) ReportDockerImages(agentID uuid.UUID, report DockerReport) error {
url := fmt.Sprintf("%s/api/v1/agents/%s/docker-images", c.baseURL, agentID)
body, err := json.Marshal(report)
if err != nil {
return err
}
req, err := http.NewRequest("POST", url, bytes.NewBuffer(body))
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/json")
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 err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
bodyBytes, _ := io.ReadAll(resp.Body)
return fmt.Errorf("failed to report docker images: %s - %s", resp.Status, string(bodyBytes))
}
return nil
}
// ReportStorageMetrics sends storage metrics to the server via dedicated endpoint
func (c *Client) ReportStorageMetrics(agentID uuid.UUID, report models.StorageMetricReport) error {
url := fmt.Sprintf("%s/api/v1/agents/%s/storage-metrics", c.baseURL, agentID)
body, err := json.Marshal(report)
if err != nil {
return fmt.Errorf("failed to marshal storage metrics: %w", err)
}
req, err := http.NewRequest("POST", url, bytes.NewBuffer(body))
if err != nil {
return fmt.Errorf("failed to create request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
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 fmt.Errorf("failed to send request: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
bodyBytes, _ := io.ReadAll(resp.Body)
return fmt.Errorf("failed to report storage metrics: %s - %s", resp.Status, string(bodyBytes))
}
return nil
}
// LogReport represents an execution log
type LogReport struct {
CommandID string `json:"command_id"`
Action string `json:"action"`
Result string `json:"result"`
Stdout string `json:"stdout"`
Stderr string `json:"stderr"`
ExitCode int `json:"exit_code"`
DurationSeconds int `json:"duration_seconds"`
Metadata map[string]string `json:"metadata,omitempty"`
}
// ReportLog sends an execution log to the server
func (c *Client) ReportLog(agentID uuid.UUID, report LogReport) error {
url := fmt.Sprintf("%s/api/v1/agents/%s/logs", c.baseURL, agentID)
// Extract subsystem from metadata if present
subsystem := ""
if report.Metadata != nil {
subsystem = report.Metadata["subsystem"]
}
// Create UpdateLogRequest with subsystem extracted from metadata
logRequest := struct {
CommandID string `json:"command_id"`
Action string `json:"action"`
Subsystem string `json:"subsystem,omitempty"`
Result string `json:"result"`
Stdout string `json:"stdout"`
Stderr string `json:"stderr"`
ExitCode int `json:"exit_code"`
DurationSeconds int `json:"duration_seconds"`
}{
CommandID: report.CommandID,
Action: report.Action,
Subsystem: subsystem,
Result: report.Result,
Stdout: report.Stdout,
Stderr: report.Stderr,
ExitCode: report.ExitCode,
DurationSeconds: report.DurationSeconds,
}
body, err := json.Marshal(logRequest)
if err != nil {
return err
}
req, err := http.NewRequest("POST", url, bytes.NewBuffer(body))
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/json")
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 err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
bodyBytes, _ := io.ReadAll(resp.Body)
return fmt.Errorf("failed to report log: %s - %s", resp.Status, string(bodyBytes))
}
return nil
}
// DependencyReport represents a dependency report after dry run
type DependencyReport struct {
PackageName string `json:"package_name"`
PackageType string `json:"package_type"`
Dependencies []string `json:"dependencies"`
UpdateID string `json:"update_id"`
DryRunResult *InstallResult `json:"dry_run_result,omitempty"`
}
// InstallResult represents the result of a package installation attempt
type InstallResult struct {
Success bool `json:"success"`
ErrorMessage string `json:"error_message,omitempty"`
Stdout string `json:"stdout,omitempty"`
Stderr string `json:"stderr,omitempty"`
ExitCode int `json:"exit_code"`
DurationSeconds int `json:"duration_seconds"`
Action string `json:"action,omitempty"`
PackagesInstalled []string `json:"packages_installed,omitempty"`
ContainersUpdated []string `json:"containers_updated,omitempty"`
Dependencies []string `json:"dependencies,omitempty"`
IsDryRun bool `json:"is_dry_run"`
}
// ReportDependencies sends dependency report to the server
func (c *Client) ReportDependencies(agentID uuid.UUID, report DependencyReport) error {
url := fmt.Sprintf("%s/api/v1/agents/%s/dependencies", c.baseURL, agentID)
body, err := json.Marshal(report)
if err != nil {
return err
}
req, err := http.NewRequest("POST", url, bytes.NewBuffer(body))
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/json")
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 err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
bodyBytes, _ := io.ReadAll(resp.Body)
return fmt.Errorf("failed to report dependencies: %s - %s", resp.Status, string(bodyBytes))
}
return nil
}
// SystemInfoReport represents system information updates
type SystemInfoReport struct {
Timestamp time.Time `json:"timestamp"`
CPUModel string `json:"cpu_model,omitempty"`
CPUCores int `json:"cpu_cores,omitempty"`
CPUThreads int `json:"cpu_threads,omitempty"`
MemoryTotal uint64 `json:"memory_total,omitempty"`
DiskTotal uint64 `json:"disk_total,omitempty"`
DiskUsed uint64 `json:"disk_used,omitempty"`
IPAddress string `json:"ip_address,omitempty"`
Processes int `json:"processes,omitempty"`
Uptime string `json:"uptime,omitempty"`
Metadata map[string]interface{} `json:"metadata,omitempty"`
}
// ReportSystemInfo sends updated system information to the server
func (c *Client) ReportSystemInfo(agentID uuid.UUID, report SystemInfoReport) error {
url := fmt.Sprintf("%s/api/v1/agents/%s/system-info", c.baseURL, agentID)
body, err := json.Marshal(report)
if err != nil {
return err
}
req, err := http.NewRequest("POST", url, bytes.NewBuffer(body))
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/json")
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 err
}
defer resp.Body.Close()
// Accept 200 OK or 404 Not Found (if endpoint doesn't exist yet)
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusNotFound {
bodyBytes, _ := io.ReadAll(resp.Body)
return fmt.Errorf("failed to report system info: %s - %s", resp.Status, string(bodyBytes))
}
return nil
}
// DetectSystem returns basic system information (deprecated, use system.GetSystemInfo instead)
func DetectSystem() (osType, osVersion, osArch string) {
osType = runtime.GOOS
osArch = runtime.GOARCH
// Read OS version
switch osType {
case "linux":
data, _ := os.ReadFile("/etc/os-release")
if data != nil {
osVersion = parseOSRelease(data)
}
case "windows":
osVersion = "Windows"
case "darwin":
osVersion = "macOS"
}
return
}
// AgentInfo represents agent information from the server
type AgentInfo struct {
ID string `json:"id"`
Hostname string `json:"hostname"`
CurrentVersion string `json:"current_version"`
OSType string `json:"os_type"`
OSVersion string `json:"os_version"`
OSArchitecture string `json:"os_architecture"`
LastCheckIn string `json:"last_check_in"`
}
// GetAgent retrieves agent information from the server
func (c *Client) GetAgent(agentID string) (*AgentInfo, error) {
url := fmt.Sprintf("%s/api/v1/agents/%s", c.baseURL, agentID)
req, err := http.NewRequest("GET", url, nil)
if err != nil {
return nil, fmt.Errorf("failed to create request: %w", err)
}
req.Header.Set("Authorization", "Bearer "+c.token)
req.Header.Set("Content-Type", "application/json")
c.addMachineIDHeader(req) // Security: Validate machine binding (v0.1.22+)
resp, err := c.http.Do(req)
if err != nil {
return nil, fmt.Errorf("failed to send request: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
return nil, fmt.Errorf("server returned status %d: %s", resp.StatusCode, string(body))
}
var agent AgentInfo
if err := json.NewDecoder(resp.Body).Decode(&agent); err != nil {
return nil, fmt.Errorf("failed to decode response: %w", err)
}
return &agent, nil
}
// parseOSRelease parses /etc/os-release to get proper distro name
func parseOSRelease(data []byte) string {
lines := strings.Split(string(data), "\n")
id := ""
prettyName := ""
version := ""
for _, line := range lines {
if strings.HasPrefix(line, "ID=") {
id = strings.Trim(strings.TrimPrefix(line, "ID="), "\"")
}
if strings.HasPrefix(line, "PRETTY_NAME=") {
prettyName = strings.Trim(strings.TrimPrefix(line, "PRETTY_NAME="), "\"")
}
if strings.HasPrefix(line, "VERSION_ID=") {
version = strings.Trim(strings.TrimPrefix(line, "VERSION_ID="), "\"")
}
}
// Prefer PRETTY_NAME if available
if prettyName != "" {
return prettyName
}
// Fall back to ID + VERSION
if id != "" {
if version != "" {
return strings.Title(id) + " " + version
}
return strings.Title(id)
}
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
}