mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-04-29 12:00:13 +00:00
The agent was crashing with 'fatal error: concurrent map writes' when handleCheckUpdatesCommand spawned a goroutine that called collectOnce concurrently with the main collection loop. Both code paths access a.prevContainerCPU without synchronization. Added a.cpuMu mutex to protect all accesses to prevContainerCPU in: - pruneStaleCPUSamples() - collectContainer() delete operation - calculateContainerCPUPercent() Related to #1063
193 lines
5.8 KiB
Go
193 lines
5.8 KiB
Go
package remoteconfig
|
|
|
|
import (
|
|
"context"
|
|
"crypto/tls"
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/http"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/rcourtman/pulse-go-rewrite/internal/utils"
|
|
"github.com/rs/zerolog"
|
|
)
|
|
|
|
// Config holds configuration for the remote config client.
|
|
type Config struct {
|
|
PulseURL string
|
|
APIToken string
|
|
AgentID string
|
|
Hostname string
|
|
InsecureSkipVerify bool
|
|
Logger zerolog.Logger
|
|
}
|
|
|
|
// Client handles fetching remote configuration from the Pulse server.
|
|
type Client struct {
|
|
cfg Config
|
|
httpClient *http.Client
|
|
}
|
|
|
|
// Response represents the JSON response from the config endpoint.
|
|
type Response struct {
|
|
Success bool `json:"success"`
|
|
HostID string `json:"hostId"`
|
|
Config struct {
|
|
CommandsEnabled *bool `json:"commandsEnabled,omitempty"`
|
|
Settings map[string]interface{} `json:"settings,omitempty"`
|
|
IssuedAt time.Time `json:"issuedAt,omitempty"`
|
|
ExpiresAt time.Time `json:"expiresAt,omitempty"`
|
|
Signature string `json:"signature,omitempty"`
|
|
} `json:"config"`
|
|
}
|
|
|
|
// New creates a new remote config client.
|
|
func New(cfg Config) *Client {
|
|
if cfg.PulseURL == "" {
|
|
cfg.PulseURL = "http://localhost:7655"
|
|
}
|
|
cfg.PulseURL = strings.TrimRight(cfg.PulseURL, "/")
|
|
|
|
tlsConfig := &tls.Config{MinVersion: tls.VersionTLS12}
|
|
if cfg.InsecureSkipVerify {
|
|
//nolint:gosec // Insecure mode is explicitly user-controlled.
|
|
tlsConfig.InsecureSkipVerify = true
|
|
}
|
|
|
|
httpClient := &http.Client{
|
|
Timeout: 10 * time.Second,
|
|
Transport: &http.Transport{
|
|
Proxy: http.ProxyFromEnvironment,
|
|
TLSClientConfig: tlsConfig,
|
|
},
|
|
CheckRedirect: func(req *http.Request, via []*http.Request) error {
|
|
return fmt.Errorf("server returned redirect to %s", req.URL)
|
|
},
|
|
}
|
|
|
|
return &Client{
|
|
cfg: cfg,
|
|
httpClient: httpClient,
|
|
}
|
|
}
|
|
|
|
// Fetch retrieves the remote configuration for this agent.
|
|
// It returns a map of settings to apply, or an error if the fetch fails.
|
|
// Returns (settings, commandsEnabled, error)
|
|
func (c *Client) Fetch(ctx context.Context) (map[string]interface{}, *bool, error) {
|
|
if c.cfg.AgentID == "" {
|
|
return nil, nil, fmt.Errorf("agent ID is required to fetch remote config")
|
|
}
|
|
|
|
signatureRequired := isConfigSignatureRequired()
|
|
hostID := c.cfg.AgentID
|
|
if resolved, err := c.resolveHostID(ctx); err != nil {
|
|
return nil, nil, err
|
|
} else if resolved != "" {
|
|
hostID = resolved
|
|
}
|
|
|
|
url := fmt.Sprintf("%s/api/agents/host/%s/config", c.cfg.PulseURL, hostID)
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
|
if err != nil {
|
|
return nil, nil, fmt.Errorf("create request: %w", err)
|
|
}
|
|
|
|
req.Header.Set("Authorization", "Bearer "+c.cfg.APIToken)
|
|
req.Header.Set("X-API-Token", c.cfg.APIToken)
|
|
req.Header.Set("User-Agent", "pulse-agent-config-client")
|
|
|
|
resp, err := c.httpClient.Do(req)
|
|
if err != nil {
|
|
return nil, nil, fmt.Errorf("do request: %w", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode >= 300 {
|
|
return nil, nil, fmt.Errorf("server responded with status %s", resp.Status)
|
|
}
|
|
|
|
var configResp Response
|
|
if err := json.NewDecoder(resp.Body).Decode(&configResp); err != nil {
|
|
return nil, nil, fmt.Errorf("decode response: %w", err)
|
|
}
|
|
|
|
if configResp.Config.Signature != "" {
|
|
if configResp.Config.IssuedAt.IsZero() || configResp.Config.ExpiresAt.IsZero() {
|
|
return nil, nil, fmt.Errorf("config signature missing timestamp metadata")
|
|
}
|
|
now := time.Now().UTC()
|
|
if now.After(configResp.Config.ExpiresAt.Add(2 * time.Minute)) {
|
|
return nil, nil, fmt.Errorf("config signature expired")
|
|
}
|
|
if configResp.Config.IssuedAt.After(now.Add(2 * time.Minute)) {
|
|
return nil, nil, fmt.Errorf("config signature issued in the future")
|
|
}
|
|
|
|
payload := SignedConfigPayload{
|
|
HostID: configResp.HostID,
|
|
IssuedAt: configResp.Config.IssuedAt,
|
|
ExpiresAt: configResp.Config.ExpiresAt,
|
|
CommandsEnabled: configResp.Config.CommandsEnabled,
|
|
Settings: configResp.Config.Settings,
|
|
}
|
|
if err := VerifyConfigPayloadSignature(payload, configResp.Config.Signature); err != nil {
|
|
return nil, nil, fmt.Errorf("config signature verification failed: %w", err)
|
|
}
|
|
} else if signatureRequired {
|
|
return nil, nil, fmt.Errorf("config signature required but missing")
|
|
} else if len(configResp.Config.Settings) > 0 || configResp.Config.CommandsEnabled != nil {
|
|
c.cfg.Logger.Warn().Msg("Remote config response missing signature - skipping verification")
|
|
}
|
|
|
|
return configResp.Config.Settings, configResp.Config.CommandsEnabled, nil
|
|
}
|
|
|
|
func isConfigSignatureRequired() bool {
|
|
return utils.ParseBool(utils.GetenvTrim("PULSE_AGENT_CONFIG_SIGNATURE_REQUIRED"))
|
|
}
|
|
|
|
func (c *Client) resolveHostID(ctx context.Context) (string, error) {
|
|
hostname := strings.TrimSpace(c.cfg.Hostname)
|
|
if hostname == "" {
|
|
return "", nil
|
|
}
|
|
|
|
url := fmt.Sprintf("%s/api/agents/host/lookup?hostname=%s", c.cfg.PulseURL, hostname)
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
|
if err != nil {
|
|
return "", fmt.Errorf("create host lookup request: %w", err)
|
|
}
|
|
|
|
req.Header.Set("Authorization", "Bearer "+c.cfg.APIToken)
|
|
req.Header.Set("X-API-Token", c.cfg.APIToken)
|
|
req.Header.Set("User-Agent", "pulse-agent-config-client")
|
|
|
|
resp, err := c.httpClient.Do(req)
|
|
if err != nil {
|
|
return "", fmt.Errorf("host lookup request: %w", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode == http.StatusNotFound {
|
|
return "", nil
|
|
}
|
|
if resp.StatusCode >= 300 {
|
|
return "", fmt.Errorf("host lookup responded with status %s", resp.Status)
|
|
}
|
|
|
|
var payload struct {
|
|
Success bool `json:"success"`
|
|
Host struct {
|
|
ID string `json:"id"`
|
|
} `json:"host"`
|
|
}
|
|
if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil {
|
|
return "", fmt.Errorf("decode host lookup response: %w", err)
|
|
}
|
|
if !payload.Success {
|
|
return "", nil
|
|
}
|
|
return strings.TrimSpace(payload.Host.ID), nil
|
|
}
|