Add fingerprint-pinned TLS mode for unified agent

This commit is contained in:
rcourtman 2026-04-22 01:36:46 +01:00
parent a8d9051b15
commit 7b1520b760
13 changed files with 178 additions and 21 deletions

View file

@ -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,

View file

@ -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) {

View file

@ -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,
}

View file

@ -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

View file

@ -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
}

View file

@ -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")
}
}

View file

@ -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{

View file

@ -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 {

View file

@ -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)
}

View file

@ -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{

View file

@ -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)
}

View file

@ -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{

View file

@ -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") {