mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-05-02 21:40:14 +00:00
335 lines
11 KiB
Go
335 lines
11 KiB
Go
package pulsecli
|
|
|
|
import (
|
|
"bufio"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"net/url"
|
|
"os"
|
|
"strings"
|
|
"syscall"
|
|
"time"
|
|
|
|
"github.com/rcourtman/pulse-go-rewrite/internal/config"
|
|
"github.com/rcourtman/pulse-go-rewrite/pkg/server"
|
|
"github.com/spf13/cobra"
|
|
)
|
|
|
|
const maxConfigImportBytes int64 = 16 << 20 // 16 MiB
|
|
|
|
func ReadBoundedRegularFile(path string, maxBytes int64) ([]byte, error) {
|
|
if maxBytes <= 0 {
|
|
return nil, fmt.Errorf("invalid max bytes %d", maxBytes)
|
|
}
|
|
|
|
info, err := os.Lstat(path)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if !info.Mode().IsRegular() {
|
|
return nil, fmt.Errorf("path is not a regular file")
|
|
}
|
|
if info.Size() > maxBytes {
|
|
return nil, fmt.Errorf("file exceeds %d bytes", maxBytes)
|
|
}
|
|
|
|
f, err := os.Open(path)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer f.Close()
|
|
|
|
data, err := io.ReadAll(io.LimitReader(f, maxBytes+1))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if int64(len(data)) > maxBytes {
|
|
return nil, fmt.Errorf("file exceeds %d bytes", maxBytes)
|
|
}
|
|
|
|
return data, nil
|
|
}
|
|
|
|
func ReadBoundedHTTPBody(reader io.Reader, declaredLength, maxBytes int64, source string) ([]byte, error) {
|
|
if maxBytes <= 0 {
|
|
return nil, fmt.Errorf("invalid max bytes %d", maxBytes)
|
|
}
|
|
if source == "" {
|
|
source = "response body"
|
|
}
|
|
if declaredLength > maxBytes {
|
|
return nil, fmt.Errorf("%s exceeds %d bytes", source, maxBytes)
|
|
}
|
|
|
|
data, err := io.ReadAll(io.LimitReader(reader, maxBytes+1))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if int64(len(data)) > maxBytes {
|
|
return nil, fmt.Errorf("%s exceeds %d bytes", source, maxBytes)
|
|
}
|
|
|
|
return data, nil
|
|
}
|
|
|
|
func GetPassphrase(deps *ConfigDeps, prompt string, confirm bool) string {
|
|
if pass := os.Getenv("PULSE_PASSPHRASE"); pass != "" {
|
|
return pass
|
|
}
|
|
|
|
if deps != nil && deps.Passphrase != nil && *deps.Passphrase != "" {
|
|
return *deps.Passphrase
|
|
}
|
|
|
|
fmt.Print(prompt)
|
|
bytePassword, err := configReadPassword(deps, int(syscall.Stdin))
|
|
fmt.Println()
|
|
if err != nil {
|
|
return ""
|
|
}
|
|
|
|
pass := string(bytePassword)
|
|
if !confirm {
|
|
return pass
|
|
}
|
|
|
|
fmt.Print("Confirm passphrase: ")
|
|
bytePassword2, err := configReadPassword(deps, int(syscall.Stdin))
|
|
fmt.Println()
|
|
if err != nil {
|
|
return ""
|
|
}
|
|
if string(bytePassword2) != pass {
|
|
fmt.Println("Passphrases do not match")
|
|
return ""
|
|
}
|
|
|
|
return pass
|
|
}
|
|
|
|
func newConfigCmd(deps *ConfigDeps) *cobra.Command {
|
|
configCmd := &cobra.Command{
|
|
Use: "config",
|
|
Short: "Configuration management commands",
|
|
Long: `Manage Pulse configuration settings`,
|
|
}
|
|
|
|
configInfoCmd := &cobra.Command{
|
|
Use: "info",
|
|
Short: "Show configuration information",
|
|
Long: `Display information about Pulse configuration`,
|
|
RunE: func(cmd *cobra.Command, args []string) error {
|
|
configPath := config.ResolveRuntimeDataDir("")
|
|
fmt.Fprintln(cmd.OutOrStdout(), "Pulse Configuration Information")
|
|
fmt.Fprintln(cmd.OutOrStdout(), "==============================")
|
|
fmt.Fprintln(cmd.OutOrStdout())
|
|
fmt.Fprintln(cmd.OutOrStdout(), "Configuration is managed through the web UI.")
|
|
fmt.Fprintf(cmd.OutOrStdout(), "Settings are stored in encrypted files at %s/\n", configPath)
|
|
fmt.Fprintln(cmd.OutOrStdout())
|
|
fmt.Fprintln(cmd.OutOrStdout(), "Configuration files:")
|
|
fmt.Fprintln(cmd.OutOrStdout(), " - nodes.enc : Encrypted Proxmox node configurations")
|
|
fmt.Fprintln(cmd.OutOrStdout(), " - email.enc : Encrypted email settings")
|
|
fmt.Fprintln(cmd.OutOrStdout(), " - system.json : System settings (polling interval, etc)")
|
|
fmt.Fprintln(cmd.OutOrStdout(), " - alerts.json : Alert rules and thresholds")
|
|
fmt.Fprintln(cmd.OutOrStdout(), " - webhooks.enc : Webhook configurations")
|
|
fmt.Fprintln(cmd.OutOrStdout())
|
|
fmt.Fprintln(cmd.OutOrStdout(), "To configure Pulse, use the Settings tab in the web UI.")
|
|
return nil
|
|
},
|
|
}
|
|
|
|
configExportCmd := &cobra.Command{
|
|
Use: "export",
|
|
Short: "Export configuration with encryption",
|
|
Long: `Export all Pulse configuration to an encrypted file`,
|
|
Example: ` # Export with interactive passphrase prompt
|
|
pulse config export -o pulse-config.enc
|
|
|
|
# Export with passphrase from environment variable
|
|
PULSE_PASSPHRASE=mysecret pulse config export -o pulse-config.enc`,
|
|
RunE: func(cmd *cobra.Command, args []string) error {
|
|
pass := GetPassphrase(deps, "Enter passphrase for encryption: ", false)
|
|
if pass == "" {
|
|
return fmt.Errorf("passphrase is required")
|
|
}
|
|
|
|
configPath := config.ResolveRuntimeDataDir("")
|
|
|
|
persistence := config.NewConfigPersistence(configPath)
|
|
exportedData, err := persistence.ExportConfig(pass)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to export configuration: %w", err)
|
|
}
|
|
|
|
if deps != nil && deps.ExportFile != nil && *deps.ExportFile != "" {
|
|
if err := os.WriteFile(*deps.ExportFile, []byte(exportedData), 0600); err != nil {
|
|
return fmt.Errorf("failed to write export file: %w", err)
|
|
}
|
|
fmt.Fprintf(cmd.OutOrStdout(), "Configuration exported to %s\n", *deps.ExportFile)
|
|
return nil
|
|
}
|
|
|
|
fmt.Fprintln(cmd.OutOrStdout(), exportedData)
|
|
return nil
|
|
},
|
|
}
|
|
|
|
configImportCmd := &cobra.Command{
|
|
Use: "import",
|
|
Short: "Import configuration from encrypted export",
|
|
Long: `Import Pulse configuration from an encrypted export file`,
|
|
Example: ` # Import with interactive passphrase prompt
|
|
pulse config import -i pulse-config.enc
|
|
|
|
# Import with passphrase from environment variable
|
|
PULSE_PASSPHRASE=mysecret pulse config import -i pulse-config.enc
|
|
|
|
# Force import without confirmation
|
|
pulse config import -i pulse-config.enc --force`,
|
|
RunE: func(cmd *cobra.Command, args []string) error {
|
|
if deps == nil || deps.ImportFile == nil || *deps.ImportFile == "" {
|
|
return fmt.Errorf("import file is required (use -i flag)")
|
|
}
|
|
|
|
data, err := ReadBoundedRegularFile(*deps.ImportFile, maxConfigImportBytes)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to read import file: %w", err)
|
|
}
|
|
|
|
pass := GetPassphrase(deps, "Enter passphrase for decryption: ", false)
|
|
if pass == "" {
|
|
return fmt.Errorf("passphrase is required")
|
|
}
|
|
|
|
forceImport := deps != nil && deps.ForceImport != nil && *deps.ForceImport
|
|
if !forceImport {
|
|
fmt.Fprintln(cmd.OutOrStdout(), "WARNING: This will overwrite all existing configuration!")
|
|
fmt.Fprint(cmd.OutOrStdout(), "Continue? (yes/no): ")
|
|
reader := bufio.NewReader(os.Stdin)
|
|
response, _ := reader.ReadString('\n')
|
|
response = strings.TrimSpace(strings.ToLower(response))
|
|
if response != "yes" && response != "y" {
|
|
fmt.Fprintln(cmd.OutOrStdout(), "Import cancelled")
|
|
return nil
|
|
}
|
|
}
|
|
|
|
configPath := config.ResolveRuntimeDataDir("")
|
|
|
|
persistence := config.NewConfigPersistence(configPath)
|
|
if err := persistence.ImportConfig(string(data), pass); err != nil {
|
|
return fmt.Errorf("failed to import configuration: %w", err)
|
|
}
|
|
|
|
fmt.Fprintln(cmd.OutOrStdout(), "Configuration imported successfully")
|
|
fmt.Fprintln(cmd.OutOrStdout(), "Please restart Pulse for changes to take effect:")
|
|
fmt.Fprintln(cmd.OutOrStdout(), " sudo systemctl restart pulse")
|
|
return nil
|
|
},
|
|
}
|
|
|
|
configAutoImportCmd := &cobra.Command{
|
|
Use: "auto-import",
|
|
Hidden: true,
|
|
Short: "Auto-import configuration on startup",
|
|
Long: `Automatically import configuration from URL or file on first startup`,
|
|
RunE: func(cmd *cobra.Command, args []string) error {
|
|
configURL := os.Getenv("PULSE_INIT_CONFIG_URL")
|
|
configData := os.Getenv("PULSE_INIT_CONFIG_DATA")
|
|
configPass := os.Getenv("PULSE_INIT_CONFIG_PASSPHRASE")
|
|
|
|
if configURL == "" && configData == "" {
|
|
return nil
|
|
}
|
|
|
|
if configPass == "" {
|
|
return fmt.Errorf("PULSE_INIT_CONFIG_PASSPHRASE is required for auto-import")
|
|
}
|
|
|
|
var encryptedData string
|
|
if configURL != "" {
|
|
parsedURL, err := url.Parse(configURL)
|
|
if err != nil {
|
|
return fmt.Errorf("invalid PULSE_INIT_CONFIG_URL: %w", err)
|
|
}
|
|
if parsedURL.Scheme != "http" && parsedURL.Scheme != "https" {
|
|
return fmt.Errorf("unsupported URL scheme %q for PULSE_INIT_CONFIG_URL", parsedURL.Scheme)
|
|
}
|
|
|
|
client := &http.Client{Timeout: 15 * time.Second}
|
|
resp, err := client.Get(configURL)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to fetch configuration from URL: %w", err)
|
|
}
|
|
|
|
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
|
if closeErr := resp.Body.Close(); closeErr != nil {
|
|
return errors.Join(
|
|
fmt.Errorf("failed to fetch configuration from URL: %s", resp.Status),
|
|
fmt.Errorf("failed to close configuration response body: %w", closeErr),
|
|
)
|
|
}
|
|
return fmt.Errorf("failed to fetch configuration from URL: %s", resp.Status)
|
|
}
|
|
|
|
body, err := ReadBoundedHTTPBody(resp.Body, resp.ContentLength, maxConfigImportBytes, "configuration response")
|
|
if err != nil {
|
|
if closeErr := resp.Body.Close(); closeErr != nil {
|
|
return errors.Join(
|
|
fmt.Errorf("failed to read configuration response: %w", err),
|
|
fmt.Errorf("failed to close configuration response body: %w", closeErr),
|
|
)
|
|
}
|
|
return fmt.Errorf("failed to read configuration response: %w", err)
|
|
}
|
|
if closeErr := resp.Body.Close(); closeErr != nil {
|
|
return fmt.Errorf("failed to close configuration response body: %w", closeErr)
|
|
}
|
|
if len(body) == 0 {
|
|
return fmt.Errorf("configuration response from URL was empty")
|
|
}
|
|
|
|
payload, err := server.NormalizeImportPayload(body)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to normalize imported configuration payload from URL: %w", err)
|
|
}
|
|
encryptedData = payload
|
|
} else {
|
|
payload, err := server.NormalizeImportPayload([]byte(configData))
|
|
if err != nil {
|
|
return fmt.Errorf("failed to normalize imported configuration payload from PULSE_INIT_CONFIG_DATA: %w", err)
|
|
}
|
|
encryptedData = payload
|
|
}
|
|
|
|
configPath := config.ResolveRuntimeDataDir("")
|
|
|
|
persistence := config.NewConfigPersistence(configPath)
|
|
if err := persistence.ImportConfig(encryptedData, configPass); err != nil {
|
|
return fmt.Errorf("failed to auto-import configuration: %w", err)
|
|
}
|
|
|
|
fmt.Fprintln(cmd.OutOrStdout(), "Configuration auto-imported successfully")
|
|
return nil
|
|
},
|
|
}
|
|
|
|
configCmd.AddCommand(configInfoCmd)
|
|
configCmd.AddCommand(configExportCmd)
|
|
configCmd.AddCommand(configImportCmd)
|
|
configCmd.AddCommand(configAutoImportCmd)
|
|
|
|
if deps != nil && deps.ExportFile != nil && deps.Passphrase != nil {
|
|
configExportCmd.Flags().StringVarP(deps.ExportFile, "output", "o", "", "Output file for encrypted configuration")
|
|
configExportCmd.Flags().StringVarP(deps.Passphrase, "passphrase", "p", "", "Passphrase for encryption (or use PULSE_PASSPHRASE env var)")
|
|
}
|
|
if deps != nil && deps.ImportFile != nil && deps.Passphrase != nil && deps.ForceImport != nil {
|
|
configImportCmd.Flags().StringVarP(deps.ImportFile, "input", "i", "", "Input file with encrypted configuration")
|
|
configImportCmd.Flags().StringVarP(deps.Passphrase, "passphrase", "p", "", "Passphrase for decryption (or use PULSE_PASSPHRASE env var)")
|
|
configImportCmd.Flags().BoolVarP(deps.ForceImport, "force", "f", false, "Force import without confirmation")
|
|
}
|
|
|
|
return configCmd
|
|
}
|