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
This commit is contained in:
rcourtman
2026-01-09 16:51:08 +00:00
parent 22059210f7
commit 3e2824a7ff
46 changed files with 509 additions and 578 deletions

1
.gitignore vendored
View File

@@ -34,6 +34,7 @@ Thumbs.db
.vscode/
*.swp
*.swo
*.code-workspace
# Go
*.exe

View File

@@ -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

View File

@@ -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 (60character hash)
- Passwords never stored in plain text

View File

@@ -5,7 +5,7 @@ import (
"io"
"os"
"github.com/rcourtman/pulse-go-rewrite/internal/auth"
"github.com/rcourtman/pulse-go-rewrite/pkg/auth"
)
var (

View File

@@ -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) {

View File

@@ -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")
}
}

View File

@@ -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)

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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")
}
}

View File

@@ -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
}

View File

@@ -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`.

View File

@@ -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.

View File

@@ -561,7 +561,7 @@ export const KubernetesClusters: Component<KubernetesClustersProps> = (props) =>
<div class="flex items-center gap-2">
<div class="text-sm font-bold text-gray-900 dark:text-gray-100 flex items-center gap-2">
Kubernetes AI Analysis
<span class="px-1.5 py-0.5 text-[10px] font-bold uppercase tracking-wider bg-blue-100 dark:bg-blue-900/50 text-blue-600 dark:text-blue-400 rounded-md">Enterprise</span>
{/* Badge removed - feature soft-locked instead */}
</div>
</div>
<div class="text-xs text-gray-500 dark:text-gray-400 mt-1 leading-relaxed">

View File

@@ -1174,11 +1174,6 @@ export const AISettings: Component = () => {
ENABLED
</span>
</Show>
<Show when={autoFixLocked()}>
<span class="px-2 py-0.5 text-[10px] font-bold uppercase tracking-wider bg-gradient-to-r from-indigo-600 via-purple-600 to-blue-600 text-white rounded-md shadow-md shadow-indigo-500/20 ring-1 ring-white/20">
Enterprise
</span>
</Show>
</label>
<p class="text-xs text-gray-600 dark:text-gray-400 mt-1">
{form.autonomousMode
@@ -1189,17 +1184,16 @@ export const AISettings: Component = () => {
<strong> Legal Disclaimer:</strong> AI models can hallucinate. You are responsible for any damage caused by autonomous actions. See <a href="https://github.com/rcourtman/Pulse/blob/main/TERMS.md" target="_blank" class="underline">Terms of Service</a>.
</div>
<Show when={autoFixLocked()}>
<p class="text-[10px] text-indigo-600 dark:text-indigo-400 mt-2 flex items-center gap-1.5 p-2 bg-indigo-50 dark:bg-indigo-900/30 rounded-lg border border-indigo-100 dark:border-indigo-800">
<Sparkles class="w-3 h-3" />
Pulse Enterprise required for autonomous mode.
<p class="text-xs text-gray-500 dark:text-gray-400 mt-2">
<a
class="ml-auto px-2 py-0.5 bg-indigo-600 text-white rounded hover:bg-indigo-700 transition shadow-sm font-semibold"
class="text-indigo-600 dark:text-indigo-400 font-medium hover:underline"
href="https://pulse.sh/pro"
target="_blank"
rel="noreferrer"
>
Upgrade
</a>
Upgrade to Pro
</a>{' '}
to enable autonomous mode.
</p>
</Show>
</div>
@@ -1271,11 +1265,6 @@ export const AISettings: Component = () => {
<label class="text-xs font-medium text-gray-600 dark:text-gray-400 flex items-center gap-1.5">
Alert-Triggered Analysis
<span class="px-1 py-0.5 text-[9px] font-medium bg-green-100 dark:bg-green-900 text-green-700 dark:text-green-300 rounded">Efficient</span>
<Show when={alertAnalysisLocked()}>
<span class="px-2 py-0.5 text-[10px] font-bold uppercase tracking-wider bg-gradient-to-r from-indigo-600 via-purple-600 to-blue-600 text-white rounded-md shadow-md shadow-indigo-500/20 ring-1 ring-white/20">
Enterprise
</span>
</Show>
</label>
<Toggle
checked={form.alertTriggeredAnalysis}
@@ -1284,16 +1273,16 @@ export const AISettings: Component = () => {
/>
</div>
<Show when={alertAnalysisLocked()}>
<p class="text-[10px] text-indigo-600 dark:text-indigo-400 mt-1">
Pulse Enterprise required for alert-triggered analysis.{' '}
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">
<a
class="underline decoration-dotted font-semibold"
class="text-indigo-600 dark:text-indigo-400 font-medium hover:underline"
href="https://pulse.sh/pro"
target="_blank"
rel="noreferrer"
>
Upgrade
</a>
Upgrade to Pro
</a>{' '}
to enable alert-triggered analysis.
</p>
</Show>
@@ -1303,11 +1292,6 @@ export const AISettings: Component = () => {
<label class="text-xs font-medium text-gray-600 dark:text-gray-400 flex items-center gap-1.5">
Auto-Fix Mode
<span class="px-1 py-0.5 text-[9px] font-medium bg-amber-100 dark:bg-amber-900 text-amber-700 dark:text-amber-300 rounded">Advanced</span>
<Show when={autoFixLocked()}>
<span class="px-2 py-0.5 text-[10px] font-bold uppercase tracking-wider bg-gradient-to-r from-indigo-500 to-purple-500 text-white rounded-md shadow-sm">
Enterprise
</span>
</Show>
</label>
<Show when={autoFixAcknowledged() || form.patrolAutoFix}>
<Toggle
@@ -1331,17 +1315,16 @@ export const AISettings: Component = () => {
</Show>
</div>
<Show when={autoFixLocked()}>
<p class="text-[10px] text-indigo-600 dark:text-indigo-400 mt-2 flex items-center gap-1.5 p-2 bg-indigo-50 dark:bg-indigo-900/30 rounded-lg border border-indigo-100 dark:border-indigo-800">
<Sparkles class="w-3 h-3" />
Pulse Enterprise required for auto-fix.
<p class="text-xs text-gray-500 dark:text-gray-400 mt-2">
<a
class="ml-auto px-2 py-0.5 bg-indigo-600 text-white rounded hover:bg-indigo-700 transition shadow-sm font-semibold"
class="text-indigo-600 dark:text-indigo-400 font-medium hover:underline"
href="https://pulse.sh/pro"
target="_blank"
rel="noreferrer"
>
Upgrade
</a>
Upgrade to Pro
</a>{' '}
to enable auto-fix.
</p>
</Show>
<Show when={!autoFixLocked() && !form.patrolAutoFix && !autoFixAcknowledged()}>

View File

@@ -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() {
<div class="flex items-center gap-3">
<Shield class="w-6 h-6 text-indigo-500" />
<h2 class="text-xl font-semibold text-gray-900 dark:text-white">Audit Log</h2>
<Show when={isEnterprise()}>
<span class="px-2 py-0.5 text-xs font-bold uppercase tracking-wider bg-gradient-to-r from-indigo-500 to-purple-500 text-white rounded-md shadow-sm">
Enterprise
</span>
</Show>
<Show when={!isEnterprise()}>
<span class="px-2 py-0.5 text-xs font-bold uppercase tracking-wider bg-gray-100 text-gray-500 dark:bg-gray-800 dark:text-gray-400 rounded-md">
Enterprise
</span>
</Show>
</div>
<div class="flex items-center gap-2">
<button
@@ -503,37 +500,23 @@ export default function AuditLogPanel() {
</div>
</div>
{/* OSS Notice / Upgrade CTA */}
{/* Upgrade CTA */}
<Show when={!isEnterprise() && !loading()}>
<div class="relative overflow-hidden group">
<div class="absolute inset-0 bg-gradient-to-r from-indigo-500/10 via-purple-500/10 to-blue-500/10 opacity-50 group-hover:opacity-100 transition-opacity"></div>
<div class="relative flex flex-col md:flex-row items-center gap-6 p-8 bg-white dark:bg-gray-800/50 border border-indigo-100 dark:border-indigo-900/50 rounded-2xl shadow-xl backdrop-blur-sm">
<div class="p-4 bg-gradient-to-br from-indigo-600 to-purple-600 rounded-2xl shadow-lg transform group-hover:scale-110 transition-transform duration-500">
<Sparkles class="w-8 h-8 text-white" />
</div>
<div class="flex-1 text-center md:text-left">
<h3 class="text-xl font-bold text-gray-900 dark:text-white flex items-center justify-center md:justify-start gap-2">
Unlock Enterprise Audit Logging
<span class="px-2 py-0.5 text-[10px] font-bold uppercase tracking-wider bg-indigo-100 dark:bg-indigo-900/50 text-indigo-600 dark:text-indigo-400 rounded-md">PRO Feature</span>
</h3>
<p class="text-sm text-gray-600 dark:text-gray-400 mt-2 leading-relaxed max-w-2xl">
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.
</p>
</div>
<div class="flex flex-col gap-3 min-w-[200px]">
<a
href="https://pulse.sh/pro"
target="_blank"
class="inline-flex items-center justify-center gap-2 px-6 py-3 text-sm font-bold bg-indigo-600 text-white rounded-xl hover:bg-indigo-700 hover:shadow-indigo-500/25 hover:shadow-lg transform active:scale-95 transition-all shadow-md"
>
Get Enterprise
<ExternalLink class="w-4 h-4" />
</a>
<p class="text-[10px] text-center text-gray-500 dark:text-gray-500 italic">
Pricing starts at $1.50/node
<div class="p-6 bg-gradient-to-r from-indigo-50 to-purple-50 dark:from-indigo-900/20 dark:to-purple-900/20 border border-indigo-100 dark:border-indigo-800 rounded-xl">
<div class="flex flex-col sm:flex-row items-center gap-4">
<div class="flex-1">
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">Audit Logging</h3>
<p class="text-sm text-gray-600 dark:text-gray-400 mt-1">
Persistent, searchable audit logs with cryptographic signature verification.
</p>
</div>
<a
href="https://pulse.sh/pro"
target="_blank"
class="px-5 py-2.5 text-sm font-semibold bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 transition-colors"
>
Upgrade to Pro
</a>
</div>
</div>
</Show>

View File

@@ -223,16 +223,6 @@ export const OIDCPanel: Component<Props> = (props) => {
size="sm"
class="flex-1"
/>
<Show when={isEnterprise()}>
<span class="px-2 py-0.5 text-xs font-bold uppercase tracking-wider bg-gradient-to-r from-indigo-500 to-purple-500 text-white rounded-md shadow-sm">
Enterprise
</span>
</Show>
<Show when={!isEnterprise()}>
<span class="px-2 py-0.5 text-xs font-bold uppercase tracking-wider bg-gray-100 text-gray-500 dark:bg-gray-800 dark:text-gray-400 rounded-md">
Enterprise
</span>
</Show>
<Toggle
checked={form.enabled}
onChange={async (event) => {
@@ -275,31 +265,21 @@ export const OIDCPanel: Component<Props> = (props) => {
</div>
</div>
<Show when={!isEnterprise() && !loading()}>
<div class="mx-6 mt-6 relative overflow-hidden group">
<div class="absolute inset-0 bg-gradient-to-r from-indigo-500/10 via-purple-500/10 to-blue-500/10 opacity-50 group-hover:opacity-100 transition-opacity"></div>
<div class="relative flex items-start gap-4 p-6 bg-white dark:bg-gray-800/50 border border-indigo-100 dark:border-indigo-900/50 rounded-2xl shadow-xl backdrop-blur-sm">
<div class="p-3 bg-gradient-to-br from-indigo-600 to-purple-600 rounded-xl shadow-lg transform group-hover:scale-110 transition-transform duration-500">
<Sparkles class="w-5 h-5 text-white" />
</div>
<div class="mx-6 mt-6 p-5 bg-gradient-to-r from-indigo-50 to-purple-50 dark:from-indigo-900/20 dark:to-purple-900/20 border border-indigo-100 dark:border-indigo-800 rounded-xl">
<div class="flex flex-col sm:flex-row items-center gap-4">
<div class="flex-1">
<h4 class="text-sm font-bold text-gray-900 dark:text-white flex items-center gap-2">
Enterprise Feature
<span class="px-1.5 py-0.5 text-[9px] font-bold uppercase tracking-wider bg-indigo-100 dark:bg-indigo-900/50 text-indigo-600 dark:text-indigo-400 rounded">SSO</span>
</h4>
<p class="text-xs text-gray-600 dark:text-gray-400 mt-1 leading-relaxed">
OIDC integration is an Enterprise-only feature. Upgrade your license to enable seamless Single Sign-On for your team.
<h4 class="text-base font-semibold text-gray-900 dark:text-white">Single Sign-On</h4>
<p class="text-sm text-gray-600 dark:text-gray-400 mt-1">
Connect Pulse to your identity provider for seamless team authentication.
</p>
<div class="mt-3">
<a
href="https://pulse.sh/pro"
target="_blank"
class="inline-flex items-center gap-1.5 text-xs font-bold text-indigo-600 dark:text-indigo-400 hover:text-indigo-700 dark:hover:text-indigo-300 transition-colors group/link"
>
Learn about Enterprise
<ExternalLink class="w-3 h-3 transform group-hover/link:translate-x-0.5 group-hover/link:-translate-y-0.5 transition-transform" />
</a>
</div>
</div>
<a
href="https://pulse.sh/pro"
target="_blank"
class="px-5 py-2.5 text-sm font-semibold bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 transition-colors"
>
Upgrade to Pro
</a>
</div>
</div>
</Show>

View File

@@ -1123,14 +1123,12 @@ const Settings: Component<SettingsProps> = (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',
},
],
},

7
go.mod
View File

@@ -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

20
go.sum
View File

@@ -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=

View File

@@ -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"
)

View File

@@ -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"
)

View File

@@ -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"

View File

@@ -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"
)

View File

@@ -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) {

View File

@@ -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 {

View File

@@ -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"
)

View File

@@ -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 {

View File

@@ -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) {

View File

@@ -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"
)

View File

@@ -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() {

View File

@@ -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

View File

@@ -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"
)

View File

@@ -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

View File

@@ -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.

View File

@@ -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) {

View File

@@ -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"
)

View File

@@ -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"
)

View File

@@ -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)
}

390
pkg/server/server.go Normal file
View File

@@ -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
}