mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-02-18 00:17:39 +01:00
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:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -34,6 +34,7 @@ Thumbs.db
|
||||
.vscode/
|
||||
*.swp
|
||||
*.swo
|
||||
*.code-workspace
|
||||
|
||||
# Go
|
||||
*.exe
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -5,7 +5,7 @@ import (
|
||||
"io"
|
||||
"os"
|
||||
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/auth"
|
||||
"github.com/rcourtman/pulse-go-rewrite/pkg/auth"
|
||||
)
|
||||
|
||||
var (
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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`.
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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()}>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
7
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
|
||||
|
||||
20
go.sum
20
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=
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
|
||||
|
||||
@@ -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
390
pkg/server/server.go
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user