mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-10-19 10:33:29 +00:00
Add fingerprint-pinned TLS mode for unified agent
This commit is contained in:
parent
a8d9051b15
commit
7b1520b760
13 changed files with 178 additions and 21 deletions
|
|
@ -115,11 +115,16 @@ func run(ctx context.Context, args []string, getenv func(string) string) error {
|
|||
logger := zerolog.New(os.Stdout).Level(cfg.LogLevel).With().Timestamp().Logger()
|
||||
cfg.Logger = &logger
|
||||
|
||||
if cfg.InsecureSkipVerify {
|
||||
if cfg.InsecureSkipVerify && cfg.ServerFingerprint == "" {
|
||||
logger.Warn().
|
||||
Str("component", "startup").
|
||||
Str("action", "tls_skip_verify_enabled").
|
||||
Msg("TLS verification disabled for agent connections (self-signed cert mode)")
|
||||
} else if cfg.ServerFingerprint != "" {
|
||||
logger.Info().
|
||||
Str("component", "startup").
|
||||
Str("action", "tls_server_fingerprint_enabled").
|
||||
Msg("Using pinned server certificate fingerprint for agent connections")
|
||||
}
|
||||
|
||||
// 2a. Handle Self-Test
|
||||
|
|
@ -175,6 +180,7 @@ func run(ctx context.Context, args []string, getenv func(string) string) error {
|
|||
Hostname: lookupHostname,
|
||||
InsecureSkipVerify: cfg.InsecureSkipVerify,
|
||||
CACertPath: cfg.CACertPath,
|
||||
ServerFingerprint: cfg.ServerFingerprint,
|
||||
Logger: logger,
|
||||
})
|
||||
defer rc.Close()
|
||||
|
|
@ -255,6 +261,7 @@ func run(ctx context.Context, args []string, getenv func(string) string) error {
|
|||
CheckInterval: 1 * time.Hour,
|
||||
InsecureSkipVerify: cfg.InsecureSkipVerify,
|
||||
CACertPath: cfg.CACertPath,
|
||||
ServerFingerprint: cfg.ServerFingerprint,
|
||||
Logger: &logger,
|
||||
Disabled: cfg.DisableAutoUpdate,
|
||||
})
|
||||
|
|
@ -277,6 +284,7 @@ func run(ctx context.Context, args []string, getenv func(string) string) error {
|
|||
Tags: cfg.Tags,
|
||||
InsecureSkipVerify: cfg.InsecureSkipVerify,
|
||||
CACertPath: cfg.CACertPath,
|
||||
ServerFingerprint: cfg.ServerFingerprint,
|
||||
LogLevel: cfg.LogLevel,
|
||||
Logger: &logger,
|
||||
EnableProxmox: cfg.EnableProxmox,
|
||||
|
|
@ -517,6 +525,7 @@ type Config struct {
|
|||
Tags []string
|
||||
InsecureSkipVerify bool
|
||||
CACertPath string
|
||||
ServerFingerprint string
|
||||
LogLevel zerolog.Level
|
||||
Logger *zerolog.Logger
|
||||
|
||||
|
|
@ -572,6 +581,7 @@ func loadConfig(args []string, getenv func(string) string) (Config, error) {
|
|||
envAgentID := strings.TrimSpace(getenv("PULSE_AGENT_ID"))
|
||||
envInsecure := strings.TrimSpace(getenv("PULSE_INSECURE_SKIP_VERIFY"))
|
||||
envCACertPath := strings.TrimSpace(getenv("PULSE_CACERT"))
|
||||
envServerFingerprint := strings.TrimSpace(getenv("PULSE_SERVER_FINGERPRINT"))
|
||||
envTags := strings.TrimSpace(getenv("PULSE_TAGS"))
|
||||
envLogLevel := strings.TrimSpace(getenv("LOG_LEVEL"))
|
||||
envEnableHost := strings.TrimSpace(getenv("PULSE_ENABLE_HOST"))
|
||||
|
|
@ -644,6 +654,7 @@ func loadConfig(args []string, getenv func(string) string) (Config, error) {
|
|||
agentIDFlag := fs.String("agent-id", envAgentID, "Override agent identifier")
|
||||
insecureFlag := fs.Bool("insecure", utils.ParseBool(envInsecure), "Skip TLS verification")
|
||||
caCertFlag := fs.String("cacert", envCACertPath, "Path to custom CA bundle for agent HTTPS transport")
|
||||
serverFingerprintFlag := fs.String("server-fingerprint", envServerFingerprint, "Expected Pulse server TLS certificate fingerprint (SHA256)")
|
||||
logLevelFlag := fs.String("log-level", defaultLogLevel(envLogLevel), "Log level")
|
||||
|
||||
enableHostFlag := fs.Bool("enable-host", defaultEnableHost, "Enable Host Agent module")
|
||||
|
|
@ -758,6 +769,7 @@ func loadConfig(args []string, getenv func(string) string) (Config, error) {
|
|||
Tags: tags,
|
||||
InsecureSkipVerify: *insecureFlag,
|
||||
CACertPath: strings.TrimSpace(*caCertFlag),
|
||||
ServerFingerprint: strings.TrimSpace(*serverFingerprintFlag),
|
||||
LogLevel: logLevel,
|
||||
EnableHost: *enableHostFlag,
|
||||
EnableDocker: *enableDockerFlag,
|
||||
|
|
|
|||
|
|
@ -799,11 +799,12 @@ func TestLoadConfig(t *testing.T) {
|
|||
|
||||
t.Run("env overrides", func(t *testing.T) {
|
||||
env := map[string]string{
|
||||
"PULSE_URL": "http://pulse.example.com",
|
||||
"PULSE_TOKEN": "my-token",
|
||||
"PULSE_ENABLE_HOST": "false",
|
||||
"PULSE_ENABLE_DOCKER": "true",
|
||||
"PULSE_CACERT": "/etc/pulse/ca.pem",
|
||||
"PULSE_URL": "http://pulse.example.com",
|
||||
"PULSE_TOKEN": "my-token",
|
||||
"PULSE_ENABLE_HOST": "false",
|
||||
"PULSE_ENABLE_DOCKER": "true",
|
||||
"PULSE_CACERT": "/etc/pulse/ca.pem",
|
||||
"PULSE_SERVER_FINGERPRINT": "aabbccdd",
|
||||
}
|
||||
cfg, err := loadConfig([]string{}, func(s string) string { return env[s] })
|
||||
if err != nil {
|
||||
|
|
@ -824,10 +825,13 @@ func TestLoadConfig(t *testing.T) {
|
|||
if cfg.CACertPath != "/etc/pulse/ca.pem" {
|
||||
t.Errorf("expected CA cert path from env, got %s", cfg.CACertPath)
|
||||
}
|
||||
if cfg.ServerFingerprint != "aabbccdd" {
|
||||
t.Errorf("expected server fingerprint from env, got %s", cfg.ServerFingerprint)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("flag overrides", func(t *testing.T) {
|
||||
cfg, err := loadConfig([]string{"-url", "http://flag.example.com", "-token", "flag-token", "-enable-host=false", "-cacert", "/tmp/custom-ca.pem"}, func(s string) string { return "" })
|
||||
cfg, err := loadConfig([]string{"-url", "http://flag.example.com", "-token", "flag-token", "-enable-host=false", "-cacert", "/tmp/custom-ca.pem", "-server-fingerprint", "1122"}, func(s string) string { return "" })
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
|
@ -843,6 +847,9 @@ func TestLoadConfig(t *testing.T) {
|
|||
if cfg.CACertPath != "/tmp/custom-ca.pem" {
|
||||
t.Errorf("expected CA cert path from flag, got %s", cfg.CACertPath)
|
||||
}
|
||||
if cfg.ServerFingerprint != "1122" {
|
||||
t.Errorf("expected server fingerprint from flag, got %s", cfg.ServerFingerprint)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("invalid interval flag", func(t *testing.T) {
|
||||
|
|
|
|||
|
|
@ -48,6 +48,7 @@ func (ws *windowsService) Execute(args []string, r <-chan svc.ChangeRequest, cha
|
|||
CheckInterval: 1 * time.Hour,
|
||||
InsecureSkipVerify: ws.cfg.InsecureSkipVerify,
|
||||
CACertPath: ws.cfg.CACertPath,
|
||||
ServerFingerprint: ws.cfg.ServerFingerprint,
|
||||
Logger: &ws.logger,
|
||||
Disabled: ws.cfg.DisableAutoUpdate,
|
||||
})
|
||||
|
|
@ -70,6 +71,7 @@ func (ws *windowsService) Execute(args []string, r <-chan svc.ChangeRequest, cha
|
|||
Tags: ws.cfg.Tags,
|
||||
InsecureSkipVerify: ws.cfg.InsecureSkipVerify,
|
||||
CACertPath: ws.cfg.CACertPath,
|
||||
ServerFingerprint: ws.cfg.ServerFingerprint,
|
||||
LogLevel: ws.cfg.LogLevel,
|
||||
Logger: &ws.logger,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -189,7 +189,8 @@ an add-only capacity posture.
|
|||
path must treat it as the existing system until live inventory rebuild
|
||||
catches up, rather than as a fresh enrolment competing for another
|
||||
monitored-system slot.
|
||||
4. Keep shared `internal/api/` helper edits isolated from agent lifecycle semantics: Patrol-specific status transport or alert-trigger wiring changes in shared handlers must not bleed into auto-register, installer, or fleet-control behavior unless this contract moves in the same slice.
|
||||
4. Keep shared agent-side TLS identity fail-closed across `cmd/pulse-agent/main.go`, `internal/hostagent/`, `internal/agentupdate/`, and adjacent remote-config transport. Self-signed deployments may use a canonical pinned Pulse server certificate fingerprint, but lifecycle transport must route that pin through reporting, enrollment, command websocket, remote-config, and self-update clients instead of widening `PULSE_INSECURE_SKIP_VERIFY` into a blanket MITM carve-out.
|
||||
5. Keep shared `internal/api/` helper edits isolated from agent lifecycle semantics: Patrol-specific status transport or alert-trigger wiring changes in shared handlers must not bleed into auto-register, installer, or fleet-control behavior unless this contract moves in the same slice.
|
||||
The same isolation rule applies to AI settings payload work in `internal/api/ai_handlers.go`: provider auth fields, masked-secret echoes, and provider-test model selection remain AI/runtime plus API-contract ownership and must not be reinterpreted as lifecycle setup or registration semantics just because they share backend helper layers.
|
||||
The same shared-helper rule now covers SSO outbound discovery and metadata fetches plus credential-file loads in `internal/api/sso_outbound.go`, `internal/api/saml_service.go`, and `internal/api/oidc_service.go`: lifecycle-adjacent setup or auth work may depend on that shared trust boundary, but it must not fork a second HTTP client, redirect policy, or file-read rule inside lifecycle-local flows.
|
||||
5. Keep legacy Unified Agent compatibility names explicitly secondary when touching shared `internal/api/` runtime helpers: the legacy host-route family and `host-agent:*` scope names may remain as ingress or migration aliases, but they must not retake primary ownership in router state, live runtime scope checks, handler commentary, or operator-facing guidance.
|
||||
|
|
@ -1573,6 +1574,14 @@ The updater/runtime surfaces must preserve the one-shot `updated_from`
|
|||
continuity handoff and the non-TLS continuity path for supported self-hosted
|
||||
installs, so upgrade-safe agent behavior does not drift between install,
|
||||
restart, and reconnect paths.
|
||||
That same shared agent transport boundary must not force operators to choose
|
||||
between public-CA trust and blanket TLS disablement. `cmd/pulse-agent/main.go`,
|
||||
`internal/hostagent/`, `internal/agentupdate/`, and adjacent remote-config
|
||||
transport must accept a canonical pinned Pulse server certificate fingerprint
|
||||
for self-signed deployments, and that pin must flow through reporting,
|
||||
enrollment, command websocket, remote-config, and self-update transport
|
||||
instead of widening `PULSE_INSECURE_SKIP_VERIFY` into an all-path MITM
|
||||
carve-out.
|
||||
That same unified-agent runtime boundary also owns vendor-aware host identity.
|
||||
When gopsutil reports generic Linux platform fields on NAS appliances,
|
||||
`internal/hostagent/` must prefer canonical platform files such as Synology DSM
|
||||
|
|
|
|||
|
|
@ -6,13 +6,20 @@ import (
|
|||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/rcourtman/pulse-go-rewrite/pkg/tlsutil"
|
||||
)
|
||||
|
||||
// NewClientTLSConfig returns a client TLS config with optional insecure mode
|
||||
// and optional custom CA bundle support.
|
||||
func NewClientTLSConfig(caBundlePath string, insecureSkipVerify bool) (*tls.Config, error) {
|
||||
func NewClientTLSConfig(caBundlePath string, insecureSkipVerify bool, expectedServerFingerprint string) (*tls.Config, error) {
|
||||
expectedServerFingerprint = strings.TrimSpace(expectedServerFingerprint)
|
||||
|
||||
tlsConfig := &tls.Config{MinVersion: tls.VersionTLS12}
|
||||
if insecureSkipVerify {
|
||||
if expectedServerFingerprint != "" {
|
||||
tlsConfig = tlsutil.FingerprintVerifier(expectedServerFingerprint)
|
||||
tlsConfig.MinVersion = tls.VersionTLS12
|
||||
} else if insecureSkipVerify {
|
||||
//nolint:gosec // Insecure mode is explicitly user-controlled.
|
||||
tlsConfig.InsecureSkipVerify = true
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,11 @@
|
|||
package agenttls
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"encoding/hex"
|
||||
"encoding/pem"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
|
@ -27,7 +31,7 @@ ARKc3gwyVxyCX3h21kFcEU2rt7C7/RcXBCyWzQ==
|
|||
`
|
||||
|
||||
func TestNewClientTLSConfig_Defaults(t *testing.T) {
|
||||
cfg, err := NewClientTLSConfig("", false)
|
||||
cfg, err := NewClientTLSConfig("", false, "")
|
||||
if err != nil {
|
||||
t.Fatalf("NewClientTLSConfig: %v", err)
|
||||
}
|
||||
|
|
@ -48,7 +52,7 @@ func TestNewClientTLSConfig_LoadsCustomCABundle(t *testing.T) {
|
|||
t.Fatalf("write cert: %v", err)
|
||||
}
|
||||
|
||||
cfg, err := NewClientTLSConfig(certPath, false)
|
||||
cfg, err := NewClientTLSConfig(certPath, false, "")
|
||||
if err != nil {
|
||||
t.Fatalf("NewClientTLSConfig: %v", err)
|
||||
}
|
||||
|
|
@ -63,7 +67,36 @@ func TestNewClientTLSConfig_RejectsInvalidBundle(t *testing.T) {
|
|||
t.Fatalf("write cert: %v", err)
|
||||
}
|
||||
|
||||
if _, err := NewClientTLSConfig(certPath, false); err == nil {
|
||||
if _, err := NewClientTLSConfig(certPath, false, ""); err == nil {
|
||||
t.Fatal("expected invalid CA bundle to fail")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewClientTLSConfig_UsesPinnedFingerprint(t *testing.T) {
|
||||
block, _ := pem.Decode([]byte(testPEMCertificate))
|
||||
if block == nil {
|
||||
t.Fatal("failed to decode test certificate")
|
||||
}
|
||||
cert, err := x509.ParseCertificate(block.Bytes)
|
||||
if err != nil {
|
||||
t.Fatalf("ParseCertificate: %v", err)
|
||||
}
|
||||
|
||||
sum := sha256.Sum256(cert.Raw)
|
||||
cfg, err := NewClientTLSConfig("", false, hex.EncodeToString(sum[:]))
|
||||
if err != nil {
|
||||
t.Fatalf("NewClientTLSConfig: %v", err)
|
||||
}
|
||||
if !cfg.InsecureSkipVerify {
|
||||
t.Fatal("expected pinned fingerprint mode to bypass CA verification in favor of explicit pinning")
|
||||
}
|
||||
if cfg.VerifyPeerCertificate == nil {
|
||||
t.Fatal("expected VerifyPeerCertificate to be configured for fingerprint pinning")
|
||||
}
|
||||
if err := cfg.VerifyPeerCertificate([][]byte{cert.Raw}, nil); err != nil {
|
||||
t.Fatalf("VerifyPeerCertificate: %v", err)
|
||||
}
|
||||
if err := cfg.VerifyPeerCertificate([][]byte{[]byte("mismatch")}, nil); err == nil {
|
||||
t.Fatal("expected mismatched certificate fingerprint to be rejected")
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -108,6 +108,9 @@ type Config struct {
|
|||
// CACertPath adds a custom CA bundle for agent transport
|
||||
CACertPath string
|
||||
|
||||
// ServerFingerprint pins the Pulse server TLS certificate when CA validation is unavailable.
|
||||
ServerFingerprint string
|
||||
|
||||
// Logger is the zerolog logger instance
|
||||
Logger *zerolog.Logger
|
||||
|
||||
|
|
@ -147,10 +150,10 @@ func New(cfg Config) *Updater {
|
|||
Msg("Invalid agent update check interval; using default")
|
||||
}
|
||||
|
||||
tlsConfig, err := agenttls.NewClientTLSConfig(cfg.CACertPath, cfg.InsecureSkipVerify)
|
||||
tlsConfig, err := agenttls.NewClientTLSConfig(cfg.CACertPath, cfg.InsecureSkipVerify, cfg.ServerFingerprint)
|
||||
if err != nil {
|
||||
logger.Warn().Err(err).Str("ca_cert_path", strings.TrimSpace(cfg.CACertPath)).Msg("Invalid custom CA bundle; continuing with default TLS roots")
|
||||
tlsConfig, _ = agenttls.NewClientTLSConfig("", cfg.InsecureSkipVerify)
|
||||
tlsConfig, _ = agenttls.NewClientTLSConfig("", cfg.InsecureSkipVerify, cfg.ServerFingerprint)
|
||||
}
|
||||
|
||||
transport := &http.Transport{
|
||||
|
|
|
|||
|
|
@ -140,6 +140,28 @@ func TestNew_UsesCustomCABundleForHTTPTransport(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestNew_UsesPinnedServerFingerprintForHTTPTransport(t *testing.T) {
|
||||
u := New(Config{
|
||||
PulseURL: "https://pulse.example.com",
|
||||
CurrentVersion: "1.0.0",
|
||||
ServerFingerprint: "aabbccdd",
|
||||
})
|
||||
|
||||
transport, ok := u.client.Transport.(*http.Transport)
|
||||
if !ok {
|
||||
t.Fatalf("transport type = %T, want *http.Transport", u.client.Transport)
|
||||
}
|
||||
if transport.TLSClientConfig == nil {
|
||||
t.Fatal("expected TLS config to be configured")
|
||||
}
|
||||
if !transport.TLSClientConfig.InsecureSkipVerify {
|
||||
t.Fatal("expected fingerprint pinning to use explicit peer verification")
|
||||
}
|
||||
if transport.TLSClientConfig.VerifyPeerCertificate == nil {
|
||||
t.Fatal("expected VerifyPeerCertificate to be configured for fingerprint pinning")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdater_CheckAndUpdate_EarlyReturns(t *testing.T) {
|
||||
u := New(Config{Disabled: true})
|
||||
u.performUpdateFn = func(ctx context.Context) error {
|
||||
|
|
|
|||
|
|
@ -41,6 +41,7 @@ type Config struct {
|
|||
Tags []string
|
||||
InsecureSkipVerify bool
|
||||
CACertPath string
|
||||
ServerFingerprint string
|
||||
RunOnce bool
|
||||
LogLevel zerolog.Level
|
||||
Logger *zerolog.Logger
|
||||
|
|
@ -229,7 +230,7 @@ func New(cfg Config) (*Agent, error) {
|
|||
if arch == "" {
|
||||
arch = runtime.GOARCH
|
||||
}
|
||||
tlsConfig, err := agenttls.NewClientTLSConfig(cfg.CACertPath, cfg.InsecureSkipVerify)
|
||||
tlsConfig, err := agenttls.NewClientTLSConfig(cfg.CACertPath, cfg.InsecureSkipVerify, cfg.ServerFingerprint)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid CA bundle: %w", err)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -175,8 +175,9 @@ func TestNew_DefaultPulseURL(t *testing.T) {
|
|||
func TestNewCommandClient_SetsSecureCommandDefaults(t *testing.T) {
|
||||
logger := zerolog.Nop()
|
||||
client := NewCommandClient(Config{
|
||||
PulseURL: "https://pulse.example",
|
||||
Logger: &logger,
|
||||
PulseURL: "https://pulse.example",
|
||||
ServerFingerprint: "aabbccdd",
|
||||
Logger: &logger,
|
||||
}, "agent-1", "node-1", "linux", "1.0.0")
|
||||
|
||||
if client.commandPolicy == nil {
|
||||
|
|
@ -185,6 +186,9 @@ func TestNewCommandClient_SetsSecureCommandDefaults(t *testing.T) {
|
|||
if client.stateDir != defaultStateDir {
|
||||
t.Fatalf("stateDir = %q, want %q", client.stateDir, defaultStateDir)
|
||||
}
|
||||
if client.serverFingerprint != "aabbccdd" {
|
||||
t.Fatalf("serverFingerprint = %q, want %q", client.serverFingerprint, "aabbccdd")
|
||||
}
|
||||
|
||||
if err := client.authorizeCommand(executeCommandPayload{Command: "systemctl restart nginx"}); err == nil || !strings.Contains(err.Error(), "requires approval") {
|
||||
t.Fatalf("expected approval-required command to fail closed, got %v", err)
|
||||
|
|
@ -197,6 +201,39 @@ func TestNewCommandClient_SetsSecureCommandDefaults(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestNew_UsesPinnedServerFingerprintForHTTPTransport(t *testing.T) {
|
||||
mc := &mockCollector{
|
||||
hostInfoFn: func(context.Context) (*gohost.InfoStat, error) {
|
||||
return &gohost.InfoStat{Hostname: "host", HostID: "hid", KernelArch: runtime.GOARCH}, nil
|
||||
},
|
||||
}
|
||||
|
||||
agent, err := New(Config{
|
||||
PulseURL: "https://pulse.example",
|
||||
APIToken: "token",
|
||||
ServerFingerprint: "aabbccdd",
|
||||
LogLevel: zerolog.InfoLevel,
|
||||
Collector: mc,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("New: %v", err)
|
||||
}
|
||||
|
||||
httpTransport, ok := agent.httpClient.Transport.(*http.Transport)
|
||||
if !ok {
|
||||
t.Fatalf("expected *http.Transport, got %T", agent.httpClient.Transport)
|
||||
}
|
||||
if httpTransport.TLSClientConfig == nil {
|
||||
t.Fatal("expected TLS config to be configured")
|
||||
}
|
||||
if !httpTransport.TLSClientConfig.InsecureSkipVerify {
|
||||
t.Fatal("expected fingerprint-pinned transport to bypass CA verification in favor of explicit pinning")
|
||||
}
|
||||
if httpTransport.TLSClientConfig.VerifyPeerCertificate == nil {
|
||||
t.Fatal("expected fingerprint-pinned transport to verify peer certificates explicitly")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNew_ResolvesVendorNASIdentityFromPlatformFiles(t *testing.T) {
|
||||
t.Run("synology dsm from version file", func(t *testing.T) {
|
||||
mc := &mockCollector{
|
||||
|
|
|
|||
|
|
@ -93,6 +93,7 @@ type CommandClient struct {
|
|||
stateDir string
|
||||
insecureSkipVerify bool
|
||||
caCertPath string
|
||||
serverFingerprint string
|
||||
commandPolicy *agentexec.CommandPolicy
|
||||
sshKnownHosts sshknownhosts.Manager
|
||||
sshKnownHostsOnce sync.Once
|
||||
|
|
@ -122,6 +123,7 @@ func NewCommandClient(cfg Config, agentID, hostname, platform, version string) *
|
|||
stateDir: stateDir,
|
||||
insecureSkipVerify: cfg.InsecureSkipVerify,
|
||||
caCertPath: cfg.CACertPath,
|
||||
serverFingerprint: cfg.ServerFingerprint,
|
||||
commandPolicy: agentexec.DefaultPolicy(),
|
||||
logger: logger,
|
||||
done: make(chan struct{}),
|
||||
|
|
@ -271,7 +273,7 @@ func (c *CommandClient) connectAndHandle(ctx context.Context) error {
|
|||
c.logger.Debug().Str("url", wsURL).Msg("Connecting to Pulse command server")
|
||||
|
||||
// Create dialer with TLS config
|
||||
tlsConfig, err := agenttls.NewClientTLSConfig(c.caCertPath, c.insecureSkipVerify)
|
||||
tlsConfig, err := agenttls.NewClientTLSConfig(c.caCertPath, c.insecureSkipVerify, c.serverFingerprint)
|
||||
if err != nil {
|
||||
return fmt.Errorf("build websocket TLS config: %w", err)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -33,6 +33,7 @@ type Config struct {
|
|||
Hostname string
|
||||
InsecureSkipVerify bool
|
||||
CACertPath string
|
||||
ServerFingerprint string
|
||||
Logger zerolog.Logger
|
||||
}
|
||||
|
||||
|
|
@ -70,10 +71,10 @@ const maxHTTPErrorBodyBytes = 4096
|
|||
func New(cfg Config) *Client {
|
||||
cfg, cfgErr := normalizeConfig(cfg)
|
||||
|
||||
tlsConfig, err := agenttls.NewClientTLSConfig(cfg.CACertPath, cfg.InsecureSkipVerify)
|
||||
tlsConfig, err := agenttls.NewClientTLSConfig(cfg.CACertPath, cfg.InsecureSkipVerify, cfg.ServerFingerprint)
|
||||
if err != nil {
|
||||
cfg.Logger.Warn().Err(err).Str("ca_cert_path", strings.TrimSpace(cfg.CACertPath)).Msg("Invalid custom CA bundle; continuing with default TLS roots")
|
||||
tlsConfig, _ = agenttls.NewClientTLSConfig("", cfg.InsecureSkipVerify)
|
||||
tlsConfig, _ = agenttls.NewClientTLSConfig("", cfg.InsecureSkipVerify, cfg.ServerFingerprint)
|
||||
}
|
||||
|
||||
httpClient := &http.Client{
|
||||
|
|
|
|||
|
|
@ -214,6 +214,27 @@ func TestClientNewDefaultsAndAgentLookupNotFound(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestClientNew_UsesPinnedServerFingerprint(t *testing.T) {
|
||||
client := New(Config{
|
||||
PulseURL: "https://pulse.example",
|
||||
ServerFingerprint: "aabbccdd",
|
||||
})
|
||||
|
||||
transport, ok := client.httpClient.Transport.(*http.Transport)
|
||||
if !ok {
|
||||
t.Fatalf("transport type = %T, want *http.Transport", client.httpClient.Transport)
|
||||
}
|
||||
if transport.TLSClientConfig == nil {
|
||||
t.Fatal("expected TLS config to be configured")
|
||||
}
|
||||
if !transport.TLSClientConfig.InsecureSkipVerify {
|
||||
t.Fatal("expected fingerprint-pinned transport to bypass CA verification in favor of explicit pinning")
|
||||
}
|
||||
if transport.TLSClientConfig.VerifyPeerCertificate == nil {
|
||||
t.Fatal("expected VerifyPeerCertificate for pinned fingerprint mode")
|
||||
}
|
||||
}
|
||||
|
||||
func TestClientFetchResolveAgentIDError(t *testing.T) {
|
||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if strings.Contains(r.URL.Path, "/lookup") {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue