Pulse/internal/remoteconfig/client.go
2026-08-31 21:33:18 +01:00

410 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
func decodeLimitedJSONResponse(resp *http.Response, maxBytes int64, destination any) error {
if err := securityutil.LimitResponseBody(resp, maxBytes); err != nil {
return err
}
encoded, err := io.ReadAll(resp.Body)
if err != nil {
return err
}
if err := json.Unmarshal(encoded, destination); err != nil {
return err
}
return nil
}
// 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 agent TLS configuration; refusing to create remote config client")
configErr = errors.Join(configErr, fmt.Errorf("invalid TLS configuration: %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 := decodeLimitedJSONResponse(resp, maxConfigResponseBodyBytes, &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.Debug().
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 := decodeLimitedJSONResponse(resp, maxAgentLookupResponseBodyBytes, &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)
if err != nil {
return cfg, err
}
cfg.PulseURL = normalizedPulseURL
return cfg, nil
}
func normalizePulseURL(raw string) (string, error) {
parsed, err := securityutil.NormalizePulseHTTPBaseURLWithOptions(raw, securityutil.PulseURLValidationOptions{
AllowLocalNetworkHTTP: true,
})
if err != nil {
return "", fmt.Errorf("invalid pulse URL: %w", err)
}
return strings.TrimRight(parsed.String(), "/"), nil
}