Files
pulse/internal/remoteconfig/client.go
T
rcourtman e8b3c7fcf7 Restore remote config signature compatibility
Keep desired config fingerprints as response metadata derived from the signed command and settings payload.

Use merged agent profile settings when building remote config fingerprints.
2026-05-13 19:00:02 +01:00

396 lines
13 KiB
Go

package remoteconfig
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"time"
"github.com/rcourtman/pulse-go-rewrite/internal/agenttls"
"github.com/rcourtman/pulse-go-rewrite/internal/securityutil"
"github.com/rcourtman/pulse-go-rewrite/internal/utils"
"github.com/rs/zerolog"
)
const (
maxConfigResponseBodyBytes int64 = 1 * 1024 * 1024
maxAgentLookupResponseBodyBytes int64 = 64 * 1024
agentConfigPathFormat = "/api/agents/agent/%s/config"
agentLookupPath = "/api/agents/agent/lookup"
)
// Config holds configuration for the remote config client.
type Config struct {
PulseURL string
APIToken string
AgentID string
Hostname string
InsecureSkipVerify bool
CACertPath string
ServerFingerprint string
Logger zerolog.Logger
}
// Client handles fetching remote configuration from the Pulse server.
type Client struct {
cfg Config
httpClient *http.Client
configErr error
}
// Close releases any idle HTTP connections held by the client transport.
func (c *Client) Close() {
if c == nil || c.httpClient == nil {
return
}
c.httpClient.CloseIdleConnections()
}
// Response represents the JSON response from the config endpoint.
type Response struct {
Success bool `json:"success"`
AgentID string `json:"agentId"`
Config struct {
CommandsEnabled *bool `json:"commandsEnabled,omitempty"`
Settings map[string]interface{} `json:"settings,omitempty"`
DesiredConfig *DesiredConfigMetadata `json:"desiredConfig,omitempty"`
IssuedAt time.Time `json:"issuedAt,omitempty"`
ExpiresAt time.Time `json:"expiresAt,omitempty"`
Signature string `json:"signature,omitempty"`
} `json:"config"`
}
const maxHTTPErrorBodyBytes = 4096
// New creates a new remote config client.
func New(cfg Config) *Client {
cfg, cfgErr := normalizeConfig(cfg)
configErr := cfgErr
var httpClient *http.Client
tlsConfig, err := agenttls.NewClientTLSConfig(cfg.CACertPath, cfg.InsecureSkipVerify, cfg.ServerFingerprint)
if err != nil {
cfg.Logger.Error().
Err(err).
Str("ca_cert_path", strings.TrimSpace(cfg.CACertPath)).
Msg("Invalid custom CA bundle; refusing to fall back to default TLS roots")
configErr = errors.Join(configErr, fmt.Errorf("invalid CA bundle: %w", err))
} else {
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,
configErr: configErr,
}
}
// 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.configErr != nil {
return nil, nil, fmt.Errorf("invalid remote config client configuration: %w", c.configErr)
}
if c.cfg.APIToken == "" {
return nil, nil, fmt.Errorf("API token is required to fetch remote config")
}
if c.cfg.AgentID == "" {
return nil, nil, fmt.Errorf("agent ID is required to fetch remote config")
}
logger := c.logger()
signatureRequired := isConfigSignatureRequired()
agentID := c.cfg.AgentID
if resolved, err := c.resolveAgentID(ctx); err != nil {
return nil, nil, fmt.Errorf("resolve agent ID: %w", err)
} else if resolved != "" {
logger.Debug().
Str("action", "resolve_agent_id_success").
Str("hostname", c.cfg.Hostname).
Str("requested_agent_id", c.cfg.AgentID).
Str("resolved_agent_id", resolved).
Msg("Remote config agent lookup resolved agent ID")
agentID = resolved
}
endpointURL := c.cfg.PulseURL + fmt.Sprintf(agentConfigPathFormat, url.PathEscape(agentID))
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpointURL, 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 {
logger.Warn().
Err(err).
Str("action", "fetch_request_failed").
Str("endpoint_url", endpointURL).
Str("agent_id", agentID).
Msg("Remote config request failed")
return nil, nil, fmt.Errorf("do request: %w", err)
}
defer func() {
if closeErr := resp.Body.Close(); closeErr != nil {
c.cfg.Logger.Warn().Err(closeErr).Msg("Failed to close config response body")
}
}()
if resp.StatusCode >= 300 {
logger.Warn().
Str("action", "fetch_non_success_status").
Str("endpoint_url", endpointURL).
Int("status_code", resp.StatusCode).
Str("status", resp.Status).
Str("agent_id", agentID).
Msg("Remote config request returned non-success status")
return nil, nil, fmt.Errorf("server responded with status %s", resp.Status)
}
var configResp Response
if err := json.NewDecoder(io.LimitReader(resp.Body, maxConfigResponseBodyBytes)).Decode(&configResp); err != nil {
logger.Warn().
Err(err).
Str("action", "decode_response_failed").
Str("endpoint_url", endpointURL).
Str("agent_id", agentID).
Msg("Remote config response decode failed")
return nil, nil, fmt.Errorf("decode response: %w", err)
}
responseAgentID := strings.TrimSpace(configResp.AgentID)
if configResp.Config.DesiredConfig != nil {
if err := ValidateDesiredConfigMetadata(*configResp.Config.DesiredConfig, configResp.Config.CommandsEnabled, configResp.Config.Settings); err != nil {
logger.Warn().
Err(err).
Str("action", "desired_config_metadata_invalid").
Str("agent_id", agentID).
Str("response_agent_id", responseAgentID).
Msg("Remote config desired metadata did not match config payload")
return nil, nil, fmt.Errorf("config desired metadata invalid: %w", err)
}
}
if configResp.Config.Signature != "" {
if responseAgentID == "" {
return nil, nil, fmt.Errorf("config signature missing agent metadata")
}
if responseAgentID != agentID {
return nil, nil, fmt.Errorf("config signature agent mismatch: expected %q, got %q", agentID, responseAgentID)
}
if configResp.Config.IssuedAt.IsZero() || configResp.Config.ExpiresAt.IsZero() {
logger.Warn().
Str("action", "signature_missing_timestamps").
Str("agent_id", agentID).
Str("response_agent_id", responseAgentID).
Msg("Remote config signature metadata missing timestamps")
return nil, nil, fmt.Errorf("config signature missing timestamp metadata")
}
now := time.Now().UTC()
if now.After(configResp.Config.ExpiresAt.Add(2 * time.Minute)) {
logger.Warn().
Str("action", "signature_expired").
Str("agent_id", agentID).
Str("response_agent_id", responseAgentID).
Time("issued_at", configResp.Config.IssuedAt).
Time("expires_at", configResp.Config.ExpiresAt).
Time("observed_at", now).
Msg("Remote config signature expired")
return nil, nil, fmt.Errorf("config signature expired")
}
if configResp.Config.IssuedAt.After(now.Add(2 * time.Minute)) {
logger.Warn().
Str("action", "signature_issued_in_future").
Str("agent_id", agentID).
Str("response_agent_id", responseAgentID).
Time("issued_at", configResp.Config.IssuedAt).
Time("observed_at", now).
Msg("Remote config signature issued in the future")
return nil, nil, fmt.Errorf("config signature issued in the future")
}
payload := SignedConfigPayload{
AgentID: responseAgentID,
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 {
logger.Warn().
Err(err).
Str("action", "signature_verification_failed").
Str("agent_id", agentID).
Str("response_agent_id", responseAgentID).
Msg("Remote config signature verification failed")
return nil, nil, fmt.Errorf("config signature verification failed: %w", err)
}
} else if signatureRequired {
logger.Warn().
Str("action", "signature_missing_required").
Str("agent_id", agentID).
Str("response_agent_id", responseAgentID).
Bool("signature_required", signatureRequired).
Msg("Remote config signature required but missing")
return nil, nil, fmt.Errorf("config signature required but missing")
} else if len(configResp.Config.Settings) > 0 || configResp.Config.CommandsEnabled != nil {
logger.Warn().
Str("action", "missing_signature_skip_verification").
Str("agent_id", agentID).
Str("response_agent_id", responseAgentID).
Int("settings_count", len(configResp.Config.Settings)).
Bool("commands_enabled_present", configResp.Config.CommandsEnabled != nil).
Bool("signature_required", signatureRequired).
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) logger() zerolog.Logger {
return c.cfg.Logger.With().Str("component", "remote_config_client").Logger()
}
func (c *Client) resolveAgentID(ctx context.Context) (string, error) {
if c.configErr != nil {
return "", fmt.Errorf("invalid remote config client configuration: %w", c.configErr)
}
if c.cfg.APIToken == "" {
return "", fmt.Errorf("API token is required for agent lookup")
}
hostname := c.cfg.Hostname
if hostname == "" {
return "", nil
}
logger := c.logger()
endpointURL := fmt.Sprintf("%s%s?hostname=%s", c.cfg.PulseURL, agentLookupPath, url.QueryEscape(hostname))
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpointURL, nil)
if err != nil {
logger.Warn().
Err(err).
Str("action", "agent_lookup_request_build_failed").
Str("endpoint_url", endpointURL).
Str("hostname", hostname).
Msg("Remote config agent lookup request creation failed")
return "", fmt.Errorf("create agent 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 {
logger.Warn().
Err(err).
Str("action", "agent_lookup_request_failed").
Str("endpoint_url", endpointURL).
Str("hostname", hostname).
Msg("Remote config agent lookup request failed")
return "", fmt.Errorf("agent lookup request: %w", err)
}
defer func() {
if closeErr := resp.Body.Close(); closeErr != nil {
c.cfg.Logger.Warn().Err(closeErr).Msg("Failed to close agent lookup response body")
}
}()
if resp.StatusCode == http.StatusNotFound {
logger.Debug().
Str("action", "agent_lookup_not_found").
Str("hostname", hostname).
Msg("Remote config agent lookup returned not found")
return "", nil
}
if resp.StatusCode >= 300 {
logger.Warn().
Str("action", "agent_lookup_non_success_status").
Str("endpoint_url", endpointURL).
Str("hostname", hostname).
Int("status_code", resp.StatusCode).
Str("status", resp.Status).
Msg("Remote config agent lookup returned non-success status")
return "", fmt.Errorf("agent lookup responded with status %s", resp.Status)
}
var payload struct {
Success bool `json:"success"`
Agent struct {
ID string `json:"id"`
} `json:"agent"`
}
if err := json.NewDecoder(io.LimitReader(resp.Body, maxAgentLookupResponseBodyBytes)).Decode(&payload); err != nil {
logger.Warn().
Err(err).
Str("action", "agent_lookup_decode_failed").
Str("endpoint_url", endpointURL).
Str("hostname", hostname).
Msg("Remote config agent lookup response decode failed")
return "", fmt.Errorf("decode agent lookup response: %w", err)
}
if !payload.Success {
logger.Debug().
Str("action", "agent_lookup_unsuccessful").
Str("hostname", hostname).
Msg("Remote config agent lookup returned unsuccessful response")
return "", nil
}
return strings.TrimSpace(payload.Agent.ID), nil
}
func normalizeConfig(cfg Config) (Config, error) {
cfg.PulseURL = strings.TrimSpace(cfg.PulseURL)
if cfg.PulseURL == "" {
cfg.PulseURL = "http://localhost:7655"
}
cfg.APIToken = strings.TrimSpace(cfg.APIToken)
cfg.AgentID = strings.TrimSpace(cfg.AgentID)
cfg.Hostname = strings.TrimSpace(cfg.Hostname)
normalizedPulseURL, err := normalizePulseURL(cfg.PulseURL, cfg.InsecureSkipVerify)
if err != nil {
return cfg, err
}
cfg.PulseURL = normalizedPulseURL
return cfg, nil
}
func normalizePulseURL(raw string, allowInsecureHTTP bool) (string, error) {
parsed, err := securityutil.NormalizePulseHTTPBaseURLWithOptions(raw, securityutil.PulseURLValidationOptions{
AllowInsecureHTTP: allowInsecureHTTP,
})
if err != nil {
return "", fmt.Errorf("invalid pulse URL: %w", err)
}
return strings.TrimRight(parsed.String(), "/"), nil
}