Breaking changes for clean alpha releases: - JWT authentication with user-provided secrets (no more development defaults) - Registration token system for secure agent enrollment - Rate limiting with user-adjustable settings - Enhanced agent configuration with proxy support - Interactive server setup wizard (--setup flag) - Heartbeat architecture separation for better UX - Package status synchronization fixes - Accurate timestamp tracking for RMM features Setup process for new installations: 1. docker-compose up -d postgres 2. ./redflag-server --setup 3. ./redflag-server --migrate 4. ./redflag-server 5. Generate tokens via admin UI 6. Deploy agents with registration tokens
493 lines
14 KiB
Go
493 lines
14 KiB
Go
package client
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"os"
|
|
"runtime"
|
|
"strings"
|
|
"time"
|
|
|
|
"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
|
|
}
|
|
|
|
// NewClient creates a new API client
|
|
func NewClient(baseURL, token string) *Client {
|
|
return &Client{
|
|
baseURL: baseURL,
|
|
token: token,
|
|
http: &http.Client{
|
|
Timeout: 30 * time.Second,
|
|
},
|
|
}
|
|
}
|
|
|
|
// 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"`
|
|
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)
|
|
|
|
body, err := json.Marshal(req)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
httpReq, err := http.NewRequest("POST", url, bytes.NewBuffer(body))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
httpReq.Header.Set("Content-Type", "application/json")
|
|
|
|
resp, err := c.http.Do(httpReq)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
bodyBytes, _ := io.ReadAll(resp.Body)
|
|
return nil, fmt.Errorf("registration failed: %s - %s", resp.Status, string(bodyBytes))
|
|
}
|
|
|
|
var result RegisterResponse
|
|
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Update client token
|
|
c.token = result.Token
|
|
|
|
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"`
|
|
}
|
|
|
|
// 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) error {
|
|
url := fmt.Sprintf("%s/api/v1/agents/renew", c.baseURL)
|
|
|
|
renewalReq := TokenRenewalRequest{
|
|
AgentID: agentID,
|
|
RefreshToken: refreshToken,
|
|
}
|
|
|
|
body, err := json.Marshal(renewalReq)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
httpReq, err := http.NewRequest("POST", url, bytes.NewBuffer(body))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
httpReq.Header.Set("Content-Type", "application/json")
|
|
|
|
resp, err := c.http.Do(httpReq)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
bodyBytes, _ := io.ReadAll(resp.Body)
|
|
return fmt.Errorf("token renewal failed: %s - %s", resp.Status, string(bodyBytes))
|
|
}
|
|
|
|
var result TokenRenewalResponse
|
|
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
|
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"`
|
|
}
|
|
|
|
// CommandsResponse contains pending commands
|
|
type CommandsResponse struct {
|
|
Commands []Command `json:"commands"`
|
|
RapidPolling *RapidPollingConfig `json:"rapid_polling,omitempty"`
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
// GetCommands retrieves pending commands from the server
|
|
// Optionally sends lightweight system metrics in the request
|
|
func (c *Client) GetCommands(agentID uuid.UUID, metrics *SystemMetrics) ([]Command, 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)
|
|
|
|
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.Commands, 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)
|
|
|
|
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
|
|
}
|
|
|
|
// 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"`
|
|
}
|
|
|
|
// 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)
|
|
|
|
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)
|
|
|
|
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)
|
|
|
|
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)
|
|
|
|
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
|
|
}
|
|
|
|
// 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"
|
|
}
|