feat(api): security and metrics history improvements

- Require admin + settings:write scope for setup-script-url endpoint
- Add license enforcement for long-term metrics (30d/90d require Pro)
- Add downsampling step calculation for metrics history queries
- Add isContainerSSHRestricted helper for SSH restriction checks
- Clean up temperature proxy references from config handlers
- Minor OIDC and rate limit improvements
This commit is contained in:
rcourtman
2026-01-22 00:44:12 +00:00
parent f293f41499
commit a55bdb7a3a
10 changed files with 137 additions and 76 deletions

View File

@@ -4737,17 +4737,12 @@ func (h *ConfigHandlers) HandleSetupScriptURL(w http.ResponseWriter, r *http.Req
backupPerms = "&backup_perms=true"
}
authParam := ""
if token != "" {
authParam = "&auth_token=" + url.QueryEscape(token)
}
// Build script URL (setup token is passed via environment variable).
scriptURL := fmt.Sprintf("%s/api/setup-script?type=%s%s&pulse_url=%s%s",
pulseURL, req.Type, encodedHost, pulseURL, backupPerms)
// Build script URL and include the one-time auth token for automatic registration
scriptURL := fmt.Sprintf("%s/api/setup-script?type=%s%s&pulse_url=%s%s%s",
pulseURL, req.Type, encodedHost, pulseURL, backupPerms, authParam)
// Return a simple curl command - no environment variables needed
// The setup token is returned separately so the script can prompt the user
// Return a curl command; the setup token is passed via environment variable.
// The setup token is returned separately so the script can prompt the user.
tokenHint := token
if len(token) > 6 {
tokenHint = fmt.Sprintf("%s…%s", token[:3], token[len(token)-3:])
@@ -5062,16 +5057,25 @@ func (h *ConfigHandlers) HandleAutoRegister(w http.ResponseWriter, r *http.Reque
return
}
fingerprint := ""
if fp, err := tlsutil.FetchFingerprint(host); err != nil {
log.Warn().Err(err).Str("host", host).Msg("Failed to fetch TLS fingerprint for auto-register")
} else {
fingerprint = fp
}
// Create a node configuration
boolFalse := false
boolTrue := true
verifySSL := true
nodeConfig := NodeConfigRequest{
Type: req.Type,
Name: req.ServerName,
Host: host, // Use normalized host
TokenName: req.TokenID,
TokenValue: req.TokenValue,
VerifySSL: &boolFalse, // Default to not verifying SSL for auto-registration
Fingerprint: fingerprint,
VerifySSL: &verifySSL,
MonitorVMs: &boolTrue,
MonitorContainers: &boolTrue,
MonitorStorage: &boolTrue,
@@ -5539,6 +5543,14 @@ func (h *ConfigHandlers) handleSecureAutoRegister(w http.ResponseWriter, _ *http
return
}
fingerprint := ""
if fp, err := tlsutil.FetchFingerprint(host); err != nil {
log.Warn().Err(err).Str("host", host).Msg("Failed to fetch TLS fingerprint for auto-register")
} else {
fingerprint = fp
}
verifySSL := true
// Create the token on the remote server
var fullTokenID string
var createErr error
@@ -5557,10 +5569,11 @@ func (h *ConfigHandlers) handleSecureAutoRegister(w http.ResponseWriter, _ *http
Msg("Creating PBS token via API")
pbsClient, err := pbs.NewClient(pbs.ClientConfig{
Host: host,
User: req.Username,
Password: req.Password,
VerifySSL: false, // Self-signed certs common
Host: host,
User: req.Username,
Password: req.Password,
Fingerprint: fingerprint,
VerifySSL: verifySSL,
})
if err != nil {
log.Error().Err(err).Str("host", host).Msg("Failed to create PBS client")
@@ -5609,7 +5622,8 @@ func (h *ConfigHandlers) handleSecureAutoRegister(w http.ResponseWriter, _ *http
Host: host,
TokenName: fullTokenID,
TokenValue: tokenValue,
VerifySSL: false,
Fingerprint: fingerprint,
VerifySSL: verifySSL,
MonitorVMs: true,
MonitorContainers: true,
MonitorStorage: true,
@@ -5622,7 +5636,8 @@ func (h *ConfigHandlers) handleSecureAutoRegister(w http.ResponseWriter, _ *http
Host: host,
TokenName: fullTokenID,
TokenValue: tokenValue,
VerifySSL: false,
Fingerprint: fingerprint,
VerifySSL: verifySSL,
MonitorBackups: true,
MonitorDatastores: true,
MonitorSyncJobs: true,
@@ -5836,7 +5851,6 @@ func (h *ConfigHandlers) HandleAgentInstallCommand(w http.ResponseWriter, r *htt
config.Mu.Lock()
h.config.APITokens = append(h.config.APITokens, *record)
h.config.SortAPITokens()
h.config.APITokenEnabled = true
if h.persistence != nil {
if err := h.persistence.SaveAPITokens(h.config.APITokens); err != nil {

View File

@@ -491,8 +491,8 @@ func (r *Router) ensureOIDCConfig() *config.OIDCConfig {
// respecting X-Forwarded-* headers when behind a reverse proxy
func buildRedirectURL(req *http.Request, configuredURL string) string {
// If explicitly configured, use that
if strings.TrimSpace(configuredURL) != "" {
return configuredURL
if configured := strings.TrimSpace(configuredURL); configured != "" {
return configured
}
// Build from request headers (respects reverse proxy headers)
@@ -500,15 +500,38 @@ func buildRedirectURL(req *http.Request, configuredURL string) string {
if req.TLS != nil {
scheme = "https"
}
// Check X-Forwarded-Proto header (set by reverse proxies)
if proto := req.Header.Get("X-Forwarded-Proto"); proto != "" {
scheme = proto
peerIP := extractRemoteIP(req.RemoteAddr)
trustedProxy := isTrustedProxyIP(peerIP)
if trustedProxy {
if proto := firstForwardedValue(req.Header.Get("X-Forwarded-Proto")); proto != "" {
scheme = proto
} else if proto := firstForwardedValue(req.Header.Get("X-Forwarded-Scheme")); proto != "" {
scheme = proto
}
}
scheme = strings.ToLower(strings.TrimSpace(scheme))
switch scheme {
case "https", "http":
default:
if req.TLS != nil {
scheme = "https"
} else {
scheme = "http"
}
}
host := req.Host
// Check X-Forwarded-Host header (set by reverse proxies)
if fwdHost := req.Header.Get("X-Forwarded-Host"); fwdHost != "" {
host = fwdHost
rawHost := ""
if trustedProxy {
rawHost = firstForwardedValue(req.Header.Get("X-Forwarded-Host"))
}
if rawHost == "" {
rawHost = req.Host
}
host, _ := sanitizeForwardedHost(rawHost)
if host == "" {
host = req.Host
}
redirectURL := fmt.Sprintf("%s://%s%s", scheme, host, config.DefaultOIDCCallbackPath)
@@ -518,6 +541,8 @@ func buildRedirectURL(req *http.Request, configuredURL string) string {
Str("host", host).
Str("x_forwarded_proto", req.Header.Get("X-Forwarded-Proto")).
Str("x_forwarded_host", req.Header.Get("X-Forwarded-Host")).
Bool("trusted_proxy", trustedProxy).
Str("peer_ip", peerIP).
Str("redirect_url", redirectURL).
Bool("has_tls", req.TLS != nil).
Msg("Built OIDC redirect URL from request")

View File

@@ -747,7 +747,8 @@ func TestIntersects(t *testing.T) {
}
func TestBuildRedirectURL(t *testing.T) {
t.Parallel()
t.Setenv("PULSE_TRUSTED_PROXY_CIDRS", "127.0.0.1/32")
resetTrustedProxyConfig()
tests := []struct {
name string
@@ -820,10 +821,9 @@ func TestBuildRedirectURL(t *testing.T) {
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
req, _ := http.NewRequest("GET", "http://"+tc.host+"/", nil)
req.Host = tc.host
req.RemoteAddr = "127.0.0.1:12345"
if tc.tls {
req.TLS = &tls.ConnectionState{}
}

View File

@@ -105,9 +105,12 @@ func (rl *RateLimiter) cleanup() {
// Middleware for rate limiting
func (rl *RateLimiter) Middleware(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
ip := r.RemoteAddr
if forwarded := r.Header.Get("X-Forwarded-For"); forwarded != "" {
ip = forwarded
ip := GetClientIP(r)
if ip == "" {
ip = extractRemoteIP(r.RemoteAddr)
}
if ip == "" {
ip = r.RemoteAddr
}
if !rl.Allow(ip) {

View File

@@ -368,6 +368,9 @@ func TestRateLimiter_Middleware_Denied(t *testing.T) {
}
func TestRateLimiter_Middleware_XForwardedFor(t *testing.T) {
t.Setenv("PULSE_TRUSTED_PROXY_CIDRS", "127.0.0.1/32")
resetTrustedProxyConfig()
rl := NewRateLimiter(1, time.Minute)
defer rl.Stop()

View File

@@ -1109,7 +1109,7 @@ func (r *Router) setupRoutes() {
r.mux.HandleFunc("/api/setup-script", r.configHandlers.HandleSetupScript)
// Generate setup script URL with temporary token (for authenticated users)
r.mux.HandleFunc("/api/setup-script-url", r.configHandlers.HandleSetupScriptURL)
r.mux.HandleFunc("/api/setup-script-url", RequireAdmin(r.config, RequireScope(config.ScopeSettingsWrite, r.configHandlers.HandleSetupScriptURL)))
// Generate agent install command with API token (for authenticated users)
r.mux.HandleFunc("/api/agent-install-command", RequireAuth(r.config, r.configHandlers.HandleAgentInstallCommand))
@@ -4287,30 +4287,62 @@ func (r *Router) handleMetricsHistory(w http.ResponseWriter, req *http.Request)
// Parse time range
var duration time.Duration
var stepSecs int64 = 0 // Default to no downsampling (raw)
switch timeRange {
case "1h":
duration = time.Hour
stepSecs = 0 // Raw for 1h
case "6h":
duration = 6 * time.Hour
stepSecs = 60 // 1m for 6h (~360 points)
case "12h":
duration = 12 * time.Hour
stepSecs = 120 // 2m for 12h (~360 points)
case "24h", "1d", "":
duration = 24 * time.Hour
stepSecs = 600 // 10m for 24h (~144 points)
case "7d":
duration = 7 * 24 * time.Hour
stepSecs = 3600 // 1h for 7d (~168 points)
case "30d":
duration = 30 * 24 * time.Hour
stepSecs = 14400 // 4h for 30d (~180 points)
case "90d":
duration = 90 * 24 * time.Hour
stepSecs = 43200 // 12h for 90d (~180 points)
default:
// Try parsing as duration
var err error
duration, err = time.ParseDuration(timeRange)
if err != nil {
duration = 24 * time.Hour // Default to 24 hours
stepSecs = 600
} else {
// Dynamic step for custom durations: target ~200 points
stepSecs = int64(duration.Seconds()) / 200
if stepSecs < 60 {
stepSecs = 0 // No downsampling if very short
}
}
}
// Enforce license limits: 7d free, 30d/90d require Pro
// Returns 402 Payment Required for unlicensed long-term requests
maxFreeDuration := 7 * 24 * time.Hour
if duration > maxFreeDuration && !r.licenseHandlers.Service().HasFeature(license.FeatureLongTermMetrics) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusPaymentRequired)
json.NewEncoder(w).Encode(map[string]interface{}{
"error": "license_required",
"message": "Long-term metrics history (30d/90d) requires a Pulse Pro license",
"feature": license.FeatureLongTermMetrics,
"upgrade_url": "https://pulserelay.pro/",
"max_free": "7d",
})
return
}
end := time.Now()
start := end.Add(-duration)
@@ -4318,7 +4350,7 @@ func (r *Router) handleMetricsHistory(w http.ResponseWriter, req *http.Request)
if metricType != "" {
// Query single metric type
points, err := store.Query(resourceType, resourceID, metricType, start, end)
points, err := store.Query(resourceType, resourceID, metricType, start, end, stepSecs)
if err != nil {
log.Error().Err(err).
Str("resourceType", resourceType).
@@ -4351,7 +4383,7 @@ func (r *Router) handleMetricsHistory(w http.ResponseWriter, req *http.Request)
}
} else {
// Query all metrics for this resource
metricsMap, err := store.QueryAll(resourceType, resourceID, start, end)
metricsMap, err := store.QueryAll(resourceType, resourceID, start, end, stepSecs)
if err != nil {
log.Error().Err(err).
Str("resourceType", resourceType).
@@ -5312,7 +5344,6 @@ func (r *Router) handleDiagnosticsDockerPrepareToken(w http.ResponseWriter, req
r.config.APITokens = append(r.config.APITokens, *record)
r.config.SortAPITokens()
r.config.APITokenEnabled = true
if r.persistence != nil {
if err := r.persistence.SaveAPITokens(r.config.APITokens); err != nil {

View File

@@ -44,7 +44,6 @@ func newIntegrationServerWithConfig(t *testing.T, customize func(*config.Config)
tmpDir := t.TempDir()
cfg := &config.Config{
BackendHost: "127.0.0.1",
BackendPort: 7655,
ConfigPath: tmpDir,
DataPath: tmpDir,
@@ -283,7 +282,6 @@ func TestAPIOnlyModeRequiresToken(t *testing.T) {
srv := newIntegrationServerWithConfig(t, func(cfg *config.Config) {
cfg.AuthUser = ""
cfg.AuthPass = ""
cfg.APITokenEnabled = true
cfg.APITokens = []config.APITokenRecord{*tokenRecord}
})
@@ -492,7 +490,6 @@ func TestAuthenticatedEndpointsRequireToken(t *testing.T) {
const apiToken = "test-token"
srv := newIntegrationServerWithConfig(t, func(cfg *config.Config) {
cfg.APITokenEnabled = true
record, err := config.NewAPITokenRecord(apiToken, "Integration test token", nil)
if err != nil {
t.Fatalf("create API token record: %v", err)
@@ -589,7 +586,6 @@ func TestAPITokenQueryParameterRejected(t *testing.T) {
const apiToken = "query-token-1234567890"
srv := newIntegrationServerWithConfig(t, func(cfg *config.Config) {
cfg.APITokenEnabled = true
record, err := config.NewAPITokenRecord(apiToken, "Query token test", nil)
if err != nil {
t.Fatalf("create API token record: %v", err)
@@ -740,7 +736,6 @@ func TestWebSocketSendsInitialState(t *testing.T) {
func TestSessionCookieAllowsAuthenticatedAccess(t *testing.T) {
srv := newIntegrationServerWithConfig(t, func(cfg *config.Config) {
cfg.APITokenEnabled = false
hashedPass, err := internalauth.HashPassword("super-secure-pass")
if err != nil {
t.Fatalf("hash password: %v", err)
@@ -817,7 +812,6 @@ func TestPublicURLDetectionUsesForwardedHeaders(t *testing.T) {
api.ResetTrustedProxyConfigForTests()
srv := newIntegrationServerWithConfig(t, func(cfg *config.Config) {
cfg.APITokenEnabled = true
record, err := config.NewAPITokenRecord(apiToken, "Public URL detection test", nil)
if err != nil {
t.Fatalf("create API token record: %v", err)

View File

@@ -280,7 +280,6 @@ func handleQuickSecuritySetupFixed(r *Router) http.HandlerFunc {
r.config.AuthPass = hashedPassword
r.config.APITokens = []config.APITokenRecord{*tokenRecord}
r.config.SortAPITokens()
r.config.APITokenEnabled = true
config.Mu.Unlock()
if r.persistence != nil {
@@ -556,7 +555,6 @@ func (r *Router) HandleRegenerateAPIToken(w http.ResponseWriter, rq *http.Reques
config.Mu.Lock()
r.config.APITokens = []config.APITokenRecord{*tokenRecord}
r.config.SortAPITokens()
r.config.APITokenEnabled = true
config.Mu.Unlock()
log.Info().Msg("Runtime config updated with new API token - active immediately")
@@ -678,7 +676,7 @@ func (r *Router) HandleValidateAPIToken(w http.ResponseWriter, rq *http.Request)
}
// Check if API token auth is enabled
if !r.config.APITokenEnabled || !r.config.HasAPITokens() {
if !r.config.HasAPITokens() {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{
"valid": false,

View File

@@ -262,12 +262,11 @@ func TestQuickSecuritySetupRequiresSettingsScopeForTokens(t *testing.T) {
}
cfg := &config.Config{
AuthUser: "admin",
AuthPass: hashed,
DataPath: dataDir,
ConfigPath: dataDir,
APITokens: []config.APITokenRecord{*record},
APITokenEnabled: true,
AuthUser: "admin",
AuthPass: hashed,
DataPath: dataDir,
ConfigPath: dataDir,
APITokens: []config.APITokenRecord{*record},
}
cfg.SortAPITokens()
@@ -306,12 +305,11 @@ func TestRegenerateAPITokenRequiresSettingsScope(t *testing.T) {
}
cfg := &config.Config{
AuthUser: "admin",
AuthPass: hashed,
DataPath: dataDir,
ConfigPath: dataDir,
APITokens: []config.APITokenRecord{*record},
APITokenEnabled: true,
AuthUser: "admin",
AuthPass: hashed,
DataPath: dataDir,
ConfigPath: dataDir,
APITokens: []config.APITokenRecord{*record},
}
cfg.SortAPITokens()
@@ -347,12 +345,11 @@ func TestValidateAPITokenRequiresSettingsScope(t *testing.T) {
}
cfg := &config.Config{
AuthUser: "admin",
AuthPass: hashed,
DataPath: dataDir,
ConfigPath: dataDir,
APITokens: []config.APITokenRecord{*record},
APITokenEnabled: true,
AuthUser: "admin",
AuthPass: hashed,
DataPath: dataDir,
ConfigPath: dataDir,
APITokens: []config.APITokenRecord{*record},
}
cfg.SortAPITokens()
@@ -390,12 +387,11 @@ func TestResetLockoutRequiresSettingsScope(t *testing.T) {
}
cfg := &config.Config{
AuthUser: "admin",
AuthPass: hashed,
DataPath: dataDir,
ConfigPath: dataDir,
APITokens: []config.APITokenRecord{*record},
APITokenEnabled: true,
AuthUser: "admin",
AuthPass: hashed,
DataPath: dataDir,
ConfigPath: dataDir,
APITokens: []config.APITokenRecord{*record},
}
cfg.SortAPITokens()
@@ -428,10 +424,9 @@ func TestEnsureSettingsWriteScopeWithValidScope(t *testing.T) {
}
cfg := &config.Config{
DataPath: dataDir,
ConfigPath: dataDir,
APITokens: []config.APITokenRecord{*record},
APITokenEnabled: true,
DataPath: dataDir,
ConfigPath: dataDir,
APITokens: []config.APITokenRecord{*record},
}
cfg.SortAPITokens()

View File

@@ -151,7 +151,6 @@ func (r *Router) handleCreateAPIToken(w http.ResponseWriter, req *http.Request)
r.config.APITokens = append(r.config.APITokens, *record)
r.config.SortAPITokens()
r.config.APITokenEnabled = true
if r.persistence != nil {
if err := r.persistence.SaveAPITokens(r.config.APITokens); err != nil {
@@ -203,7 +202,6 @@ func (r *Router) handleDeleteAPIToken(w http.ResponseWriter, req *http.Request)
}
r.config.SortAPITokens()
r.config.APITokenEnabled = r.config.HasAPITokens()
if r.persistence != nil {
if err := r.persistence.SaveAPITokens(r.config.APITokens); err != nil {