mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-07-10 00:14:38 +00:00
Operators can set PULSE_AGENT_INGEST_PORT to serve agent report and management traffic (/api/agents/*) on a second listener, so that surface can be placed on its own network or firewall boundary without exposing the web UI or the rest of the REST API on that port. The dedicated port serves only the /api/agents/* prefix and 404s everything else. The option is additive and fail-closed: it is disabled at 0, the main listener keeps serving agent ingest so existing single-port deployments and agents are unaffected, and validation rejects out-of-range ports or collisions with the frontend or HTTP redirect ports.
453 lines
12 KiB
Go
453 lines
12 KiB
Go
package config
|
|
|
|
import (
|
|
"encoding/base64"
|
|
"errors"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
)
|
|
|
|
func TestLoad_Defaults(t *testing.T) {
|
|
// Avoid relying on /etc/pulse existing on the machine running tests.
|
|
// We still want to verify "defaults" behavior when PULSE_DATA_DIR is unset.
|
|
tmpDefault := t.TempDir()
|
|
prevDefault := defaultDataDir
|
|
defaultDataDir = tmpDefault
|
|
t.Cleanup(func() { defaultDataDir = prevDefault })
|
|
|
|
// Clear env vars that might affect defaults
|
|
os.Unsetenv("PULSE_DATA_DIR")
|
|
os.Unsetenv("FRONTEND_PORT")
|
|
|
|
cfg, err := Load()
|
|
require.NoError(t, err)
|
|
|
|
assert.Equal(t, 7655, cfg.FrontendPort)
|
|
assert.Equal(t, tmpDefault, cfg.DataPath)
|
|
}
|
|
|
|
func TestLoad_DefaultHostedCommercialBaseURLAvoidsRetiredTrialPath(t *testing.T) {
|
|
t.Setenv("PULSE_DATA_DIR", t.TempDir())
|
|
t.Setenv("PULSE_PRO_TRIAL_SIGNUP_URL", "")
|
|
|
|
cfg, err := Load()
|
|
require.NoError(t, err)
|
|
|
|
if strings.Contains(cfg.ProTrialSignupURL, "start-pro-trial") {
|
|
t.Fatalf("ProTrialSignupURL=%q must not default to retired trial signup route", cfg.ProTrialSignupURL)
|
|
}
|
|
}
|
|
|
|
func TestResolveRuntimeDataDir(t *testing.T) {
|
|
tmpDefault := t.TempDir()
|
|
prevDefault := defaultDataDir
|
|
defaultDataDir = tmpDefault
|
|
t.Cleanup(func() { defaultDataDir = prevDefault })
|
|
|
|
t.Run("explicit_path_wins", func(t *testing.T) {
|
|
t.Setenv("PULSE_DATA_DIR", t.TempDir())
|
|
explicit := t.TempDir()
|
|
assert.Equal(t, explicit, ResolveRuntimeDataDir(explicit))
|
|
})
|
|
|
|
t.Run("env_path_fallback", func(t *testing.T) {
|
|
envDir := t.TempDir()
|
|
t.Setenv("PULSE_DATA_DIR", envDir)
|
|
assert.Equal(t, envDir, ResolveRuntimeDataDir(""))
|
|
})
|
|
|
|
t.Run("default_fallback", func(t *testing.T) {
|
|
os.Unsetenv("PULSE_DATA_DIR")
|
|
assert.Equal(t, tmpDefault, ResolveRuntimeDataDir(""))
|
|
})
|
|
}
|
|
|
|
func TestLoad_EnvOverrides(t *testing.T) {
|
|
// Set some env vars
|
|
t.Setenv("FRONTEND_PORT", "8080")
|
|
tempDir := t.TempDir()
|
|
t.Setenv("PULSE_DATA_DIR", tempDir)
|
|
t.Setenv("HTTPS_ENABLED", "true")
|
|
t.Setenv("PULSE_AUTH_USER", "admin")
|
|
|
|
cfg, err := Load()
|
|
require.NoError(t, err)
|
|
|
|
assert.Equal(t, 8080, cfg.FrontendPort)
|
|
assert.Equal(t, tempDir, cfg.DataPath)
|
|
assert.True(t, cfg.HTTPSEnabled)
|
|
assert.Equal(t, "admin", cfg.AuthUser)
|
|
}
|
|
|
|
func TestLoad_AgentIngestPortDefaultsDisabled(t *testing.T) {
|
|
t.Setenv("PULSE_DATA_DIR", t.TempDir())
|
|
os.Unsetenv("PULSE_AGENT_INGEST_PORT")
|
|
|
|
cfg, err := Load()
|
|
require.NoError(t, err)
|
|
assert.Equal(t, 0, cfg.AgentIngestPort)
|
|
}
|
|
|
|
func TestLoad_AgentIngestPortEnvOverride(t *testing.T) {
|
|
t.Setenv("PULSE_DATA_DIR", t.TempDir())
|
|
t.Setenv("PULSE_AGENT_INGEST_PORT", "7656")
|
|
|
|
cfg, err := Load()
|
|
require.NoError(t, err)
|
|
assert.Equal(t, 7656, cfg.AgentIngestPort)
|
|
}
|
|
|
|
func TestLoad_AgentIngestPortInvalidValueIgnored(t *testing.T) {
|
|
t.Setenv("PULSE_DATA_DIR", t.TempDir())
|
|
t.Setenv("PULSE_AGENT_INGEST_PORT", "not-a-port")
|
|
|
|
cfg, err := Load()
|
|
require.NoError(t, err)
|
|
assert.Equal(t, 0, cfg.AgentIngestPort)
|
|
}
|
|
|
|
func TestLoad_AgentIngestPortRejectsFrontendPortCollision(t *testing.T) {
|
|
t.Setenv("PULSE_DATA_DIR", t.TempDir())
|
|
t.Setenv("FRONTEND_PORT", "7655")
|
|
t.Setenv("PULSE_AGENT_INGEST_PORT", "7655")
|
|
|
|
_, err := Load()
|
|
require.Error(t, err)
|
|
assert.Contains(t, err.Error(), "agent ingest port")
|
|
}
|
|
|
|
func TestLoad_ProxmoxGuestDockerDetectionEnvOptIn(t *testing.T) {
|
|
t.Setenv("PULSE_DATA_DIR", t.TempDir())
|
|
|
|
cfg, err := Load()
|
|
require.NoError(t, err)
|
|
assert.False(t, cfg.EnableProxmoxGuestDockerDetection)
|
|
assert.False(t, cfg.EnableProxmoxGuestDockerInventory)
|
|
assert.Empty(t, cfg.ProxmoxGuestDockerInventoryVMIDs)
|
|
assert.False(t, cfg.EnvOverrides["PULSE_ENABLE_PROXMOX_GUEST_DOCKER_DETECTION"])
|
|
assert.False(t, cfg.EnvOverrides["PULSE_ENABLE_PROXMOX_GUEST_DOCKER_INVENTORY"])
|
|
assert.False(t, cfg.EnvOverrides["PULSE_PROXMOX_GUEST_DOCKER_INVENTORY_VMIDS"])
|
|
|
|
t.Setenv("PULSE_ENABLE_PROXMOX_GUEST_DOCKER_DETECTION", "true")
|
|
t.Setenv("PULSE_ENABLE_PROXMOX_GUEST_DOCKER_INVENTORY", "true")
|
|
t.Setenv("PULSE_PROXMOX_GUEST_DOCKER_INVENTORY_VMIDS", "101,102")
|
|
|
|
cfg, err = Load()
|
|
require.NoError(t, err)
|
|
assert.True(t, cfg.EnableProxmoxGuestDockerDetection)
|
|
assert.True(t, cfg.EnableProxmoxGuestDockerInventory)
|
|
assert.Equal(t, "101,102", cfg.ProxmoxGuestDockerInventoryVMIDs)
|
|
assert.True(t, cfg.EnvOverrides["PULSE_ENABLE_PROXMOX_GUEST_DOCKER_DETECTION"])
|
|
assert.True(t, cfg.EnvOverrides["PULSE_ENABLE_PROXMOX_GUEST_DOCKER_INVENTORY"])
|
|
assert.True(t, cfg.EnvOverrides["PULSE_PROXMOX_GUEST_DOCKER_INVENTORY_VMIDS"])
|
|
}
|
|
|
|
func TestLoad_MetricsStorageEnvOverrides(t *testing.T) {
|
|
t.Setenv("PULSE_DATA_DIR", t.TempDir())
|
|
t.Setenv("PULSE_METRICS_DB_PATH", "/dev/shm/pulse/metrics.db")
|
|
t.Setenv("PULSE_METRICS_ROLLUP_INTERVAL", "30m")
|
|
|
|
cfg, err := Load()
|
|
require.NoError(t, err)
|
|
|
|
assert.Equal(t, "/dev/shm/pulse/metrics.db", cfg.MetricsDBPath)
|
|
assert.Equal(t, 30*time.Minute, cfg.MetricsRollupInterval)
|
|
assert.True(t, cfg.EnvOverrides["PULSE_METRICS_DB_PATH"])
|
|
assert.True(t, cfg.EnvOverrides["PULSE_METRICS_ROLLUP_INTERVAL"])
|
|
}
|
|
|
|
func TestLoad_InvalidMetricsRollupIntervalIgnored(t *testing.T) {
|
|
t.Setenv("PULSE_DATA_DIR", t.TempDir())
|
|
t.Setenv("PULSE_METRICS_ROLLUP_INTERVAL", "2m")
|
|
|
|
cfg, err := Load()
|
|
require.NoError(t, err)
|
|
|
|
assert.Zero(t, cfg.MetricsRollupInterval)
|
|
assert.False(t, cfg.EnvOverrides["PULSE_METRICS_ROLLUP_INTERVAL"])
|
|
}
|
|
|
|
func TestLoad_DotEnv(t *testing.T) {
|
|
tempDir := t.TempDir()
|
|
envFile := filepath.Join(tempDir, ".env")
|
|
content := `PULSE_AUTH_USER="dotenvuser"`
|
|
require.NoError(t, os.WriteFile(envFile, []byte(content), 0644))
|
|
|
|
t.Setenv("PULSE_DATA_DIR", tempDir)
|
|
|
|
// Ensure no leakage
|
|
os.Unsetenv("PULSE_AUTH_USER")
|
|
|
|
cfg, err := Load()
|
|
require.NoError(t, err)
|
|
|
|
// godotenv.Load sets os env vars directly, bypassing t.Setenv cleanup
|
|
t.Cleanup(func() {
|
|
os.Unsetenv("PULSE_AUTH_USER")
|
|
})
|
|
|
|
assert.Equal(t, "dotenvuser", cfg.AuthUser)
|
|
}
|
|
|
|
func TestLoad_APITokensEnvIgnored(t *testing.T) {
|
|
os.Unsetenv("API_TOKEN")
|
|
t.Setenv("API_TOKENS", "token1,token2")
|
|
t.Setenv("PULSE_DATA_DIR", t.TempDir())
|
|
|
|
cfg, err := Load()
|
|
require.NoError(t, err)
|
|
|
|
assert.Len(t, cfg.APITokens, 0)
|
|
assert.False(t, cfg.HasAPITokens())
|
|
}
|
|
|
|
func TestLoad_LegacyAPITokenEnvIgnored(t *testing.T) {
|
|
t.Setenv("PULSE_DATA_DIR", t.TempDir())
|
|
t.Setenv("API_TOKEN", "legacytoken")
|
|
|
|
cfg, err := Load()
|
|
require.NoError(t, err)
|
|
|
|
assert.Len(t, cfg.APITokens, 0)
|
|
assert.False(t, cfg.HasAPITokens())
|
|
}
|
|
|
|
func TestLoad_APITokens_LegacyOrgBindingMigrated(t *testing.T) {
|
|
tempDir := t.TempDir()
|
|
t.Setenv("PULSE_DATA_DIR", tempDir)
|
|
os.Unsetenv("API_TOKEN")
|
|
os.Unsetenv("API_TOKENS")
|
|
|
|
p := NewConfigPersistence(tempDir)
|
|
legacy := []APITokenRecord{
|
|
{
|
|
ID: "legacy-token",
|
|
Name: "Legacy",
|
|
Hash: "legacy-hash",
|
|
Prefix: "legacy",
|
|
Suffix: "hash",
|
|
CreatedAt: time.Now().UTC(),
|
|
Scopes: []string{ScopeWildcard},
|
|
},
|
|
}
|
|
require.NoError(t, p.SaveAPITokens(legacy))
|
|
|
|
cfg, err := Load()
|
|
require.NoError(t, err)
|
|
require.Len(t, cfg.APITokens, 1)
|
|
assert.Equal(t, "default", cfg.APITokens[0].OrgID)
|
|
|
|
persisted, err := p.LoadAPITokens()
|
|
require.NoError(t, err)
|
|
require.Len(t, persisted, 1)
|
|
assert.Equal(t, "default", persisted[0].OrgID)
|
|
}
|
|
|
|
func TestLoad_APITokens_MissingIDMigrated(t *testing.T) {
|
|
tempDir := t.TempDir()
|
|
t.Setenv("PULSE_DATA_DIR", tempDir)
|
|
os.Unsetenv("API_TOKEN")
|
|
os.Unsetenv("API_TOKENS")
|
|
|
|
p := NewConfigPersistence(tempDir)
|
|
legacy := []APITokenRecord{
|
|
{
|
|
ID: "",
|
|
Name: "Legacy Missing ID",
|
|
Hash: "legacy-hash",
|
|
Prefix: "legacy",
|
|
Suffix: "hash",
|
|
CreatedAt: time.Now().UTC(),
|
|
Scopes: []string{ScopeWildcard},
|
|
OrgID: "default",
|
|
},
|
|
}
|
|
require.NoError(t, p.SaveAPITokens(legacy))
|
|
|
|
cfg, err := Load()
|
|
require.NoError(t, err)
|
|
require.Len(t, cfg.APITokens, 1)
|
|
assert.NotEmpty(t, cfg.APITokens[0].ID)
|
|
|
|
persisted, err := p.LoadAPITokens()
|
|
require.NoError(t, err)
|
|
require.Len(t, persisted, 1)
|
|
assert.NotEmpty(t, persisted[0].ID)
|
|
}
|
|
|
|
func TestLoad_MockEnv(t *testing.T) {
|
|
tempDir := t.TempDir()
|
|
t.Setenv("PULSE_DATA_DIR", tempDir)
|
|
require.NoError(t, os.WriteFile(filepath.Join(tempDir, ".env"), []byte(`PULSE_MOCK_TEST="true"`), 0644))
|
|
|
|
t.Cleanup(func() {
|
|
os.Unsetenv("PULSE_MOCK_TEST")
|
|
})
|
|
|
|
_, err := Load()
|
|
require.NoError(t, err)
|
|
|
|
assert.Equal(t, "true", os.Getenv("PULSE_MOCK_TEST"))
|
|
}
|
|
|
|
func TestLoad_ProxyAuth(t *testing.T) {
|
|
t.Setenv("PULSE_DATA_DIR", t.TempDir())
|
|
t.Setenv("PROXY_AUTH_SECRET", "secret")
|
|
t.Setenv("PROXY_AUTH_USER_HEADER", "X-User")
|
|
|
|
cfg, err := Load()
|
|
require.NoError(t, err)
|
|
|
|
assert.Equal(t, "secret", cfg.ProxyAuthSecret)
|
|
assert.Equal(t, "X-User", cfg.ProxyAuthUserHeader)
|
|
}
|
|
|
|
func TestLoad_OIDCEnvIgnored(t *testing.T) {
|
|
t.Setenv("PULSE_DATA_DIR", t.TempDir())
|
|
t.Setenv("OIDC_ENABLED", "true")
|
|
t.Setenv("OIDC_ISSUER_URL", "https://issuer.com")
|
|
t.Setenv("OIDC_CLIENT_ID", "client-id")
|
|
t.Setenv("OIDC_CLIENT_SECRET", "client-secret")
|
|
|
|
cfg, err := Load()
|
|
require.NoError(t, err)
|
|
require.NotNil(t, cfg)
|
|
}
|
|
|
|
func TestLoad_AuthPass_AutoHash(t *testing.T) {
|
|
t.Setenv("PULSE_DATA_DIR", t.TempDir())
|
|
pass := "mysecretpassword"
|
|
t.Setenv("PULSE_AUTH_PASS", pass)
|
|
|
|
cfg, err := Load()
|
|
require.NoError(t, err)
|
|
|
|
assert.NotEqual(t, pass, cfg.AuthPass)
|
|
assert.True(t, IsPasswordHashed(cfg.AuthPass))
|
|
}
|
|
|
|
func TestLoad_AuthPass_PreHashed(t *testing.T) {
|
|
t.Setenv("PULSE_DATA_DIR", t.TempDir())
|
|
hash := "$2a$10$N9qo8uLOickgx2ZMRZoMyeIjZAgcfl7p92ldGxad68LJZdL17lhWy"
|
|
t.Setenv("PULSE_AUTH_PASS", hash)
|
|
|
|
cfg, err := Load()
|
|
require.NoError(t, err)
|
|
|
|
assert.Equal(t, hash, cfg.AuthPass)
|
|
}
|
|
|
|
func TestLoad_AuthPass_HashFailureFailsClosed(t *testing.T) {
|
|
t.Setenv("PULSE_DATA_DIR", t.TempDir())
|
|
t.Setenv("PULSE_AUTH_PASS", "plainpass")
|
|
|
|
originalHashPasswordFn := hashPasswordFn
|
|
hashPasswordFn = func(string) (string, error) {
|
|
return "", errors.New("boom")
|
|
}
|
|
t.Cleanup(func() {
|
|
hashPasswordFn = originalHashPasswordFn
|
|
})
|
|
|
|
cfg, err := Load()
|
|
require.Error(t, err)
|
|
assert.Nil(t, cfg)
|
|
assert.ErrorContains(t, err, "hash PULSE_AUTH_PASS")
|
|
}
|
|
|
|
func TestLoad_Persistence(t *testing.T) {
|
|
tempDir := t.TempDir()
|
|
t.Setenv("PULSE_DATA_DIR", tempDir)
|
|
|
|
// 1. Create nodes.json using Persistence (handles encryption)
|
|
p := NewConfigPersistence(tempDir)
|
|
// nodes := NodesConfig{...}
|
|
require.NoError(t, p.SaveNodesConfig(
|
|
[]PVEInstance{{Host: "https://pve1", TokenName: "t", TokenValue: "v"}},
|
|
nil,
|
|
nil,
|
|
))
|
|
|
|
// 2. Create system_settings.json
|
|
sysContent := `{
|
|
"pvePollingInterval": 45,
|
|
"logLevel": "debug"
|
|
}`
|
|
require.NoError(t, os.WriteFile(filepath.Join(tempDir, "system.json"), []byte(sysContent), 0644)) // Note: filename is system.json or system_settings.json?
|
|
|
|
cfg, err := Load()
|
|
require.NoError(t, err)
|
|
|
|
// Debug: Check if path is correct
|
|
assert.Equal(t, tempDir, cfg.ConfigPath)
|
|
|
|
require.Len(t, cfg.PVEInstances, 1)
|
|
assert.Equal(t, "https://pve1:8006", cfg.PVEInstances[0].Host)
|
|
assert.Equal(t, 45*time.Second, cfg.PVEPollingInterval)
|
|
assert.Equal(t, "debug", cfg.LogLevel)
|
|
}
|
|
|
|
func TestLoad_DisablesAutoUpdatesForRCChannel(t *testing.T) {
|
|
tempDir := t.TempDir()
|
|
t.Setenv("PULSE_DATA_DIR", tempDir)
|
|
|
|
p := NewConfigPersistence(tempDir)
|
|
require.NoError(t, p.SaveSystemSettings(SystemSettings{
|
|
UpdateChannel: "rc",
|
|
AutoUpdateEnabled: true,
|
|
}))
|
|
|
|
cfg, err := Load()
|
|
require.NoError(t, err)
|
|
|
|
assert.Equal(t, "rc", cfg.UpdateChannel)
|
|
assert.False(t, cfg.AutoUpdateEnabled)
|
|
}
|
|
|
|
func TestLoad_ReadErrors(t *testing.T) {
|
|
if os.Getuid() == 0 {
|
|
t.Skip("Skipping permission tests as root")
|
|
}
|
|
|
|
tempDir := t.TempDir()
|
|
t.Setenv("PULSE_DATA_DIR", tempDir)
|
|
|
|
// Create unreadable .env
|
|
envFile := filepath.Join(tempDir, ".env")
|
|
require.NoError(t, os.WriteFile(envFile, []byte("FOO=bar"), 0000))
|
|
|
|
// Create encryption key first (required before creating .enc files)
|
|
key := make([]byte, 32)
|
|
for i := range key {
|
|
key[i] = byte(i)
|
|
}
|
|
encoded := base64.StdEncoding.EncodeToString(key)
|
|
require.NoError(t, os.WriteFile(filepath.Join(tempDir, ".encryption.key"), []byte(encoded), 0600))
|
|
|
|
// Create unreadable nodes.enc
|
|
require.NoError(t, os.WriteFile(filepath.Join(tempDir, "nodes.enc"), []byte("data"), 0000))
|
|
|
|
// Load should warn but succeed with defaults
|
|
cfg, err := Load()
|
|
require.NoError(t, err)
|
|
assert.NotNil(t, cfg)
|
|
}
|
|
|
|
func TestLoad_Persistence_InvalidFiles(t *testing.T) {
|
|
tempDir := t.TempDir()
|
|
t.Setenv("PULSE_DATA_DIR", tempDir)
|
|
|
|
// Invalid JSON
|
|
require.NoError(t, os.WriteFile(filepath.Join(tempDir, "nodes.json"), []byte("{invalid"), 0644))
|
|
|
|
cfg, err := Load()
|
|
require.NoError(t, err)
|
|
// Should not crash, just empty/defailts
|
|
assert.Empty(t, cfg.PVEInstances)
|
|
}
|