diff --git a/internal/api/config_handlers.go b/internal/api/config_handlers.go index 1163f28ea..4df7d05b4 100644 --- a/internal/api/config_handlers.go +++ b/internal/api/config_handlers.go @@ -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 { diff --git a/internal/api/oidc_handlers.go b/internal/api/oidc_handlers.go index 11ff70d8e..ffb5d20ad 100644 --- a/internal/api/oidc_handlers.go +++ b/internal/api/oidc_handlers.go @@ -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") diff --git a/internal/api/oidc_handlers_test.go b/internal/api/oidc_handlers_test.go index 161f823b0..150b58183 100644 --- a/internal/api/oidc_handlers_test.go +++ b/internal/api/oidc_handlers_test.go @@ -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{} } diff --git a/internal/api/ratelimit.go b/internal/api/ratelimit.go index 4f49bfea3..70c496c71 100644 --- a/internal/api/ratelimit.go +++ b/internal/api/ratelimit.go @@ -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) { diff --git a/internal/api/ratelimit_test.go b/internal/api/ratelimit_test.go index 5f8dfd070..f75ffe0c1 100644 --- a/internal/api/ratelimit_test.go +++ b/internal/api/ratelimit_test.go @@ -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() diff --git a/internal/api/router.go b/internal/api/router.go index bce996612..d3d7ca573 100644 --- a/internal/api/router.go +++ b/internal/api/router.go @@ -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 { diff --git a/internal/api/router_integration_test.go b/internal/api/router_integration_test.go index d53a45a60..21525e739 100644 --- a/internal/api/router_integration_test.go +++ b/internal/api/router_integration_test.go @@ -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) diff --git a/internal/api/security_setup_fix.go b/internal/api/security_setup_fix.go index 9f1d48e0b..3fa3a90e6 100644 --- a/internal/api/security_setup_fix.go +++ b/internal/api/security_setup_fix.go @@ -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, diff --git a/internal/api/security_setup_fix_test.go b/internal/api/security_setup_fix_test.go index 11af821be..6194cdafe 100644 --- a/internal/api/security_setup_fix_test.go +++ b/internal/api/security_setup_fix_test.go @@ -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() diff --git a/internal/api/security_tokens.go b/internal/api/security_tokens.go index 69caa0178..8f86ac6a3 100644 --- a/internal/api/security_tokens.go +++ b/internal/api/security_tokens.go @@ -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 {