mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-07-09 16:00:59 +00:00
parent
282bee7a28
commit
6344d17fd1
24 changed files with 295 additions and 119 deletions
|
|
@ -29,6 +29,8 @@ that binary, not separate customer-facing agent products.
|
|||
3. `internal/api/unified_agent.go`
|
||||
4. `internal/agentupdate/update.go`
|
||||
5. `internal/hostagent/agent.go`
|
||||
5a. `internal/dockeragent/agent.go`
|
||||
5b. `internal/kubernetesagent/agent.go`
|
||||
6. `cmd/pulse-agent/main.go`
|
||||
7. `scripts/install.sh`
|
||||
8. `scripts/install.ps1`
|
||||
|
|
@ -90,6 +92,8 @@ that binary, not separate customer-facing agent products.
|
|||
60. `internal/remoteconfig/client.go`
|
||||
61. `internal/agenttls/config.go`
|
||||
62. `internal/api/agent_exec_token_binding.go`
|
||||
63. `internal/securityutil/httpurl.go`
|
||||
64. `pkg/securityutil/httpurl.go`
|
||||
|
||||
## Shared Boundaries
|
||||
|
||||
|
|
@ -195,7 +199,8 @@ state instead of inferring it from outbound usage telemetry.
|
|||
artifact URL, so install-time registration keeps one API root regardless of
|
||||
whether the script came from `/api/setup-script` or `/api/setup-script-url`.
|
||||
22. `internal/api/unified_agent.go` shared with `api-contracts`: unified agent download and installer handlers are both an agent lifecycle control surface and a canonical API payload contract boundary.
|
||||
23. `scripts/install.ps1` shared with `deployment-installability`: the Windows installer is both a deployment installability entry point and a canonical agent lifecycle runtime continuity boundary.
|
||||
23. `internal/kubernetesagent/agent.go` shared with `monitoring`: the Kubernetes native agent runtime is both a monitoring inventory source and an agent lifecycle Pulse control-plane transport client.
|
||||
24. `scripts/install.ps1` shared with `deployment-installability`: the Windows installer is both a deployment installability entry point and a canonical agent lifecycle runtime continuity boundary.
|
||||
The Windows installer must support a non-mutating download preflight that
|
||||
can run before Administrator-only install work, must accept token-file
|
||||
enrollment input, and must persist plain-HTTP/insecure runtime continuity
|
||||
|
|
@ -204,7 +209,7 @@ state instead of inferring it from outbound usage telemetry.
|
|||
`pulse-agent` runs so installer "healthy" verification and post-install
|
||||
smoke checks prove a live agent runtime, not merely a running service
|
||||
wrapper.
|
||||
24. `scripts/install.sh` shared with `deployment-installability`: the shell installer is both a deployment installability entry point and a canonical agent lifecycle runtime continuity boundary.
|
||||
25. `scripts/install.sh` shared with `deployment-installability`: the shell installer is both a deployment installability entry point and a canonical agent lifecycle runtime continuity boundary.
|
||||
|
||||
Server update planning is part of the same lifecycle contract. The System
|
||||
Updates plan must surface a structured upgrade-readiness verdict before an
|
||||
|
|
@ -1063,7 +1068,7 @@ surface and no new `internal/api/` lifecycle handler.
|
|||
7. 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.
|
||||
8. Add or change the unified agent CLI entrypoint, version/help exit semantics, or startup argument/error routing through `cmd/pulse-agent/main.go`.
|
||||
The CLI entrypoint owns propagation of persistence context into runtime-owned helpers. When installer-selected state roots differ from the default, `cmd/pulse-agent/main.go` must pass that exact `StateDir` through both the host-agent runtime and updater startup paths instead of letting one path silently fall back to `/var/lib/pulse-agent`.
|
||||
The same runtime-owned boundary also owns Pulse control-plane URL validation for agent startup, remote config, updater continuity, and command transport. Non-loopback control-plane URLs remain HTTPS/WSS by default, but explicitly insecure agent/dev-runtime flows may use plain HTTP/WS for LAN development control planes; installer-persisted dev URLs must not be accepted by one runtime path and rejected by another.
|
||||
The same runtime-owned boundary also owns Pulse control-plane URL validation for agent startup, remote config, updater continuity, and command transport. Public control-plane hostnames remain HTTPS/WSS, but self-hosted local control planes may use plain HTTP/WS when the host is loopback, a private or link-local IP, a single-label LAN name, or a local DNS suffix such as `.local`, `.lan`, `.home`, `.home.arpa`, or `.internal`; installer-persisted local HTTP URLs must not be accepted by one runtime path and rejected by another.
|
||||
The unified agent CLI copy follows the same command-execution vocabulary as the install surface. `cmd/pulse-agent/main.go` may keep the `--enable-commands` flag name for compatibility, but the help text and inline comments must describe command execution as Pulse command execution for Patrol actions and governed Proxmox LXC Docker inventory rather than reviving AI auto-fix language.
|
||||
The unified agent CLI copy also owns operator-facing Docker / Podman runtime
|
||||
labels. `cmd/pulse-agent/main.go` may keep the historical
|
||||
|
|
@ -2573,17 +2578,18 @@ operators configure a non-root SSH user for deploy fan-out, privileged token
|
|||
write and install steps must escalate through non-interactive `sudo` on the
|
||||
remote node instead of hard-coding `root@` for every SSH hop or silently
|
||||
falling back to a second unaudited privilege path.
|
||||
That same transport boundary also keeps plaintext Pulse URLs loopback-only.
|
||||
`internal/securityutil/httpurl.go` owns the canonical Pulse transport
|
||||
That same transport boundary also keeps plaintext Pulse URLs local/private.
|
||||
`pkg/securityutil/httpurl.go`, surfaced internally through
|
||||
`internal/securityutil/httpurl.go`, owns the canonical Pulse transport
|
||||
normalization used by `internal/hostagent/agent.go`,
|
||||
`internal/hostagent/commands.go`, `internal/agentupdate/update.go`,
|
||||
`internal/dockeragent/agent.go`, `internal/kubernetesagent/agent.go`, and
|
||||
`internal/remoteconfig/client.go`. Those runtime clients may keep local-
|
||||
development `http://` or `ws://` only for loopback hosts, but private-network
|
||||
and remote Pulse URLs must still use HTTPS/WSS. `InsecureSkipVerify` may
|
||||
relax certificate verification on TLS transport; it must not reopen plaintext
|
||||
HTTP for private-network updater, websocket, reporting, or remote-config
|
||||
paths.
|
||||
`internal/remoteconfig/client.go`. Those runtime clients may keep self-hosted
|
||||
`http://` or `ws://` only for loopback, private/link-local IP, single-label, or
|
||||
local DNS Pulse origins; public remote Pulse URLs must still use HTTPS/WSS.
|
||||
`InsecureSkipVerify` may relax certificate verification on TLS transport; it
|
||||
must not reopen public plaintext HTTP for updater, websocket, reporting, or
|
||||
remote-config paths.
|
||||
That same first-run lifecycle boundary also keeps unauthenticated setup local.
|
||||
Lifecycle-adjacent quick setup or recovery entrypoints may exist before an
|
||||
operator has configured auth, but they must stay direct-loopback only and any
|
||||
|
|
|
|||
|
|
@ -86,7 +86,8 @@ block a distinct live host by hostname alone.
|
|||
|
||||
## Shared Boundaries
|
||||
|
||||
1. `internal/proxmoxidentity/backup_identity.go` shared with `alerts`, `storage-recovery`: Proxmox PBS backup subject identity is a shared runtime boundary for monitoring backup freshness, backup-age alert attribution, and recovery-point guest mapping.
|
||||
1. `internal/kubernetesagent/agent.go` shared with `agent-lifecycle`: the Kubernetes native agent runtime is both a monitoring inventory source and an agent lifecycle Pulse control-plane transport client.
|
||||
2. `internal/proxmoxidentity/backup_identity.go` shared with `alerts`, `storage-recovery`: Proxmox PBS backup subject identity is a shared runtime boundary for monitoring backup freshness, backup-age alert attribution, and recovery-point guest mapping.
|
||||
|
||||
## Extension Points
|
||||
|
||||
|
|
|
|||
|
|
@ -939,6 +939,14 @@
|
|||
"deployment-installability"
|
||||
]
|
||||
},
|
||||
{
|
||||
"path": "internal/kubernetesagent/agent.go",
|
||||
"rationale": "the Kubernetes native agent runtime is both a monitoring inventory source and an agent lifecycle Pulse control-plane transport client",
|
||||
"subsystems": [
|
||||
"agent-lifecycle",
|
||||
"monitoring"
|
||||
]
|
||||
},
|
||||
{
|
||||
"path": "internal/proxmoxidentity/backup_identity.go",
|
||||
"rationale": "Proxmox PBS backup subject identity is a shared runtime boundary for monitoring backup freshness, backup-age alert attribution, and recovery-point guest mapping",
|
||||
|
|
@ -1070,7 +1078,11 @@
|
|||
"internal/api/config_setup_handlers.go",
|
||||
"internal/api/setup_script_render.go",
|
||||
"internal/api/unified_agent.go",
|
||||
"internal/dockeragent/agent.go",
|
||||
"internal/kubernetesagent/agent.go",
|
||||
"internal/remoteconfig/client.go",
|
||||
"internal/securityutil/httpurl.go",
|
||||
"pkg/securityutil/httpurl.go",
|
||||
"scripts/install.ps1",
|
||||
"scripts/install.sh"
|
||||
],
|
||||
|
|
@ -1136,14 +1148,21 @@
|
|||
"match_prefixes": [],
|
||||
"match_files": [
|
||||
"internal/agenttls/config.go",
|
||||
"internal/remoteconfig/client.go"
|
||||
"internal/dockeragent/agent.go",
|
||||
"internal/kubernetesagent/agent.go",
|
||||
"internal/remoteconfig/client.go",
|
||||
"internal/securityutil/httpurl.go",
|
||||
"pkg/securityutil/httpurl.go"
|
||||
],
|
||||
"allow_same_subsystem_tests": false,
|
||||
"test_prefixes": [],
|
||||
"exact_files": [
|
||||
"internal/agenttls/config_test.go",
|
||||
"internal/dockeragent/agent_internal_test.go",
|
||||
"internal/kubernetesagent/agent_new_test.go",
|
||||
"internal/remoteconfig/client_additional_test.go",
|
||||
"internal/remoteconfig/client_test.go"
|
||||
"internal/remoteconfig/client_test.go",
|
||||
"internal/securityutil/httpurl_test.go"
|
||||
]
|
||||
},
|
||||
{
|
||||
|
|
@ -1173,6 +1192,8 @@
|
|||
"internal/agentupdate/coverage_test.go",
|
||||
"internal/hostagent/agent_metrics_test.go",
|
||||
"internal/hostagent/agent_new_test.go",
|
||||
"internal/hostagent/command_client_test.go",
|
||||
"internal/hostagent/commands_deploy_test.go",
|
||||
"internal/hostagent/send_report_test.go"
|
||||
]
|
||||
},
|
||||
|
|
@ -4530,6 +4551,7 @@
|
|||
"test_prefixes": [],
|
||||
"exact_files": [
|
||||
"internal/kubernetesagent/agent_inventory_test.go",
|
||||
"internal/kubernetesagent/agent_new_test.go",
|
||||
"internal/monitoring/kubernetes_agents_test.go",
|
||||
"internal/unifiedresources/adapter_coverage_test.go",
|
||||
"internal/unifiedresources/kubernetes_registry_test.go"
|
||||
|
|
|
|||
|
|
@ -318,26 +318,25 @@ func TestValidatePulseURL(t *testing.T) {
|
|||
}
|
||||
})
|
||||
|
||||
t.Run("RejectRemoteHTTPInInsecureMode", func(t *testing.T) {
|
||||
t.Run("RejectPublicHTTPInInsecureMode", func(t *testing.T) {
|
||||
u := newUpdaterForTest("http://pulse.example.com")
|
||||
u.cfg.InsecureSkipVerify = true
|
||||
if err := u.validatePulseURL(); err != nil {
|
||||
t.Fatalf("expected remote http URL in insecure mode to be allowed, got %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("RejectPrivateNetworkHTTP", func(t *testing.T) {
|
||||
u := newUpdaterForTest("http://10.0.0.5:7655")
|
||||
if err := u.validatePulseURL(); err == nil {
|
||||
t.Fatalf("expected private-network http URL to be rejected")
|
||||
t.Fatalf("expected public http URL in insecure mode to be rejected")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("AllowPrivateNetworkHTTPInInsecureMode", func(t *testing.T) {
|
||||
t.Run("AllowPrivateNetworkHTTP", func(t *testing.T) {
|
||||
u := newUpdaterForTest("http://10.0.0.5:7655")
|
||||
u.cfg.InsecureSkipVerify = true
|
||||
if err := u.validatePulseURL(); err != nil {
|
||||
t.Fatalf("expected private-network http URL in insecure mode to be allowed, got %v", err)
|
||||
t.Fatalf("expected private-network http URL to be allowed, got %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("AllowHomeDomainHTTP", func(t *testing.T) {
|
||||
u := newUpdaterForTest("http://ct-pulse.home:7655")
|
||||
if err := u.validatePulseURL(); err != nil {
|
||||
t.Fatalf("expected .home http URL to be allowed, got %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -617,7 +617,7 @@ func (u *Updater) validatePulseURL() error {
|
|||
}
|
||||
|
||||
parsed, err := securityutil.NormalizePulseHTTPBaseURLWithOptions(pulseURL, securityutil.PulseURLValidationOptions{
|
||||
AllowInsecureHTTP: u.cfg.InsecureSkipVerify,
|
||||
AllowLocalNetworkHTTP: true,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid Pulse URL: %w", err)
|
||||
|
|
|
|||
|
|
@ -316,7 +316,7 @@ func normalizeTargets(raw []TargetConfig) ([]TargetConfig, error) {
|
|||
return nil, fmt.Errorf("pulse target %s is missing API token", targetURL)
|
||||
}
|
||||
|
||||
normalizedURL, err := normalizeTargetURL(targetURL, target.InsecureSkipVerify)
|
||||
normalizedURL, err := normalizeTargetURL(targetURL)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid pulse target URL %q: %w", targetURL, err)
|
||||
}
|
||||
|
|
@ -337,9 +337,9 @@ func normalizeTargets(raw []TargetConfig) ([]TargetConfig, error) {
|
|||
return normalized, nil
|
||||
}
|
||||
|
||||
func normalizeTargetURL(raw string, allowInsecureHTTP bool) (string, error) {
|
||||
func normalizeTargetURL(raw string) (string, error) {
|
||||
parsed, err := securityutil.NormalizePulseHTTPBaseURLWithOptions(raw, securityutil.PulseURLValidationOptions{
|
||||
AllowInsecureHTTP: allowInsecureHTTP,
|
||||
AllowLocalNetworkHTTP: true,
|
||||
})
|
||||
if err != nil {
|
||||
return "", err
|
||||
|
|
|
|||
|
|
@ -75,7 +75,7 @@ func TestNormalizeTargetsInvalid(t *testing.T) {
|
|||
}
|
||||
|
||||
func TestNormalizeTargetURL(t *testing.T) {
|
||||
normalized, err := normalizeTargetURL(" HTTPS://Pulse.EXAMPLE.com:443/api/ ", false)
|
||||
normalized, err := normalizeTargetURL(" HTTPS://Pulse.EXAMPLE.com:443/api/ ")
|
||||
if err != nil {
|
||||
t.Fatalf("normalizeTargetURL returned error: %v", err)
|
||||
}
|
||||
|
|
@ -84,8 +84,8 @@ func TestNormalizeTargetURL(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestNormalizeTargetURLAllowsInsecureRemoteHTTP(t *testing.T) {
|
||||
normalized, err := normalizeTargetURL(" http://10.0.0.5:7655/api/ ", true)
|
||||
func TestNormalizeTargetURLAllowsLocalNetworkHTTP(t *testing.T) {
|
||||
normalized, err := normalizeTargetURL(" http://10.0.0.5:7655/api/ ")
|
||||
if err != nil {
|
||||
t.Fatalf("normalizeTargetURL returned error: %v", err)
|
||||
}
|
||||
|
|
@ -94,8 +94,14 @@ func TestNormalizeTargetURLAllowsInsecureRemoteHTTP(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestNormalizeTargetsAllowsInsecureRemoteHTTP(t *testing.T) {
|
||||
targets, err := normalizeTargets([]TargetConfig{{URL: "http://10.0.0.5:7655", Token: "token", InsecureSkipVerify: true}})
|
||||
func TestNormalizeTargetURLRejectsPublicHTTP(t *testing.T) {
|
||||
if _, err := normalizeTargetURL("http://pulse.example.com:7655/api"); err == nil {
|
||||
t.Fatal("expected public HTTP Pulse target to be rejected")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeTargetsAllowsLocalNetworkHTTP(t *testing.T) {
|
||||
targets, err := normalizeTargets([]TargetConfig{{URL: "http://10.0.0.5:7655", Token: "token"}})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
|
@ -112,8 +118,7 @@ func TestNormalizeTargetsRejectsInsecureOrInvalidURLs(t *testing.T) {
|
|||
name string
|
||||
url string
|
||||
}{
|
||||
{name: "non-loopback http", url: "http://pulse.example.com"},
|
||||
{name: "private-network http", url: "http://10.0.0.5:7655"},
|
||||
{name: "public http", url: "http://pulse.example.com"},
|
||||
{name: "unsupported scheme", url: "ftp://pulse.example.com"},
|
||||
{name: "missing scheme", url: "pulse.example.com"},
|
||||
{name: "query string", url: "https://pulse.example.com?x=1"},
|
||||
|
|
|
|||
|
|
@ -159,7 +159,7 @@ func New(cfg Config) (*Agent, error) {
|
|||
pulseURL = "http://localhost:7655"
|
||||
}
|
||||
var err error
|
||||
pulseURL, err = normalizePulseURL(pulseURL, cfg.InsecureSkipVerify)
|
||||
pulseURL, err = normalizePulseURL(pulseURL)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid pulse URL: %w", err)
|
||||
}
|
||||
|
|
@ -1009,9 +1009,9 @@ func normalisePlatform(platform string) string {
|
|||
}
|
||||
}
|
||||
|
||||
func normalizePulseURL(rawURL string, allowInsecureHTTP bool) (string, error) {
|
||||
func normalizePulseURL(rawURL string) (string, error) {
|
||||
parsed, err := securityutil.NormalizePulseHTTPBaseURLWithOptions(rawURL, securityutil.PulseURLValidationOptions{
|
||||
AllowInsecureHTTP: allowInsecureHTTP,
|
||||
AllowLocalNetworkHTTP: true,
|
||||
})
|
||||
if err != nil {
|
||||
return "", err
|
||||
|
|
|
|||
|
|
@ -57,7 +57,7 @@ func TestNew_AllowsMissingAPITokenWhenEnrollmentDisabled(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestNew_AllowsInsecureRemoteHTTPPulseURL(t *testing.T) {
|
||||
func TestNew_AllowsLocalNetworkHTTPPulseURL(t *testing.T) {
|
||||
mc := &mockCollector{
|
||||
hostInfoFn: func(context.Context) (*gohost.InfoStat, error) {
|
||||
return &gohost.InfoStat{Hostname: "host", HostID: "hid", KernelArch: runtime.GOARCH}, nil
|
||||
|
|
@ -65,11 +65,10 @@ func TestNew_AllowsInsecureRemoteHTTPPulseURL(t *testing.T) {
|
|||
}
|
||||
|
||||
agent, err := New(Config{
|
||||
PulseURL: "http://192.168.0.98:7655",
|
||||
APIToken: "token",
|
||||
InsecureSkipVerify: true,
|
||||
LogLevel: zerolog.InfoLevel,
|
||||
Collector: mc,
|
||||
PulseURL: "http://192.168.0.98:7655",
|
||||
APIToken: "token",
|
||||
LogLevel: zerolog.InfoLevel,
|
||||
Collector: mc,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("New() error = %v", err)
|
||||
|
|
@ -953,14 +952,9 @@ func TestNew_RejectsInvalidPulseURL(t *testing.T) {
|
|||
want string
|
||||
}{
|
||||
{
|
||||
name: "non-loopback http rejected",
|
||||
name: "public http rejected",
|
||||
url: "http://example.com",
|
||||
want: "must use https unless host is loopback",
|
||||
},
|
||||
{
|
||||
name: "private-network http rejected",
|
||||
url: "http://10.0.0.5:7655",
|
||||
want: "must use https unless host is loopback",
|
||||
want: "must use https unless host is loopback or local/private",
|
||||
},
|
||||
{
|
||||
name: "query rejected",
|
||||
|
|
@ -997,6 +991,27 @@ func TestNew_RejectsInvalidPulseURL(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestNew_AllowsSelfHostedLocalHTTPPulseURL(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: "http://ct-pulse.home:7655/",
|
||||
APIToken: "token",
|
||||
LogLevel: zerolog.InfoLevel,
|
||||
Collector: mc,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("New: %v", err)
|
||||
}
|
||||
if agent.cfg.PulseURL != "http://ct-pulse.home:7655" {
|
||||
t.Fatalf("cfg.PulseURL = %q, want http://ct-pulse.home:7655", agent.cfg.PulseURL)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNew_NormalizesAndValidatesProxmoxType(t *testing.T) {
|
||||
mc := &mockCollector{
|
||||
hostInfoFn: func(context.Context) (*gohost.InfoStat, error) {
|
||||
|
|
|
|||
|
|
@ -99,9 +99,9 @@ func TestCommandClientBuildWebSocketURL(t *testing.T) {
|
|||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "private-network http rejected",
|
||||
name: "private-network http becomes ws",
|
||||
pulseURL: "http://10.0.0.5:7655",
|
||||
wantErr: true,
|
||||
want: "ws://10.0.0.5:7655/api/agent/ws",
|
||||
},
|
||||
{
|
||||
name: "non-loopback ws rejected",
|
||||
|
|
@ -109,9 +109,9 @@ func TestCommandClientBuildWebSocketURL(t *testing.T) {
|
|||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "private-network ws rejected",
|
||||
name: "private-network ws preserved",
|
||||
pulseURL: "ws://10.0.0.5:7655",
|
||||
wantErr: true,
|
||||
want: "ws://10.0.0.5:7655/api/agent/ws",
|
||||
},
|
||||
{
|
||||
name: "query rejected",
|
||||
|
|
@ -129,9 +129,9 @@ func TestCommandClientBuildWebSocketURL(t *testing.T) {
|
|||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "insecure remote http becomes ws",
|
||||
pulseURL: "http://10.0.0.5:7655/pulse",
|
||||
want: "ws://10.0.0.5:7655/pulse/api/agent/ws",
|
||||
name: "home domain http becomes ws",
|
||||
pulseURL: "http://ct-pulse.home:7655/pulse",
|
||||
want: "ws://ct-pulse.home:7655/pulse/api/agent/ws",
|
||||
},
|
||||
{
|
||||
name: "missing host returns error",
|
||||
|
|
@ -143,9 +143,6 @@ func TestCommandClientBuildWebSocketURL(t *testing.T) {
|
|||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
client := &CommandClient{pulseURL: tt.pulseURL}
|
||||
if tt.name == "insecure remote http becomes ws" {
|
||||
client.insecureSkipVerify = true
|
||||
}
|
||||
got, err := client.buildWebSocketURL()
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Fatalf("buildWebSocketURL() err = %v, wantErr %v", err, tt.wantErr)
|
||||
|
|
@ -195,18 +192,20 @@ func TestCommandClientBuildWebSocketOrigin(t *testing.T) {
|
|||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "insecure remote http stays http origin",
|
||||
name: "private-network http stays http origin",
|
||||
pulseURL: "http://10.0.0.5:7655/pulse",
|
||||
want: "http://10.0.0.5:7655",
|
||||
},
|
||||
{
|
||||
name: "home domain http stays http origin",
|
||||
pulseURL: "http://ct-pulse.home:7655/pulse",
|
||||
want: "http://ct-pulse.home:7655",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
client := &CommandClient{pulseURL: tt.pulseURL}
|
||||
if tt.name == "insecure remote http stays http origin" {
|
||||
client.insecureSkipVerify = true
|
||||
}
|
||||
got, err := client.buildWebSocketOrigin()
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Fatalf("buildWebSocketOrigin() err = %v, wantErr %v", err, tt.wantErr)
|
||||
|
|
|
|||
|
|
@ -375,7 +375,7 @@ func (c *CommandClient) connectAndHandle(ctx context.Context) error {
|
|||
|
||||
func (c *CommandClient) buildWebSocketURL() (string, error) {
|
||||
parsed, err := securityutil.NormalizePulseWebSocketBaseURLWithOptions(c.pulseURL, securityutil.PulseURLValidationOptions{
|
||||
AllowInsecureHTTP: c.insecureSkipVerify,
|
||||
AllowLocalNetworkHTTP: true,
|
||||
})
|
||||
if err != nil {
|
||||
return "", err
|
||||
|
|
@ -395,7 +395,7 @@ func (c *CommandClient) buildWebSocketURL() (string, error) {
|
|||
|
||||
func (c *CommandClient) buildWebSocketOrigin() (string, error) {
|
||||
return securityutil.HTTPOriginForWebSocketBaseURLWithOptions(c.pulseURL, securityutil.PulseURLValidationOptions{
|
||||
AllowInsecureHTTP: c.insecureSkipVerify,
|
||||
AllowLocalNetworkHTTP: true,
|
||||
})
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -259,7 +259,7 @@ func (c *CommandClient) preflightTarget(
|
|||
"preflight_complete", "failed", fmt.Sprintf("Invalid node IP: %s", target.NodeIP), data, false)
|
||||
return false
|
||||
}
|
||||
normalizedPulseURL, err := normalizePulseURL(pulseURL, c.insecureSkipVerify)
|
||||
normalizedPulseURL, err := normalizePulseURL(pulseURL)
|
||||
if err != nil {
|
||||
data := marshalPreflightResult(false, false, false, "", err.Error())
|
||||
c.sendDeployProgress(conn, requestID, jobID, target.TargetID,
|
||||
|
|
@ -366,7 +366,7 @@ func (c *CommandClient) installTarget(
|
|||
"install_complete", "failed", fmt.Sprintf("Invalid node IP: %s", target.NodeIP), data, false)
|
||||
return false
|
||||
}
|
||||
normalizedPulseURL, err := normalizePulseURL(pulseURL, c.insecureSkipVerify)
|
||||
normalizedPulseURL, err := normalizePulseURL(pulseURL)
|
||||
if err != nil {
|
||||
data := marshalInstallResult(-1, err.Error())
|
||||
c.sendDeployProgress(conn, requestID, jobID, target.TargetID,
|
||||
|
|
@ -549,7 +549,7 @@ func (c *CommandClient) writeTokenSSH(ctx context.Context, nodeIP, token string)
|
|||
|
||||
// runInstallSSH runs the Pulse install script on a remote node via SSH.
|
||||
func (c *CommandClient) runInstallSSH(ctx context.Context, nodeIP, pulseURL string) (int, string, error) {
|
||||
normalizedPulseURL, err := normalizePulseURL(pulseURL, c.insecureSkipVerify)
|
||||
normalizedPulseURL, err := normalizePulseURL(pulseURL)
|
||||
if err != nil {
|
||||
return -1, "", err
|
||||
}
|
||||
|
|
|
|||
|
|
@ -217,18 +217,18 @@ func TestRunInstallSSH_IncludesEnrollAndEnableCommands(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestRunInstallSSH_RejectsNonLoopbackPlainHTTP(t *testing.T) {
|
||||
func TestRunInstallSSH_RejectsPublicPlainHTTP(t *testing.T) {
|
||||
c := &CommandClient{
|
||||
logger: zerolog.New(zerolog.NewTestWriter(t)),
|
||||
sshKnownHosts: stubKnownHostsManager{path: "/tmp/pulse-test-known-hosts"},
|
||||
}
|
||||
|
||||
if _, _, err := c.runInstallSSH(context.Background(), "10.0.0.1", "http://10.0.0.1:7655"); err == nil {
|
||||
t.Fatal("expected non-loopback plain HTTP Pulse URL to be rejected")
|
||||
if _, _, err := c.runInstallSSH(context.Background(), "10.0.0.1", "http://pulse.example.com:7655"); err == nil {
|
||||
t.Fatal("expected public plain HTTP Pulse URL to be rejected")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunInstallSSH_AllowsNonLoopbackPlainHTTPInInsecureMode(t *testing.T) {
|
||||
func TestRunInstallSSH_AllowsLocalNetworkPlainHTTP(t *testing.T) {
|
||||
origExec := execCommandContext
|
||||
defer func() { execCommandContext = origExec }()
|
||||
|
||||
|
|
@ -239,9 +239,8 @@ func TestRunInstallSSH_AllowsNonLoopbackPlainHTTPInInsecureMode(t *testing.T) {
|
|||
}
|
||||
|
||||
c := &CommandClient{
|
||||
insecureSkipVerify: true,
|
||||
logger: zerolog.New(zerolog.NewTestWriter(t)),
|
||||
sshKnownHosts: stubKnownHostsManager{path: "/tmp/pulse-test-known-hosts"},
|
||||
logger: zerolog.New(zerolog.NewTestWriter(t)),
|
||||
sshKnownHosts: stubKnownHostsManager{path: "/tmp/pulse-test-known-hosts"},
|
||||
}
|
||||
|
||||
exitCode, _, err := c.runInstallSSH(context.Background(), "10.0.0.1", "http://10.0.0.1:7655")
|
||||
|
|
|
|||
|
|
@ -355,7 +355,7 @@ func NewProxmoxSetup(logger zerolog.Logger, httpClient *http.Client, collector S
|
|||
// 2. Creates the monitoring user and API token
|
||||
// 3. Registers the node with Pulse via auto-register
|
||||
func (p *ProxmoxSetup) Run(ctx context.Context) (*ProxmoxSetupResult, error) {
|
||||
pulseURL, err := normalizePulseURL(p.pulseURL, p.insecureSkipVerify)
|
||||
pulseURL, err := normalizePulseURL(p.pulseURL)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid pulse URL: %w", err)
|
||||
}
|
||||
|
|
@ -431,7 +431,7 @@ func (p *ProxmoxSetup) Run(ctx context.Context) (*ProxmoxSetupResult, error) {
|
|||
// supported configuration). Each type gets its own registration and state tracking.
|
||||
// Returns results for all types that were processed (skipping already-registered ones).
|
||||
func (p *ProxmoxSetup) RunAll(ctx context.Context) ([]*ProxmoxSetupResult, error) {
|
||||
pulseURL, err := normalizePulseURL(p.pulseURL, p.insecureSkipVerify)
|
||||
pulseURL, err := normalizePulseURL(p.pulseURL)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid pulse URL: %w", err)
|
||||
}
|
||||
|
|
@ -490,7 +490,7 @@ func (p *ProxmoxSetup) RunAll(ctx context.Context) ([]*ProxmoxSetupResult, error
|
|||
// health-check rotation, which would cause uncontrolled token churn if Pulse
|
||||
// is temporarily unreachable.
|
||||
func (p *ProxmoxSetup) RunHealthCheck(ctx context.Context) ([]*ProxmoxSetupResult, error) {
|
||||
pulseURL, err := normalizePulseURL(p.pulseURL, p.insecureSkipVerify)
|
||||
pulseURL, err := normalizePulseURL(p.pulseURL)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid pulse URL: %w", err)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -120,8 +120,8 @@ func TestParseProxmoxProductType(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestProxmoxSetupNormalizePulseURL_AllowsInsecureRemoteHTTP(t *testing.T) {
|
||||
got, err := normalizePulseURL("http://10.0.0.5:7655/pulse/", true)
|
||||
func TestProxmoxSetupNormalizePulseURL_AllowsLocalNetworkHTTP(t *testing.T) {
|
||||
got, err := normalizePulseURL("http://10.0.0.5:7655/pulse/")
|
||||
if err != nil {
|
||||
t.Fatalf("normalizePulseURL() error = %v", err)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -153,7 +153,7 @@ func New(cfg Config) (*Agent, error) {
|
|||
if pulseURL == "" {
|
||||
pulseURL = "http://localhost:7655"
|
||||
}
|
||||
pulseURL, err := normalizePulseURL(pulseURL, cfg.InsecureSkipVerify)
|
||||
pulseURL, err := normalizePulseURL(pulseURL)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid pulse URL: %w", err)
|
||||
}
|
||||
|
|
@ -244,9 +244,9 @@ func New(cfg Config) (*Agent, error) {
|
|||
return agent, nil
|
||||
}
|
||||
|
||||
func normalizePulseURL(rawURL string, allowInsecureHTTP bool) (string, error) {
|
||||
func normalizePulseURL(rawURL string) (string, error) {
|
||||
parsed, err := securityutil.NormalizePulseHTTPBaseURLWithOptions(rawURL, securityutil.PulseURLValidationOptions{
|
||||
AllowInsecureHTTP: allowInsecureHTTP,
|
||||
AllowLocalNetworkHTTP: true,
|
||||
})
|
||||
if err != nil {
|
||||
return "", err
|
||||
|
|
|
|||
|
|
@ -124,9 +124,19 @@ func TestNormalizePulseURL(t *testing.T) {
|
|||
want: "http://localhost:7655",
|
||||
},
|
||||
{
|
||||
name: "rejects private-network http",
|
||||
raw: "http://10.0.0.5:7655",
|
||||
wantError: "must use https unless host is loopback",
|
||||
name: "allows private-network http",
|
||||
raw: "http://10.0.0.5:7655",
|
||||
want: "http://10.0.0.5:7655",
|
||||
},
|
||||
{
|
||||
name: "allows home domain http",
|
||||
raw: "http://ct-pulse.home:7655",
|
||||
want: "http://ct-pulse.home:7655",
|
||||
},
|
||||
{
|
||||
name: "rejects public http",
|
||||
raw: "http://pulse.example.com:7655",
|
||||
wantError: "must use https unless host is loopback or local/private",
|
||||
},
|
||||
{
|
||||
name: "missing scheme",
|
||||
|
|
@ -162,7 +172,7 @@ func TestNormalizePulseURL(t *testing.T) {
|
|||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got, err := normalizePulseURL(tt.raw, false)
|
||||
got, err := normalizePulseURL(tt.raw)
|
||||
if tt.wantError != "" {
|
||||
if err == nil {
|
||||
t.Fatalf("normalizePulseURL(%q) expected error", tt.raw)
|
||||
|
|
@ -182,8 +192,8 @@ func TestNormalizePulseURL(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestNormalizePulseURLAllowsInsecureRemoteHTTP(t *testing.T) {
|
||||
got, err := normalizePulseURL("http://10.0.0.5:7655/base/", true)
|
||||
func TestNormalizePulseURLAllowsLocalNetworkHTTP(t *testing.T) {
|
||||
got, err := normalizePulseURL("http://10.0.0.5:7655/base/")
|
||||
if err != nil {
|
||||
t.Fatalf("normalizePulseURL() error = %v", err)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -375,7 +375,7 @@ func normalizeConfig(cfg Config) (Config, error) {
|
|||
cfg.AgentID = strings.TrimSpace(cfg.AgentID)
|
||||
cfg.Hostname = strings.TrimSpace(cfg.Hostname)
|
||||
|
||||
normalizedPulseURL, err := normalizePulseURL(cfg.PulseURL, cfg.InsecureSkipVerify)
|
||||
normalizedPulseURL, err := normalizePulseURL(cfg.PulseURL)
|
||||
if err != nil {
|
||||
return cfg, err
|
||||
}
|
||||
|
|
@ -384,9 +384,9 @@ func normalizeConfig(cfg Config) (Config, error) {
|
|||
return cfg, nil
|
||||
}
|
||||
|
||||
func normalizePulseURL(raw string, allowInsecureHTTP bool) (string, error) {
|
||||
func normalizePulseURL(raw string) (string, error) {
|
||||
parsed, err := securityutil.NormalizePulseHTTPBaseURLWithOptions(raw, securityutil.PulseURLValidationOptions{
|
||||
AllowInsecureHTTP: allowInsecureHTTP,
|
||||
AllowLocalNetworkHTTP: true,
|
||||
})
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("invalid pulse URL: %w", err)
|
||||
|
|
|
|||
|
|
@ -445,22 +445,21 @@ func TestClientFetchConfigValidation(t *testing.T) {
|
|||
wantText: "invalid remote config client configuration",
|
||||
},
|
||||
{
|
||||
name: "private-network http rejected",
|
||||
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: "must use https unless host is loopback",
|
||||
},
|
||||
{
|
||||
name: "private-network http allowed in insecure mode",
|
||||
cfg: Config{
|
||||
PulseURL: "http://10.0.0.5:7655",
|
||||
APIToken: "token",
|
||||
AgentID: "agent-1",
|
||||
InsecureSkipVerify: true,
|
||||
},
|
||||
wantText: "do request:",
|
||||
},
|
||||
{
|
||||
|
|
@ -494,6 +493,16 @@ func TestClientFetchConfigValidation(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
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
|
||||
|
|
|
|||
|
|
@ -27,6 +27,10 @@ func IsLoopbackHost(host string) bool {
|
|||
return pubsec.IsLoopbackHost(host)
|
||||
}
|
||||
|
||||
func IsLocalNetworkHost(host string) bool {
|
||||
return pubsec.IsLocalNetworkHost(host)
|
||||
}
|
||||
|
||||
func NormalizePulseHTTPBaseURL(raw string) (*url.URL, error) {
|
||||
return pubsec.NormalizePulseHTTPBaseURL(raw)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -42,6 +42,39 @@ func TestIsLoopbackHost(t *testing.T) {
|
|||
})
|
||||
}
|
||||
|
||||
func TestIsLocalNetworkHost(t *testing.T) {
|
||||
t.Run("true", func(t *testing.T) {
|
||||
cases := []string{
|
||||
"localhost",
|
||||
"127.0.0.1",
|
||||
"10.0.0.5",
|
||||
"172.16.4.20",
|
||||
"192.168.1.15",
|
||||
"fe80::1",
|
||||
"pulse",
|
||||
"ct-pulse.home",
|
||||
"pulse.local",
|
||||
"pulse.lan",
|
||||
"pulse.internal",
|
||||
"pulse.home.arpa",
|
||||
}
|
||||
for _, tc := range cases {
|
||||
if !IsLocalNetworkHost(tc) {
|
||||
t.Fatalf("expected local/private host for %q", tc)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("false", func(t *testing.T) {
|
||||
cases := []string{"", "example.com", "pulse.example.com", "8.8.8.8"}
|
||||
for _, tc := range cases {
|
||||
if IsLocalNetworkHost(tc) {
|
||||
t.Fatalf("expected public/non-local host for %q", tc)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestNormalizePulseHTTPBaseURL(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
|
|
@ -105,7 +138,7 @@ func TestNormalizePulseHTTPBaseURL(t *testing.T) {
|
|||
|
||||
func TestNormalizePulseHTTPBaseURLWithOptions(t *testing.T) {
|
||||
got, err := NormalizePulseHTTPBaseURLWithOptions("http://10.0.0.5:7655/pulse/", PulseURLValidationOptions{
|
||||
AllowInsecureHTTP: true,
|
||||
AllowLocalNetworkHTTP: true,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("NormalizePulseHTTPBaseURLWithOptions() error = %v", err)
|
||||
|
|
@ -113,6 +146,22 @@ func TestNormalizePulseHTTPBaseURLWithOptions(t *testing.T) {
|
|||
if got.String() != "http://10.0.0.5:7655/pulse" {
|
||||
t.Fatalf("NormalizePulseHTTPBaseURLWithOptions() = %q", got.String())
|
||||
}
|
||||
|
||||
got, err = NormalizePulseHTTPBaseURLWithOptions("http://ct-pulse.home:7655/", PulseURLValidationOptions{
|
||||
AllowLocalNetworkHTTP: true,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("NormalizePulseHTTPBaseURLWithOptions(.home) error = %v", err)
|
||||
}
|
||||
if got.String() != "http://ct-pulse.home:7655" {
|
||||
t.Fatalf("NormalizePulseHTTPBaseURLWithOptions(.home) = %q", got.String())
|
||||
}
|
||||
|
||||
if _, err := NormalizePulseHTTPBaseURLWithOptions("http://pulse.example.com:7655", PulseURLValidationOptions{
|
||||
AllowLocalNetworkHTTP: true,
|
||||
}); err == nil || !strings.Contains(err.Error(), "must use https unless host is loopback or local/private") {
|
||||
t.Fatalf("NormalizePulseHTTPBaseURLWithOptions(public HTTP) error = %v, want public HTTP rejection", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeSecureHTTPBaseURL(t *testing.T) {
|
||||
|
|
@ -229,7 +278,7 @@ func TestNormalizePulseWebSocketBaseURL(t *testing.T) {
|
|||
|
||||
func TestNormalizePulseWebSocketBaseURLWithOptions(t *testing.T) {
|
||||
got, err := NormalizePulseWebSocketBaseURLWithOptions("http://10.0.0.5:7655/pulse", PulseURLValidationOptions{
|
||||
AllowInsecureHTTP: true,
|
||||
AllowLocalNetworkHTTP: true,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("NormalizePulseWebSocketBaseURLWithOptions() error = %v", err)
|
||||
|
|
@ -237,6 +286,12 @@ func TestNormalizePulseWebSocketBaseURLWithOptions(t *testing.T) {
|
|||
if got.String() != "ws://10.0.0.5:7655/pulse" {
|
||||
t.Fatalf("NormalizePulseWebSocketBaseURLWithOptions() = %q", got.String())
|
||||
}
|
||||
|
||||
if _, err := NormalizePulseWebSocketBaseURLWithOptions("http://pulse.example.com:7655/pulse", PulseURLValidationOptions{
|
||||
AllowLocalNetworkHTTP: true,
|
||||
}); err == nil || !strings.Contains(err.Error(), "must use https/wss unless host is loopback or local/private") {
|
||||
t.Fatalf("NormalizePulseWebSocketBaseURLWithOptions(public HTTP) error = %v, want public HTTP rejection", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSameHostWebSocketOrigin(t *testing.T) {
|
||||
|
|
@ -299,7 +354,7 @@ func TestHTTPOriginForWebSocketBaseURL(t *testing.T) {
|
|||
|
||||
func TestHTTPOriginForWebSocketBaseURLWithOptions(t *testing.T) {
|
||||
got, err := HTTPOriginForWebSocketBaseURLWithOptions("http://10.0.0.5:7655/pulse", PulseURLValidationOptions{
|
||||
AllowInsecureHTTP: true,
|
||||
AllowLocalNetworkHTTP: true,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("HTTPOriginForWebSocketBaseURLWithOptions() error = %v", err)
|
||||
|
|
|
|||
|
|
@ -114,12 +114,45 @@ func IsLoopbackHost(host string) bool {
|
|||
return ip != nil && ip.IsLoopback()
|
||||
}
|
||||
|
||||
// IsLocalNetworkHost reports whether host names a loopback, private, or
|
||||
// operator-local network origin rather than a public internet hostname.
|
||||
func IsLocalNetworkHost(host string) bool {
|
||||
normalized := strings.ToLower(strings.TrimSpace(strings.Trim(host, "[]")))
|
||||
normalized = strings.TrimSuffix(normalized, ".")
|
||||
if normalized == "" {
|
||||
return false
|
||||
}
|
||||
if IsLoopbackHost(normalized) {
|
||||
return true
|
||||
}
|
||||
|
||||
if ip := net.ParseIP(normalized); ip != nil {
|
||||
return ip.IsPrivate() || ip.IsLinkLocalUnicast()
|
||||
}
|
||||
|
||||
if !strings.Contains(normalized, ".") {
|
||||
return true
|
||||
}
|
||||
|
||||
for _, suffix := range []string{".local", ".lan", ".home", ".home.arpa", ".internal"} {
|
||||
if strings.HasSuffix(normalized, suffix) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// PulseURLValidationOptions controls optional relaxations for Pulse runtime
|
||||
// transports. The default zero value preserves the strict production contract.
|
||||
type PulseURLValidationOptions struct {
|
||||
// AllowInsecureHTTP permits plain HTTP/WS to non-loopback hosts. This is
|
||||
// reserved for explicitly insecure agent/dev-runtime flows.
|
||||
// AllowInsecureHTTP permits plain HTTP/WS to local/private non-loopback
|
||||
// hosts. Deprecated: use AllowLocalNetworkHTTP for new runtime callers.
|
||||
AllowInsecureHTTP bool
|
||||
|
||||
// AllowLocalNetworkHTTP permits plain HTTP/WS to private IP, link-local,
|
||||
// single-label, and local DNS control-plane hosts for self-hosted installs.
|
||||
AllowLocalNetworkHTTP bool
|
||||
}
|
||||
|
||||
// NormalizePulseHTTPBaseURL validates a Pulse control-plane base URL.
|
||||
|
|
@ -216,11 +249,8 @@ func normalizePulseBaseURL(raw string, websocket bool, opts PulseURLValidationOp
|
|||
parsed.Scheme = "https"
|
||||
}
|
||||
case "http":
|
||||
if !IsLoopbackHost(parsed.Hostname()) && !opts.AllowInsecureHTTP {
|
||||
if websocket {
|
||||
return nil, fmt.Errorf("Pulse URL %q must use https/wss unless host is loopback", raw)
|
||||
}
|
||||
return nil, fmt.Errorf("Pulse URL %q must use https unless host is loopback", raw)
|
||||
if !pulseURLAllowsPlaintextHost(parsed.Hostname(), opts) {
|
||||
return nil, pulseURLPlaintextError(raw, websocket, opts)
|
||||
}
|
||||
if websocket {
|
||||
parsed.Scheme = "ws"
|
||||
|
|
@ -236,8 +266,8 @@ func normalizePulseBaseURL(raw string, websocket bool, opts PulseURLValidationOp
|
|||
if !websocket {
|
||||
return nil, fmt.Errorf("Pulse URL %q has unsupported scheme %q", raw, parsed.Scheme)
|
||||
}
|
||||
if !IsLoopbackHost(parsed.Hostname()) && !opts.AllowInsecureHTTP {
|
||||
return nil, fmt.Errorf("Pulse URL %q must use https/wss unless host is loopback", raw)
|
||||
if !pulseURLAllowsPlaintextHost(parsed.Hostname(), opts) {
|
||||
return nil, pulseURLPlaintextError(raw, websocket, opts)
|
||||
}
|
||||
parsed.Scheme = "ws"
|
||||
default:
|
||||
|
|
@ -253,6 +283,24 @@ func normalizePulseBaseURL(raw string, websocket bool, opts PulseURLValidationOp
|
|||
return parsed, nil
|
||||
}
|
||||
|
||||
func pulseURLAllowsPlaintextHost(host string, opts PulseURLValidationOptions) bool {
|
||||
if IsLoopbackHost(host) {
|
||||
return true
|
||||
}
|
||||
return (opts.AllowInsecureHTTP || opts.AllowLocalNetworkHTTP) && IsLocalNetworkHost(host)
|
||||
}
|
||||
|
||||
func pulseURLPlaintextError(raw string, websocket bool, opts PulseURLValidationOptions) error {
|
||||
allowedHosts := "loopback"
|
||||
if opts.AllowInsecureHTTP || opts.AllowLocalNetworkHTTP {
|
||||
allowedHosts = "loopback or local/private"
|
||||
}
|
||||
if websocket {
|
||||
return fmt.Errorf("Pulse URL %q must use https/wss unless host is %s", raw, allowedHosts)
|
||||
}
|
||||
return fmt.Errorf("Pulse URL %q must use https unless host is %s", raw, allowedHosts)
|
||||
}
|
||||
|
||||
// AppendURLPath appends path segments onto a validated base URL.
|
||||
func AppendURLPath(base *url.URL, segments ...string) *url.URL {
|
||||
cloned := cloneURL(base)
|
||||
|
|
|
|||
|
|
@ -545,6 +545,8 @@ class CanonicalCompletionGuardTest(unittest.TestCase):
|
|||
"internal/agentupdate/coverage_test.go",
|
||||
"internal/hostagent/agent_metrics_test.go",
|
||||
"internal/hostagent/agent_new_test.go",
|
||||
"internal/hostagent/command_client_test.go",
|
||||
"internal/hostagent/commands_deploy_test.go",
|
||||
"internal/hostagent/send_report_test.go",
|
||||
],
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4345,6 +4345,8 @@ class SubsystemLookupTest(unittest.TestCase):
|
|||
"internal/agentupdate/coverage_test.go",
|
||||
"internal/hostagent/agent_metrics_test.go",
|
||||
"internal/hostagent/agent_new_test.go",
|
||||
"internal/hostagent/command_client_test.go",
|
||||
"internal/hostagent/commands_deploy_test.go",
|
||||
"internal/hostagent/send_report_test.go",
|
||||
],
|
||||
)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue