Files
pulse/internal/remoteconfig/client_additional_test.go
T
rcourtman 93fff4f1d8 Add an explicit operator override for plaintext HTTP to non-local Pulse hosts
Fleets on networks numbered from nominally public IP space (issue
#1522: an AD estate on 192.20.0.0/16) cannot pass the agent's
local-network plaintext heuristic, and the only workaround was pointing
a .internal DNS alias at the server, which bypasses the same control
less visibly than a flag would. --allow-plaintext-http
(PULSE_AGENT_ALLOW_PLAINTEXT_HTTP) records process-wide consent once at
agent startup before any module validates a URL, covers every agent
transport including the websocket command channel, warns at startup
that the API token travels in cleartext, defaults closed, and is never
emitted by generated install commands or settable by the server.
2026-07-14 16:42:02 +01:00

700 lines
21 KiB
Go

package remoteconfig
import (
"bytes"
"context"
"crypto/ed25519"
"encoding/base64"
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"time"
"github.com/rcourtman/pulse-go-rewrite/pkg/securityutil"
"github.com/rs/zerolog"
)
func TestClientFetchWithSignature(t *testing.T) {
pub, priv, err := ed25519.GenerateKey(nil)
if err != nil {
t.Fatalf("GenerateKey: %v", err)
}
t.Setenv("PULSE_AGENT_CONFIG_PUBLIC_KEYS", base64.StdEncoding.EncodeToString(pub))
issuedAt := time.Now().UTC()
expiresAt := issuedAt.Add(5 * time.Minute)
commands := true
settings := map[string]interface{}{"interval": "1m"}
desired, err := BuildDesiredConfigMetadata(&commands, settings)
if err != nil {
t.Fatalf("BuildDesiredConfigMetadata: %v", err)
}
payload := SignedConfigPayload{
AgentID: "agent-1",
IssuedAt: issuedAt,
ExpiresAt: expiresAt,
CommandsEnabled: &commands,
Settings: settings,
}
signature, err := SignConfigPayload(payload, priv)
if err != nil {
t.Fatalf("SignConfigPayload: %v", err)
}
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/api/agents/agent/agent-1/config" {
w.WriteHeader(http.StatusNotFound)
return
}
resp := Response{
Success: true,
AgentID: "agent-1",
}
resp.Config.CommandsEnabled = &commands
resp.Config.Settings = settings
resp.Config.DesiredConfig = &desired
resp.Config.IssuedAt = issuedAt
resp.Config.ExpiresAt = expiresAt
resp.Config.Signature = signature
_ = json.NewEncoder(w).Encode(resp)
}))
defer ts.Close()
client := New(Config{
PulseURL: ts.URL,
APIToken: "token-123",
AgentID: "agent-1",
})
gotSettings, gotCommands, err := client.Fetch(context.Background())
if err != nil {
t.Fatalf("Fetch error: %v", err)
}
if gotCommands == nil || *gotCommands != true {
t.Fatalf("expected commands enabled, got %v", gotCommands)
}
if gotSettings["interval"] != "1m" {
t.Fatalf("unexpected settings: %#v", gotSettings)
}
}
func TestClientFetchRejectsTamperedDesiredConfigWithLegacySignature(t *testing.T) {
pub, priv, err := ed25519.GenerateKey(nil)
if err != nil {
t.Fatalf("GenerateKey: %v", err)
}
t.Setenv("PULSE_AGENT_CONFIG_PUBLIC_KEYS", base64.StdEncoding.EncodeToString(pub))
issuedAt := time.Now().UTC()
expiresAt := issuedAt.Add(5 * time.Minute)
commands := true
settings := map[string]interface{}{"interval": "1m"}
payload := SignedConfigPayload{
AgentID: "agent-1",
IssuedAt: issuedAt,
ExpiresAt: expiresAt,
CommandsEnabled: &commands,
Settings: settings,
}
signature, err := SignConfigPayload(payload, priv)
if err != nil {
t.Fatalf("SignConfigPayload: %v", err)
}
tampered := DesiredConfigMetadata{Version: desiredConfigFingerprintVersion, Hash: "sha256:0000"}
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
resp := Response{
Success: true,
AgentID: "agent-1",
}
resp.Config.CommandsEnabled = &commands
resp.Config.Settings = settings
resp.Config.DesiredConfig = &tampered
resp.Config.IssuedAt = issuedAt
resp.Config.ExpiresAt = expiresAt
resp.Config.Signature = signature
_ = json.NewEncoder(w).Encode(resp)
}))
defer ts.Close()
client := New(Config{
PulseURL: ts.URL,
APIToken: "token-123",
AgentID: "agent-1",
})
if _, _, err := client.Fetch(context.Background()); err == nil || !strings.Contains(err.Error(), "desired config fingerprint mismatch") {
t.Fatalf("expected desired metadata tamper error, got %v", err)
}
}
func TestClientFetchValidatesDesiredConfigMetadata(t *testing.T) {
commands := true
settings := map[string]interface{}{"interval": "1m"}
desired, err := BuildDesiredConfigMetadata(&commands, settings)
if err != nil {
t.Fatalf("BuildDesiredConfigMetadata: %v", err)
}
for _, tt := range []struct {
name string
metadata DesiredConfigMetadata
wantErr bool
wantErrMsg string
}{
{name: "matches", metadata: desired},
{name: "mismatched hash", metadata: DesiredConfigMetadata{Version: desired.Version, Hash: "sha256:0000"}, wantErr: true, wantErrMsg: "desired config fingerprint mismatch"},
{name: "missing version", metadata: DesiredConfigMetadata{Hash: desired.Hash}, wantErr: true, wantErrMsg: "metadata is incomplete"},
} {
t.Run(tt.name, func(t *testing.T) {
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
resp := Response{
Success: true,
AgentID: "agent-1",
}
resp.Config.CommandsEnabled = &commands
resp.Config.Settings = settings
resp.Config.DesiredConfig = &tt.metadata
_ = json.NewEncoder(w).Encode(resp)
}))
defer ts.Close()
client := New(Config{
PulseURL: ts.URL,
APIToken: "token-123",
AgentID: "agent-1",
})
gotSettings, gotCommands, err := client.Fetch(context.Background())
if tt.wantErr {
if err == nil || !strings.Contains(err.Error(), tt.wantErrMsg) {
t.Fatalf("expected error containing %q, got %v", tt.wantErrMsg, err)
}
return
}
if err != nil {
t.Fatalf("Fetch error: %v", err)
}
if gotCommands == nil || *gotCommands != true {
t.Fatalf("expected commands enabled, got %v", gotCommands)
}
if gotSettings["interval"] != "1m" {
t.Fatalf("unexpected settings: %#v", gotSettings)
}
})
}
}
func TestClientFetchSignatureFailures(t *testing.T) {
pub, priv, err := ed25519.GenerateKey(nil)
if err != nil {
t.Fatalf("GenerateKey: %v", err)
}
t.Setenv("PULSE_AGENT_CONFIG_PUBLIC_KEYS", base64.StdEncoding.EncodeToString(pub))
settings := map[string]interface{}{"interval": "1m"}
makeResp := func(agentID, sig string, issued, expires time.Time) Response {
resp := Response{
Success: true,
AgentID: agentID,
}
resp.Config.Settings = settings
resp.Config.Signature = sig
resp.Config.IssuedAt = issued
resp.Config.ExpiresAt = expires
return resp
}
issuedAt := time.Now().UTC()
expiresAt := issuedAt.Add(5 * time.Minute)
payload := SignedConfigPayload{
AgentID: "agent-1",
IssuedAt: issuedAt,
ExpiresAt: expiresAt,
Settings: settings,
}
signature, err := SignConfigPayload(payload, priv)
if err != nil {
t.Fatalf("SignConfigPayload: %v", err)
}
tests := []struct {
name string
resp Response
wantText string
}{
{name: "missing agent metadata", resp: makeResp("", signature, issuedAt, expiresAt), wantText: "missing agent metadata"},
{name: "agent mismatch", resp: makeResp("agent-2", signature, issuedAt, expiresAt), wantText: "agent mismatch"},
{name: "missing timestamps", resp: makeResp("agent-1", signature, time.Time{}, time.Time{}), wantText: "missing timestamp"},
{name: "expired", resp: makeResp("agent-1", signature, issuedAt.Add(-10*time.Minute), issuedAt.Add(-5*time.Minute)), wantText: "expired"},
{name: "future", resp: makeResp("agent-1", signature, issuedAt.Add(10*time.Minute), issuedAt.Add(20*time.Minute)), wantText: "future"},
{name: "invalid signature", resp: makeResp("agent-1", "nope", issuedAt, expiresAt), wantText: "verification failed"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_ = json.NewEncoder(w).Encode(tt.resp)
}))
defer ts.Close()
client := New(Config{PulseURL: ts.URL, APIToken: "t", AgentID: "agent-1"})
_, _, err := client.Fetch(context.Background())
if err == nil || !strings.Contains(err.Error(), tt.wantText) {
t.Fatalf("expected error containing %q, got %v", tt.wantText, err)
}
})
}
}
func TestClientFetchAgentLookupAndErrors(t *testing.T) {
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/api/agents/agent/lookup":
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`{"success":true,"agent":{"id":"agent-9"}}`))
case "/api/agents/agent/agent-9/config":
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`{"success":true,"agentId":"agent-9","config":{"settings":{"mode":"ok"}}}`))
default:
w.WriteHeader(http.StatusNotFound)
}
}))
defer ts.Close()
client := New(Config{
PulseURL: ts.URL,
APIToken: "t",
AgentID: "agent-1",
Hostname: "known",
})
settings, _, err := client.Fetch(context.Background())
if err != nil {
t.Fatalf("Fetch error: %v", err)
}
if settings["mode"] != "ok" {
t.Fatalf("unexpected settings: %#v", settings)
}
redirect := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "/other", http.StatusFound)
}))
defer redirect.Close()
client = New(Config{PulseURL: redirect.URL, APIToken: "t", AgentID: "agent-1"})
if _, _, err := client.Fetch(context.Background()); err == nil {
t.Fatalf("expected redirect error")
}
badJSON := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
w.Write([]byte("{bad"))
}))
defer badJSON.Close()
client = New(Config{PulseURL: badJSON.URL, APIToken: "t", AgentID: "agent-1"})
if _, _, err := client.Fetch(context.Background()); err == nil {
t.Fatalf("expected decode error")
}
}
func TestClientNewDefaultsAndAgentLookupNotFound(t *testing.T) {
client := New(Config{PulseURL: " ", InsecureSkipVerify: true})
if client.cfg.PulseURL != "http://localhost:7655" {
t.Fatalf("unexpected default PulseURL: %s", client.cfg.PulseURL)
}
transport, ok := client.httpClient.Transport.(*http.Transport)
if !ok || transport.TLSClientConfig == nil || !transport.TLSClientConfig.InsecureSkipVerify {
t.Fatalf("expected insecure TLS config")
}
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNotFound)
}))
defer ts.Close()
client = New(Config{
PulseURL: ts.URL,
APIToken: "t",
Hostname: "missing",
})
if got, err := client.resolveAgentID(context.Background()); err != nil || got != "" {
t.Fatalf("expected empty agent ID, got %q err=%v", got, err)
}
}
func TestClientNew_UsesPinnedServerFingerprint(t *testing.T) {
client := New(Config{
PulseURL: "https://pulse.example",
ServerFingerprint: "aabbccddaabbccddaabbccddaabbccddaabbccddaabbccddaabbccddaabbccdd",
})
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 TestClientNew_InvalidCustomCABundleFailsClosed(t *testing.T) {
certPath := filepath.Join(t.TempDir(), "invalid.pem")
if err := os.WriteFile(certPath, []byte("not-a-cert"), 0o600); err != nil {
t.Fatalf("write cert: %v", err)
}
client := New(Config{
PulseURL: "https://pulse.example",
APIToken: "token",
AgentID: "agent-1",
CACertPath: certPath,
})
if client.httpClient != nil {
t.Fatal("expected HTTP client to remain disabled when the custom CA bundle is invalid")
}
if _, _, err := client.Fetch(context.Background()); err == nil || !strings.Contains(err.Error(), "invalid TLS configuration") {
t.Fatalf("expected invalid CA bundle error, got %v", err)
}
}
func TestClientFetchResolveAgentIDError(t *testing.T) {
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if strings.Contains(r.URL.Path, "/lookup") {
w.WriteHeader(http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusNotFound)
}))
defer ts.Close()
client := New(Config{
PulseURL: ts.URL,
APIToken: "t",
AgentID: "agent-1",
Hostname: "known",
})
if _, _, err := client.Fetch(context.Background()); err == nil {
t.Fatalf("expected resolve agent error")
}
}
type errorRoundTripper struct{}
func (errorRoundTripper) RoundTrip(*http.Request) (*http.Response, error) {
return nil, context.Canceled
}
func TestClientFetchInvalidURL(t *testing.T) {
client := New(Config{
PulseURL: "http://bad url",
APIToken: "t",
AgentID: "agent-1",
})
if _, _, err := client.Fetch(context.Background()); err == nil || !strings.Contains(err.Error(), "invalid remote config client configuration") {
t.Fatalf("expected invalid configuration error, got %v", err)
}
}
func TestClientResolveAgentIDRequestErrors(t *testing.T) {
client := New(Config{
PulseURL: "http://bad url",
APIToken: "t",
Hostname: "host",
})
if _, err := client.resolveAgentID(context.Background()); err == nil || !strings.Contains(err.Error(), "invalid remote config client configuration") {
t.Fatalf("expected invalid configuration error, got %v", err)
}
client = New(Config{
PulseURL: "https://example.com",
APIToken: "t",
Hostname: "host",
})
client.httpClient = &http.Client{Transport: errorRoundTripper{}}
if _, err := client.resolveAgentID(context.Background()); err == nil || !strings.Contains(err.Error(), "agent lookup request") {
t.Fatalf("expected transport error, got %v", err)
}
}
func TestClientFetchConfigValidation(t *testing.T) {
tests := []struct {
name string
cfg Config
wantText string
}{
{
name: "invalid pulse URL scheme",
cfg: Config{
PulseURL: "ftp://example.com",
APIToken: "token",
AgentID: "agent-1",
},
wantText: "invalid remote config client configuration",
},
{
name: "public http rejected",
cfg: Config{
PulseURL: "http://pulse.example.com:7655",
APIToken: "token",
AgentID: "agent-1",
},
wantText: "must use https unless host is loopback or local/private",
},
{
name: "private-network http allowed",
cfg: Config{
PulseURL: "http://10.0.0.5:7655",
APIToken: "token",
AgentID: "agent-1",
},
wantText: "do request:",
},
{
name: "missing API token",
cfg: Config{
PulseURL: "https://example.com",
APIToken: " ",
AgentID: "agent-1",
},
wantText: "API token is required",
},
{
name: "missing agent ID",
cfg: Config{
PulseURL: "https://example.com",
APIToken: "token",
AgentID: " ",
},
wantText: "agent ID is required",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
client := New(tt.cfg)
_, _, err := client.Fetch(context.Background())
if err == nil || !strings.Contains(err.Error(), tt.wantText) {
t.Fatalf("expected error containing %q, got %v", tt.wantText, err)
}
})
}
}
func TestNormalizeConfigAllowsSelfHostedLocalHTTPPulseURL(t *testing.T) {
cfg, err := normalizeConfig(Config{PulseURL: "http://ct-pulse.home:7655/"})
if err != nil {
t.Fatalf("normalizeConfig() error = %v", err)
}
if cfg.PulseURL != "http://ct-pulse.home:7655" {
t.Fatalf("PulseURL = %q, want http://ct-pulse.home:7655", cfg.PulseURL)
}
}
func TestClientResolveAgentIDEscapesHostnameQuery(t *testing.T) {
const hostname = " host with spaces/and?chars "
var gotRawQuery string
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotRawQuery = r.URL.RawQuery
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`{"success":true,"agent":{"id":"agent-123"}}`))
}))
defer ts.Close()
client := New(Config{
PulseURL: ts.URL,
APIToken: "token",
Hostname: hostname,
})
got, err := client.resolveAgentID(context.Background())
if err != nil {
t.Fatalf("resolveAgentID error: %v", err)
}
if got != "agent-123" {
t.Fatalf("expected agent-123, got %q", got)
}
if gotRawQuery != "hostname=host+with+spaces%2Fand%3Fchars" {
t.Fatalf("unexpected hostname query encoding: %q", gotRawQuery)
}
}
func TestClientFetchEscapesAgentIDPath(t *testing.T) {
var gotEscapedPath string
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotEscapedPath = r.URL.EscapedPath()
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`{"success":true,"agentId":"agent/1","config":{"settings":{"mode":"ok"}}}`))
}))
defer ts.Close()
client := New(Config{
PulseURL: ts.URL,
APIToken: "token",
AgentID: "agent/1",
})
settings, _, err := client.Fetch(context.Background())
if err != nil {
t.Fatalf("Fetch error: %v", err)
}
if !strings.Contains(gotEscapedPath, "agent%2F1") {
t.Fatalf("expected escaped agent ID in path, got %q", gotEscapedPath)
}
if settings["mode"] != "ok" {
t.Fatalf("unexpected settings: %#v", settings)
}
}
func TestClientResolveAgentIDPreventsQueryInjection(t *testing.T) {
const hostname = "known&admin=true"
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/api/agents/agent/lookup" {
w.WriteHeader(http.StatusNotFound)
return
}
if got := r.URL.Query().Get("hostname"); got != hostname {
t.Fatalf("expected hostname %q, got %q", hostname, got)
}
if got := r.URL.Query().Get("admin"); got != "" {
t.Fatalf("expected no injected admin query, got %q", got)
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"success":false}`))
}))
defer ts.Close()
client := New(Config{PulseURL: ts.URL, APIToken: "t", Hostname: hostname})
if got, err := client.resolveAgentID(context.Background()); err != nil || got != "" {
t.Fatalf("expected empty agent ID, got %q err=%v", got, err)
}
}
func TestClientFetchLogsStructuredContextWhenSignatureMissing(t *testing.T) {
var logs bytes.Buffer
logger := zerolog.New(&logs)
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`{
"success": true,
"agentId": "agent-1",
"config": {
"commandsEnabled": true,
"settings": {
"interval": "1m"
}
}
}`))
}))
defer ts.Close()
client := New(Config{
PulseURL: ts.URL,
APIToken: "token-123",
AgentID: "agent-1",
Logger: logger,
})
_, _, err := client.Fetch(context.Background())
if err != nil {
t.Fatalf("Fetch failed: %v", err)
}
entry := decodeLastLogEntry(t, &logs)
if entry["component"] != "remote_config_client" {
t.Fatalf("expected component remote_config_client, got %#v", entry["component"])
}
if entry["action"] != "missing_signature_skip_verification" {
t.Fatalf("expected action missing_signature_skip_verification, got %#v", entry["action"])
}
if entry["agent_id"] != "agent-1" {
t.Fatalf("expected agent_id agent-1, got %#v", entry["agent_id"])
}
}
func TestClientResolveAgentIDLogsStructuredContextOnStatusError(t *testing.T) {
var logs bytes.Buffer
logger := zerolog.New(&logs)
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusInternalServerError)
}))
defer ts.Close()
client := New(Config{
PulseURL: ts.URL,
APIToken: "token",
Hostname: "known",
Logger: logger,
})
if _, err := client.resolveAgentID(context.Background()); err == nil {
t.Fatal("expected error for agent lookup server failure")
}
entry := decodeLastLogEntry(t, &logs)
if entry["component"] != "remote_config_client" {
t.Fatalf("expected component remote_config_client, got %#v", entry["component"])
}
if entry["action"] != "agent_lookup_non_success_status" {
t.Fatalf("expected action agent_lookup_non_success_status, got %#v", entry["action"])
}
if entry["status_code"] != float64(http.StatusInternalServerError) {
t.Fatalf("expected status_code %d, got %#v", http.StatusInternalServerError, entry["status_code"])
}
}
func decodeLastLogEntry(t *testing.T, logs *bytes.Buffer) map[string]interface{} {
t.Helper()
raw := strings.TrimSpace(logs.String())
if raw == "" {
t.Fatal("expected structured log entry, got none")
}
lines := strings.Split(raw, "\n")
last := strings.TrimSpace(lines[len(lines)-1])
if last == "" {
t.Fatal("expected non-empty structured log line")
}
var entry map[string]interface{}
if err := json.Unmarshal([]byte(last), &entry); err != nil {
t.Fatalf("failed to decode structured log entry %q: %v", last, err)
}
return entry
}
func TestNormalizePulseURLHonorsOperatorPlaintextConsent(t *testing.T) {
// pulse.example.com is a dotted public name with no local suffix, so the
// default posture refuses plain HTTP for it (resolution either fails or
// yields public addresses).
if _, err := normalizePulseURL("http://pulse.plaintext-consent.invalid:7655"); err == nil {
t.Fatal("plain HTTP to a non-local host was accepted without operator consent")
}
securityutil.SetOperatorPlaintextHTTPConsent(true)
t.Cleanup(func() { securityutil.SetOperatorPlaintextHTTPConsent(false) })
normalized, err := normalizePulseURL("http://pulse.plaintext-consent.invalid:7655")
if err != nil {
t.Fatalf("operator consent did not reach remote-config URL validation: %v", err)
}
if normalized != "http://pulse.plaintext-consent.invalid:7655" {
t.Fatalf("unexpected normalized URL %q", normalized)
}
}