From 3e2824a7ff8d27f6ba2fbbc70dccd19aefba06ae Mon Sep 17 00:00:00 2001 From: rcourtman Date: Fri, 9 Jan 2026 16:51:08 +0000 Subject: [PATCH] feat: remove Enterprise badges, simplify Pro upgrade prompts - Replace barrel import in AuditLogPanel.tsx to fix ad-blocker crash - Remove all Enterprise/Pro badges from nav and feature headers - Simplify upgrade CTAs to clean 'Upgrade to Pro' links - Update docs: PULSE_PRO.md, API.md, README.md, SECURITY.md - Align terminology: single Pro tier, no separate Enterprise tier Also includes prior refactoring: - Move auth package to pkg/auth for enterprise reuse - Export server functions for testability - Stabilize CLI tests --- .gitignore | 1 + README.md | 2 +- SECURITY.md | 2 +- cmd/hashpw/main.go | 2 +- cmd/hashpw/main_test.go | 2 +- cmd/pulse/auto_import_test.go | 21 +- cmd/pulse/commands_test.go | 9 +- cmd/pulse/config.go | 5 +- cmd/pulse/import_payload.go | 56 --- cmd/pulse/import_payload_test.go | 14 +- cmd/pulse/main.go | 358 +--------------- docs/API.md | 2 +- docs/PULSE_PRO.md | 2 +- .../Kubernetes/KubernetesClusters.tsx | 2 +- .../src/components/Settings/AISettings.tsx | 47 +-- .../src/components/Settings/AuditLogPanel.tsx | 61 +-- .../src/components/Settings/OIDCPanel.tsx | 44 +- .../src/components/Settings/Settings.tsx | 2 - go.mod | 7 +- go.sum | 20 +- internal/api/auth.go | 2 +- internal/api/bootstrap_token.go | 2 +- internal/api/config_handlers.go | 2 +- .../api/config_handlers_auto_register_test.go | 2 +- internal/api/config_handlers_pve_user_test.go | 2 +- internal/api/rbac_test.go | 2 +- internal/api/router.go | 2 +- internal/api/router_integration_test.go | 2 +- internal/api/router_test.go | 2 +- internal/api/security_setup_fix.go | 2 +- internal/api/security_setup_fix_test.go | 2 +- internal/api/security_test.go | 2 +- internal/api/security_tokens.go | 2 +- internal/api/system_settings_handlers_test.go | 2 +- internal/config/api_tokens.go | 2 +- internal/config/api_tokens_test.go | 2 +- internal/config/config.go | 2 +- internal/config/watcher.go | 2 +- internal/monitoring/monitor_polling.go | 2 +- {internal => pkg}/auth/auth_test.go | 0 {internal => pkg}/auth/authorizer.go | 0 {internal => pkg}/auth/coverage_test.go | 0 {internal => pkg}/auth/password.go | 0 {internal => pkg}/auth/permissions.go | 0 {internal => pkg}/auth/token.go | 0 pkg/server/server.go | 390 ++++++++++++++++++ 46 files changed, 509 insertions(+), 578 deletions(-) delete mode 100644 cmd/pulse/import_payload.go rename {internal => pkg}/auth/auth_test.go (100%) rename {internal => pkg}/auth/authorizer.go (100%) rename {internal => pkg}/auth/coverage_test.go (100%) rename {internal => pkg}/auth/password.go (100%) rename {internal => pkg}/auth/permissions.go (100%) rename {internal => pkg}/auth/token.go (100%) create mode 100644 pkg/server/server.go diff --git a/.gitignore b/.gitignore index 785396a62..288fec15a 100644 --- a/.gitignore +++ b/.gitignore @@ -34,6 +34,7 @@ Thumbs.db .vscode/ *.swp *.swo +*.code-workspace # Go *.exe diff --git a/README.md b/README.md index cbfca9c43..62d91c7fb 100644 --- a/README.md +++ b/README.md @@ -45,7 +45,7 @@ Designed for homelabs, sysadmins, and MSPs who need a "single pane of glass" wit ### Security & Operations - **Secure by Design**: Credentials encrypted at rest, strict API scoping - **One-Click Updates**: Easy upgrades for supported deployments -- **OIDC/SSO**: Enterprise authentication support +- **OIDC/SSO**: Single sign-on authentication - **Privacy Focused**: No telemetry, all data stays on your server ## ⚡ Quick Start diff --git a/SECURITY.md b/SECURITY.md index ae91705f1..8577a4fd1 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -247,7 +247,7 @@ for sensitive data. - **Minimum passphrase**: 12 characters required for exports - **Security tab**: check status in *Settings → Security → Overview* -### Enterprise Security (When Authentication Enabled) +### Advanced Security (When Authentication Enabled) - **Password security** - Bcrypt hashing with cost factor 12 (60‑character hash) - Passwords never stored in plain text diff --git a/cmd/hashpw/main.go b/cmd/hashpw/main.go index 154213787..23812be01 100644 --- a/cmd/hashpw/main.go +++ b/cmd/hashpw/main.go @@ -5,7 +5,7 @@ import ( "io" "os" - "github.com/rcourtman/pulse-go-rewrite/internal/auth" + "github.com/rcourtman/pulse-go-rewrite/pkg/auth" ) var ( diff --git a/cmd/hashpw/main_test.go b/cmd/hashpw/main_test.go index b2ed8a202..968298eb4 100644 --- a/cmd/hashpw/main_test.go +++ b/cmd/hashpw/main_test.go @@ -6,7 +6,7 @@ import ( "strings" "testing" - "github.com/rcourtman/pulse-go-rewrite/internal/auth" + "github.com/rcourtman/pulse-go-rewrite/pkg/auth" ) func TestRunUsage(t *testing.T) { diff --git a/cmd/pulse/auto_import_test.go b/cmd/pulse/auto_import_test.go index 5f4fb06c1..119ea8900 100644 --- a/cmd/pulse/auto_import_test.go +++ b/cmd/pulse/auto_import_test.go @@ -6,6 +6,7 @@ import ( "testing" "github.com/rcourtman/pulse-go-rewrite/internal/config" + "github.com/rcourtman/pulse-go-rewrite/pkg/server" ) func TestShouldAutoImport(t *testing.T) { @@ -15,13 +16,13 @@ func TestShouldAutoImport(t *testing.T) { // No env vars => false. t.Setenv("PULSE_INIT_CONFIG_DATA", "") t.Setenv("PULSE_INIT_CONFIG_FILE", "") - if shouldAutoImport() { + if server.ShouldAutoImport() { t.Fatalf("expected false when no env vars set") } // Env var set => true. t.Setenv("PULSE_INIT_CONFIG_DATA", "anything") - if !shouldAutoImport() { + if !server.ShouldAutoImport() { t.Fatalf("expected true when init config env var set") } @@ -29,7 +30,7 @@ func TestShouldAutoImport(t *testing.T) { if err := os.WriteFile(filepath.Join(dir, "nodes.enc"), []byte("exists"), 0o600); err != nil { t.Fatalf("write nodes.enc: %v", err) } - if shouldAutoImport() { + if server.ShouldAutoImport() { t.Fatalf("expected false when nodes.enc exists") } } @@ -49,7 +50,7 @@ func TestPerformAutoImport_ValidPayload(t *testing.T) { t.Setenv("PULSE_INIT_CONFIG_DATA", exported) t.Setenv("PULSE_INIT_CONFIG_FILE", "") - if err := performAutoImport(); err != nil { + if err := server.PerformAutoImport(); err != nil { t.Fatalf("performAutoImport: %v", err) } @@ -65,7 +66,7 @@ func TestPerformAutoImport_MissingPassphrase(t *testing.T) { t.Setenv("PULSE_INIT_CONFIG_DATA", "data") t.Setenv("PULSE_INIT_CONFIG_FILE", "") - if err := performAutoImport(); err == nil { + if err := server.PerformAutoImport(); err == nil { t.Fatalf("expected error") } } @@ -77,7 +78,7 @@ func TestPerformAutoImport_MissingData(t *testing.T) { t.Setenv("PULSE_INIT_CONFIG_DATA", "") t.Setenv("PULSE_INIT_CONFIG_FILE", "") - if err := performAutoImport(); err == nil { + if err := server.PerformAutoImport(); err == nil { t.Fatalf("expected error") } } @@ -102,7 +103,7 @@ func TestPerformAutoImport_File(t *testing.T) { t.Setenv("PULSE_INIT_CONFIG_FILE", importFile) t.Setenv("PULSE_INIT_CONFIG_DATA", "") - if err := performAutoImport(); err != nil { + if err := server.PerformAutoImport(); err != nil { t.Fatalf("performAutoImport with file: %v", err) } } @@ -113,7 +114,7 @@ func TestPerformAutoImport_FileReadError(t *testing.T) { t.Setenv("PULSE_INIT_CONFIG_PASSPHRASE", "pass") t.Setenv("PULSE_INIT_CONFIG_FILE", filepath.Join(dir, "nonexistent")) - if err := performAutoImport(); err == nil { + if err := server.PerformAutoImport(); err == nil { t.Fatal("expected error reading nonexistent file") } } @@ -124,7 +125,7 @@ func TestPerformAutoImport_NormalizeError(t *testing.T) { t.Setenv("PULSE_INIT_CONFIG_PASSPHRASE", "pass") t.Setenv("PULSE_INIT_CONFIG_DATA", " ") // Will trigger "payload is empty" error - if err := performAutoImport(); err == nil { + if err := server.PerformAutoImport(); err == nil { t.Fatal("expected error from normalizeImportPayload") } } @@ -138,7 +139,7 @@ func TestPerformAutoImport_FileNormalizeError(t *testing.T) { os.WriteFile(importFile, []byte(" "), 0600) t.Setenv("PULSE_INIT_CONFIG_FILE", importFile) - if err := performAutoImport(); err == nil { + if err := server.PerformAutoImport(); err == nil { t.Fatal("expected error from normalizeImportPayload for file") } } diff --git a/cmd/pulse/commands_test.go b/cmd/pulse/commands_test.go index 3c3961763..88229de06 100644 --- a/cmd/pulse/commands_test.go +++ b/cmd/pulse/commands_test.go @@ -17,6 +17,7 @@ import ( "time" "github.com/gorilla/websocket" + "github.com/rcourtman/pulse-go-rewrite/pkg/server" "github.com/spf13/cobra" "github.com/stretchr/testify/assert" ) @@ -557,25 +558,25 @@ func TestConfigAutoImport_Errors(t *testing.T) { func TestNormalizeImportPayload(t *testing.T) { // Empty case - _, err := normalizeImportPayload([]byte(" ")) + _, err := server.NormalizeImportPayload([]byte(" ")) assert.Error(t, err) assert.Contains(t, err.Error(), "configuration payload is empty") // Base64 case (where decoded doesn't look like base64) // base64("!!") = "ISE=" - s, err := normalizeImportPayload([]byte(" ISE= ")) + s, err := server.NormalizeImportPayload([]byte(" ISE= ")) assert.NoError(t, err) assert.Equal(t, "ISE=", s) // Base64-of-Base64 case (unwraps) // base64("test") = "dGVzdA==" // test also looks like base64 (4 chars, alphanumeric) - s, err = normalizeImportPayload([]byte(" dGVzdA== ")) + s, err = server.NormalizeImportPayload([]byte(" dGVzdA== ")) assert.NoError(t, err) assert.Equal(t, "test", s) // Plain case (not base64) - s, err = normalizeImportPayload([]byte("!!")) + s, err = server.NormalizeImportPayload([]byte("!!")) assert.NoError(t, err) // Should be base64 encoded assert.Equal(t, base64.StdEncoding.EncodeToString([]byte("!!")), s) diff --git a/cmd/pulse/config.go b/cmd/pulse/config.go index abed2897e..92ded588c 100644 --- a/cmd/pulse/config.go +++ b/cmd/pulse/config.go @@ -12,6 +12,7 @@ import ( "time" "github.com/rcourtman/pulse-go-rewrite/internal/config" + "github.com/rcourtman/pulse-go-rewrite/pkg/server" "github.com/spf13/cobra" "golang.org/x/term" ) @@ -253,13 +254,13 @@ var configAutoImportCmd = &cobra.Command{ return fmt.Errorf("configuration response from URL was empty") } - payload, err := normalizeImportPayload(body) + payload, err := server.NormalizeImportPayload(body) if err != nil { return err } encryptedData = payload } else if configData != "" { - payload, err := normalizeImportPayload([]byte(configData)) + payload, err := server.NormalizeImportPayload([]byte(configData)) if err != nil { return err } diff --git a/cmd/pulse/import_payload.go b/cmd/pulse/import_payload.go deleted file mode 100644 index 8bb35abe3..000000000 --- a/cmd/pulse/import_payload.go +++ /dev/null @@ -1,56 +0,0 @@ -package main - -import ( - "encoding/base64" - "fmt" - "strings" -) - -func normalizeImportPayload(raw []byte) (string, error) { - trimmed := strings.TrimSpace(string(raw)) - if trimmed == "" { - return "", fmt.Errorf("configuration payload is empty") - } - - // 1) If it's base64, keep as-is (this is what ConfigPersistence.ImportConfig expects). - // 2) If it's base64-of-base64 (common when passing through systems that base64-encode values), - // unwrap one layer. - if decoded, err := base64.StdEncoding.DecodeString(trimmed); err == nil { - decodedTrimmed := strings.TrimSpace(string(decoded)) - if looksLikeBase64(decodedTrimmed) { - return decodedTrimmed, nil - } - return trimmed, nil - } - - // Otherwise treat it as raw encrypted bytes and base64-encode it. - return base64.StdEncoding.EncodeToString(raw), nil -} - -func looksLikeBase64(s string) bool { - if s == "" { - return false - } - // Allow whitespace that often appears in wrapped output. - compact := strings.Map(func(r rune) rune { - switch r { - case '\n', '\r', '\t', ' ': - return -1 - default: - return r - } - }, s) - - if compact == "" || len(compact)%4 != 0 { - return false - } - for i := 0; i < len(compact); i++ { - c := compact[i] - isAlphaNum := (c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') - if isAlphaNum || c == '+' || c == '/' || c == '=' { - continue - } - return false - } - return true -} diff --git a/cmd/pulse/import_payload_test.go b/cmd/pulse/import_payload_test.go index 0715e7458..8814cc4ec 100644 --- a/cmd/pulse/import_payload_test.go +++ b/cmd/pulse/import_payload_test.go @@ -3,11 +3,13 @@ package main import ( "encoding/base64" "testing" + + "github.com/rcourtman/pulse-go-rewrite/pkg/server" ) func TestNormalizeImportPayload_Base64Passthrough(t *testing.T) { raw := []byte(" Zm9vYmFy \n") // base64("foobar") - out, err := normalizeImportPayload(raw) + out, err := server.NormalizeImportPayload(raw) if err != nil { t.Fatalf("err = %v", err) } @@ -19,7 +21,7 @@ func TestNormalizeImportPayload_Base64Passthrough(t *testing.T) { func TestNormalizeImportPayload_Base64OfBase64Unwrap(t *testing.T) { inner := "Zm9vYmFy" // base64("foobar") outer := base64.StdEncoding.EncodeToString([]byte(inner)) - out, err := normalizeImportPayload([]byte(outer)) + out, err := server.NormalizeImportPayload([]byte(outer)) if err != nil { t.Fatalf("err = %v", err) } @@ -30,7 +32,7 @@ func TestNormalizeImportPayload_Base64OfBase64Unwrap(t *testing.T) { func TestNormalizeImportPayload_RawBytesGetEncoded(t *testing.T) { raw := []byte{0x00, 0x01, 0x02, 0xff, 0x10} - out, err := normalizeImportPayload(raw) + out, err := server.NormalizeImportPayload(raw) if err != nil { t.Fatalf("err = %v", err) } @@ -44,13 +46,13 @@ func TestNormalizeImportPayload_RawBytesGetEncoded(t *testing.T) { } func TestLooksLikeBase64(t *testing.T) { - if looksLikeBase64("") { + if server.LooksLikeBase64("") { t.Fatalf("empty should be false") } - if !looksLikeBase64("Zm9vYmFy") { + if !server.LooksLikeBase64("Zm9vYmFy") { t.Fatalf("expected base64 true") } - if looksLikeBase64("not-base64!!!") { + if server.LooksLikeBase64("not-base64!!!") { t.Fatalf("expected base64 false") } } diff --git a/cmd/pulse/main.go b/cmd/pulse/main.go index d9f5c021e..c0b727e27 100644 --- a/cmd/pulse/main.go +++ b/cmd/pulse/main.go @@ -3,37 +3,19 @@ package main import ( "context" "fmt" - "net/http" - "os" - "os/signal" - "path/filepath" - "strings" - "syscall" - "time" - "github.com/rcourtman/pulse-go-rewrite/internal/agentbinaries" - "github.com/rcourtman/pulse-go-rewrite/internal/alerts" - "github.com/rcourtman/pulse-go-rewrite/internal/api" - "github.com/rcourtman/pulse-go-rewrite/internal/config" - "github.com/rcourtman/pulse-go-rewrite/internal/license" - "github.com/rcourtman/pulse-go-rewrite/internal/logging" - "github.com/rcourtman/pulse-go-rewrite/internal/metrics" - _ "github.com/rcourtman/pulse-go-rewrite/internal/mock" // Import for init() to run - "github.com/rcourtman/pulse-go-rewrite/internal/monitoring" - "github.com/rcourtman/pulse-go-rewrite/internal/websocket" - "github.com/rs/zerolog/log" + "github.com/rcourtman/pulse-go-rewrite/pkg/server" "github.com/spf13/cobra" ) // Version information (set at build time with -ldflags) var ( - Version = "dev" - BuildTime = "unknown" - GitCommit = "unknown" + Version = "dev" + BuildTime = "unknown" + GitCommit = "unknown" + metricsPort = 9091 ) -var metricsPort = 9091 - var rootCmd = &cobra.Command{ Use: "pulse", Short: "Pulse - Proxmox VE and PBS monitoring system", @@ -44,6 +26,11 @@ var rootCmd = &cobra.Command{ }, } +func runServer(ctx context.Context) error { + server.MetricsPort = metricsPort + return server.Run(ctx, Version) +} + func init() { // Add config command rootCmd.AddCommand(configCmd) @@ -69,330 +56,7 @@ var versionCmd = &cobra.Command{ func main() { if err := rootCmd.Execute(); err != nil { - // handle exit code in rootCmd or RunE + // osExit is defined in bootstrap.go osExit(1) } } - -func runServer(ctx context.Context) error { - // Initialize logger with baseline defaults for early startup logs - logging.Init(logging.Config{ - Format: "auto", - Level: "info", - Component: "pulse", - }) - - // Check for auto-import on first startup - if shouldAutoImport() { - if err := performAutoImport(); err != nil { - log.Error().Err(err).Msg("Auto-import failed, continuing with normal startup") - } - } - - // Load unified configuration - cfg, err := config.Load() - if err != nil { - return fmt.Errorf("failed to load configuration: %w", err) - } - - // Re-initialize logging with configuration-driven settings - logging.Init(logging.Config{ - Format: cfg.LogFormat, - Level: cfg.LogLevel, - Component: "pulse", - FilePath: cfg.LogFile, - MaxSizeMB: cfg.LogMaxSize, - MaxAgeDays: cfg.LogMaxAge, - Compress: cfg.LogCompress, - }) - - // Initialize license public key for Pro feature validation - license.InitPublicKey() - - log.Info().Msg("Starting Pulse monitoring server") - - // Validate agent binaries are available for download - agentbinaries.EnsureHostAgentBinaries(Version) - - // Create derived context that cancels on interrupt - ctx, cancel := context.WithCancel(ctx) - defer cancel() - - metricsAddr := fmt.Sprintf("%s:%d", cfg.BackendHost, metricsPort) - startMetricsServer(ctx, metricsAddr) - - // Initialize WebSocket hub first - wsHub := websocket.NewHub(nil) - // Set allowed origins from configuration - if cfg.AllowedOrigins != "" { - if cfg.AllowedOrigins == "*" { - // Explicit wildcard - allow all origins (less secure) - wsHub.SetAllowedOrigins([]string{"*"}) - } else { - // Use configured origins - wsHub.SetAllowedOrigins(strings.Split(cfg.AllowedOrigins, ",")) - } - } else { - // Default: don't set any specific origins - // This allows the WebSocket hub to use its lenient check for local/private networks - // The hub will automatically allow connections from common local/Docker scenarios - // while still being secure for public deployments - wsHub.SetAllowedOrigins([]string{}) - } - go wsHub.Run() - - // Initialize reloadable monitoring system - reloadableMonitor, err := monitoring.NewReloadableMonitor(cfg, wsHub) - if err != nil { - return fmt.Errorf("failed to initialize monitoring system: %w", err) - } - - // Set state getter for WebSocket hub - // IMPORTANT: Return StateFrontend (not StateSnapshot) to match broadcast format. - // StateSnapshot uses time.Time fields while StateFrontend uses Unix timestamps, - // and includes frontend-specific field transformations. Without this conversion, - // nodes/hosts would be missing on initial page load but appear after broadcasts. - wsHub.SetStateGetter(func() interface{} { - // GetMonitor().GetState() returns models.StateSnapshot - state := reloadableMonitor.GetMonitor().GetState() - // Convert to frontend format, matching what BroadcastState does - return state.ToFrontend() - }) - - // Wire up Prometheus metrics for alert lifecycle - alerts.SetMetricHooks( - metrics.RecordAlertFired, - metrics.RecordAlertResolved, - metrics.RecordAlertSuppressed, - metrics.RecordAlertAcknowledged, - ) - log.Info().Msg("Alert metrics hooks registered") - - // Start monitoring - reloadableMonitor.Start(ctx) - - // Initialize API server with reload function - var router *api.Router - reloadFunc := func() error { - if err := reloadableMonitor.Reload(); err != nil { - return err - } - if router != nil { - router.SetMonitor(reloadableMonitor.GetMonitor()) - if cfg := reloadableMonitor.GetConfig(); cfg != nil { - router.SetConfig(cfg) - } - } - return nil - } - router = api.NewRouter(cfg, reloadableMonitor.GetMonitor(), wsHub, reloadFunc, Version) - - // Inject resource store into monitor for WebSocket broadcasts - // This must be done after router creation since resourceHandlers is created in NewRouter - router.SetMonitor(reloadableMonitor.GetMonitor()) - - // Start AI patrol service for background infrastructure monitoring - router.StartPatrol(ctx) - - // Wire alert-triggered AI analysis (token-efficient real-time insights when alerts fire) - router.WireAlertTriggeredAI() - - // Create HTTP server with unified configuration - // In production, serve everything (frontend + API) on the frontend port - // NOTE: We use ReadHeaderTimeout instead of ReadTimeout to avoid affecting - // WebSocket connections. ReadTimeout sets a deadline on the underlying connection - // that persists even after WebSocket upgrade, causing premature disconnections. - // ReadHeaderTimeout only applies during header reading, not the full request body. - srv := &http.Server{ - Addr: fmt.Sprintf("%s:%d", cfg.BackendHost, cfg.FrontendPort), - Handler: router.Handler(), - ReadHeaderTimeout: 15 * time.Second, - WriteTimeout: 0, // Disabled to support SSE/streaming - each handler manages its own deadline - IdleTimeout: 120 * time.Second, - } - - // Start config watcher for .env file changes - configWatcher, err := config.NewConfigWatcher(cfg) - if err != nil { - log.Warn().Err(err).Msg("Failed to create config watcher, .env changes will require restart") - } else { - // Set callback to reload monitor when mock.env changes - configWatcher.SetMockReloadCallback(func() { - log.Info().Msg("mock.env changed, reloading monitor") - if err := reloadableMonitor.Reload(); err != nil { - log.Error().Err(err).Msg("Failed to reload monitor after mock.env change") - } else if router != nil { - router.SetMonitor(reloadableMonitor.GetMonitor()) - if cfg := reloadableMonitor.GetConfig(); cfg != nil { - router.SetConfig(cfg) - } - } - }) - - // Set callback to rebuild token bindings when API tokens are reloaded from disk. - // This fixes issue #773 where agent token bindings become orphaned after config reload. - configWatcher.SetAPITokenReloadCallback(func() { - if monitor := reloadableMonitor.GetMonitor(); monitor != nil { - monitor.RebuildTokenBindings() - } - }) - - if err := configWatcher.Start(); err != nil { - log.Warn().Err(err).Msg("Failed to start config watcher") - } - defer configWatcher.Stop() - } - - // Start server - go func() { - if cfg.HTTPSEnabled && cfg.TLSCertFile != "" && cfg.TLSKeyFile != "" { - log.Info(). - Str("host", cfg.BackendHost). - Int("port", cfg.FrontendPort). - Str("protocol", "HTTPS"). - Msg("Server listening") - if err := srv.ListenAndServeTLS(cfg.TLSCertFile, cfg.TLSKeyFile); err != nil && err != http.ErrServerClosed { - log.Error().Err(err).Msg("Failed to start HTTPS server") - } - } else { - if cfg.HTTPSEnabled { - log.Warn().Msg("HTTPS_ENABLED is true but TLS_CERT_FILE or TLS_KEY_FILE not configured, falling back to HTTP") - } - log.Info(). - Str("host", cfg.BackendHost). - Int("port", cfg.FrontendPort). - Str("protocol", "HTTP"). - Msg("Server listening") - if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed { - log.Error().Err(err).Msg("Failed to start HTTP server") - } - } - }() - - // Setup signal handlers - sigChan := make(chan os.Signal, 1) - reloadChan := make(chan os.Signal, 1) - - // SIGTERM and SIGINT for shutdown - signal.Notify(sigChan, os.Interrupt, syscall.SIGTERM) - // SIGHUP for config reload - signal.Notify(reloadChan, syscall.SIGHUP) - - // Handle signals - for { - select { - case <-ctx.Done(): - log.Info().Msg("Context cancelled, shutting down...") - goto shutdown - - case <-reloadChan: - log.Info().Msg("Received SIGHUP, reloading configuration...") - - // Reload .env manually (watcher will also pick it up) - if configWatcher != nil { - configWatcher.ReloadConfig() - } - - if err := reloadFunc(); err != nil { - log.Error().Err(err).Msg("Failed to reload monitor after SIGHUP") - } else { - log.Info().Msg("Runtime configuration reloaded") - } - - case <-sigChan: - log.Info().Msg("Shutting down server...") - goto shutdown - } - } - -shutdown: - - // Graceful shutdown - shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 30*time.Second) - defer shutdownCancel() - - if err := srv.Shutdown(shutdownCtx); err != nil { - log.Error().Err(err).Msg("Server shutdown error") - } - - // Stop monitoring - cancel() - reloadableMonitor.Stop() - - // Stop config watcher - if configWatcher != nil { - configWatcher.Stop() - } - - log.Info().Msg("Server stopped") - return nil -} - -// shouldAutoImport checks if auto-import environment variables are set -func shouldAutoImport() bool { - // Check if config already exists - configPath := os.Getenv("PULSE_DATA_DIR") - if configPath == "" { - configPath = "/etc/pulse" - } - - // If nodes.enc already exists, skip auto-import - if _, err := os.Stat(filepath.Join(configPath, "nodes.enc")); err == nil { - return false - } - - // Check for auto-import environment variables - return os.Getenv("PULSE_INIT_CONFIG_DATA") != "" || - os.Getenv("PULSE_INIT_CONFIG_FILE") != "" -} - -// performAutoImport imports configuration from environment variables -func performAutoImport() error { - configData := os.Getenv("PULSE_INIT_CONFIG_DATA") - configFile := os.Getenv("PULSE_INIT_CONFIG_FILE") - configPass := os.Getenv("PULSE_INIT_CONFIG_PASSPHRASE") - - if configPass == "" { - return fmt.Errorf("PULSE_INIT_CONFIG_PASSPHRASE is required for auto-import") - } - - var encryptedData string - - // Get data from file or direct data - if configFile != "" { - data, err := os.ReadFile(configFile) - if err != nil { - return fmt.Errorf("failed to read config file: %w", err) - } - payload, err := normalizeImportPayload(data) - if err != nil { - return err - } - encryptedData = payload - } else if configData != "" { - payload, err := normalizeImportPayload([]byte(configData)) - if err != nil { - return err - } - encryptedData = payload - } else { - return fmt.Errorf("no config data provided") - } - - // Load configuration path - configPath := os.Getenv("PULSE_DATA_DIR") - if configPath == "" { - configPath = "/etc/pulse" - } - - // Create persistence manager - persistence := config.NewConfigPersistence(configPath) - - // Import configuration - if err := persistence.ImportConfig(encryptedData, configPass); err != nil { - return fmt.Errorf("failed to import configuration: %w", err) - } - - log.Info().Msg("Configuration auto-imported successfully") - return nil -} diff --git a/docs/API.md b/docs/API.md index b2133dad0..5b2fc7481 100644 --- a/docs/API.md +++ b/docs/API.md @@ -201,7 +201,7 @@ Returns a new raw token (shown once) and updates stored hashes: --- -## 🧾 Audit Log (Enterprise) +## 🧾 Audit Log (Pro) These endpoints require admin access and the `settings:read` scope. In OSS builds, the list endpoint returns an empty set and `persistentLogging: false`. diff --git a/docs/PULSE_PRO.md b/docs/PULSE_PRO.md index 3a6fb86e6..6d6c3737b 100644 --- a/docs/PULSE_PRO.md +++ b/docs/PULSE_PRO.md @@ -4,7 +4,7 @@ Pulse Pro unlocks advanced AI automation features on top of the free Pulse platf ## What You Get -### Enterprise Audit Log +### Audit Log - Persistent audit trail with SQLite storage and HMAC signing. - Queryable via `/api/audit` and verified per event in the Security → Audit Log UI. - Supports filtering, verification badges, and signature checks for tamper detection. diff --git a/frontend-modern/src/components/Kubernetes/KubernetesClusters.tsx b/frontend-modern/src/components/Kubernetes/KubernetesClusters.tsx index 8b608a92e..2841a61a0 100644 --- a/frontend-modern/src/components/Kubernetes/KubernetesClusters.tsx +++ b/frontend-modern/src/components/Kubernetes/KubernetesClusters.tsx @@ -561,7 +561,7 @@ export const KubernetesClusters: Component = (props) =>
Kubernetes AI Analysis - Enterprise + {/* Badge removed - feature soft-locked instead */}
diff --git a/frontend-modern/src/components/Settings/AISettings.tsx b/frontend-modern/src/components/Settings/AISettings.tsx index c4180b69a..0b861055c 100644 --- a/frontend-modern/src/components/Settings/AISettings.tsx +++ b/frontend-modern/src/components/Settings/AISettings.tsx @@ -1174,11 +1174,6 @@ export const AISettings: Component = () => { ENABLED - - - Enterprise - -

{form.autonomousMode @@ -1189,17 +1184,16 @@ export const AISettings: Component = () => { ⚠️ Legal Disclaimer: AI models can hallucinate. You are responsible for any damage caused by autonomous actions. See Terms of Service.

-

- - Pulse Enterprise required for autonomous mode. +

- Upgrade - + Upgrade to Pro + {' '} + to enable autonomous mode.

@@ -1271,11 +1265,6 @@ export const AISettings: Component = () => { { /> -

- Pulse Enterprise required for alert-triggered analysis.{' '} +

- Upgrade - + Upgrade to Pro + {' '} + to enable alert-triggered analysis.

@@ -1303,11 +1292,6 @@ export const AISettings: Component = () => { { -

- - Pulse Enterprise required for auto-fix. +

- Upgrade - + Upgrade to Pro + {' '} + to enable auto-fix.

diff --git a/frontend-modern/src/components/Settings/AuditLogPanel.tsx b/frontend-modern/src/components/Settings/AuditLogPanel.tsx index ab31389e6..681f09ed6 100644 --- a/frontend-modern/src/components/Settings/AuditLogPanel.tsx +++ b/frontend-modern/src/components/Settings/AuditLogPanel.tsx @@ -1,5 +1,12 @@ import { createSignal, Show, For, onMount, createMemo, onCleanup, createEffect } from 'solid-js'; -import { Shield, CheckCircle, XCircle, RefreshCw, Filter, Info, Play, X } from 'lucide-solid'; +import Shield from 'lucide-solid/icons/shield'; +import CheckCircle from 'lucide-solid/icons/check-circle'; +import XCircle from 'lucide-solid/icons/x-circle'; +import RefreshCw from 'lucide-solid/icons/refresh-cw'; +import Filter from 'lucide-solid/icons/filter'; +import Info from 'lucide-solid/icons/info'; +import Play from 'lucide-solid/icons/play'; +import X from 'lucide-solid/icons/x'; import { showTooltip, hideTooltip } from '@/components/shared/Tooltip'; import Toggle from '@/components/shared/Toggle'; import { @@ -441,16 +448,6 @@ export default function AuditLogPanel() {

Audit Log

- - - Enterprise - - - - - Enterprise - -
- {/* OSS Notice / Upgrade CTA */} + {/* Upgrade CTA */} -
-
-
-
- -
-
-

- Unlock Enterprise Audit Logging - PRO Feature -

-

- Upgrade to Pulse Enterprise for persistent, searchable audit logs and cryptographically signed event verification. - Ensure high-level compliance, security, and accountability for your mission-critical infrastructure. -

-
-
- - Get Enterprise - - -

- Pricing starts at $1.50/node +

+
+
+

Audit Logging

+

+ Persistent, searchable audit logs with cryptographic signature verification.

+ + Upgrade to Pro +
diff --git a/frontend-modern/src/components/Settings/OIDCPanel.tsx b/frontend-modern/src/components/Settings/OIDCPanel.tsx index 7f6664561..3c9c3d859 100644 --- a/frontend-modern/src/components/Settings/OIDCPanel.tsx +++ b/frontend-modern/src/components/Settings/OIDCPanel.tsx @@ -223,16 +223,6 @@ export const OIDCPanel: Component = (props) => { size="sm" class="flex-1" /> - - - Enterprise - - - - - Enterprise - - { @@ -275,31 +265,21 @@ export const OIDCPanel: Component = (props) => {
-
-
-
-
- -
+
+
-

- Enterprise Feature - SSO -

-

- OIDC integration is an Enterprise-only feature. Upgrade your license to enable seamless Single Sign-On for your team. +

Single Sign-On

+

+ Connect Pulse to your identity provider for seamless team authentication.

-
+ + Upgrade to Pro +
diff --git a/frontend-modern/src/components/Settings/Settings.tsx b/frontend-modern/src/components/Settings/Settings.tsx index ed7428d8e..b68757cd5 100644 --- a/frontend-modern/src/components/Settings/Settings.tsx +++ b/frontend-modern/src/components/Settings/Settings.tsx @@ -1123,14 +1123,12 @@ const Settings: Component = (props) => { label: 'Single Sign-On', icon: Key, iconProps: { strokeWidth: 2 }, - badge: 'Enterprise', }, { id: 'security-audit', label: 'Audit Log', icon: Activity, iconProps: { strokeWidth: 2 }, - badge: 'Enterprise', }, ], }, diff --git a/go.mod b/go.mod index 72de9bb7a..60496fbe2 100644 --- a/go.mod +++ b/go.mod @@ -38,15 +38,12 @@ require ( require ( github.com/Microsoft/go-winio v0.4.21 // indirect github.com/beorn7/perks v1.0.1 // indirect - github.com/buger/goterm v1.0.4 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/containerd/errdefs v1.0.0 // indirect github.com/containerd/errdefs/pkg v0.3.0 // indirect github.com/containerd/log v0.1.0 // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect - github.com/diskfs/go-diskfs v1.5.0 // indirect github.com/distribution/reference v0.6.0 // indirect - github.com/djherbis/times v1.6.0 // indirect github.com/docker/go-connections v0.6.0 // indirect github.com/docker/go-units v0.5.0 // indirect github.com/dustin/go-humanize v1.0.1 // indirect @@ -68,14 +65,11 @@ require ( github.com/google/gofuzz v1.2.0 // indirect github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.3 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect - github.com/jinzhu/copier v0.3.4 // indirect github.com/josharian/intern v1.0.0 // indirect github.com/json-iterator/go v1.1.12 // indirect github.com/klauspost/compress v1.18.2 // indirect github.com/kylelemons/godebug v1.1.0 // indirect github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 // indirect - github.com/luthermonson/go-proxmox v0.3.1 // indirect - github.com/magefile/mage v1.14.0 // indirect github.com/mailru/easyjson v0.7.7 // indirect github.com/mattn/go-colorable v0.1.14 // indirect github.com/mattn/go-isatty v0.0.20 // indirect @@ -94,6 +88,7 @@ require ( github.com/prometheus/common v0.67.4 // indirect github.com/prometheus/procfs v0.19.2 // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect + github.com/sirupsen/logrus v1.9.4-0.20230606125235-dd1b4c2e81af // indirect github.com/spf13/pflag v1.0.10 // indirect github.com/tklauser/go-sysconf v0.3.16 // indirect github.com/tklauser/numcpus v0.11.0 // indirect diff --git a/go.sum b/go.sum index c65695248..0a897d6e9 100644 --- a/go.sum +++ b/go.sum @@ -6,8 +6,6 @@ github.com/Microsoft/go-winio v0.4.21 h1:+6mVbXh4wPzUrl1COX9A+ZCvEpYsOBZ6/+kwDnv github.com/Microsoft/go-winio v0.4.21/go.mod h1:JPGBdM1cNvN/6ISo+n8V5iA4v8pBzdOpzfwIujj1a84= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= -github.com/buger/goterm v1.0.4 h1:Z9YvGmOih81P0FbVtEYTFF6YsSgxSUKEhf/f9bTMXbY= -github.com/buger/goterm v1.0.4/go.mod h1:HiFWV3xnkolgrBV3mY8m0X0Pumt4zg4QhbdOzQtB8tE= github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM= github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= @@ -27,12 +25,8 @@ github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSs github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/diskfs/go-diskfs v1.5.0 h1:0SANkrab4ifiZBytk380gIesYh5Gc+3i40l7qsrYP4s= -github.com/diskfs/go-diskfs v1.5.0/go.mod h1:bRFumZeGFCO8C2KNswrQeuj2m1WCVr4Ms5IjWMczMDk= github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk= github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E= -github.com/djherbis/times v1.6.0 h1:w2ctJ92J8fBvWPxugmXIv7Nz7Q3iDMKNx9v5ocVH20c= -github.com/djherbis/times v1.6.0/go.mod h1:gOHeRAz2h+VJNZ5Gmc/o7iD9k4wW7NMVqieYCY99oc0= github.com/docker/docker v28.5.2+incompatible h1:DBX0Y0zAjZbSrm1uzOkdr1onVghKaftjlSWt4AFexzM= github.com/docker/docker v28.5.2+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk= github.com/docker/go-connections v0.6.0 h1:LlMG9azAe1TqfR7sO+NJttz1gy6KO7VJBh+pMmjSD94= @@ -94,8 +88,6 @@ github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.3 h1:NmZ1PKzSTQbuGHw9DGPFomqkkLW github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.3/go.mod h1:zQrxl1YP88HQlA6i9c63DSVPFklWpGX4OWAc9bFuaH4= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= -github.com/jinzhu/copier v0.3.4 h1:mfU6jI9PtCeUjkjQ322dlff9ELjGDu975C2p/nrubVI= -github.com/jinzhu/copier v0.3.4/go.mod h1:DfbEm0FYsaqBcKcFuvmOZb218JkPGtvSHsKg8S8hyyg= github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= @@ -117,10 +109,6 @@ github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0 github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 h1:6E+4a0GO5zZEnZ81pIr0yLvtUWk2if982qA3F3QD6H4= github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0/go.mod h1:zJYVVT2jmtg6P3p1VtQj7WsuWi/y4VnjVBn7F8KPB3I= -github.com/luthermonson/go-proxmox v0.3.1 h1:h64s4/zIEQ06TBo0phFKcckV441YpvUPgLfRAptYsjY= -github.com/luthermonson/go-proxmox v0.3.1/go.mod h1:oyFgg2WwTEIF0rP6ppjiixOHa5ebK1p8OaRiFhvICBQ= -github.com/magefile/mage v1.14.0 h1:6QDX3g6z1YvJ4olPhT1wksUcSa/V0a1B+pJb73fBjyo= -github.com/magefile/mage v1.14.0/go.mod h1:z5UZb/iS3GoOSn0JgWuiw7dxlurVYTu+/jHXqQg881A= github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0= github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= @@ -190,8 +178,8 @@ github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQD github.com/shirou/gopsutil/v4 v4.25.11 h1:X53gB7muL9Gnwwo2evPSE+SfOrltMoR6V3xJAXZILTY= github.com/shirou/gopsutil/v4 v4.25.11/go.mod h1:EivAfP5x2EhLp2ovdpKSozecVXn1TmuG7SMzs/Wh4PU= github.com/sirupsen/logrus v1.7.0/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0= -github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= -github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= +github.com/sirupsen/logrus v1.9.4-0.20230606125235-dd1b4c2e81af h1:Sp5TG9f7K39yfB+If0vjp97vuT74F72r8hfRpP8jLU0= +github.com/sirupsen/logrus v1.9.4-0.20230606125235-dd1b4c2e81af/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= github.com/spf13/cobra v1.10.1 h1:lJeBwCfmrnXthfAupyUTzJ/J4Nc1RsHC/mSRU2dll/s= github.com/spf13/cobra v1.10.1/go.mod h1:7SmJGaTHFVBY0jW4NXGluQoLvhqFQM+6XSKD+P4XaB0= github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= @@ -202,6 +190,7 @@ github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSS github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= @@ -272,8 +261,7 @@ golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20210331175145-43e1dd70ce54/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20220615213510-4f61da869c0c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= diff --git a/internal/api/auth.go b/internal/api/auth.go index 821554005..1b495247b 100644 --- a/internal/api/auth.go +++ b/internal/api/auth.go @@ -13,8 +13,8 @@ import ( "sync" "time" - internalauth "github.com/rcourtman/pulse-go-rewrite/internal/auth" "github.com/rcourtman/pulse-go-rewrite/internal/config" + internalauth "github.com/rcourtman/pulse-go-rewrite/pkg/auth" "github.com/rs/zerolog/log" ) diff --git a/internal/api/bootstrap_token.go b/internal/api/bootstrap_token.go index 0ce6cfbb3..f6182feb0 100644 --- a/internal/api/bootstrap_token.go +++ b/internal/api/bootstrap_token.go @@ -11,7 +11,7 @@ import ( "path/filepath" "strings" - internalauth "github.com/rcourtman/pulse-go-rewrite/internal/auth" + internalauth "github.com/rcourtman/pulse-go-rewrite/pkg/auth" "github.com/rs/zerolog/log" ) diff --git a/internal/api/config_handlers.go b/internal/api/config_handlers.go index 5136afb81..54faf550b 100644 --- a/internal/api/config_handlers.go +++ b/internal/api/config_handlers.go @@ -22,7 +22,6 @@ import ( "golang.org/x/crypto/ssh" - internalauth "github.com/rcourtman/pulse-go-rewrite/internal/auth" "github.com/rcourtman/pulse-go-rewrite/internal/config" discoveryinternal "github.com/rcourtman/pulse-go-rewrite/internal/discovery" "github.com/rcourtman/pulse-go-rewrite/internal/mock" @@ -31,6 +30,7 @@ import ( "github.com/rcourtman/pulse-go-rewrite/internal/system" "github.com/rcourtman/pulse-go-rewrite/internal/tempproxy" "github.com/rcourtman/pulse-go-rewrite/internal/websocket" + internalauth "github.com/rcourtman/pulse-go-rewrite/pkg/auth" pkgdiscovery "github.com/rcourtman/pulse-go-rewrite/pkg/discovery" "github.com/rcourtman/pulse-go-rewrite/pkg/pbs" "github.com/rcourtman/pulse-go-rewrite/pkg/pmg" diff --git a/internal/api/config_handlers_auto_register_test.go b/internal/api/config_handlers_auto_register_test.go index 7b4d777f1..6f789322d 100644 --- a/internal/api/config_handlers_auto_register_test.go +++ b/internal/api/config_handlers_auto_register_test.go @@ -8,8 +8,8 @@ import ( "testing" "time" - internalauth "github.com/rcourtman/pulse-go-rewrite/internal/auth" "github.com/rcourtman/pulse-go-rewrite/internal/config" + internalauth "github.com/rcourtman/pulse-go-rewrite/pkg/auth" "github.com/rcourtman/pulse-go-rewrite/pkg/proxmox" ) diff --git a/internal/api/config_handlers_pve_user_test.go b/internal/api/config_handlers_pve_user_test.go index c1212d5c9..34e522265 100644 --- a/internal/api/config_handlers_pve_user_test.go +++ b/internal/api/config_handlers_pve_user_test.go @@ -4,8 +4,8 @@ import ( "testing" "time" - "github.com/rcourtman/pulse-go-rewrite/internal/auth" "github.com/rcourtman/pulse-go-rewrite/internal/config" + "github.com/rcourtman/pulse-go-rewrite/pkg/auth" ) func TestNormalizePVEUser(t *testing.T) { diff --git a/internal/api/rbac_test.go b/internal/api/rbac_test.go index b26a310ab..5dece51a3 100644 --- a/internal/api/rbac_test.go +++ b/internal/api/rbac_test.go @@ -7,8 +7,8 @@ import ( "strings" "testing" - "github.com/rcourtman/pulse-go-rewrite/internal/auth" "github.com/rcourtman/pulse-go-rewrite/internal/config" + "github.com/rcourtman/pulse-go-rewrite/pkg/auth" ) type mockAuthorizer struct { diff --git a/internal/api/router.go b/internal/api/router.go index cba49d7fc..f9bf9891b 100644 --- a/internal/api/router.go +++ b/internal/api/router.go @@ -29,7 +29,6 @@ import ( "github.com/rcourtman/pulse-go-rewrite/internal/agentexec" "github.com/rcourtman/pulse-go-rewrite/internal/ai" "github.com/rcourtman/pulse-go-rewrite/internal/alerts" - internalauth "github.com/rcourtman/pulse-go-rewrite/internal/auth" "github.com/rcourtman/pulse-go-rewrite/internal/config" "github.com/rcourtman/pulse-go-rewrite/internal/license" "github.com/rcourtman/pulse-go-rewrite/internal/models" @@ -39,6 +38,7 @@ import ( "github.com/rcourtman/pulse-go-rewrite/internal/updates" "github.com/rcourtman/pulse-go-rewrite/internal/utils" "github.com/rcourtman/pulse-go-rewrite/internal/websocket" + internalauth "github.com/rcourtman/pulse-go-rewrite/pkg/auth" "github.com/rs/zerolog/log" ) diff --git a/internal/api/router_integration_test.go b/internal/api/router_integration_test.go index 17eac101e..d53a45a60 100644 --- a/internal/api/router_integration_test.go +++ b/internal/api/router_integration_test.go @@ -16,13 +16,13 @@ import ( gorillaws "github.com/gorilla/websocket" "github.com/rcourtman/pulse-go-rewrite/internal/api" - internalauth "github.com/rcourtman/pulse-go-rewrite/internal/auth" "github.com/rcourtman/pulse-go-rewrite/internal/config" "github.com/rcourtman/pulse-go-rewrite/internal/mock" "github.com/rcourtman/pulse-go-rewrite/internal/models" "github.com/rcourtman/pulse-go-rewrite/internal/monitoring" "github.com/rcourtman/pulse-go-rewrite/internal/updates" internalws "github.com/rcourtman/pulse-go-rewrite/internal/websocket" + internalauth "github.com/rcourtman/pulse-go-rewrite/pkg/auth" ) type integrationServer struct { diff --git a/internal/api/router_test.go b/internal/api/router_test.go index b5adf64c8..fbf30ef52 100644 --- a/internal/api/router_test.go +++ b/internal/api/router_test.go @@ -7,8 +7,8 @@ import ( "net/http/httptest" "testing" - "github.com/rcourtman/pulse-go-rewrite/internal/auth" "github.com/rcourtman/pulse-go-rewrite/internal/config" + "github.com/rcourtman/pulse-go-rewrite/pkg/auth" ) func TestIsDirectLoopbackRequest(t *testing.T) { diff --git a/internal/api/security_setup_fix.go b/internal/api/security_setup_fix.go index 5649f8b70..9f1d48e0b 100644 --- a/internal/api/security_setup_fix.go +++ b/internal/api/security_setup_fix.go @@ -10,9 +10,9 @@ import ( "strings" "time" - internalauth "github.com/rcourtman/pulse-go-rewrite/internal/auth" "github.com/rcourtman/pulse-go-rewrite/internal/config" "github.com/rcourtman/pulse-go-rewrite/internal/updates" + internalauth "github.com/rcourtman/pulse-go-rewrite/pkg/auth" "github.com/rs/zerolog/log" ) diff --git a/internal/api/security_setup_fix_test.go b/internal/api/security_setup_fix_test.go index 17203bb6b..11af821be 100644 --- a/internal/api/security_setup_fix_test.go +++ b/internal/api/security_setup_fix_test.go @@ -11,8 +11,8 @@ import ( "testing" "time" - internalauth "github.com/rcourtman/pulse-go-rewrite/internal/auth" "github.com/rcourtman/pulse-go-rewrite/internal/config" + internalauth "github.com/rcourtman/pulse-go-rewrite/pkg/auth" ) func resetRecoveryStore() { diff --git a/internal/api/security_test.go b/internal/api/security_test.go index 6134c4287..4650b3249 100644 --- a/internal/api/security_test.go +++ b/internal/api/security_test.go @@ -8,8 +8,8 @@ import ( "testing" "time" - "github.com/rcourtman/pulse-go-rewrite/internal/auth" "github.com/rcourtman/pulse-go-rewrite/internal/config" + "github.com/rcourtman/pulse-go-rewrite/pkg/auth" ) // fixedTimeForTest returns a fixed time for deterministic testing diff --git a/internal/api/security_tokens.go b/internal/api/security_tokens.go index 55a5c48bb..69caa0178 100644 --- a/internal/api/security_tokens.go +++ b/internal/api/security_tokens.go @@ -9,8 +9,8 @@ import ( "strings" "time" - internalauth "github.com/rcourtman/pulse-go-rewrite/internal/auth" "github.com/rcourtman/pulse-go-rewrite/internal/config" + internalauth "github.com/rcourtman/pulse-go-rewrite/pkg/auth" "github.com/rs/zerolog/log" ) diff --git a/internal/api/system_settings_handlers_test.go b/internal/api/system_settings_handlers_test.go index 5e73c48a3..2090f8ba1 100644 --- a/internal/api/system_settings_handlers_test.go +++ b/internal/api/system_settings_handlers_test.go @@ -11,11 +11,11 @@ import ( "testing" "time" - internalauth "github.com/rcourtman/pulse-go-rewrite/internal/auth" "github.com/rcourtman/pulse-go-rewrite/internal/config" "github.com/rcourtman/pulse-go-rewrite/internal/discovery" "github.com/rcourtman/pulse-go-rewrite/internal/notifications" "github.com/rcourtman/pulse-go-rewrite/internal/websocket" + internalauth "github.com/rcourtman/pulse-go-rewrite/pkg/auth" ) // MockMonitor implementation diff --git a/internal/config/api_tokens.go b/internal/config/api_tokens.go index 3cb7d6c57..f0f5c015e 100644 --- a/internal/config/api_tokens.go +++ b/internal/config/api_tokens.go @@ -6,7 +6,7 @@ import ( "time" "github.com/google/uuid" - "github.com/rcourtman/pulse-go-rewrite/internal/auth" + "github.com/rcourtman/pulse-go-rewrite/pkg/auth" ) // Canonical API token scope strings. diff --git a/internal/config/api_tokens_test.go b/internal/config/api_tokens_test.go index dcd352f21..ccb200703 100644 --- a/internal/config/api_tokens_test.go +++ b/internal/config/api_tokens_test.go @@ -6,7 +6,7 @@ import ( "testing" "time" - "github.com/rcourtman/pulse-go-rewrite/internal/auth" + "github.com/rcourtman/pulse-go-rewrite/pkg/auth" ) func TestAPITokenRecordHasScope(t *testing.T) { diff --git a/internal/config/config.go b/internal/config/config.go index 3deb932d2..c5b14c8e8 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -24,9 +24,9 @@ import ( "github.com/google/uuid" "github.com/joho/godotenv" - "github.com/rcourtman/pulse-go-rewrite/internal/auth" "github.com/rcourtman/pulse-go-rewrite/internal/logging" "github.com/rcourtman/pulse-go-rewrite/internal/utils" + "github.com/rcourtman/pulse-go-rewrite/pkg/auth" "github.com/rcourtman/pulse-go-rewrite/pkg/tlsutil" "github.com/rs/zerolog/log" ) diff --git a/internal/config/watcher.go b/internal/config/watcher.go index a2ddc861c..2d72fbc3a 100644 --- a/internal/config/watcher.go +++ b/internal/config/watcher.go @@ -13,7 +13,7 @@ import ( "github.com/fsnotify/fsnotify" "github.com/google/uuid" "github.com/joho/godotenv" - "github.com/rcourtman/pulse-go-rewrite/internal/auth" + "github.com/rcourtman/pulse-go-rewrite/pkg/auth" "github.com/rs/zerolog/log" ) diff --git a/internal/monitoring/monitor_polling.go b/internal/monitoring/monitor_polling.go index b1f069a20..6b7daa86a 100644 --- a/internal/monitoring/monitor_polling.go +++ b/internal/monitoring/monitor_polling.go @@ -1629,7 +1629,7 @@ func (m *Monitor) pollStorageWithNodes(ctx context.Context, instanceName string, m.state.UpdateStorageForInstance(storageInstanceName, allStorage) // Poll Ceph cluster data after refreshing storage information - if !pve.DisableCeph { + if instanceCfg == nil || !instanceCfg.DisableCeph { m.pollCephCluster(ctx, instanceName, client, cephDetected) } diff --git a/internal/auth/auth_test.go b/pkg/auth/auth_test.go similarity index 100% rename from internal/auth/auth_test.go rename to pkg/auth/auth_test.go diff --git a/internal/auth/authorizer.go b/pkg/auth/authorizer.go similarity index 100% rename from internal/auth/authorizer.go rename to pkg/auth/authorizer.go diff --git a/internal/auth/coverage_test.go b/pkg/auth/coverage_test.go similarity index 100% rename from internal/auth/coverage_test.go rename to pkg/auth/coverage_test.go diff --git a/internal/auth/password.go b/pkg/auth/password.go similarity index 100% rename from internal/auth/password.go rename to pkg/auth/password.go diff --git a/internal/auth/permissions.go b/pkg/auth/permissions.go similarity index 100% rename from internal/auth/permissions.go rename to pkg/auth/permissions.go diff --git a/internal/auth/token.go b/pkg/auth/token.go similarity index 100% rename from internal/auth/token.go rename to pkg/auth/token.go diff --git a/pkg/server/server.go b/pkg/server/server.go new file mode 100644 index 000000000..43cee147d --- /dev/null +++ b/pkg/server/server.go @@ -0,0 +1,390 @@ +package server + +import ( + "context" + "encoding/base64" + "fmt" + "net/http" + "os" + "os/signal" + "path/filepath" + "strings" + "syscall" + "time" + + "github.com/prometheus/client_golang/prometheus/promhttp" + "github.com/rcourtman/pulse-go-rewrite/internal/agentbinaries" + "github.com/rcourtman/pulse-go-rewrite/internal/alerts" + "github.com/rcourtman/pulse-go-rewrite/internal/api" + "github.com/rcourtman/pulse-go-rewrite/internal/config" + "github.com/rcourtman/pulse-go-rewrite/internal/license" + "github.com/rcourtman/pulse-go-rewrite/internal/logging" + "github.com/rcourtman/pulse-go-rewrite/internal/metrics" + _ "github.com/rcourtman/pulse-go-rewrite/internal/mock" // Import for init() to run + "github.com/rcourtman/pulse-go-rewrite/internal/monitoring" + "github.com/rcourtman/pulse-go-rewrite/internal/websocket" + "github.com/rs/zerolog/log" +) + +// Version information +var ( + MetricsPort = 9091 +) + +// Run starts the Pulse monitoring server. +func Run(ctx context.Context, version string) error { + // Initialize logger with baseline defaults for early startup logs + logging.Init(logging.Config{ + Format: "auto", + Level: "info", + Component: "pulse", + }) + + // Check for auto-import on first startup + if ShouldAutoImport() { + if err := PerformAutoImport(); err != nil { + log.Error().Err(err).Msg("Auto-import failed, continuing with normal startup") + } + } + + // Load unified configuration + cfg, err := config.Load() + if err != nil { + return fmt.Errorf("failed to load configuration: %w", err) + } + + // Re-initialize logging with configuration-driven settings + logging.Init(logging.Config{ + Format: cfg.LogFormat, + Level: cfg.LogLevel, + Component: "pulse", + FilePath: cfg.LogFile, + MaxSizeMB: cfg.LogMaxSize, + MaxAgeDays: cfg.LogMaxAge, + Compress: cfg.LogCompress, + }) + + // Initialize license public key for Pro feature validation + license.InitPublicKey() + + log.Info().Msg("Starting Pulse monitoring server") + + // Validate agent binaries are available for download + agentbinaries.EnsureHostAgentBinaries(version) + + // Create derived context that cancels on interrupt + ctx, cancel := context.WithCancel(ctx) + defer cancel() + + // Metrics port is configurable via MetricsPort variable + metricsAddr := fmt.Sprintf("%s:%d", cfg.BackendHost, MetricsPort) + startMetricsServer(ctx, metricsAddr) + + // Initialize WebSocket hub first + wsHub := websocket.NewHub(nil) + // Set allowed origins from configuration + if cfg.AllowedOrigins != "" { + if cfg.AllowedOrigins == "*" { + // Explicit wildcard - allow all origins (less secure) + wsHub.SetAllowedOrigins([]string{"*"}) + } else { + // Use configured origins + wsHub.SetAllowedOrigins(strings.Split(cfg.AllowedOrigins, ",")) + } + } else { + // Default: don't set any specific origins + wsHub.SetAllowedOrigins([]string{}) + } + go wsHub.Run() + + // Initialize reloadable monitoring system + reloadableMonitor, err := monitoring.NewReloadableMonitor(cfg, wsHub) + if err != nil { + return fmt.Errorf("failed to initialize monitoring system: %w", err) + } + + // Set state getter for WebSocket hub + wsHub.SetStateGetter(func() interface{} { + state := reloadableMonitor.GetMonitor().GetState() + return state.ToFrontend() + }) + + // Wire up Prometheus metrics for alert lifecycle + alerts.SetMetricHooks( + metrics.RecordAlertFired, + metrics.RecordAlertResolved, + metrics.RecordAlertSuppressed, + metrics.RecordAlertAcknowledged, + ) + log.Info().Msg("Alert metrics hooks registered") + + // Start monitoring + reloadableMonitor.Start(ctx) + + // Initialize API server with reload function + var router *api.Router + reloadFunc := func() error { + if err := reloadableMonitor.Reload(); err != nil { + return err + } + if router != nil { + router.SetMonitor(reloadableMonitor.GetMonitor()) + if cfg := reloadableMonitor.GetConfig(); cfg != nil { + router.SetConfig(cfg) + } + } + return nil + } + router = api.NewRouter(cfg, reloadableMonitor.GetMonitor(), wsHub, reloadFunc, version) + + // Inject resource store into monitor for WebSocket broadcasts + router.SetMonitor(reloadableMonitor.GetMonitor()) + + // Start AI patrol service for background infrastructure monitoring + router.StartPatrol(ctx) + + // Wire alert-triggered AI analysis + router.WireAlertTriggeredAI() + + // Create HTTP server with unified configuration + srv := &http.Server{ + Addr: fmt.Sprintf("%s:%d", cfg.BackendHost, cfg.FrontendPort), + Handler: router.Handler(), + ReadHeaderTimeout: 15 * time.Second, + WriteTimeout: 0, // Disabled to support SSE/streaming + IdleTimeout: 120 * time.Second, + } + + // Start config watcher for .env file changes + configWatcher, err := config.NewConfigWatcher(cfg) + if err != nil { + log.Warn().Err(err).Msg("Failed to create config watcher, .env changes will require restart") + } else { + configWatcher.SetMockReloadCallback(func() { + log.Info().Msg("mock.env changed, reloading monitor") + if err := reloadableMonitor.Reload(); err != nil { + log.Error().Err(err).Msg("Failed to reload monitor after mock.env change") + } else if router != nil { + router.SetMonitor(reloadableMonitor.GetMonitor()) + if cfg := reloadableMonitor.GetConfig(); cfg != nil { + router.SetConfig(cfg) + } + } + }) + + configWatcher.SetAPITokenReloadCallback(func() { + if monitor := reloadableMonitor.GetMonitor(); monitor != nil { + monitor.RebuildTokenBindings() + } + }) + + if err := configWatcher.Start(); err != nil { + log.Warn().Err(err).Msg("Failed to start config watcher") + } + defer configWatcher.Stop() + } + + // Start server + go func() { + if cfg.HTTPSEnabled && cfg.TLSCertFile != "" && cfg.TLSKeyFile != "" { + log.Info(). + Str("host", cfg.BackendHost). + Int("port", cfg.FrontendPort). + Str("protocol", "HTTPS"). + Msg("Server listening") + if err := srv.ListenAndServeTLS(cfg.TLSCertFile, cfg.TLSKeyFile); err != nil && err != http.ErrServerClosed { + log.Error().Err(err).Msg("Failed to start HTTPS server") + } + } else { + if cfg.HTTPSEnabled { + log.Warn().Msg("HTTPS_ENABLED is true but TLS_CERT_FILE or TLS_KEY_FILE not configured, falling back to HTTP") + } + log.Info(). + Str("host", cfg.BackendHost). + Int("port", cfg.FrontendPort). + Str("protocol", "HTTP"). + Msg("Server listening") + if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed { + log.Error().Err(err).Msg("Failed to start HTTP server") + } + } + }() + + // Setup signal handlers + sigChan := make(chan os.Signal, 1) + reloadChan := make(chan os.Signal, 1) + + signal.Notify(sigChan, os.Interrupt, syscall.SIGTERM) + signal.Notify(reloadChan, syscall.SIGHUP) + + for { + select { + case <-ctx.Done(): + log.Info().Msg("Context cancelled, shutting down...") + goto shutdown + + case <-reloadChan: + log.Info().Msg("Received SIGHUP, reloading configuration...") + if configWatcher != nil { + configWatcher.ReloadConfig() + } + + if err := reloadFunc(); err != nil { + log.Error().Err(err).Msg("Failed to reload monitor after SIGHUP") + } else { + log.Info().Msg("Runtime configuration reloaded") + } + + case <-sigChan: + log.Info().Msg("Shutting down server...") + goto shutdown + } + } + +shutdown: + shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 30*time.Second) + defer shutdownCancel() + + if err := srv.Shutdown(shutdownCtx); err != nil { + log.Error().Err(err).Msg("Server shutdown error") + } + + cancel() + reloadableMonitor.Stop() + + if configWatcher != nil { + configWatcher.Stop() + } + + log.Info().Msg("Server stopped") + return nil +} + +// startMetricsServer is moved from main.go +func startMetricsServer(ctx context.Context, addr string) { + mux := http.NewServeMux() + mux.Handle("/metrics", promhttp.Handler()) + + srv := &http.Server{ + Addr: addr, + Handler: mux, + ReadHeaderTimeout: 5 * time.Second, + } + + go func() { + log.Info().Str("addr", addr).Msg("Metrics server listening") + if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed { + log.Error().Err(err).Msg("Metrics server failed") + } + }() + + go func() { + <-ctx.Done() + shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + _ = srv.Shutdown(shutdownCtx) + }() +} + +func ShouldAutoImport() bool { + configPath := os.Getenv("PULSE_DATA_DIR") + if configPath == "" { + configPath = "/etc/pulse" + } + + if _, err := os.Stat(filepath.Join(configPath, "nodes.enc")); err == nil { + return false + } + + return os.Getenv("PULSE_INIT_CONFIG_DATA") != "" || + os.Getenv("PULSE_INIT_CONFIG_FILE") != "" +} + +func PerformAutoImport() error { + configData := os.Getenv("PULSE_INIT_CONFIG_DATA") + configFile := os.Getenv("PULSE_INIT_CONFIG_FILE") + configPass := os.Getenv("PULSE_INIT_CONFIG_PASSPHRASE") + + if configPass == "" { + return fmt.Errorf("PULSE_INIT_CONFIG_PASSPHRASE is required for auto-import") + } + + var encryptedData string + + if configFile != "" { + data, err := os.ReadFile(configFile) + if err != nil { + return fmt.Errorf("failed to read config file: %w", err) + } + payload, err := NormalizeImportPayload(data) + if err != nil { + return err + } + encryptedData = payload + } else if configData != "" { + payload, err := NormalizeImportPayload([]byte(configData)) + if err != nil { + return err + } + encryptedData = payload + } else { + return fmt.Errorf("no config data provided") + } + + configPath := os.Getenv("PULSE_DATA_DIR") + if configPath == "" { + configPath = "/etc/pulse" + } + + persistence := config.NewConfigPersistence(configPath) + if err := persistence.ImportConfig(encryptedData, configPass); err != nil { + return fmt.Errorf("failed to import configuration: %w", err) + } + + log.Info().Msg("Configuration auto-imported successfully") + return nil +} + +func NormalizeImportPayload(raw []byte) (string, error) { + trimmed := strings.TrimSpace(string(raw)) + if trimmed == "" { + return "", fmt.Errorf("configuration payload is empty") + } + + if decoded, err := base64.StdEncoding.DecodeString(trimmed); err == nil { + decodedTrimmed := strings.TrimSpace(string(decoded)) + if LooksLikeBase64(decodedTrimmed) { + return decodedTrimmed, nil + } + return trimmed, nil + } + + return base64.StdEncoding.EncodeToString(raw), nil +} + +func LooksLikeBase64(s string) bool { + if s == "" { + return false + } + compact := strings.Map(func(r rune) rune { + switch r { + case '\n', '\r', '\t', ' ': + return -1 + default: + return r + } + }, s) + + if compact == "" || len(compact)%4 != 0 { + return false + } + for i := 0; i < len(compact); i++ { + c := compact[i] + isAlphaNum := (c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') + if isAlphaNum || c == '+' || c == '/' || c == '=' { + continue + } + return false + } + return true +}