diff --git a/cmd/proxsave/base_dir_test.go b/cmd/proxsave/base_dir_test.go new file mode 100644 index 00000000..4d29adca --- /dev/null +++ b/cmd/proxsave/base_dir_test.go @@ -0,0 +1,28 @@ +package main + +import ( + "path/filepath" + "sync" + "testing" +) + +func forceDetectedBaseDirForTest(t *testing.T, baseDir string) { + t.Helper() + origInfo := execInfo + origOnce := execInfoOnce + + execPath := filepath.Join(baseDir, "build", "proxsave") + execInfo = ExecInfo{ + ExecPath: execPath, + ExecDir: filepath.Dir(execPath), + BaseDir: baseDir, + HasBase: true, + } + execInfoOnce = sync.Once{} + execInfoOnce.Do(func() {}) + + t.Cleanup(func() { + execInfo = origInfo + execInfoOnce = origOnce + }) +} diff --git a/cmd/proxsave/config_helpers.go b/cmd/proxsave/config_helpers.go index 0d3d0ca4..bab65581 100644 --- a/cmd/proxsave/config_helpers.go +++ b/cmd/proxsave/config_helpers.go @@ -24,10 +24,7 @@ func resolveInstallConfigPath(configPath string) (string, error) { return configPath, nil } - baseDir, ok := detectBaseDir() - if !ok { - return "", fmt.Errorf("unable to determine base directory for configuration") - } + baseDir, _ := detectedBaseDirOrFallback() return filepath.Join(baseDir, configPath), nil } @@ -52,6 +49,37 @@ func setEnvValue(template, key, value string) string { return utils.SetEnvValue(template, key, value) } +func unsetEnvValue(template, key string) string { + key = strings.TrimSpace(key) + if key == "" { + return template + } + + lines := strings.Split(template, "\n") + out := make([]string, 0, len(lines)) + for _, line := range lines { + trimmed := strings.TrimSpace(line) + if utils.IsComment(trimmed) { + out = append(out, line) + continue + } + parts := strings.SplitN(trimmed, "=", 2) + if len(parts) != 2 { + out = append(out, line) + continue + } + parsedKey := strings.TrimSpace(parts[0]) + if fields := strings.Fields(parsedKey); len(fields) >= 2 && fields[0] == "export" { + parsedKey = fields[1] + } + if strings.EqualFold(parsedKey, key) { + continue + } + out = append(out, line) + } + return strings.Join(out, "\n") +} + func sanitizeEnvValue(value string) string { value = strings.Map(func(r rune) rune { if r == '\n' || r == '\r' || r == '\x00' { diff --git a/cmd/proxsave/decrypt_cmd.go b/cmd/proxsave/decrypt_cmd.go index 8939f808..5533fb54 100644 --- a/cmd/proxsave/decrypt_cmd.go +++ b/cmd/proxsave/decrypt_cmd.go @@ -31,22 +31,12 @@ func runDecryptWorkflowOnly(ctx context.Context, configPath string, bootstrap *l return err } - cfg, err := config.LoadConfig(configPath) + autoBaseDir, _ := detectedBaseDirOrFallback() + cfg, err := config.LoadConfigWithBaseDir(configPath, autoBaseDir) if err != nil { return fmt.Errorf("failed to load configuration: %w", err) } - autoBaseDir, _ := detectBaseDir() - if cfg.BaseDir == "" { - if autoBaseDir == "" { - if _, err := os.Stat("/opt/proxsave"); err == nil { - autoBaseDir = "/opt/proxsave" - } else { - autoBaseDir = "/opt/proxmox-backup" - } - } - cfg.BaseDir = autoBaseDir - } _ = os.Setenv("BASE_DIR", cfg.BaseDir) logLevel := cfg.DebugLevel diff --git a/cmd/proxsave/encryption_setup.go b/cmd/proxsave/encryption_setup.go index 0d493c36..feaa42c0 100644 --- a/cmd/proxsave/encryption_setup.go +++ b/cmd/proxsave/encryption_setup.go @@ -20,7 +20,8 @@ type encryptionSetupResult struct { } func runInitialEncryptionSetupWithUI(ctx context.Context, configPath string, ui orchestrator.AgeSetupUI) (*encryptionSetupResult, error) { - cfg, err := config.LoadConfig(configPath) + baseDir, _ := detectedBaseDirOrFallback() + cfg, err := config.LoadConfigWithBaseDir(configPath, baseDir) if err != nil { return nil, fmt.Errorf("failed to reload configuration after install: %w", err) } diff --git a/cmd/proxsave/encryption_setup_test.go b/cmd/proxsave/encryption_setup_test.go index 72456499..b68ba6b7 100644 --- a/cmd/proxsave/encryption_setup_test.go +++ b/cmd/proxsave/encryption_setup_test.go @@ -21,6 +21,7 @@ func TestRunInitialEncryptionSetupWithUIReloadsConfig(t *testing.T) { } baseDir := t.TempDir() + forceDetectedBaseDirForTest(t, baseDir) configPath := filepath.Join(baseDir, "env", "backup.env") if err := os.MkdirAll(filepath.Dir(configPath), 0o700); err != nil { t.Fatalf("MkdirAll: %v", err) @@ -59,6 +60,7 @@ func TestRunInitialEncryptionSetupWithUIUsesProvidedUI(t *testing.T) { } baseDir := t.TempDir() + forceDetectedBaseDirForTest(t, baseDir) configPath := filepath.Join(baseDir, "env", "backup.env") if err := os.MkdirAll(filepath.Dir(configPath), 0o700); err != nil { t.Fatalf("MkdirAll: %v", err) @@ -107,6 +109,7 @@ func TestRunInitialEncryptionSetupWithUIReusesExistingFileWithoutReportingWrite( } baseDir := t.TempDir() + forceDetectedBaseDirForTest(t, baseDir) recipientPath := filepath.Join(baseDir, "identity", "age", "recipient.txt") if err := os.MkdirAll(filepath.Dir(recipientPath), 0o700); err != nil { t.Fatalf("MkdirAll: %v", err) diff --git a/cmd/proxsave/helpers_test.go b/cmd/proxsave/helpers_test.go index dd3490f2..2b93cb7b 100644 --- a/cmd/proxsave/helpers_test.go +++ b/cmd/proxsave/helpers_test.go @@ -741,10 +741,11 @@ func TestFeaturesNeedNetwork(t *testing.T) { func TestDisableNetworkFeaturesForRun(t *testing.T) { cfg := &config.Config{ - TelegramEnabled: true, - EmailEnabled: true, - EmailDeliveryMethod: "relay", - CloudEnabled: true, + TelegramEnabled: true, + EmailEnabled: true, + EmailDeliveryMethod: "relay", + EmailFallbackSendmail: true, + CloudEnabled: true, } // Mock bootstrap logger (nil is ok for this test) @@ -757,9 +758,9 @@ func TestDisableNetworkFeaturesForRun(t *testing.T) { t.Error("CloudEnabled should be disabled") } - // Email with relay should be disabled - if cfg.EmailEnabled && cfg.EmailDeliveryMethod == "relay" { - t.Error("Email relay should be disabled") + // Email relay should switch to local sendmail fallback when enabled. + if !cfg.EmailEnabled || cfg.EmailDeliveryMethod != "sendmail" { + t.Errorf("Email relay should switch to sendmail fallback, enabled=%v method=%q", cfg.EmailEnabled, cfg.EmailDeliveryMethod) } } diff --git a/cmd/proxsave/install.go b/cmd/proxsave/install.go index 4b8609cd..99ae3e09 100644 --- a/cmd/proxsave/install.go +++ b/cmd/proxsave/install.go @@ -43,7 +43,7 @@ func runInstall(ctx context.Context, configPath string, bootstrap *logging.Boots } configPath = resolvedPath - baseDir := deriveBaseDirFromConfig(configPath) + baseDir, _ := detectedBaseDirOrFallback() _ = os.Setenv("BASE_DIR", baseDir) done := logging.DebugStartBootstrap(bootstrap, "install workflow (cli)", "config=%s base=%s", configPath, baseDir) @@ -393,14 +393,6 @@ func printInstallFooter(installErr error, configPath, baseDir, telegramCode, per fmt.Println() } -func deriveBaseDirFromConfig(configPath string) string { - baseDir := filepath.Dir(filepath.Dir(configPath)) - if baseDir == "" || baseDir == "." || baseDir == string(filepath.Separator) { - baseDir = "/opt/proxsave" - } - return baseDir -} - func cleanupTempConfig(tmpConfigPath string) { if tmpConfigPath == "" { return @@ -461,6 +453,7 @@ func runConfigWizardCLI(ctx context.Context, reader *bufio.Reader, configPath, t if skipConfigWizard { return installConfigResult{SkipConfigWizard: true}, nil } + template = config.RemoveRuntimeDerivedEnvKeys(template) logging.DebugStepBootstrap(bootstrap, "install config wizard (cli)", "configuring secondary storage") if template, err = configureSecondaryStorage(ctx, reader, template); err != nil { @@ -497,6 +490,7 @@ func runConfigWizardCLI(ctx context.Context, reader *bufio.Reader, configPath, t } logging.DebugStepBootstrap(bootstrap, "install config wizard (cli)", "writing configuration") + template = config.RemoveRuntimeDerivedEnvKeys(template) if err := writeConfigFile(configPath, tmpConfigPath, template); err != nil { return installConfigResult{}, err } @@ -750,13 +744,20 @@ func configureNotifications(ctx context.Context, reader *bufio.Reader, template } fmt.Println("\n--- Email ---") - enableEmail, err := promptYesNo(ctx, reader, "Enable email notifications (central relay)? [y/N]: ", false) + fmt.Println("Default email delivery uses the TIS24 cloud relay, with local sendmail as failover.") + fmt.Println("ProxSave does not collect raw SMTP settings; choose pmf only when Proxmox Notifications is configured.") + enableEmail, err := promptYesNo(ctx, reader, "Enable email notifications? [y/N]: ", false) if err != nil { return "", err } if enableEmail { + method, err := promptEmailDeliveryMethod(ctx, reader, "relay") + if err != nil { + return "", err + } template = setEnvValue(template, "EMAIL_ENABLED", "true") - template = setEnvValue(template, "EMAIL_DELIVERY_METHOD", "relay") + template = setEnvValue(template, "EMAIL_DELIVERY_METHOD", method) + template = unsetEnvValue(template, "EMAIL_FALLBACK_PMF") template = setEnvValue(template, "EMAIL_FALLBACK_SENDMAIL", "true") } else { template = setEnvValue(template, "EMAIL_ENABLED", "false") @@ -764,6 +765,34 @@ func configureNotifications(ctx context.Context, reader *bufio.Reader, template return template, nil } +func promptEmailDeliveryMethod(ctx context.Context, reader *bufio.Reader, defaultMethod string) (string, error) { + defaultMethod = config.NormalizeEmailDeliveryMethod(defaultMethod) + if defaultMethod != "relay" && defaultMethod != "sendmail" && defaultMethod != "pmf" { + defaultMethod = "relay" + } + + fmt.Println("Email delivery methods:") + fmt.Println(" relay TIS24 cloud relay over outbound HTTPS (default)") + fmt.Println(" sendmail Local /usr/sbin/sendmail (fallback/default failover; requires a local MTA)") + fmt.Println(" pmf Proxmox Notifications via proxmox-mail-forward (SMTP lives in Proxmox)") + for { + resp, err := promptOptional(ctx, reader, fmt.Sprintf("Email delivery method [%s]: ", defaultMethod)) + if err != nil { + return "", err + } + method := defaultMethod + if strings.TrimSpace(resp) != "" { + method = config.NormalizeEmailDeliveryMethod(resp) + } + switch method { + case "pmf", "relay", "sendmail": + return method, nil + default: + fmt.Println("Please enter 'pmf', 'relay', or 'sendmail'. Aliases like 'proxmox-notifications' are accepted for pmf.") + } + } +} + func configureEncryption(ctx context.Context, reader *bufio.Reader, template *string) (bool, error) { fmt.Println("\n--- Encryption ---") enableEncryption, err := promptYesNo(ctx, reader, "Enable backup encryption? [y/N]: ", false) diff --git a/cmd/proxsave/install_helpers_test.go b/cmd/proxsave/install_helpers_test.go index c224222f..5af4ef55 100644 --- a/cmd/proxsave/install_helpers_test.go +++ b/cmd/proxsave/install_helpers_test.go @@ -6,24 +6,64 @@ import ( "testing" ) -func TestDeriveBaseDirFromConfig(t *testing.T) { - tests := []struct { - name string - configPath string - want string - }{ - {"typical", "/opt/proxsave/env/backup.env", "/opt/proxsave"}, - {"root file fallback", "/backup.env", "/opt/proxsave"}, - {"relative fallback", "backup.env", "/opt/proxsave"}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - if got := deriveBaseDirFromConfig(tt.configPath); got != tt.want { - t.Fatalf("deriveBaseDirFromConfig(%q) = %q, want %q", tt.configPath, got, tt.want) - } - }) - } +func TestResolveBaseDirFromExecutablePath(t *testing.T) { + t.Run("standard proxsave build layout", func(t *testing.T) { + got, found := resolveBaseDirFromExecutablePath("/opt/proxsave/build/proxsave") + if !found || got != "/opt/proxsave" { + t.Fatalf("base dir = %q, found=%v; want /opt/proxsave true", got, found) + } + }) + + t.Run("standard legacy build layout", func(t *testing.T) { + got, found := resolveBaseDirFromExecutablePath("/opt/proxmox-backup/build/proxsave") + if !found || got != "/opt/proxmox-backup" { + t.Fatalf("base dir = %q, found=%v; want /opt/proxmox-backup true", got, found) + } + }) + + t.Run("symlink to installed binary", func(t *testing.T) { + root := t.TempDir() + base := filepath.Join(root, "proxsave") + build := filepath.Join(base, "build") + if err := os.MkdirAll(build, 0o755); err != nil { + t.Fatalf("MkdirAll: %v", err) + } + target := filepath.Join(build, "proxsave") + if err := os.WriteFile(target, []byte("#!/bin/sh\n"), 0o755); err != nil { + t.Fatalf("WriteFile: %v", err) + } + linkDir := filepath.Join(root, "bin") + if err := os.MkdirAll(linkDir, 0o755); err != nil { + t.Fatalf("MkdirAll link dir: %v", err) + } + link := filepath.Join(linkDir, "proxsave") + if err := os.Symlink(target, link); err != nil { + t.Fatalf("Symlink: %v", err) + } + + got, found := resolveBaseDirFromExecutablePath(link) + if !found || got != base { + t.Fatalf("base dir = %q, found=%v; want %q true", got, found, base) + } + }) + + t.Run("install marker", func(t *testing.T) { + base := t.TempDir() + if err := os.MkdirAll(filepath.Join(base, "configs"), 0o755); err != nil { + t.Fatalf("MkdirAll marker: %v", err) + } + got, found := resolveBaseDirFromExecutablePath(filepath.Join(base, "bin", "proxsave")) + if !found || got != base { + t.Fatalf("base dir = %q, found=%v; want %q true", got, found, base) + } + }) + + t.Run("fallback", func(t *testing.T) { + got, found := resolveBaseDirFromExecutablePath(filepath.Join(t.TempDir(), "proxsave")) + if found || got != "/opt/proxsave" { + t.Fatalf("base dir = %q, found=%v; want /opt/proxsave false", got, found) + } + }) } func TestCleanupTempConfig(t *testing.T) { diff --git a/cmd/proxsave/install_test.go b/cmd/proxsave/install_test.go index 46ae7e6b..70e54e2c 100644 --- a/cmd/proxsave/install_test.go +++ b/cmd/proxsave/install_test.go @@ -544,6 +544,39 @@ func TestConfigureNotifications(t *testing.T) { } } +func TestConfigureNotificationsEmailDefaultsToRelaySendmailFallback(t *testing.T) { + var result string + var err error + ctx := context.Background() + reader := bufio.NewReader(strings.NewReader("n\ny\n\n")) + captureStdout(t, func() { + result, err = configureNotifications(ctx, reader, "") + }) + if err != nil { + t.Fatalf("configureNotifications error: %v", err) + } + for _, want := range []string{ + "TELEGRAM_ENABLED=false", + "EMAIL_ENABLED=true", + "EMAIL_DELIVERY_METHOD=relay", + "EMAIL_FALLBACK_SENDMAIL=true", + } { + if !strings.Contains(result, want) { + t.Fatalf("missing %q in template: %q", want, result) + } + } +} + +func TestPromptEmailDeliveryMethodAcceptsProxmoxAlias(t *testing.T) { + method, err := promptEmailDeliveryMethod(context.Background(), bufio.NewReader(strings.NewReader("proxmox-notifications\n")), "relay") + if err != nil { + t.Fatalf("promptEmailDeliveryMethod error: %v", err) + } + if method != "pmf" { + t.Fatalf("method=%q, want pmf", method) + } +} + func TestConfigureEncryption(t *testing.T) { var enabled bool var err error @@ -656,6 +689,34 @@ func TestRunConfigWizardCLIReturnsCronSchedule(t *testing.T) { } } +func TestRunConfigWizardCLIEditExistingRemovesRuntimeDerivedKeys(t *testing.T) { + cfgFile := createTempFile(t, "BASE_DIR=/custom\nCRON_HOUR=2\nMARKER=1\n") + tmpConfigPath := cfgFile + ".tmp" + reader := bufio.NewReader(strings.NewReader("2\nn\nn\nn\nn\nn\nn\n03:15\n")) + + var err error + captureStdout(t, func() { + _, err = runConfigWizardCLI(context.Background(), reader, cfgFile, tmpConfigPath, "/opt/proxsave", nil) + }) + if err != nil { + t.Fatalf("runConfigWizardCLI returned error: %v", err) + } + + content, readErr := os.ReadFile(cfgFile) + if readErr != nil { + t.Fatalf("expected config file to be written: %v", readErr) + } + values := parseWrittenEnvForTest(string(content)) + for _, key := range []string{"BASE_DIR", "CRON_SCHEDULE", "CRON_HOUR", "CRON_MINUTE"} { + if _, ok := values[key]; ok { + t.Fatalf("expected %s to be removed from config:\n%s", key, content) + } + } + if values["MARKER"] != "1" { + t.Fatalf("expected existing MARKER to be preserved, got %q in:\n%s", values["MARKER"], content) + } +} + func TestRunConfigWizardCLISkipLeavesCronScheduleEmpty(t *testing.T) { cfgFile := createTempFile(t, "EXISTING=1\n") tmpConfigPath := cfgFile + ".tmp" @@ -714,3 +775,23 @@ func createTempFile(t *testing.T, content string) string { _ = f.Close() return f.Name() } + +func parseWrittenEnvForTest(content string) map[string]string { + values := map[string]string{} + for _, line := range strings.Split(content, "\n") { + trimmed := strings.TrimSpace(line) + if trimmed == "" || strings.HasPrefix(trimmed, "#") { + continue + } + parts := strings.SplitN(trimmed, "=", 2) + if len(parts) != 2 { + continue + } + key := strings.TrimSpace(parts[0]) + if fields := strings.Fields(key); len(fields) >= 2 && fields[0] == "export" { + key = fields[1] + } + values[strings.ToUpper(key)] = strings.TrimSpace(parts[1]) + } + return values +} diff --git a/cmd/proxsave/install_tui.go b/cmd/proxsave/install_tui.go index bb9555d0..124afe95 100644 --- a/cmd/proxsave/install_tui.go +++ b/cmd/proxsave/install_tui.go @@ -23,8 +23,8 @@ func runInstallTUI(ctx context.Context, configPath string, bootstrap *logging.Bo } configPath = resolvedPath - // Derive BASE_DIR from the configuration path - baseDir := deriveBaseDirFromConfig(configPath) + // Derive BASE_DIR from the installed executable path. + baseDir, _ := detectedBaseDirOrFallback() _ = os.Setenv("BASE_DIR", baseDir) // Before starting the TUI wizard, perform a best-effort cleanup of any existing diff --git a/cmd/proxsave/main_config_modes.go b/cmd/proxsave/main_config_modes.go index 3ee7383c..1b9c7dde 100644 --- a/cmd/proxsave/main_config_modes.go +++ b/cmd/proxsave/main_config_modes.go @@ -25,7 +25,8 @@ func runUpgradeConfigJSONMode(args *cli.Args) (int, bool) { return types.ExitConfigError.Int(), true } - result, err := config.UpgradeConfigFile(args.ConfigPath) + baseDir, _ := detectedBaseDirOrFallback() + result, err := config.UpgradeConfigFileWithBaseDir(args.ConfigPath, baseDir) if err != nil { fmt.Fprintf(os.Stderr, "ERROR: Failed to upgrade configuration: %v\n", err) return types.ExitConfigError.Int(), true @@ -66,7 +67,8 @@ func runUpgradeConfigMode(_ context.Context, args *cli.Args, bootstrap *logging. } bootstrap.Printf("Upgrading configuration file: %s", args.ConfigPath) - result, err := config.UpgradeConfigFile(args.ConfigPath) + baseDir, _ := detectedBaseDirOrFallback() + result, err := config.UpgradeConfigFileWithBaseDir(args.ConfigPath, baseDir) if err != nil { bootstrap.Error("ERROR: Failed to upgrade configuration: %v", err) return types.ExitConfigError.Int(), true diff --git a/cmd/proxsave/main_network.go b/cmd/proxsave/main_network.go index c0f5af30..a05222fb 100644 --- a/cmd/proxsave/main_network.go +++ b/cmd/proxsave/main_network.go @@ -27,7 +27,7 @@ func featuresNeedNetwork(cfg *config.Config) (bool, []string) { } } // Email via relay - if cfg.EmailEnabled && strings.EqualFold(cfg.EmailDeliveryMethod, "relay") { + if cfg.EmailEnabled && config.NormalizeEmailDeliveryMethod(cfg.EmailDeliveryMethod) == "relay" { reasons = append(reasons, "Email relay delivery") } // Gotify @@ -83,7 +83,7 @@ func cloudNetworkEnabled(cfg *config.Config) bool { return cfg.CloudEnabled } func telegramNetworkEnabled(cfg *config.Config) bool { return cfg.TelegramEnabled } func emailRelayNetworkEnabled(cfg *config.Config) bool { - return cfg.EmailEnabled && strings.EqualFold(cfg.EmailDeliveryMethod, "relay") + return cfg.EmailEnabled && config.NormalizeEmailDeliveryMethod(cfg.EmailDeliveryMethod) == "relay" } func gotifyNetworkEnabled(cfg *config.Config) bool { return cfg.GotifyEnabled } diff --git a/cmd/proxsave/main_runtime.go b/cmd/proxsave/main_runtime.go index 96dc9b8b..434aa921 100644 --- a/cmd/proxsave/main_runtime.go +++ b/cmd/proxsave/main_runtime.go @@ -85,18 +85,10 @@ func bootstrapRuntime(ctx context.Context, args *cli.Args, bootstrap *logging.Bo func loadRunConfig(args *cli.Args, bootstrap *logging.BootstrapLogger) (*config.Config, string, bool, int, bool) { autoBaseDir, autoFound := detectBaseDir() if autoBaseDir == "" { - if _, err := os.Stat("/opt/proxsave"); err == nil { - autoBaseDir = "/opt/proxsave" - } else { - autoBaseDir = "/opt/proxmox-backup" - } + autoBaseDir = fallbackBaseDir() } initialEnvBaseDir := os.Getenv("BASE_DIR") - if initialEnvBaseDir == "" { - _ = os.Setenv("BASE_DIR", autoBaseDir) - } - if err := ensureConfigExists(args.ConfigPath, bootstrap); err != nil { bootstrap.Error("ERROR: %v", err) return nil, "", false, types.ExitConfigError.Int(), false @@ -104,14 +96,11 @@ func loadRunConfig(args *cli.Args, bootstrap *logging.BootstrapLogger) (*config. bootstrap.Printf("Loading configuration from: %s", args.ConfigPath) logging.DebugStepBootstrap(bootstrap, "main run", "loading configuration") - cfg, err := config.LoadConfig(args.ConfigPath) + cfg, err := config.LoadConfigWithBaseDir(args.ConfigPath, autoBaseDir) if err != nil { bootstrap.Error("ERROR: Failed to load configuration: %v", err) return nil, "", false, types.ExitConfigError.Int(), false } - if cfg.BaseDir == "" { - cfg.BaseDir = autoBaseDir - } _ = os.Setenv("BASE_DIR", cfg.BaseDir) bootstrap.Println("✓ Configuration loaded successfully") return cfg, initialEnvBaseDir, autoFound, types.ExitSuccess.Int(), true diff --git a/cmd/proxsave/main_runtime_log.go b/cmd/proxsave/main_runtime_log.go index 2031a558..a0169189 100644 --- a/cmd/proxsave/main_runtime_log.go +++ b/cmd/proxsave/main_runtime_log.go @@ -1,11 +1,7 @@ // Package main contains the proxsave command entrypoint. package main -import ( - "strings" - - "github.com/tis24dev/proxsave/internal/logging" -) +import "github.com/tis24dev/proxsave/internal/logging" func logRunContext(rt *appRuntime) { logRunDryRunStatus(rt) @@ -17,6 +13,7 @@ func logRunContext(rt *appRuntime) { logging.Info("Compression: %s (level %d, mode %s)", rt.cfg.CompressionType, rt.cfg.CompressionLevel, rt.cfg.CompressionMode) logging.Info("Base directory: %s (%s)", rt.cfg.BaseDir, baseDirSource) logging.Info("Configuration file: %s (%s)", rt.args.ConfigPath, runConfigPathSource(rt)) + logIgnoredBaseDirOverrides(rt) } func logRunDryRunStatus(rt *appRuntime) { @@ -31,18 +28,24 @@ func logRunDryRunStatus(rt *appRuntime) { } func runBaseDirSource(rt *appRuntime) string { - if rawBaseDir, ok := rt.cfg.Get("BASE_DIR"); ok && strings.TrimSpace(rawBaseDir) != "" { - return "configured in backup.env" - } - if rt.initialEnvBaseDir != "" { - return "from environment (BASE_DIR)" - } if rt.autoBaseDirFound { return "auto-detected from executable path" } return "default fallback" } +func logIgnoredBaseDirOverrides(rt *appRuntime) { + if rt == nil || rt.cfg == nil { + return + } + if val, ok := rt.cfg.IgnoredBaseDirConfig(); ok { + logging.Warning("Ignoring deprecated BASE_DIR from backup.env (%q); using detected base directory %s", val, rt.cfg.BaseDir) + } + if val, ok := rt.cfg.IgnoredBaseDirEnv(); ok { + logging.Warning("Ignoring deprecated BASE_DIR from environment (%q); using detected base directory %s", val, rt.cfg.BaseDir) + } +} + func runConfigPathSource(rt *appRuntime) string { if rt.args.ConfigPathSource != "" { return rt.args.ConfigPathSource diff --git a/cmd/proxsave/new_install.go b/cmd/proxsave/new_install.go index 357c21cd..9ee6fb35 100644 --- a/cmd/proxsave/new_install.go +++ b/cmd/proxsave/new_install.go @@ -29,9 +29,10 @@ func buildNewInstallPlan(configPath string) (newInstallPlan, error) { buildSig = "n/a" } + baseDir, _ := detectedBaseDirOrFallback() return newInstallPlan{ ResolvedConfigPath: resolvedPath, - BaseDir: deriveBaseDirFromConfig(resolvedPath), + BaseDir: baseDir, BuildSignature: buildSig, PreservedEntries: newInstallPreservedEntries(), }, nil diff --git a/cmd/proxsave/new_install_test.go b/cmd/proxsave/new_install_test.go index 2daaa26f..8ced7df5 100644 --- a/cmd/proxsave/new_install_test.go +++ b/cmd/proxsave/new_install_test.go @@ -57,8 +57,9 @@ func TestNewInstallPreservedEntries(t *testing.T) { } func TestBuildNewInstallPlan(t *testing.T) { - baseDir := t.TempDir() - configPath := filepath.Join(baseDir, "env", "backup.env") + configRoot := t.TempDir() + configPath := filepath.Join(configRoot, "env", "backup.env") + wantBaseDir, _ := detectedBaseDirOrFallback() plan, err := buildNewInstallPlan(configPath) if err != nil { @@ -67,8 +68,8 @@ func TestBuildNewInstallPlan(t *testing.T) { if plan.ResolvedConfigPath != configPath { t.Fatalf("resolved config path = %q, want %q", plan.ResolvedConfigPath, configPath) } - if plan.BaseDir != baseDir { - t.Fatalf("base dir = %q, want %q", plan.BaseDir, baseDir) + if plan.BaseDir != wantBaseDir { + t.Fatalf("base dir = %q, want %q", plan.BaseDir, wantBaseDir) } if strings.TrimSpace(plan.BuildSignature) == "" { t.Fatalf("build signature should not be empty") @@ -82,6 +83,7 @@ func TestBuildNewInstallPlanUsesNAWhenBuildSignatureBlank(t *testing.T) { registerNewInstallBuildSignature(t, func() string { return " " }) baseDir := t.TempDir() + forceDetectedBaseDirForTest(t, baseDir) configPath := filepath.Join(baseDir, "env", "backup.env") plan, err := buildNewInstallPlan(configPath) @@ -215,6 +217,7 @@ func TestRunNewInstallCLIUsesCLIConfirmOnly(t *testing.T) { }() baseDir := t.TempDir() + forceDetectedBaseDirForTest(t, baseDir) configPath := filepath.Join(baseDir, "env", "backup.env") stalePath := filepath.Join(baseDir, "stale.txt") if err := os.WriteFile(stalePath, []byte("stale"), 0o600); err != nil { @@ -277,6 +280,7 @@ func TestRunNewInstallCancelSkipsReset(t *testing.T) { }() baseDir := t.TempDir() + forceDetectedBaseDirForTest(t, baseDir) configPath := filepath.Join(baseDir, "env", "backup.env") markerPath := filepath.Join(baseDir, "marker.txt") if err := os.WriteFile(markerPath, []byte("keep"), 0o600); err != nil { @@ -320,6 +324,7 @@ func TestRunNewInstallTUIPassesContextToConfirm(t *testing.T) { }() baseDir := t.TempDir() + forceDetectedBaseDirForTest(t, baseDir) configPath := filepath.Join(baseDir, "env", "backup.env") ctx := t.Context() @@ -367,6 +372,7 @@ func TestRunNewInstallTUIUsesTUIConfirmAndRunInstallTUI(t *testing.T) { }() baseDir := t.TempDir() + forceDetectedBaseDirForTest(t, baseDir) configPath := filepath.Join(baseDir, "env", "backup.env") stalePath := filepath.Join(baseDir, "stale.txt") if err := os.WriteFile(stalePath, []byte("stale"), 0o600); err != nil { diff --git a/cmd/proxsave/newkey.go b/cmd/proxsave/newkey.go index 5adbf853..be80f3f4 100644 --- a/cmd/proxsave/newkey.go +++ b/cmd/proxsave/newkey.go @@ -52,15 +52,7 @@ func runNewKey(ctx context.Context, configPath string, logLevel types.LogLevel, } configPath = resolvedPath - // Derive BASE_DIR from the configuration path - baseDir := filepath.Dir(filepath.Dir(configPath)) - if baseDir == "" || baseDir == "." || baseDir == string(filepath.Separator) { - if _, err := os.Stat("/opt/proxsave"); err == nil { - baseDir = "/opt/proxsave" - } else { - baseDir = "/opt/proxmox-backup" - } - } + baseDir, _ := detectedBaseDirOrFallback() _ = os.Setenv("BASE_DIR", baseDir) logging.DebugStepBootstrap(bootstrap, "newkey workflow", "config=%s base=%s", configPath, baseDir) @@ -133,7 +125,7 @@ func loadNewKeyConfig(configPath, baseDir string) (*config.Config, string, error } if _, err := os.Stat(configPath); err == nil { - loaded, err := config.LoadConfig(configPath) + loaded, err := config.LoadConfigWithBaseDir(configPath, baseDir) if err != nil { return nil, "", fmt.Errorf("load configuration for newkey: %w", err) } diff --git a/cmd/proxsave/permissions.go b/cmd/proxsave/permissions.go index 4631d0d7..b063f601 100644 --- a/cmd/proxsave/permissions.go +++ b/cmd/proxsave/permissions.go @@ -163,7 +163,10 @@ func fixPermissionsAfterInstall(ctx context.Context, configPath, baseDir string, return "skipped", "permissions normalization skipped (configuration path unavailable)" } - cfg, err := config.LoadConfig(configPath) + if baseDir == "" { + baseDir, _ = detectedBaseDirOrFallback() + } + cfg, err := config.LoadConfigWithBaseDir(configPath, baseDir) if err != nil { if bootstrap != nil { bootstrap.Warning("Post-install: skipping permission fix, failed to load configuration: %v", err) @@ -171,10 +174,6 @@ func fixPermissionsAfterInstall(ctx context.Context, configPath, baseDir string, return "error", "unable to normalize permissions: failed to load configuration (see log)" } - if strings.TrimSpace(cfg.BaseDir) == "" && baseDir != "" { - cfg.BaseDir = baseDir - } - logger := logging.New(types.LogLevelInfo, cfg.UseColor) // Force-enable security checks in a safe, non-blocking way for install. diff --git a/cmd/proxsave/runtime_helpers.go b/cmd/proxsave/runtime_helpers.go index 2de51d52..9c04fa55 100644 --- a/cmd/proxsave/runtime_helpers.go +++ b/cmd/proxsave/runtime_helpers.go @@ -80,18 +80,65 @@ func detectExecInfo() ExecInfo { } execDir := filepath.Dir(execPath) - dir := execDir - originalDir := dir - baseDir := "" + baseDir, hasBase := resolveBaseDirFromExecutablePath(execPath) + return ExecInfo{ + ExecPath: execPath, + ExecDir: execDir, + BaseDir: baseDir, + HasBase: hasBase, + } +} + +func detectBaseDir() (string, bool) { + info := getExecInfo() + return info.BaseDir, info.HasBase +} + +func detectedBaseDirOrFallback() (string, bool) { + baseDir, found := detectBaseDir() + if strings.TrimSpace(baseDir) == "" { + return fallbackBaseDir(), false + } + return baseDir, found +} + +func resolveBaseDirFromExecutablePath(execPath string) (string, bool) { + clean := filepath.Clean(strings.TrimSpace(execPath)) + if clean == "" || clean == "." { + return fallbackBaseDir(), false + } + if resolved, err := filepath.EvalSymlinks(clean); err == nil && strings.TrimSpace(resolved) != "" { + clean = filepath.Clean(resolved) + } + if baseDir, ok := baseDirFromStandardExecutableLayout(clean); ok { + return baseDir, true + } + if baseDir, ok := baseDirFromInstallMarkers(filepath.Dir(clean)); ok { + return baseDir, true + } + return fallbackBaseDir(), false +} + +func baseDirFromStandardExecutableLayout(execPath string) (string, bool) { + dir := filepath.Dir(execPath) + if filepath.Base(dir) != "build" { + return "", false + } + parent := filepath.Dir(dir) + if parent == "" || parent == "." || parent == string(filepath.Separator) { + return "", false + } + return parent, true +} + +func baseDirFromInstallMarkers(startDir string) (string, bool) { + dir := filepath.Clean(strings.TrimSpace(startDir)) for dir != "" && dir != "." { - if info, err := os.Stat(filepath.Join(dir, "env")); err == nil && info.IsDir() { - baseDir = dir - break - } - if info, err := os.Stat(filepath.Join(dir, "script")); err == nil && info.IsDir() { - baseDir = dir - break + for _, marker := range []string{"configs", "env", "script", "identity"} { + if info, err := os.Stat(filepath.Join(dir, marker)); err == nil && info.IsDir() { + return dir, true + } } parent := filepath.Dir(dir) if parent == dir { @@ -99,24 +146,17 @@ func detectExecInfo() ExecInfo { } dir = parent } + return "", false +} - if baseDir == "" { - if parent := filepath.Dir(originalDir); parent != "" && parent != "." && parent != string(filepath.Separator) { - baseDir = parent - } +func fallbackBaseDir() string { + if info, err := os.Stat("/opt/proxsave"); err == nil && info.IsDir() { + return "/opt/proxsave" } - - return ExecInfo{ - ExecPath: execPath, - ExecDir: execDir, - BaseDir: baseDir, - HasBase: baseDir != "", + if info, err := os.Stat("/opt/proxmox-backup"); err == nil && info.IsDir() { + return "/opt/proxmox-backup" } -} - -func detectBaseDir() (string, bool) { - info := getExecInfo() - return info.BaseDir, info.HasBase + return "/opt/proxsave" } func collectExecPathCandidates() []string { diff --git a/cmd/proxsave/upgrade.go b/cmd/proxsave/upgrade.go index dbfcefb3..77ac5c20 100644 --- a/cmd/proxsave/upgrade.go +++ b/cmd/proxsave/upgrade.go @@ -44,10 +44,7 @@ type releaseInfo struct { // - upgrades backup.env by adding missing keys from the new template (preserving existing values) // - refreshes symlinks/cron/docs and normalizes permissions/ownership func runUpgrade(ctx context.Context, args *cli.Args, bootstrap *logging.BootstrapLogger) int { - baseDir := filepath.Dir(filepath.Dir(args.ConfigPath)) - if baseDir == "" || baseDir == "." || baseDir == string(filepath.Separator) { - baseDir = "/opt/proxsave" - } + baseDir, _ := detectedBaseDirOrFallback() _ = os.Setenv("BASE_DIR", baseDir) logLevel := types.LogLevelInfo @@ -92,15 +89,12 @@ func runUpgrade(ctx context.Context, args *cli.Args, bootstrap *logging.Bootstra bootstrap.Printf("Base directory: %s", baseDir) bootstrap.Println("") - cfg, err := config.LoadConfig(args.ConfigPath) + _, err := config.LoadConfigWithBaseDir(args.ConfigPath, baseDir) if err != nil { bootstrap.Error("ERROR: Failed to load configuration: %v", err) workflowErr = err return types.ExitConfigError.Int() } - if strings.TrimSpace(cfg.BaseDir) == "" { - cfg.BaseDir = baseDir - } // Discover the latest available release on GitHub and compare with the // currently installed version before proceeding. diff --git a/docs/BACKUP_ENV_MAPPING.md b/docs/BACKUP_ENV_MAPPING.md index 24d5eb81..25f9895c 100644 --- a/docs/BACKUP_ENV_MAPPING.md +++ b/docs/BACKUP_ENV_MAPPING.md @@ -51,9 +51,10 @@ COROSYNC_CONFIG_PATH = SAME CUSTOM_BACKUP_PATHS = SAME DEBUG_LEVEL = SAME DISABLE_NETWORK_PREFLIGHT = SAME -EMAIL_DELIVERY_METHOD = SAME (now supports `relay`, `sendmail` (/usr/sbin/sendmail), and `pmf` (proxmox-mail-forward / Proxmox Notifications)) +EMAIL_DELIVERY_METHOD = SAME (supports `relay`, `sendmail` (/usr/sbin/sendmail), and `pmf` (proxmox-mail-forward / Proxmox Notifications); aliases like `proxmox-notifications` normalize to `pmf`) EMAIL_ENABLED = SAME -EMAIL_FALLBACK_SENDMAIL = SAME (historical name; when EMAIL_DELIVERY_METHOD=relay, enables fallback to `pmf`) +EMAIL_FALLBACK_SENDMAIL = SAME (local sendmail failover for relay; for pmf the fallback chain is relay then sendmail) +EMAIL_FALLBACK_PMF = TRANSITIONAL alias accepted for compatibility with older templates; prefer `EMAIL_FALLBACK_SENDMAIL` EMAIL_FROM = SAME EMAIL_RECIPIENT = SAME ENABLE_DEDUPLICATION = SAME diff --git a/docs/CLI_REFERENCE.md b/docs/CLI_REFERENCE.md index bfd14304..611cea0a 100644 --- a/docs/CLI_REFERENCE.md +++ b/docs/CLI_REFERENCE.md @@ -143,7 +143,7 @@ Some interactive commands support two interface modes: 2. Optionally configures secondary storage (`SECONDARY_PATH` required if enabled; `SECONDARY_LOG_PATH` optional; invalid secondary paths are re-prompted/rejected; disabling secondary storage clears both saved secondary paths) 3. Optionally configures cloud storage (rclone) 4. Optionally enables firewall rules collection (`BACKUP_FIREWALL_RULES=false` by default) -5. Optionally sets up notifications (Telegram, Email; Email defaults to `EMAIL_DELIVERY_METHOD=relay`) +5. Optionally sets up notifications (Telegram, Email; Email asks for a delivery method and defaults to `EMAIL_DELIVERY_METHOD=relay` with `EMAIL_FALLBACK_SENDMAIL=true`) 6. Optionally configures encryption (AGE setup) 7. Optionally selects a cron time (HH:MM, default `02:00`) for the `proxsave` cron entry in both CLI and TUI install flows 8. Optionally runs a post-install dry-run audit and offers to disable unused collectors (actionable hints like `set BACKUP_*=false to disable`) @@ -830,6 +830,9 @@ CONFIG_FILE=/etc/pbs/prod.env ./build/proxsave # Force dry-run mode DRY_RUN=true ./build/proxsave +# BASE_DIR is not an override; it is detected from the installed executable. +# BASE_DIR in the environment or backup.env is deprecated and ignored. + # PBS restore behavior # Selected interactively during `--restore` on PBS hosts (Merge vs Clean 1:1). @@ -840,7 +843,7 @@ DEBUG_LEVEL=extreme ./build/proxsave --log-level debug USE_COLOR=false ./build/proxsave ``` -**Priority**: Environment variables > Configuration file > Defaults +**Priority**: Environment variables > Configuration file > Defaults, except `BASE_DIR`, which is always runtime-detected. --- diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index 37d4df43..12e63a1a 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -209,12 +209,13 @@ When `SET_BACKUP_PERMISSIONS=true`, the system applies Bash-compatible ownership - Non-fatal: All failures logged as warnings - Backup continues even if permission changes fail - User/group not found: logs warning and skips operation +- Backup/log paths on non-POSIX filesystems such as CIFS/SMB, NTFS, FAT/exFAT, FUSE, or network filesystems without Unix ownership support are detected and skipped for `chown`/`chmod` and security permission warnings. Windows-backed CIFS shares are expected to manage permissions on the Windows side. **Use cases**: - Migration from legacy Bash version - Multi-user environments requiring specific ownership - Shared backup storage with group access -- NFS/CIFS mounts requiring specific ownership +- POSIX-capable NFS mounts requiring specific ownership **Example**: ```bash @@ -249,8 +250,9 @@ MIN_DISK_SPACE_CLOUD_GB=1 # Cloud storage (not enforced for remote) ```bash # Base directory for all operations (auto-detected at runtime) -# BASE_DIR is derived from the executable/config location, so it is usually not -# written in backup.env. +# BASE_DIR is auto-detected from the installed proxsave executable; do not set it +# in backup.env. Active BASE_DIR=... lines are deprecated and ignored. +# BASE_DIR=/opt/proxsave # Lock file directory LOCK_PATH=${BASE_DIR}/lock @@ -265,7 +267,7 @@ BACKUP_PATH=${BASE_DIR}/backup LOG_PATH=${BASE_DIR}/log ``` -**Path resolution**: `${BASE_DIR}` expands automatically. Scalar string values also support `$VAR` / `${VAR}` expansion (config keys first, then environment variables). +**Path resolution**: `${BASE_DIR}` expands automatically from the installed `proxsave` executable path. Scalar string values also support `$VAR` / `${VAR}` expansion (config keys first, then environment variables), but `BASE_DIR` itself is not configurable from `backup.env` or the parent environment. --- @@ -841,7 +843,7 @@ EMAIL_ENABLED=false # true | false # Delivery method EMAIL_DELIVERY_METHOD=relay # relay | sendmail | pmf -# Fallback to pmf (proxmox-mail-forward) if relay fails +# Fallback to local sendmail if the primary path cannot deliver EMAIL_FALLBACK_SENDMAIL=true # true | false # Recipient @@ -855,16 +857,22 @@ EMAIL_FROM=no-reply@proxmox.tis24.it If `EMAIL_ENABLED` is omitted, the default remains `false`. The legacy alias `EMAIL_ENABLE` is still accepted during migration and runtime loading. -**Delivery methods**: -- **relay**: Uses cloud relay (outbound HTTPS) -- **sendmail**: Uses `/usr/sbin/sendmail` (requires a working local MTA, e.g. postfix) -- **pmf**: Uses Proxmox Notifications via `proxmox-mail-forward` +**Which delivery method should I choose?** + +| Method | Best when | Where SMTP is configured | +| --- | --- | --- | +| `relay` | Default for new installs. Uses the built-in TIS24 cloud relay over outbound HTTPS. | In the relay service; ProxSave only needs a recipient. | +| `sendmail` | The node already has a local MTA such as Postfix, Exim, or Sendmail. | In the local MTA. ProxSave calls `/usr/sbin/sendmail`. | +| `pmf` | You explicitly want Proxmox Notifications via `proxmox-mail-forward`. | In Proxmox (`Datacenter -> Notifications` on PVE, or the PBS notification UI/config). ProxSave does not ask for SMTP host/port/user/password. | + +`pmf` may also be written as `proxmox`, `proxmox-notifications`, or `proxmox-mail-forward`; ProxSave normalizes those aliases to `pmf`. **Notes**: -- Allowed values for `EMAIL_DELIVERY_METHOD` are: `relay`, `sendmail`, `pmf` (invalid values will skip Email with a warning). -- `EMAIL_FALLBACK_SENDMAIL` is a historical name (kept for compatibility). When `EMAIL_DELIVERY_METHOD=relay`, it enables fallback to **pmf** (it will not fall back to `/usr/sbin/sendmail`). +- Allowed values for `EMAIL_DELIVERY_METHOD` are: `pmf`, `relay`, `sendmail` (invalid values will skip Email with a warning). +- `EMAIL_FALLBACK_SENDMAIL=true` controls local `/usr/sbin/sendmail` failover. `EMAIL_FALLBACK_PMF` is accepted only as a transitional alias from older templates. - `relay` requires a real mailbox recipient and blocks `root@…` recipients; set `EMAIL_RECIPIENT` to a non-root mailbox if needed. -- When relay preconditions fail before delivery starts (for example missing recipient, autodetect failure, or blocked `root@…` recipient) and fallback is enabled, ProxSave may bypass relay and invoke `pmf` directly. +- Default install behavior is `relay -> sendmail`. +- If you manually set `EMAIL_DELIVERY_METHOD=pmf`, fallback order is `pmf -> relay -> sendmail` when `EMAIL_FALLBACK_SENDMAIL=true`. - When logs say the relay "accepted request", it means the worker and upstream email API accepted the submission. It does **not** guarantee final inbox delivery (the message may still bounce, be deferred, or land in spam later). - If `EMAIL_RECIPIENT` is empty, ProxSave auto-detects the recipient from the `root@pam` user: - **PVE**: Proxmox API via `pvesh get /access/users/root@pam` → fallback to `pveum user list` → fallback to `/etc/pve/user.cfg` diff --git a/docs/EXAMPLES.md b/docs/EXAMPLES.md index 61b2bdae..8a8e73ab 100644 --- a/docs/EXAMPLES.md +++ b/docs/EXAMPLES.md @@ -569,21 +569,23 @@ WEBHOOK_PUSHOVER_PRIORITY=0 #### 2. Email Configuration ```bash -# Option A: Cloud relay (outbound HTTPS) +# Option A: Cloud relay (default, outbound HTTPS) # - Set EMAIL_DELIVERY_METHOD=relay and configure EMAIL_RECIPIENT (or leave empty for root@pam auto-detect) # - Relay blocks root@… recipients; use a real non-root mailbox for EMAIL_RECIPIENT # - No local SMTP/MTA setup required on the node -# - Optional: set EMAIL_FALLBACK_SENDMAIL=true to fall back to EMAIL_DELIVERY_METHOD=pmf when the relay fails +# - Optional/default: set EMAIL_FALLBACK_SENDMAIL=true to fall back to local sendmail when the relay fails # Option B: Local sendmail (/usr/sbin/sendmail) # - Set EMAIL_DELIVERY_METHOD=sendmail # - Requires a working local MTA (e.g. postfix) on the node # - EMAIL_RECIPIENT is required (or auto-detected from Proxmox root@pam if configured) -# Option C: Proxmox Notifications via proxmox-mail-forward +# Option C: Proxmox Notifications (manual) # - Set EMAIL_DELIVERY_METHOD=pmf -# - Ensure Proxmox Notifications targets/matchers are configured +# - Configure SMTP/Sendmail targets and matchers in Proxmox Notifications +# - ProxSave does not need SMTP host/port/user/password # - EMAIL_RECIPIENT is optional (only used for the To: header) +# - With EMAIL_FALLBACK_SENDMAIL=true, fallback order is pmf -> relay -> sendmail # - Optional quick check (runs the forwarder directly; run as root): printf "To: root\nSubject: proxsave test\n\nHello from proxsave\n" | sudo /usr/libexec/proxmox-mail-forward ``` diff --git a/docs/INSTALL.md b/docs/INSTALL.md index 86a0ff10..3a4ebb77 100644 --- a/docs/INSTALL.md +++ b/docs/INSTALL.md @@ -229,7 +229,7 @@ Final install steps still run: 2. **Secondary storage**: Optional path for backup/log copies; disabling it clears both saved secondary paths from `backup.env` 3. **Cloud storage (rclone)**: Optional rclone configuration (supports `CLOUD_REMOTE` as a remote name (recommended) or legacy `remote:path`; `CLOUD_LOG_PATH` supports path-only (recommended) or `otherremote:/path`) 4. **Firewall rules**: Optional firewall rules collection toggle (`BACKUP_FIREWALL_RULES=false` by default; supports iptables/nftables) -5. **Notifications**: Enable Telegram (centralized) and Email notifications (wizard defaults to `EMAIL_DELIVERY_METHOD=relay`; you can switch to `sendmail` or `pmf` later) +5. **Notifications**: Enable Telegram (centralized) and Email notifications; Email asks for a delivery method and defaults to `relay` with `sendmail` failover. Use `pmf` only when you want Proxmox Notifications via `proxmox-mail-forward`. 6. **Encryption**: AGE encryption setup (runs sub-wizard immediately if enabled) 7. **Cron schedule**: Choose cron time (HH:MM, default `02:00`) for the `proxsave` cron entry in both CLI and TUI install modes 8. **Post-install check (optional)**: Runs `proxsave --dry-run` and shows actionable warnings like `set BACKUP_*=false to disable`, allowing you to disable unused collectors and reduce WARNING noise @@ -282,6 +282,8 @@ When shown, it does **not** modify your `backup.env`. It only: After completion, edit `configs/backup.env` manually for advanced options. +`BASE_DIR` is detected from the installed `proxsave` executable. Do not add an active `BASE_DIR=...` line to `backup.env`; upgrades remove it and runtime ignores it if present. + --- ## Upgrading from Previous Bash Version (v0.7.4-bash or Earlier) diff --git a/docs/MIGRATION_GUIDE.md b/docs/MIGRATION_GUIDE.md index 0c58a3da..0858470b 100644 --- a/docs/MIGRATION_GUIDE.md +++ b/docs/MIGRATION_GUIDE.md @@ -321,6 +321,7 @@ These Bash variables are **not needed** in Go (skip them during migration): **Paths**: - `BACKUP_ENV_PATH` → Go uses fixed `configs/backup.env` - `SCRIPT_DIR` → Go binary is self-contained +- `BASE_DIR` → Go auto-detects it from the installed `proxsave` executable; active migrated values are ignored/removed **Internal Logic**: - `BACKUP_TIMESTAMP_FORMAT` → Go uses ISO 8601 internally diff --git a/docs/TROUBLESHOOTING.md b/docs/TROUBLESHOOTING.md index 30e789c0..ca424402 100644 --- a/docs/TROUBLESHOOTING.md +++ b/docs/TROUBLESHOOTING.md @@ -147,6 +147,9 @@ AUTO_FIX_PERMISSIONS=true └── proxsave 755 (-rwxr-xr-x) ``` +**CIFS/SMB or Windows-backed shares**: +Linux permission modes and `root:root` ownership are often synthetic on CIFS/SMB mounts, especially when the server is Windows. ProxSave detects non-POSIX backup/log filesystems and skips POSIX permission/ownership warnings for `BACKUP_PATH`, `LOG_PATH`, `SECONDARY_PATH`, and `SECONDARY_LOG_PATH`. If warnings persist, confirm the share is mounted before ProxSave starts and that the mount type appears as `cifs`/`smb` in `/proc/mounts`. + --- #### Error: `Invalid configuration value for COMPRESSION_TYPE` @@ -557,7 +560,23 @@ EMAIL_DELIVERY_METHOD=sendmail # /usr/sbin/sendmail (local MTA required) EMAIL_DELIVERY_METHOD=pmf # Proxmox Notifications via proxmox-mail-forward ``` -If Email is enabled but you don't see it being dispatched, ensure `EMAIL_DELIVERY_METHOD` is exactly one of: `relay`, `sendmail`, `pmf` (typos will skip Email with a warning like: `Email: enabled but not initialized (...)`). +If Email is enabled but you don't see it being dispatched, ensure `EMAIL_DELIVERY_METHOD` is one of: `pmf`, `relay`, `sendmail` (aliases such as `proxmox-notifications` normalize to `pmf`; typos will skip Email with a warning like: `Email: enabled but not initialized (...)`). + +##### If `EMAIL_DELIVERY_METHOD=pmf` + +This mode uses Proxmox Notifications via `proxmox-mail-forward`. It is the recommended mode on Proxmox hosts when you expected SMTP settings in ProxSave: configure SMTP targets/matchers in Proxmox, then let ProxSave hand the message to Proxmox. + +- `EMAIL_RECIPIENT` is optional in this mode and is only used for the `To:` header. +- If PMF fails and `EMAIL_FALLBACK_SENDMAIL=true`, ProxSave tries the relay first and then local sendmail. +- Verify `proxmox-mail-forward` exists: + ```bash + test -x /usr/libexec/proxmox-mail-forward && echo "proxmox-mail-forward OK" || echo "proxmox-mail-forward not found" + ``` +- Verify Proxmox Notifications configuration in the UI (`Datacenter -> Notifications` on PVE, or the PBS notification UI/config). +- Direct handoff test: + ```bash + printf "To: root\nSubject: proxsave test\n\nHello from proxsave\n" | sudo /usr/libexec/proxmox-mail-forward + ``` ##### If `EMAIL_DELIVERY_METHOD=relay` @@ -570,7 +589,7 @@ If Email is enabled but you don't see it being dispatched, ensure `EMAIL_DELIVER - **PBS**: `proxmox-backup-manager user list` → fallback to `/etc/proxmox-backup/user.cfg` - **Dual**: intentionally reuses the **PVE** path for `root@pam` email discovery - Relay blocks `root@…` recipients; use a real non-root mailbox for `EMAIL_RECIPIENT`. -- If `EMAIL_FALLBACK_SENDMAIL=true`, ProxSave will fall back to `EMAIL_DELIVERY_METHOD=pmf` when the relay fails. If relay cannot even start because recipient resolution/preconditions fail, ProxSave can bypass relay and invoke the PMF fallback directly. +- If `EMAIL_FALLBACK_SENDMAIL=true`, ProxSave will fall back to local `/usr/sbin/sendmail` when relay delivery fails. If relay cannot start because no recipient is available, sendmail cannot help either; configure `EMAIL_RECIPIENT` or the `root@pam` email in Proxmox. - Check the proxsave logs for `email-relay` warnings/errors. - `Email relay accepted request ...` means the relay accepted the submission. It does **not** guarantee final inbox delivery; later provider-side failures/bounces are outside the ProxSave process. @@ -624,17 +643,6 @@ This mode uses `/usr/sbin/sendmail`, so your node must have a working local MTA ``` - Check your MTA status and queue (`systemctl status postfix`, `mailq`, `/var/log/mail.log`). -##### If `EMAIL_DELIVERY_METHOD=pmf` - -This mode uses Proxmox Notifications via `proxmox-mail-forward` (final recipients are configured in Proxmox, not in proxsave). - -- `EMAIL_RECIPIENT` is optional in this mode and is only used for the `To:` header. -- Verify `proxmox-mail-forward` exists: - ```bash - test -x /usr/libexec/proxmox-mail-forward && echo "proxmox-mail-forward OK" || echo "proxmox-mail-forward not found" - ``` -- Verify Proxmox Notifications configuration in the UI (`Datacenter -> Notifications`). - --- #### Error: `Backup path full` warnings but backup succeeds diff --git a/internal/backup/archiver.go b/internal/backup/archiver.go index 5a99c3ef..8f9a7691 100644 --- a/internal/backup/archiver.go +++ b/internal/backup/archiver.go @@ -611,11 +611,9 @@ func (a *Archiver) createXZArchive(ctx context.Context, sourceDir, outputPath st }() if err := cmd.Start(); err != nil { - _ = pw.Close() - if startErr := <-errChan; startErr != nil { - return startErr - } - return fmt.Errorf("failed to start xz: %w", err) + startErr := fmt.Errorf("failed to start xz: %w", err) + drainTarWriterAfterCompressorStartFailure(pw, errChan, startErr) + return startErr } tarErr := <-errChan @@ -684,11 +682,9 @@ func (a *Archiver) createZstdArchive(ctx context.Context, sourceDir, outputPath }() if err := cmd.Start(); err != nil { - _ = pw.Close() - if startErr := <-errChan; startErr != nil { - return startErr - } - return fmt.Errorf("failed to start zstd: %w", err) + startErr := fmt.Errorf("failed to start zstd: %w", err) + drainTarWriterAfterCompressorStartFailure(pw, errChan, startErr) + return startErr } tarErr := <-errChan @@ -728,6 +724,11 @@ func (a *Archiver) attachStderrLogger(cmd *exec.Cmd, algo string) error { return nil } +func drainTarWriterAfterCompressorStartFailure(pw *io.PipeWriter, errChan <-chan error, startErr error) { + _ = pw.CloseWithError(startErr) + _ = <-errChan +} + func (a *Archiver) pipeTarThroughCommand(ctx context.Context, sourceDir, outputPath string, cmd *exec.Cmd, algo string) (err error) { outFile, err := os.OpenFile(outputPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0640) if err != nil { @@ -767,11 +768,9 @@ func (a *Archiver) pipeTarThroughCommand(ctx context.Context, sourceDir, outputP }() if err := cmd.Start(); err != nil { - _ = pw.Close() - if startErr := <-errChan; startErr != nil { - return startErr - } - return fmt.Errorf("failed to start %s: %w", algo, err) + startErr := fmt.Errorf("failed to start %s: %w", algo, err) + drainTarWriterAfterCompressorStartFailure(pw, errChan, startErr) + return startErr } if tarErr := <-errChan; tarErr != nil { diff --git a/internal/backup/archiver_test.go b/internal/backup/archiver_test.go index fb09de31..13febf5e 100644 --- a/internal/backup/archiver_test.go +++ b/internal/backup/archiver_test.go @@ -8,6 +8,7 @@ import ( "fmt" "io" "os" + "os/exec" "path/filepath" "reflect" "strings" @@ -532,6 +533,74 @@ func TestCompressionErrorWrap(t *testing.T) { } } +func TestCompressorStartFailureReturnsStartError(t *testing.T) { + tmp := t.TempDir() + sourceDir := filepath.Join(tmp, "source") + if err := os.MkdirAll(sourceDir, 0o755); err != nil { + t.Fatalf("mkdir source: %v", err) + } + if err := os.WriteFile(filepath.Join(sourceDir, "file.txt"), []byte("content"), 0o644); err != nil { + t.Fatalf("write source file: %v", err) + } + + logger := logging.New(types.LogLevelError, false) + missingCommand := filepath.Join(tmp, "missing-compressor") + newArchiver := func() *Archiver { + a := NewArchiver(logger, &ArchiverConfig{ + Compression: types.CompressionXZ, + CompressionLevel: 3, + }) + a.deps.CommandContext = func(ctx context.Context, _ string, args ...string) (*exec.Cmd, error) { + return exec.CommandContext(ctx, missingCommand, args...), nil + } + return a + } + + tests := []struct { + name string + run func(context.Context, *Archiver, string, string) error + want string + }{ + { + name: "xz", + run: func(ctx context.Context, a *Archiver, source, output string) error { + return a.createXZArchive(ctx, source, output) + }, + want: "failed to start xz", + }, + { + name: "zstd", + run: func(ctx context.Context, a *Archiver, source, output string) error { + return a.createZstdArchive(ctx, source, output) + }, + want: "failed to start zstd", + }, + { + name: "generic", + run: func(ctx context.Context, a *Archiver, source, output string) error { + cmd := exec.CommandContext(ctx, missingCommand, "-c") + return a.pipeTarThroughCommand(ctx, source, output, cmd, "fake") + }, + want: "failed to start fake", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := tt.run(context.Background(), newArchiver(), sourceDir, filepath.Join(tmp, tt.name+".tar")) + if err == nil { + t.Fatalf("expected start failure") + } + if !strings.Contains(err.Error(), tt.want) { + t.Fatalf("error=%v, want to contain %q", err, tt.want) + } + if errors.Is(err, io.ErrClosedPipe) { + t.Fatalf("returned pipe error instead of compressor start error: %v", err) + } + }) + } +} + func TestArchiverCompressionGetters(t *testing.T) { logger := logging.New(types.LogLevelInfo, false) cfg := &ArchiverConfig{ diff --git a/internal/backup/collector_pbs_datastore.go b/internal/backup/collector_pbs_datastore.go index 858c0281..6e92e43e 100644 --- a/internal/backup/collector_pbs_datastore.go +++ b/internal/backup/collector_pbs_datastore.go @@ -550,6 +550,9 @@ func (c *Collector) runPBSPXARStep(ctx context.Context, state *pbsPxarState, fn dsWorkers = 1 } + childCtx, cancel := context.WithCancel(ctx) + defer cancel() + var ( wg sync.WaitGroup sem = make(chan struct{}, dsWorkers) @@ -564,18 +567,22 @@ func (c *Collector) runPBSPXARStep(ctx context.Context, state *pbsPxarState, fn defer wg.Done() select { case sem <- struct{}{}: - case <-ctx.Done(): + case <-childCtx.Done(): return } defer func() { <-sem }() + if err := childCtx.Err(); err != nil { + return + } - if err := fn(ctx, ds, state); err != nil { + if err := fn(childCtx, ds, state); err != nil { if errors.Is(err, context.Canceled) { return } errMu.Lock() if firstErr == nil { firstErr = err + cancel() } errMu.Unlock() } diff --git a/internal/backup/collector_pxar_datastore_test.go b/internal/backup/collector_pxar_datastore_test.go index 5d9b4d19..cb2f8fad 100644 --- a/internal/backup/collector_pxar_datastore_test.go +++ b/internal/backup/collector_pxar_datastore_test.go @@ -2,9 +2,11 @@ package backup import ( "context" + "errors" "os" "path/filepath" "strings" + "sync/atomic" "testing" "github.com/tis24dev/proxsave/internal/types" @@ -52,3 +54,30 @@ func TestWritePxarListReportWithFiles(t *testing.T) { t.Fatalf("expected pxar file listed, got %s", string(content)) } } + +func TestRunPBSPXARStepCancelsPendingWorkersOnFirstError(t *testing.T) { + cfg := GetDefaultCollectorConfig() + cfg.PxarDatastoreConcurrency = 1 + c := NewCollector(newTestLogger(), cfg, t.TempDir(), types.ProxmoxBS, false) + state := &pbsPxarState{ + eligible: []pbsDatastore{ + {Name: "ds1", Path: "/tmp/ds1"}, + {Name: "ds2", Path: "/tmp/ds2"}, + {Name: "ds3", Path: "/tmp/ds3"}, + }, + } + + errBoom := errors.New("pxar failed") + var calls int32 + err := c.runPBSPXARStep(context.Background(), state, func(ctx context.Context, _ pbsDatastore, _ *pbsPxarState) error { + atomic.AddInt32(&calls, 1) + return errBoom + }) + + if !errors.Is(err, errBoom) { + t.Fatalf("runPBSPXARStep error = %v, want %v", err, errBoom) + } + if got := atomic.LoadInt32(&calls); got != 1 { + t.Fatalf("fn called %d times, want 1", got) + } +} diff --git a/internal/checks/checks.go b/internal/checks/checks.go index f9caaa86..6cbe30ff 100644 --- a/internal/checks/checks.go +++ b/internal/checks/checks.go @@ -20,6 +20,7 @@ import ( // checks to allow tests to inject controlled failures (e.g., EIO) without // depending on specific filesystem behavior. var createTestFile = os.Create +var closeTestFile = func(f *os.File) error { return f.Close() } var ( osStat = os.Stat @@ -391,7 +392,6 @@ func (c *Checker) CheckLockFile() CheckResult { c.logger.Warning("Failed to sync lock file %s: %v", lockPath, err) } if err := f.Close(); err != nil { - c.logger.Warning("Failed to close lock file %s: %v", lockPath, err) c.removePartialLockFile(lockPath) result.Error = fmt.Errorf("failed to close lock file: %w", err) result.Message = result.Error.Error() @@ -441,8 +441,14 @@ func (c *Checker) CheckPermissions() CheckResult { for attempt := 1; attempt <= maxAttempts; attempt++ { f, err := createTestFile(testFile) if err == nil { - if closeErr := f.Close(); closeErr != nil { + if closeErr := closeTestFile(f); closeErr != nil { lastErr = closeErr + if errors.Is(closeErr, syscall.EIO) && attempt < maxAttempts { + c.logger.Warning("I/O error while closing permission test file in %s (attempt %d/%d), will retry: %v", + dir, attempt, maxAttempts, closeErr) + time.Sleep(retryDelay) + continue + } } else { lastErr = nil } @@ -637,6 +643,7 @@ func (c *Checker) CheckTempDirectory() CheckResult { result.Message = result.Error.Error() return result } + defer func() { _ = osRemove(testSymlink) }() if err := osRemove(testSymlink); err != nil && !os.IsNotExist(err) { c.logger.Warning("Failed to remove temp symlink test %s: %v", testSymlink, err) } diff --git a/internal/checks/checks_test.go b/internal/checks/checks_test.go index f9283e64..f0c820c7 100644 --- a/internal/checks/checks_test.go +++ b/internal/checks/checks_test.go @@ -891,6 +891,43 @@ func TestCheckTempDirectory_SymlinkSupport(t *testing.T) { } } +func TestCheckTempDirectory_DefersSymlinkCleanupAfterInlineRemoveFailure(t *testing.T) { + logger := logging.New(types.LogLevelInfo, false) + logger.SetOutput(io.Discard) + + origTempRoot := tempRootPath + origRemove := osRemove + t.Cleanup(func() { + tempRootPath = origTempRoot + osRemove = origRemove + }) + + tempRootPath = t.TempDir() + testSymlink := filepath.Join(tempRootPath, ".proxsave-symlink-test") + removeSymlinkCalls := 0 + osRemove = func(name string) error { + if name == testSymlink { + removeSymlinkCalls++ + if removeSymlinkCalls == 1 { + return syscall.EIO + } + } + return origRemove(name) + } + + checker := NewChecker(logger, GetDefaultCheckerConfig(t.TempDir(), t.TempDir(), t.TempDir())) + result := checker.CheckTempDirectory() + if !result.Passed { + t.Fatalf("expected CheckTempDirectory to pass, got: %v", result.Error) + } + if removeSymlinkCalls != 2 { + t.Fatalf("expected inline and deferred symlink cleanup attempts, got %d", removeSymlinkCalls) + } + if _, err := os.Lstat(testSymlink); !os.IsNotExist(err) { + t.Fatalf("expected deferred symlink cleanup, lstat err=%v", err) + } +} + func TestRunAllChecks_IncludesTempDirectory(t *testing.T) { // Ensure /tmp/proxsave exists if err := os.MkdirAll(filepath.Join("/tmp", "proxsave"), 0o755); err != nil { @@ -1190,6 +1227,8 @@ func TestCheckLockFile_CloseFailsRemovesPartialLock(t *testing.T) { origSync := syncFile t.Cleanup(func() { syncFile = origSync }) + // Closing here causes the subsequent production Close() to return os.ErrClosed + // and exercise the close-failure branch. syncFile = func(f *os.File) error { return f.Close() } @@ -1368,6 +1407,52 @@ func TestCheckPermissions_RetriesOnEIOThenSucceeds(t *testing.T) { } } +func TestCheckPermissions_RetriesOnCloseEIOThenSucceeds(t *testing.T) { + logger := logging.New(types.LogLevelInfo, false) + logger.SetOutput(io.Discard) + + backupDir := t.TempDir() + logDir := t.TempDir() + config := GetDefaultCheckerConfig(backupDir, logDir, t.TempDir()) + + origCreate := createTestFile + origClose := closeTestFile + t.Cleanup(func() { + createTestFile = origCreate + closeTestFile = origClose + }) + + attempts := 0 + createTestFile = func(name string) (*os.File, error) { + attempts++ + return os.Create(name) + } + + closeEIOs := 0 + closeTestFile = func(f *os.File) error { + if err := f.Close(); err != nil { + return err + } + if closeEIOs < 2 { + closeEIOs++ + return syscall.EIO + } + return nil + } + + checker := NewChecker(logger, config) + result := checker.CheckPermissions() + if !result.Passed { + t.Fatalf("expected CheckPermissions to pass after close retries, got: %v", result.Error) + } + if closeEIOs != 2 { + t.Fatalf("expected 2 close EIO attempts, got %d", closeEIOs) + } + if attempts != 4 { + t.Fatalf("expected 4 attempts total (3 for backup dir, 1 for log dir), got %d", attempts) + } +} + func TestCheckPermissions_RemoveTestFileWarning(t *testing.T) { logger := logging.New(types.LogLevelInfo, false) logger.SetOutput(io.Discard) diff --git a/internal/config/config.go b/internal/config/config.go index 7e014cfe..61f3c89a 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -17,14 +17,16 @@ import ( ) const ( - telegramEnabledKey = "TELEGRAM_ENABLED" - telegramEnableLegacyKey = "TELEGRAM_ENABLE" - emailEnabledKey = "EMAIL_ENABLED" - emailEnableLegacyKey = "EMAIL_ENABLE" - gotifyEnabledKey = "GOTIFY_ENABLED" - gotifyEnableLegacyKey = "GOTIFY_ENABLE" - webhookEnabledKey = "WEBHOOK_ENABLED" - webhookEnableLegacyKey = "WEBHOOK_ENABLE" + telegramEnabledKey = "TELEGRAM_ENABLED" + telegramEnableLegacyKey = "TELEGRAM_ENABLE" + emailEnabledKey = "EMAIL_ENABLED" + emailEnableLegacyKey = "EMAIL_ENABLE" + emailFallbackSendmailKey = "EMAIL_FALLBACK_SENDMAIL" + emailFallbackPMFLegacyKey = "EMAIL_FALLBACK_PMF" + gotifyEnabledKey = "GOTIFY_ENABLED" + gotifyEnableLegacyKey = "GOTIFY_ENABLE" + webhookEnabledKey = "WEBHOOK_ENABLED" + webhookEnableLegacyKey = "WEBHOOK_ENABLE" ) var ( @@ -54,6 +56,26 @@ var ( configHostnameFunc = os.Hostname ) +// NormalizeEmailDeliveryMethod maps user-facing aliases to the internal delivery +// method identifiers used by the notification runtime. +func NormalizeEmailDeliveryMethod(method string) string { + normalized := strings.ToLower(strings.TrimSpace(method)) + if normalized == "" { + return "relay" + } + normalized = strings.NewReplacer("_", "-", " ", "-").Replace(normalized) + switch normalized { + case "relay", "cloud", "cloud-relay", "tis24-relay": + return "relay" + case "sendmail", "local", "local-mta", "mta": + return "sendmail" + case "pmf", "pfm", "proxmox", "proxmox-notification", "proxmox-notifications", "proxmox-mail-forward": + return "pmf" + default: + return normalized + } +} + // Config contains the full backup system configuration. type Config struct { // General settings @@ -167,7 +189,7 @@ type Config struct { // Email Notifications EmailEnabled bool EmailDeliveryMethod string // "relay", "sendmail", or "pmf" - EmailFallbackSendmail bool + EmailFallbackSendmail bool // True means email delivery may fall back to local /usr/sbin/sendmail. EmailRecipient string // Single recipient, empty = auto-detect EmailFrom string @@ -283,11 +305,20 @@ type Config struct { PBSFingerprint string // Auto-detected from PBS certificate // raw configuration map - raw map[string]string + raw map[string]string + ignoredBaseDirConfig string + ignoredBaseDirEnv string + detectedBaseDir string } // LoadConfig reads the backup.env configuration file. func LoadConfig(configPath string) (*Config, error) { + return LoadConfigWithBaseDir(configPath, defaultBaseDir()) +} + +// LoadConfigWithBaseDir reads the backup.env configuration file and expands +// ${BASE_DIR} using the caller-provided runtime base directory. +func LoadConfigWithBaseDir(configPath, detectedBaseDir string) (*Config, error) { if !utils.FileExists(configPath) { return nil, fmt.Errorf("configuration file not found: %s", configPath) } @@ -297,9 +328,22 @@ func LoadConfig(configPath string) (*Config, error) { return nil, err } + detectedBaseDir = strings.TrimSpace(detectedBaseDir) + if detectedBaseDir == "" { + detectedBaseDir = defaultBaseDir() + } + cfg := &Config{ - ConfigPath: configPath, - raw: rawValues, + ConfigPath: configPath, + BaseDir: detectedBaseDir, + raw: rawValues, + detectedBaseDir: detectedBaseDir, + } + if rawBaseDir := strings.TrimSpace(rawValues["BASE_DIR"]); rawBaseDir != "" { + cfg.ignoredBaseDirConfig = rawBaseDir + } + if envBaseDir := strings.TrimSpace(os.Getenv("BASE_DIR")); envBaseDir != "" && filepath.Clean(envBaseDir) != filepath.Clean(detectedBaseDir) { + cfg.ignoredBaseDirEnv = envBaseDir } // Override with environment variables (env vars take precedence over file) @@ -336,7 +380,7 @@ func (c *Config) loadEnvOverrides() { "RETENTION_DAILY", "RETENTION_WEEKLY", "RETENTION_MONTHLY", "RETENTION_YEARLY", "BUNDLE_ASSOCIATED_FILES", "ENCRYPT_ARCHIVE", "AGE_RECIPIENT", "AGE_RECIPIENT_FILE", "TELEGRAM_ENABLE", "TELEGRAM_ENABLED", "BOT_TELEGRAM_TYPE", "TELEGRAM_BOT_TOKEN", "TELEGRAM_CHAT_ID", - "EMAIL_ENABLE", "EMAIL_ENABLED", "EMAIL_DELIVERY_METHOD", "EMAIL_FALLBACK_SENDMAIL", + "EMAIL_ENABLE", "EMAIL_ENABLED", "EMAIL_DELIVERY_METHOD", "EMAIL_FALLBACK_PMF", "EMAIL_FALLBACK_SENDMAIL", "EMAIL_RECIPIENT", "EMAIL_FROM", "GOTIFY_ENABLE", "GOTIFY_ENABLED", "GOTIFY_SERVER_URL", "GOTIFY_TOKEN", "GOTIFY_PRIORITY_SUCCESS", "GOTIFY_PRIORITY_WARNING", "GOTIFY_PRIORITY_FAILURE", @@ -521,9 +565,11 @@ func (c *Config) parseOptimizationSettings() { func (c *Config) parseSecuritySettings() { c.DisableNetworkPreflight = c.getBool("DISABLE_NETWORK_PREFLIGHT", false) - // Base directory - envBaseDir := os.Getenv("BASE_DIR") - c.BaseDir = c.getString("BASE_DIR", envBaseDir) + // Base directory is runtime-derived. BASE_DIR in backup.env or the parent + // environment is deprecated and intentionally ignored. + if c.BaseDir == "" { + c.BaseDir = strings.TrimSpace(c.detectedBaseDir) + } if c.BaseDir == "" { c.BaseDir = defaultBaseDir() } @@ -673,8 +719,8 @@ func (c *Config) parseNotificationSettings() { c.ServerID = "" c.EmailEnabled = c.getBoolWithLegacyAlias(emailEnabledKey, emailEnableLegacyKey, false) - c.EmailDeliveryMethod = c.getString("EMAIL_DELIVERY_METHOD", "relay") - c.EmailFallbackSendmail = c.getBool("EMAIL_FALLBACK_SENDMAIL", true) + c.EmailDeliveryMethod = NormalizeEmailDeliveryMethod(c.getString("EMAIL_DELIVERY_METHOD", "relay")) + c.EmailFallbackSendmail = c.getBoolWithFallback([]string{emailFallbackSendmailKey, emailFallbackPMFLegacyKey}, true) c.EmailRecipient = c.getString("EMAIL_RECIPIENT", "") c.EmailFrom = c.getString("EMAIL_FROM", "no-reply@proxmox.tis24.it") @@ -973,9 +1019,6 @@ func mergeStringSlices(base, extra []string) []string { // Helper methods with fallback support (try multiple keys) func defaultBaseDir() string { - if val := strings.TrimSpace(os.Getenv("BASE_DIR")); val != "" { - return val - } if _, err := os.Stat("/opt/proxsave"); err == nil { return "/opt/proxsave" } @@ -985,29 +1028,17 @@ func defaultBaseDir() string { return "/opt/proxsave" } -// expandEnvVars expands environment variables and special variables like ${BASE_DIR} -func expandEnvVars(s string) string { - // Expand ${VAR} and $VAR style variables - result := os.Expand(s, func(key string) string { - // Special handling for BASE_DIR - if key == "BASE_DIR" { - // Check if BASE_DIR is set in environment, otherwise use default - return defaultBaseDir() - } - return os.Getenv(key) - }) - return result -} - type configVarExpander struct { raw map[string]string + baseDir string cache map[string]string inProgress map[string]bool } -func newConfigVarExpander(raw map[string]string) *configVarExpander { +func newConfigVarExpander(raw map[string]string, baseDir string) *configVarExpander { return &configVarExpander{ raw: raw, + baseDir: strings.TrimSpace(baseDir), cache: make(map[string]string), inProgress: make(map[string]bool), } @@ -1035,15 +1066,11 @@ func (e *configVarExpander) resolve(key string) string { e.inProgress[upperKey] = true defer delete(e.inProgress, upperKey) - // Keep the historical behavior where BASE_DIR expands even when it's not set - // in the config or environment. if upperKey == "BASE_DIR" { - if rawVal, ok := e.raw[upperKey]; ok && strings.TrimSpace(rawVal) != "" { - expanded := e.expand(rawVal) - e.cache[upperKey] = expanded - return expanded + expanded := strings.TrimSpace(e.baseDir) + if expanded == "" { + expanded = defaultBaseDir() } - expanded := defaultBaseDir() e.cache[upperKey] = expanded return expanded } @@ -1066,7 +1093,7 @@ func (c *Config) expandConfigVars(s string) string { if strings.IndexByte(s, '$') == -1 { return s } - return newConfigVarExpander(c.raw).expand(s) + return newConfigVarExpander(c.raw, c.BaseDir).expand(s) } func (c *Config) getStringWithFallback(keys []string, defaultValue string) string { @@ -1226,6 +1253,24 @@ func (c *Config) Get(key string) (string, bool) { return val, ok } +// IgnoredBaseDirConfig returns the deprecated BASE_DIR value found in +// backup.env, if any. The value is informational only and is not applied. +func (c *Config) IgnoredBaseDirConfig() (string, bool) { + if c == nil || strings.TrimSpace(c.ignoredBaseDirConfig) == "" { + return "", false + } + return c.ignoredBaseDirConfig, true +} + +// IgnoredBaseDirEnv returns the deprecated BASE_DIR value inherited from the +// parent environment when it differs from the detected runtime base directory. +func (c *Config) IgnoredBaseDirEnv() (string, bool) { + if c == nil || strings.TrimSpace(c.ignoredBaseDirEnv) == "" { + return "", false + } + return c.ignoredBaseDirEnv, true +} + // Set sets a value in the configuration. func (c *Config) Set(key, value string) { c.raw[key] = value @@ -1654,5 +1699,3 @@ func (c *Config) GetRetentionPolicy() string { } return "simple" } - -// expandEnvVars expands environment variables and special variables like ${BASE_DIR} diff --git a/internal/config/config_test.go b/internal/config/config_test.go index e9e6bce0..2266cf49 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -78,8 +78,9 @@ BACKUP_BLACKLIST=/var/data/tmp } setBaseDirEnv(t, "/env/base/dir") + detectedBaseDir := "/detected/base/dir" - cfg, err := LoadConfig(configPath) + cfg, err := LoadConfigWithBaseDir(configPath, detectedBaseDir) if err != nil { t.Fatalf("LoadConfig() error = %v", err) } @@ -129,8 +130,8 @@ BACKUP_BLACKLIST=/var/data/tmp t.Error("Expected MetricsEnabled to be true") } - if cfg.BaseDir != "/env/base/dir" { - t.Errorf("BaseDir = %q; want %q", cfg.BaseDir, "/env/base/dir") + if cfg.BaseDir != detectedBaseDir { + t.Errorf("BaseDir = %q; want %q", cfg.BaseDir, detectedBaseDir) } if !cfg.SecurityCheckEnabled { @@ -222,7 +223,7 @@ PBS_DATASTORE_PATH=/mnt/pbs1,/mnt/pbs2,/mnt/pbs3 t.Fatalf("Failed to create config file: %v", err) } - cfg, err := LoadConfig(configPath) + cfg, err := LoadConfigWithBaseDir(configPath, "/custom/base") if err != nil { t.Fatalf("LoadConfig() error = %v", err) } @@ -279,7 +280,8 @@ AGE_RECIPIENT_FILE=${BASE_DIR}/identity/age/recipient.txt setBaseDirEnv(t, "/custom/base") - cfg, err := LoadConfig(configPath) + detectedBaseDir := "/custom/base" + cfg, err := LoadConfigWithBaseDir(configPath, detectedBaseDir) if err != nil { t.Fatalf("LoadConfig() error = %v", err) } @@ -316,7 +318,8 @@ SECONDARY_PATH=remote:path t.Fatalf("Failed to create config file: %v", err) } - cfg, err := LoadConfig(configPath) + detectedBaseDir := "/quotes/base" + cfg, err := LoadConfigWithBaseDir(configPath, detectedBaseDir) if err != nil { t.Fatalf("LoadConfig() error = %v", err) } @@ -409,7 +412,8 @@ LOG_PATH=/path/without/quotes setBaseDirEnv(t, "/quotes/base") - cfg, err := LoadConfig(configPath) + detectedBaseDir := "/quotes/base" + cfg, err := LoadConfigWithBaseDir(configPath, detectedBaseDir) if err != nil { t.Fatalf("LoadConfig() error = %v", err) } @@ -426,8 +430,8 @@ LOG_PATH=/path/without/quotes t.Errorf("LogPath = %q; want %q", cfg.LogPath, "/path/without/quotes") } - if cfg.BaseDir != "/quotes/base" { - t.Errorf("BaseDir = %q; want %q", cfg.BaseDir, "/quotes/base") + if cfg.BaseDir != detectedBaseDir { + t.Errorf("BaseDir = %q; want %q", cfg.BaseDir, detectedBaseDir) } } @@ -451,7 +455,7 @@ DEBUG_LEVEL=4 setBaseDirEnv(t, "/comments/base") - cfg, err := LoadConfig(configPath) + cfg, err := LoadConfigWithBaseDir(configPath, "/defaults/base") if err != nil { t.Fatalf("LoadConfig() error = %v", err) } @@ -504,7 +508,8 @@ func TestConfigDefaults(t *testing.T) { setBaseDirEnv(t, "/defaults/base") - cfg, err := LoadConfig(configPath) + detectedBaseDir := "/defaults/base" + cfg, err := LoadConfigWithBaseDir(configPath, detectedBaseDir) if err != nil { t.Fatalf("LoadConfig() error = %v", err) } @@ -534,8 +539,8 @@ func TestConfigDefaults(t *testing.T) { t.Error("Expected default EmailEnabled to be false") } - if cfg.BaseDir != "/defaults/base" { - t.Errorf("Default BaseDir = %q; want %q", cfg.BaseDir, "/defaults/base") + if cfg.BaseDir != detectedBaseDir { + t.Errorf("Default BaseDir = %q; want %q", cfg.BaseDir, detectedBaseDir) } } @@ -573,6 +578,54 @@ WEBHOOK_ENABLE=true } } +func TestLoadConfigEmailDeliveryAliasesAndFallbackSendmail(t *testing.T) { + tmpDir := t.TempDir() + configPath := filepath.Join(tmpDir, "email_aliases.env") + + content := `EMAIL_ENABLED=true +EMAIL_DELIVERY_METHOD=proxmox-notifications +EMAIL_FALLBACK_SENDMAIL=false +EMAIL_FALLBACK_PMF=true +` + if err := os.WriteFile(configPath, []byte(content), 0o600); err != nil { + t.Fatalf("Failed to create test config: %v", err) + } + + setBaseDirEnv(t, "/email/aliases/base") + + cfg, err := LoadConfig(configPath) + if err != nil { + t.Fatalf("LoadConfig() error = %v", err) + } + + if cfg.EmailDeliveryMethod != "pmf" { + t.Fatalf("EmailDeliveryMethod=%q, want pmf", cfg.EmailDeliveryMethod) + } + if cfg.EmailFallbackSendmail { + t.Fatalf("EMAIL_FALLBACK_SENDMAIL should take precedence over transitional EMAIL_FALLBACK_PMF") + } +} + +func TestNormalizeEmailDeliveryMethod(t *testing.T) { + tests := map[string]string{ + "": "relay", + "cloud relay": "relay", + "local_mta": "sendmail", + "pfm": "pmf", + "proxmox": "pmf", + "proxmox-mail-forward": "pmf", + "proxmox notifications": "pmf", + "unexpected-experiment": "unexpected-experiment", + " unexpected_EXPERIMENT ": "unexpected-experiment", + } + + for input, want := range tests { + if got := NormalizeEmailDeliveryMethod(input); got != want { + t.Fatalf("NormalizeEmailDeliveryMethod(%q)=%q, want %q", input, got, want) + } + } +} + func TestLoadEnvOverridesNotificationLegacyEnableAliases(t *testing.T) { tmpDir := t.TempDir() configPath := filepath.Join(tmpDir, "legacy_notification_env_override.env") @@ -620,7 +673,7 @@ BOT_TELEGRAM_TYPE=centralized } } -func TestLoadConfigBaseDirFromConfig(t *testing.T) { +func TestLoadConfigIgnoresBaseDirFromConfig(t *testing.T) { tmpDir := t.TempDir() configPath := filepath.Join(tmpDir, "base_dir.env") @@ -631,19 +684,26 @@ BACKUP_PATH=${BASE_DIR}/backup-data t.Fatalf("Failed to create test config: %v", err) } - setBaseDirEnv(t, "") + setBaseDirEnv(t, "/env/base") + detectedBaseDir := "/detected/base" - cfg, err := LoadConfig(configPath) + cfg, err := LoadConfigWithBaseDir(configPath, detectedBaseDir) if err != nil { t.Fatalf("LoadConfig() error = %v", err) } - if cfg.BaseDir != "/custom/base" { - t.Errorf("BaseDir = %q; want %q", cfg.BaseDir, "/custom/base") + if cfg.BaseDir != detectedBaseDir { + t.Errorf("BaseDir = %q; want %q", cfg.BaseDir, detectedBaseDir) } - if cfg.BackupPath != "/custom/base/backup-data" { - t.Errorf("BackupPath = %q; want %q", cfg.BackupPath, "/custom/base/backup-data") + if cfg.BackupPath != "/detected/base/backup-data" { + t.Errorf("BackupPath = %q; want %q", cfg.BackupPath, "/detected/base/backup-data") + } + if val, ok := cfg.IgnoredBaseDirConfig(); !ok || val != "/custom/base" { + t.Fatalf("IgnoredBaseDirConfig = %q, %v; want /custom/base true", val, ok) + } + if val, ok := cfg.IgnoredBaseDirEnv(); !ok || val != "/env/base" { + t.Fatalf("IgnoredBaseDirEnv = %q, %v; want /env/base true", val, ok) } } @@ -957,18 +1017,16 @@ func TestConfigFallbackHelpers(t *testing.T) { } } -func TestExpandEnvVarsAndBaseDir(t *testing.T) { - setBaseDirEnv(t, "/env/base") - +func TestConfigVarExpanderUsesDetectedBaseDir(t *testing.T) { t.Setenv("FOO", "bar") in := "${FOO}/$FOO/${BASE_DIR}/suffix" - got := expandEnvVars(in) + got := newConfigVarExpander(map[string]string{}, "/detected/base").expand(in) if !strings.Contains(got, "bar/bar") { t.Fatalf("expandEnvVars FOO not expanded correctly: %q", got) } - if !strings.Contains(got, "/env/base/") { - t.Fatalf("expandEnvVars BASE_DIR not expanded from env base dir: %q", got) + if !strings.Contains(got, "/detected/base/") { + t.Fatalf("expandEnvVars BASE_DIR not expanded from detected base dir: %q", got) } } diff --git a/internal/config/env_mutation.go b/internal/config/env_mutation.go index 1fca9a80..6f474d42 100644 --- a/internal/config/env_mutation.go +++ b/internal/config/env_mutation.go @@ -41,3 +41,50 @@ func ApplySecondaryStorageSettings(template string, enabled bool, secondaryPath template = utils.SetEnvValue(template, "SECONDARY_LOG_PATH", "") return template } + +// RemoveEnvKeys removes active KEY=VALUE entries from an env template while +// preserving comments and unrelated lines. +func RemoveEnvKeys(template string, keys ...string) string { + if len(keys) == 0 { + return template + } + remove := make(map[string]struct{}, len(keys)) + for _, key := range keys { + key = strings.ToUpper(strings.TrimSpace(key)) + if key != "" { + remove[key] = struct{}{} + } + } + if len(remove) == 0 { + return template + } + + lines := strings.Split(template, "\n") + out := make([]string, 0, len(lines)) + for _, line := range lines { + trimmed := strings.TrimSpace(line) + if utils.IsComment(trimmed) { + out = append(out, line) + continue + } + key, _, ok := utils.SplitKeyValue(line) + if !ok { + out = append(out, line) + continue + } + if fields := strings.Fields(key); len(fields) >= 2 && fields[0] == "export" { + key = fields[1] + } + if _, drop := remove[strings.ToUpper(strings.TrimSpace(key))]; drop { + continue + } + out = append(out, line) + } + return strings.Join(out, "\n") +} + +// RemoveRuntimeDerivedEnvKeys strips config keys that are intentionally derived +// at runtime instead of stored in backup.env. +func RemoveRuntimeDerivedEnvKeys(template string) string { + return RemoveEnvKeys(template, "BASE_DIR", "CRON_SCHEDULE", "CRON_HOUR", "CRON_MINUTE") +} diff --git a/internal/config/migration.go b/internal/config/migration.go index e8448578..631724b5 100644 --- a/internal/config/migration.go +++ b/internal/config/migration.go @@ -149,6 +149,9 @@ func mergeTemplateWithLegacy(template string, legacy map[string]string) (string, } for key := range legacy { + if _, ignored := ignoredLegacyMigrationKeys[strings.ToUpper(strings.TrimSpace(key))]; ignored { + continue + } if !usedLegacy[key] { summary.UnmappedLegacyKeys = append(summary.UnmappedLegacyKeys, key) } @@ -162,6 +165,10 @@ func mergeTemplateWithLegacy(template string, legacy map[string]string) (string, return merged, summary } +var ignoredLegacyMigrationKeys = map[string]struct{}{ + "BASE_DIR": {}, +} + func renderValueLines(key, value string) []string { if blockValueKeys[key] { blockLines := strings.Split(value, "\n") diff --git a/internal/config/migration_test.go b/internal/config/migration_test.go index a4bd4fbe..2218b259 100644 --- a/internal/config/migration_test.go +++ b/internal/config/migration_test.go @@ -155,6 +155,21 @@ func TestMergeTemplateWithLegacyTracksUnmappedKeys(t *testing.T) { } } +func TestMergeTemplateWithLegacyIgnoresDeprecatedBaseDir(t *testing.T) { + legacy := map[string]string{ + "BASE_DIR": "/legacy/base", + } + + merged, summary := mergeTemplateWithLegacy(testTemplate, legacy) + + if strings.Contains(merged, "\nBASE_DIR=") || strings.HasPrefix(merged, "BASE_DIR=") { + t.Fatalf("merged template should not contain active BASE_DIR:\n%s", merged) + } + if len(summary.UnmappedLegacyKeys) != 0 { + t.Fatalf("UnmappedLegacyKeys = %v; want []", summary.UnmappedLegacyKeys) + } +} + const baseInstallTemplate = `BACKUP_ENABLED=true BACKUP_PATH=/default/backup LOG_PATH=/default/log diff --git a/internal/config/templates/backup.env b/internal/config/templates/backup.env index 0089f6ed..2f7be435 100644 --- a/internal/config/templates/backup.env +++ b/internal/config/templates/backup.env @@ -49,6 +49,8 @@ MIN_DISK_SPACE_CLOUD_GB=1 # ---------------------------------------------------------------------- # Local paths # ---------------------------------------------------------------------- +# BASE_DIR is auto-detected from the installed proxsave executable; do not set it in backup.env. +# BASE_DIR=/opt/proxsave LOCK_PATH=${BASE_DIR}/lock SECURE_ACCOUNT=${BASE_DIR}/secure_account @@ -222,9 +224,9 @@ TELEGRAM_CHAT_ID= # For personal mode only # Email Notifications EMAIL_ENABLED=false -EMAIL_DELIVERY_METHOD=relay # "relay" (cloud relay) | "sendmail" (/usr/sbin/sendmail) | "pmf" (proxmox-mail-forward / Proxmox Notifications) -EMAIL_FALLBACK_SENDMAIL=true # Historical name: when EMAIL_DELIVERY_METHOD=relay, fallback to "pmf" (proxmox-mail-forward) if relay fails -EMAIL_RECIPIENT= # Leave empty for auto-detection from Proxmox +EMAIL_DELIVERY_METHOD=relay # "relay" = TIS24 HTTPS relay (default) | "sendmail" = local /usr/sbin/sendmail | "pmf" = Proxmox Notifications +EMAIL_FALLBACK_SENDMAIL=true # Fall back to local sendmail when relay fails; if method=pmf, fallback order is relay then sendmail +EMAIL_RECIPIENT= # relay/sendmail require a recipient (empty = root@pam auto-detect); pmf uses this only as a To: header EMAIL_FROM=no-reply@proxmox.tis24.it # Gotify Notifications @@ -375,7 +377,7 @@ BACKUP_BLACKLIST=" SKIP_PERMISSION_CHECK=false # true = skip permission checks (test only) BACKUP_USER=backup # Owner user for backup/log directories BACKUP_GROUP=backup # Owner group for backup/log directories -SET_BACKUP_PERMISSIONS=false # true = apply Bash-style chown/chmod on backup/log +SET_BACKUP_PERMISSIONS=false # true = apply Bash-style chown/chmod on backup/log (skips CIFS/SMB/NTFS/FUSE) # ============================================================================== # End file diff --git a/internal/config/upgrade.go b/internal/config/upgrade.go index 0a402f47..7f50d770 100644 --- a/internal/config/upgrade.go +++ b/internal/config/upgrade.go @@ -74,6 +74,12 @@ type UpgradeResult struct { // to a "Custom keys" section at the bottom of the file. // 4. The original file is backed up before writing the new version. func UpgradeConfigFile(configPath string) (*UpgradeResult, error) { + return UpgradeConfigFileWithBaseDir(configPath, defaultBaseDir()) +} + +// UpgradeConfigFileWithBaseDir merges the user's configuration with the +// embedded template and validates the result against the detected base dir. +func UpgradeConfigFileWithBaseDir(configPath, baseDir string) (*UpgradeResult, error) { result, newContent, originalContent, err := computeConfigUpgrade(configPath) if err != nil { return result, err @@ -113,7 +119,7 @@ func UpgradeConfigFile(configPath string) (*UpgradeResult, error) { result.BackupPath = backupPath // Post-upgrade validation: ensure the upgraded configuration can be parsed. - if _, err := LoadConfig(configPath); err != nil { + if _, err := LoadConfigWithBaseDir(configPath, baseDir); err != nil { // Attempt automatic rollback to the backup. _ = os.Rename(backupPath, configPath) return result, fmt.Errorf("upgraded config invalid, restored backup: %w", err) @@ -175,7 +181,7 @@ func computeConfigUpgrade(configPath string) (*UpgradeResult, string, []byte, er // Prune deprecated keys that are now auto-detected at runtime. // - // BASE_DIR is derived from the executable/config location. + // BASE_DIR is derived from the installed executable path. // CRON_* scheduling is managed via crontab, not backup.env. deprecatedUpperKeys := map[string]struct{}{ "BASE_DIR": {}, @@ -211,7 +217,7 @@ func computeConfigUpgrade(configPath string) (*UpgradeResult, string, []byte, er keys = append(keys, k) } sort.Strings(keys) - warnings = append(warnings, fmt.Sprintf("Removed deprecated keys from backup.env: %s (BASE_DIR is auto-detected; cron is managed via crontab)", strings.Join(keys, ", "))) + warnings = append(warnings, fmt.Sprintf("Removed deprecated keys from backup.env: %s (BASE_DIR is auto-detected from the installed executable; cron is managed via crontab)", strings.Join(keys, ", "))) } // 2. Walk the template line-by-line and collect template entries. diff --git a/internal/identity/identity.go b/internal/identity/identity.go index 22a9d546..d0168dda 100644 --- a/internal/identity/identity.go +++ b/internal/identity/identity.go @@ -310,21 +310,29 @@ func isVirtualInterface(iface string, logger *logging.Logger) bool { func isBridgeInterface(iface string) bool { if runtime.GOOS != "linux" { - name := strings.ToLower(iface) - return strings.HasPrefix(name, "vmbr") || strings.HasPrefix(name, "br") || strings.HasPrefix(name, "bridge") + return isBridgeInterfaceByName(iface) } _, err := os.Stat(filepath.Join("/sys/class/net", iface, "bridge")) return err == nil } +func isBridgeInterfaceByName(iface string) bool { + name := strings.ToLower(iface) + return strings.HasPrefix(name, "vmbr") || strings.HasPrefix(name, "br") || strings.HasPrefix(name, "bridge") +} + func isWirelessInterface(iface string) bool { if runtime.GOOS != "linux" { - return strings.HasPrefix(strings.ToLower(iface), "wl") + return isWirelessInterfaceByName(iface) } _, err := os.Stat(filepath.Join("/sys/class/net", iface, "wireless")) return err == nil } +func isWirelessInterfaceByName(iface string) bool { + return strings.HasPrefix(strings.ToLower(iface), "wl") +} + func isLocallyAdministeredMAC(mac string) bool { fields := strings.Split(mac, ":") if len(fields) == 0 { diff --git a/internal/identity/identity_test.go b/internal/identity/identity_test.go index 25bad342..9eff5ea2 100644 --- a/internal/identity/identity_test.go +++ b/internal/identity/identity_test.go @@ -1138,8 +1138,6 @@ func TestReadAddrAssignType(t *testing.T) { } func TestIsBridgeInterfaceByName(t *testing.T) { - // On non-Linux, detection falls back to interface names. On Linux, - // sysfs decides the result and these synthetic names may not exist. tests := []struct { name string want bool @@ -1156,21 +1154,15 @@ func TestIsBridgeInterfaceByName(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - got := isBridgeInterface(tt.name) - if runtime.GOOS == "linux" { - t.Logf("sysfs bridge detection for %q returned %v", tt.name, got) - return - } + got := isBridgeInterfaceByName(tt.name) if got != tt.want { - t.Fatalf("isBridgeInterface(%q)=%v; want %v", tt.name, got, tt.want) + t.Fatalf("isBridgeInterfaceByName(%q)=%v; want %v", tt.name, got, tt.want) } }) } } func TestIsWirelessInterfaceByName(t *testing.T) { - // On non-Linux, detection falls back to interface names. On Linux, - // sysfs decides the result and these synthetic names may not exist. tests := []struct { name string want bool @@ -1184,13 +1176,9 @@ func TestIsWirelessInterfaceByName(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - got := isWirelessInterface(tt.name) - if runtime.GOOS == "linux" { - t.Logf("sysfs wireless detection for %q returned %v", tt.name, got) - return - } + got := isWirelessInterfaceByName(tt.name) if got != tt.want { - t.Fatalf("isWirelessInterface(%q)=%v; want %v", tt.name, got, tt.want) + t.Fatalf("isWirelessInterfaceByName(%q)=%v; want %v", tt.name, got, tt.want) } }) } diff --git a/internal/logging/logger.go b/internal/logging/logger.go index 2e80b719..19ca0a6e 100644 --- a/internal/logging/logger.go +++ b/internal/logging/logger.go @@ -72,10 +72,11 @@ func (l *Logger) OpenLogFile(logPath string) error { defer l.mu.Unlock() // If a log file is already open, close it first. if l.logFile != nil { - if err := l.logFile.Close(); err != nil { + err := l.logFile.Close() + l.logFile = nil + if err != nil { return fmt.Errorf("failed to close existing log file: %w", err) } - l.logFile = nil } // Create the log file (O_CREATE|O_WRONLY|O_APPEND). diff --git a/internal/logging/logger_test.go b/internal/logging/logger_test.go index 89f426e1..043ea07a 100644 --- a/internal/logging/logger_test.go +++ b/internal/logging/logger_test.go @@ -477,6 +477,27 @@ func TestOpenLogFileReopenClosesPrevious(t *testing.T) { } } +func TestOpenLogFileClearsStaleHandleWhenCloseFails(t *testing.T) { + tmp := t.TempDir() + first := filepath.Join(tmp, "first.log") + second := filepath.Join(tmp, "second.log") + + logger := New(types.LogLevelInfo, false) + if err := logger.OpenLogFile(first); err != nil { + t.Fatalf("OpenLogFile(first) error: %v", err) + } + if err := logger.logFile.Close(); err != nil { + t.Fatalf("pre-close log file: %v", err) + } + + if err := logger.OpenLogFile(second); err == nil { + t.Fatalf("expected reopen to report close error") + } + if logger.logFile != nil { + t.Fatalf("expected stale logFile handle to be cleared") + } +} + func TestPackageLevelFatalUsesDefaultLoggerExitFunc(t *testing.T) { var buf bytes.Buffer logger := New(types.LogLevelDebug, false) diff --git a/internal/notify/email.go b/internal/notify/email.go index e04644da..16d3b458 100644 --- a/internal/notify/email.go +++ b/internal/notify/email.go @@ -165,8 +165,8 @@ func (e *EmailNotifier) Send(ctx context.Context, data *NotificationData) (*Noti switch e.config.DeliveryMethod { case EmailDeliveryRelay: if e.config.FallbackSendmail { - e.logger.Info("Email delivery method: relay (fallback: pmf enabled)") - e.logger.Debug("Email delivery plan: primary=relay fallback=pmf relay_requires_recipient=true pmf_recipient_optional=true") + e.logger.Info("Email delivery method: relay (fallback: sendmail enabled)") + e.logger.Debug("Email delivery plan: primary=relay fallback=sendmail relay_requires_recipient=true sendmail_requires_recipient=true") } else { e.logger.Info("Email delivery method: relay (fallback: disabled)") e.logger.Debug("Email delivery plan: primary=relay fallback=disabled relay_requires_recipient=true") @@ -176,7 +176,11 @@ func (e *EmailNotifier) Send(ctx context.Context, data *NotificationData) (*Noti e.logger.Debug("Email delivery plan: primary=sendmail fallback=disabled relay_requires_recipient=true") case EmailDeliveryPMF: e.logger.Info("Email delivery method: pmf (proxmox-mail-forward)") - e.logger.Debug("Email delivery plan: primary=pmf fallback=disabled recipient_optional=true") + if e.config.FallbackSendmail { + e.logger.Debug("Email delivery plan: primary=pmf fallback=relay,sendmail recipient_optional=true relay_requires_recipient=true sendmail_requires_recipient=true") + } else { + e.logger.Debug("Email delivery plan: primary=pmf fallback=relay recipient_optional=true relay_requires_recipient=true") + } default: e.logger.Info("Email delivery method: %s", e.config.DeliveryMethod) } @@ -185,7 +189,6 @@ func (e *EmailNotifier) Send(ctx context.Context, data *NotificationData) (*Noti recipient := strings.TrimSpace(e.config.Recipient) autoDetected := false recipientSource := "configured" - relayPMFFallbackEnabled := e.config.DeliveryMethod == EmailDeliveryRelay && e.config.FallbackSendmail var preflightFallbackReason string var preflightFallbackCause error if recipient == "" { @@ -197,12 +200,6 @@ func (e *EmailNotifier) Send(ctx context.Context, data *NotificationData) (*Noti switch { case e.config.DeliveryMethod == EmailDeliveryPMF: e.logger.Info(" Proceeding anyway because EMAIL_DELIVERY_METHOD=pmf routes via Proxmox Notifications; recipient is only used for the To: header") - case relayPMFFallbackEnabled: - preflightFallbackReason = "recipient_autodetect_failed" - preflightFallbackCause = fmt.Errorf("no valid email recipient: %w", err) - e.logger.Warning("WARNING: Relay delivery requires a valid recipient; auto-detection failed") - e.logger.Info(" Bypassing relay and invoking PMF fallback before relay attempt") - e.logger.Debug("Email fallback decision: stage=preflight reason=%s cause=%v", preflightFallbackReason, preflightFallbackCause) default: e.logger.Warning("WARNING: Email notification skipped because no valid recipient is available") e.logger.Info(" Configure EMAIL_RECIPIENT or set an email address for root@pam inside Proxmox") @@ -222,16 +219,6 @@ func (e *EmailNotifier) Send(ctx context.Context, data *NotificationData) (*Noti recipient = strings.TrimSpace(recipient) if recipient == "" { switch { - case relayPMFFallbackEnabled: - if preflightFallbackReason == "" { - preflightFallbackReason = "recipient_missing" - } - if preflightFallbackCause == nil { - preflightFallbackCause = fmt.Errorf("no valid email recipient configured") - } - e.logger.Warning("WARNING: Email recipient is empty after configuration/detection") - e.logger.Info(" Bypassing relay and invoking PMF fallback before relay attempt because proxmox-mail-forward routes via Proxmox Notifications and does not require a resolved recipient") - e.logger.Debug("Email fallback decision: stage=preflight reason=%s cause=%v", preflightFallbackReason, preflightFallbackCause) case e.config.DeliveryMethod == EmailDeliveryRelay || e.config.DeliveryMethod == EmailDeliverySendmail: e.logger.Warning("WARNING: Email recipient is empty after configuration/detection") e.logger.Info(" Configure EMAIL_RECIPIENT or set an email address for root@pam inside Proxmox") @@ -247,7 +234,7 @@ func (e *EmailNotifier) Send(ctx context.Context, data *NotificationData) (*Noti if e.config.DeliveryMethod == EmailDeliveryRelay && isRootRecipient(recipient) { redactedRecipient := redactEmail(recipient) - if relayPMFFallbackEnabled { + if e.config.FallbackSendmail { if autoDetected { e.logger.Warning("WARNING: Auto-detected recipient %s belongs to root and is blocked for relay delivery", redactedRecipient) } else { @@ -255,7 +242,7 @@ func (e *EmailNotifier) Send(ctx context.Context, data *NotificationData) (*Noti } preflightFallbackReason = "recipient_blocked_root" preflightFallbackCause = fmt.Errorf("recipient %s is not allowed (root accounts are blocked)", redactedRecipient) - e.logger.Info(" Bypassing relay and invoking PMF fallback before relay attempt") + e.logger.Info(" Bypassing relay and invoking sendmail fallback before relay attempt") e.logger.Debug("Email fallback decision: stage=preflight reason=%s cause=%v", preflightFallbackReason, preflightFallbackCause) } else { if autoDetected { @@ -295,19 +282,19 @@ func (e *EmailNotifier) Send(ctx context.Context, data *NotificationData) (*Noti var relayErr error // Store original relay error if fallback is used if preflightFallbackReason != "" { - err = e.sendViaPMFFallback(ctx, result, recipient, subject, htmlBody, textBody, data, "preflight", preflightFallbackReason, preflightFallbackCause) + err = e.sendViaSendmailFallback(ctx, result, recipient, subject, htmlBody, textBody, data, "preflight", preflightFallbackReason, preflightFallbackCause) } else if e.config.DeliveryMethod == EmailDeliveryRelay { result.Method = "email-relay" err = e.sendViaRelay(ctx, recipient, subject, htmlBody, textBody, data) - // Fallback to PMF if relay fails and fallback is enabled + // Fallback to local sendmail if relay fails and fallback is enabled. if err != nil && e.config.FallbackSendmail { relayErr = err // Store original relay error e.logger.Warning("WARNING: Cloud relay failed: %v", err) - e.logger.Info("Attempting PMF fallback after relay delivery failure...") + e.logger.Info("Attempting sendmail fallback after relay delivery failure...") e.logger.Debug("Email fallback decision: stage=delivery reason=relay_send_failed cause=%v", relayErr) - err = e.sendViaPMFFallback(ctx, result, recipient, subject, htmlBody, textBody, data, "delivery", "relay_send_failed", relayErr) + err = e.sendViaSendmailFallback(ctx, result, recipient, subject, htmlBody, textBody, data, "delivery", "relay_send_failed", relayErr) } } else if e.config.DeliveryMethod == EmailDeliveryPMF { result.Method = "email-pmf" @@ -319,6 +306,10 @@ func (e *EmailNotifier) Send(ctx context.Context, data *NotificationData) (*Noti result.Metadata["email_backend_path"] = backendPath } err = sendErr + if err != nil { + e.logger.Warning("WARNING: PMF delivery failed: %v", err) + err = e.sendPMFFallbackChain(ctx, result, recipient, subject, htmlBody, textBody, data, err) + } } else { result.Method = "email-sendmail" queueID, backend, backendPath, sendErr := e.sendViaSendmail(ctx, recipient, subject, htmlBody, textBody, data) @@ -348,7 +339,7 @@ func (e *EmailNotifier) Send(ctx context.Context, data *NotificationData) (*Noti // Success (either primary or fallback) if result.UsedFallback { // Fallback succeeded after relay failure - e.logger.Warning("⚠️ Email sent via fallback after relay failure") + e.logger.Warning("⚠️ Email sent via fallback after primary delivery failure") } // Log according to delivery method to avoid implying guaranteed inbox delivery @@ -373,7 +364,7 @@ func (e *EmailNotifier) Send(ctx context.Context, data *NotificationData) (*Noti return result, nil } -func (e *EmailNotifier) sendViaPMFFallback( +func (e *EmailNotifier) sendViaSendmailFallback( ctx context.Context, result *NotificationResult, recipient, subject, htmlBody, textBody string, @@ -381,7 +372,7 @@ func (e *EmailNotifier) sendViaPMFFallback( stage, reason string, cause error, ) error { - result.Method = "email-pmf-fallback" + result.Method = "email-sendmail-fallback" result.UsedFallback = true if stage != "" { result.Metadata["fallback_stage"] = stage @@ -392,14 +383,17 @@ func (e *EmailNotifier) sendViaPMFFallback( switch stage { case "preflight": - e.logger.Info("PMF fallback activated during preflight (%s)", reason) + e.logger.Info("Sendmail fallback activated during preflight (%s)", reason) case "delivery": - e.logger.Info("PMF fallback activated after relay delivery failure") + e.logger.Info("Sendmail fallback activated after primary delivery failure") default: - e.logger.Info("PMF fallback activated") + e.logger.Info("Sendmail fallback activated") } - backend, backendPath, err := e.sendViaPMF(ctx, recipient, subject, htmlBody, textBody, data) + queueID, backend, backendPath, err := e.sendViaSendmail(ctx, recipient, subject, htmlBody, textBody, data) + if queueID != "" { + result.Metadata["mail_queue_id"] = queueID + } if backend != "" { result.Metadata["email_backend"] = backend } @@ -412,6 +406,71 @@ func (e *EmailNotifier) sendViaPMFFallback( return err } +func (e *EmailNotifier) sendViaRelayFallback( + ctx context.Context, + result *NotificationResult, + recipient, subject, htmlBody, textBody string, + data *NotificationData, + stage, reason string, + cause error, +) error { + result.Method = "email-relay-fallback" + result.UsedFallback = true + if stage != "" { + result.Metadata["fallback_stage"] = stage + } + if reason != "" { + result.Metadata["fallback_reason"] = reason + } + + e.logger.Info("Relay fallback activated after PMF delivery failure") + err := e.sendViaRelay(ctx, recipient, subject, htmlBody, textBody, data) + if err == nil && cause != nil { + result.Error = cause + } + return err +} + +func (e *EmailNotifier) sendPMFFallbackChain( + ctx context.Context, + result *NotificationResult, + recipient, subject, htmlBody, textBody string, + data *NotificationData, + pmfErr error, +) error { + relayAllowed := strings.TrimSpace(recipient) != "" && !isRootRecipient(recipient) + if relayAllowed { + e.logger.Info("Attempting relay fallback after PMF delivery failure...") + e.logger.Debug("Email fallback decision: stage=delivery reason=pmf_send_failed cause=%v", pmfErr) + relayErr := e.sendViaRelayFallback(ctx, result, recipient, subject, htmlBody, textBody, data, "delivery", "pmf_send_failed", pmfErr) + if relayErr == nil { + return nil + } + e.logger.Warning("WARNING: Relay fallback failed after PMF failure: %v", relayErr) + if !e.config.FallbackSendmail { + return fmt.Errorf("pmf failed: %w; relay fallback failed: %v", pmfErr, relayErr) + } + result.Metadata["relay_fallback_error"] = relayErr.Error() + } else { + if strings.TrimSpace(recipient) == "" { + e.logger.Info("Skipping relay fallback after PMF failure because no recipient is available") + } else { + e.logger.Info("Skipping relay fallback after PMF failure because relay blocks root recipients") + } + if !e.config.FallbackSendmail { + return pmfErr + } + } + + if strings.TrimSpace(recipient) == "" { + return fmt.Errorf("pmf failed: %w; sendmail fallback unavailable because no valid recipient is configured", pmfErr) + } + + e.logger.Info("Attempting sendmail fallback after PMF/relay delivery failure...") + e.logger.Debug("Email fallback decision: stage=delivery reason=pmf_or_relay_send_failed cause=%v", pmfErr) + return e.sendViaSendmailFallback(ctx, result, recipient, subject, htmlBody, textBody, data, "delivery", "pmf_or_relay_send_failed", pmfErr) +} + func describeEmailMethod(method string) string { switch method { case "email-relay": @@ -420,6 +479,10 @@ func describeEmailMethod(method string) string { return "sendmail" case "email-pmf": return "proxmox-mail-forward" + case "email-relay-fallback": + return "cloud relay fallback" + case "email-sendmail-fallback": + return "sendmail fallback" case "email-pmf-fallback": return "proxmox-mail-forward fallback" default: @@ -1139,6 +1202,12 @@ func encodeQuotedPrintableBody(body string) string { return encoded.String() } +func sanitizeHeaderValue(value string) string { + value = strings.ReplaceAll(value, "\r", "") + value = strings.ReplaceAll(value, "\n", "") + return strings.TrimSpace(value) +} + func (e *EmailNotifier) buildEmailMessage(recipient, subject, htmlBody, textBody string, data *NotificationData) (emailMessage, toHeader string) { e.logger.Debug("=== Building email message ===") @@ -1147,12 +1216,16 @@ func (e *EmailNotifier) buildEmailMessage(recipient, subject, htmlBody, textBody // Build email headers and body var email strings.Builder - toHeader = strings.TrimSpace(recipient) + toHeader = sanitizeHeaderValue(recipient) if toHeader == "" { toHeader = "root" } + fromHeader := sanitizeHeaderValue(e.config.From) + if fromHeader == "" { + fromHeader = "no-reply@proxmox.tis24.it" + } fmt.Fprintf(&email, "To: %s\n", toHeader) - fmt.Fprintf(&email, "From: %s\n", e.config.From) + fmt.Fprintf(&email, "From: %s\n", fromHeader) fmt.Fprintf(&email, "Subject: =?UTF-8?B?%s?=\n", encodedSubject) email.WriteString("MIME-Version: 1.0\n") diff --git a/internal/notify/email_delivery_methods_test.go b/internal/notify/email_delivery_methods_test.go index d9807683..e3f0df22 100644 --- a/internal/notify/email_delivery_methods_test.go +++ b/internal/notify/email_delivery_methods_test.go @@ -128,7 +128,7 @@ func TestEmailNotifier_SendPMF_AllowsMissingRecipientAndInvokesForwarder(t *test } } -func TestEmailNotifier_RelayFallback_UsesPMFOnly(t *testing.T) { +func TestEmailNotifier_RelayFallback_UsesSendmail(t *testing.T) { logger := logging.New(types.LogLevelDebug, false) // Force relay failure. @@ -140,21 +140,31 @@ func TestEmailNotifier_RelayFallback_UsesPMFOnly(t *testing.T) { })) defer server.Close() - capturePath := filepath.Join(t.TempDir(), "pmf_capture.txt") - t.Setenv("PMF_CAPTURE_PATH", capturePath) + dir := t.TempDir() + capturePath := filepath.Join(t.TempDir(), "sendmail_capture.txt") + t.Setenv("SENDMAIL_CAPTURE_PATH", capturePath) - pmfScriptPath := writeCaptureScript(t, "proxmox-mail-forward", "PMF_CAPTURE_PATH") + sendmailPath := writeCmd(t, dir, "sendmail", `#!/bin/sh +set -eu +cat > "${SENDMAIL_CAPTURE_PATH}" +echo "queued as FALLBACKQID" +exit 0 +`) + writeCmd(t, dir, "mailq", "#!/bin/sh\necho \"Mail queue is empty\"\nexit 0\n") + writeCmd(t, dir, "tail", "#!/bin/sh\nexit 0\n") + writeCmd(t, dir, "journalctl", "#!/bin/sh\nexit 0\n") + writeCmd(t, dir, "systemctl", "#!/bin/sh\nexit 3\n") origPath := os.Getenv("PATH") - t.Setenv("PATH", filepath.Dir(pmfScriptPath)+string(os.PathListSeparator)+origPath) + t.Setenv("PATH", dir+string(os.PathListSeparator)+origPath) - origCandidates := pmfLookPathCandidates - pmfLookPathCandidates = []string{"proxmox-mail-forward"} - t.Cleanup(func() { pmfLookPathCandidates = origCandidates }) + origSendmailPath := sendmailBinaryPath + sendmailBinaryPath = sendmailPath + t.Cleanup(func() { sendmailBinaryPath = origSendmailPath }) notifier, err := NewEmailNotifier(EmailConfig{ Enabled: true, DeliveryMethod: EmailDeliveryRelay, - FallbackSendmail: true, // historical name; now means fallback to PMF + FallbackSendmail: true, Recipient: "admin@example.com", From: "no-reply@proxmox.example.com", CloudRelayConfig: CloudRelayConfig{ @@ -175,13 +185,13 @@ func TestEmailNotifier_RelayFallback_UsesPMFOnly(t *testing.T) { t.Fatalf("Send() returned unexpected error: %v", err) } if !result.Success { - t.Fatalf("expected Success=true due to PMF fallback, got false (err=%v)", result.Error) + t.Fatalf("expected Success=true due to sendmail fallback, got false (err=%v)", result.Error) } if !result.UsedFallback { t.Fatalf("expected UsedFallback=true") } - if result.Method != "email-pmf-fallback" { - t.Fatalf("expected Method=email-pmf-fallback, got %q", result.Method) + if result.Method != "email-sendmail-fallback" { + t.Fatalf("expected Method=email-sendmail-fallback, got %q", result.Method) } if result.Error == nil { t.Fatalf("expected original relay error preserved in result.Error") @@ -193,34 +203,72 @@ func TestEmailNotifier_RelayFallback_UsesPMFOnly(t *testing.T) { got, err := os.ReadFile(capturePath) if err != nil { - t.Fatalf("read pmf capture: %v", err) + t.Fatalf("read sendmail capture: %v", err) } if !strings.Contains(string(got), "To: admin@example.com\n") { - t.Fatalf("expected To: admin@example.com header in PMF message") + t.Fatalf("expected To: admin@example.com header in sendmail message") } } -func TestEmailNotifier_RelayFallback_UsesPMFWhenRecipientDetectionFails(t *testing.T) { +func TestEmailNotifier_RelayFallback_DoesNotBypassMissingRecipient(t *testing.T) { logger := logging.New(types.LogLevelDebug, false) - capturePath := filepath.Join(t.TempDir(), "pmf_capture.txt") - t.Setenv("PMF_CAPTURE_PATH", capturePath) + notifier, err := NewEmailNotifier(EmailConfig{ + Enabled: true, + DeliveryMethod: EmailDeliveryRelay, + FallbackSendmail: true, + Recipient: "", + From: "no-reply@proxmox.example.com", + }, types.ProxmoxUnknown, logger) + if err != nil { + t.Fatalf("NewEmailNotifier() error = %v", err) + } - pmfScriptPath := writeCaptureScript(t, "proxmox-mail-forward", "PMF_CAPTURE_PATH") + result, err := notifier.Send(context.Background(), createTestNotificationData()) + if err != nil { + t.Fatalf("Send() returned unexpected error: %v", err) + } + if result.Success { + t.Fatalf("expected Success=false when relay and sendmail have no recipient") + } + if result.UsedFallback { + t.Fatalf("expected no fallback attempt without a recipient") + } + if result.Error == nil { + t.Fatalf("expected missing recipient error") + } +} + +func TestEmailNotifier_RelayFallback_UsesSendmailWhenRootRecipientBlocked(t *testing.T) { + logger := logging.New(types.LogLevelDebug, false) + + dir := t.TempDir() + capturePath := filepath.Join(t.TempDir(), "sendmail_capture.txt") + t.Setenv("SENDMAIL_CAPTURE_PATH", capturePath) + + sendmailPath := writeCmd(t, dir, "sendmail", `#!/bin/sh +set -eu +cat > "${SENDMAIL_CAPTURE_PATH}" +exit 0 +`) + writeCmd(t, dir, "mailq", "#!/bin/sh\necho \"Mail queue is empty\"\nexit 0\n") + writeCmd(t, dir, "tail", "#!/bin/sh\nexit 0\n") + writeCmd(t, dir, "journalctl", "#!/bin/sh\nexit 0\n") + writeCmd(t, dir, "systemctl", "#!/bin/sh\nexit 3\n") origPath := os.Getenv("PATH") - t.Setenv("PATH", filepath.Dir(pmfScriptPath)+string(os.PathListSeparator)+origPath) + t.Setenv("PATH", dir+string(os.PathListSeparator)+origPath) - origCandidates := pmfLookPathCandidates - pmfLookPathCandidates = []string{"proxmox-mail-forward"} - t.Cleanup(func() { pmfLookPathCandidates = origCandidates }) + origSendmailPath := sendmailBinaryPath + sendmailBinaryPath = sendmailPath + t.Cleanup(func() { sendmailBinaryPath = origSendmailPath }) notifier, err := NewEmailNotifier(EmailConfig{ Enabled: true, DeliveryMethod: EmailDeliveryRelay, FallbackSendmail: true, - Recipient: "", + Recipient: "root@example.com", From: "no-reply@proxmox.example.com", - }, types.ProxmoxUnknown, logger) + }, types.ProxmoxBS, logger) if err != nil { t.Fatalf("NewEmailNotifier() error = %v", err) } @@ -230,19 +278,19 @@ func TestEmailNotifier_RelayFallback_UsesPMFWhenRecipientDetectionFails(t *testi t.Fatalf("Send() returned unexpected error: %v", err) } if !result.Success { - t.Fatalf("expected Success=true due to PMF preflight fallback, got false (err=%v)", result.Error) + t.Fatalf("expected Success=true due to sendmail preflight fallback, got false (err=%v)", result.Error) } if !result.UsedFallback { t.Fatalf("expected UsedFallback=true") } - if result.Method != "email-pmf-fallback" { - t.Fatalf("expected Method=email-pmf-fallback, got %q", result.Method) + if result.Method != "email-sendmail-fallback" { + t.Fatalf("expected Method=email-sendmail-fallback, got %q", result.Method) } if got, _ := result.Metadata["fallback_stage"].(string); got != "preflight" { t.Fatalf("fallback_stage=%q want %q", got, "preflight") } - if got, _ := result.Metadata["fallback_reason"].(string); got != "recipient_autodetect_failed" { - t.Fatalf("fallback_reason=%q want %q", got, "recipient_autodetect_failed") + if got, _ := result.Metadata["fallback_reason"].(string); got != "recipient_blocked_root" { + t.Fatalf("fallback_reason=%q want %q", got, "recipient_blocked_root") } if result.Error == nil { t.Fatalf("expected original preflight cause preserved in result.Error") @@ -250,34 +298,43 @@ func TestEmailNotifier_RelayFallback_UsesPMFWhenRecipientDetectionFails(t *testi got, err := os.ReadFile(capturePath) if err != nil { - t.Fatalf("read pmf capture: %v", err) + t.Fatalf("read sendmail capture: %v", err) } msg := string(got) - if !strings.Contains(msg, "To: root\n") { - t.Fatalf("expected To: root header when recipient resolution fails, got:\n%s", msg) + if !strings.Contains(msg, "To: root@example.com\n") { + t.Fatalf("expected To: root@example.com header in sendmail message, got:\n%s", msg) } } -func TestEmailNotifier_RelayFallback_UsesPMFWhenRootRecipientBlocked(t *testing.T) { +func TestEmailNotifier_PMFFallback_UsesRelayFirst(t *testing.T) { logger := logging.New(types.LogLevelDebug, false) - capturePath := filepath.Join(t.TempDir(), "pmf_capture.txt") - t.Setenv("PMF_CAPTURE_PATH", capturePath) - - pmfScriptPath := writeCaptureScript(t, "proxmox-mail-forward", "PMF_CAPTURE_PATH") - origPath := os.Getenv("PATH") - t.Setenv("PATH", filepath.Dir(pmfScriptPath)+string(os.PathListSeparator)+origPath) - origCandidates := pmfLookPathCandidates - pmfLookPathCandidates = []string{"proxmox-mail-forward"} + pmfLookPathCandidates = []string{filepath.Join(t.TempDir(), "missing-proxmox-mail-forward")} t.Cleanup(func() { pmfLookPathCandidates = origCandidates }) + callCount := 0 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + callCount++ + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{"success":true}`)) + })) + defer server.Close() + notifier, err := NewEmailNotifier(EmailConfig{ Enabled: true, - DeliveryMethod: EmailDeliveryRelay, + DeliveryMethod: EmailDeliveryPMF, FallbackSendmail: true, - Recipient: "root@example.com", + Recipient: "admin@example.com", From: "no-reply@proxmox.example.com", + CloudRelayConfig: CloudRelayConfig{ + WorkerURL: server.URL, + WorkerToken: "token", + HMACSecret: "secret", + Timeout: 5, + MaxRetries: 0, + RetryDelay: 0, + }, }, types.ProxmoxBS, logger) if err != nil { t.Fatalf("NewEmailNotifier() error = %v", err) @@ -288,31 +345,97 @@ func TestEmailNotifier_RelayFallback_UsesPMFWhenRootRecipientBlocked(t *testing. t.Fatalf("Send() returned unexpected error: %v", err) } if !result.Success { - t.Fatalf("expected Success=true due to PMF preflight fallback, got false (err=%v)", result.Error) + t.Fatalf("expected Success=true due to relay fallback, got false (err=%v)", result.Error) } if !result.UsedFallback { t.Fatalf("expected UsedFallback=true") } - if result.Method != "email-pmf-fallback" { - t.Fatalf("expected Method=email-pmf-fallback, got %q", result.Method) + if result.Method != "email-relay-fallback" { + t.Fatalf("expected Method=email-relay-fallback, got %q", result.Method) } - if got, _ := result.Metadata["fallback_stage"].(string); got != "preflight" { - t.Fatalf("fallback_stage=%q want %q", got, "preflight") + if result.Error == nil { + t.Fatalf("expected original PMF error preserved in result.Error") } - if got, _ := result.Metadata["fallback_reason"].(string); got != "recipient_blocked_root" { - t.Fatalf("fallback_reason=%q want %q", got, "recipient_blocked_root") + if callCount != 1 { + t.Fatalf("expected relay fallback to be attempted once, got %d", callCount) + } +} + +func TestEmailNotifier_PMFFallback_UsesSendmailAfterRelayFailure(t *testing.T) { + logger := logging.New(types.LogLevelDebug, false) + + origCandidates := pmfLookPathCandidates + pmfLookPathCandidates = []string{filepath.Join(t.TempDir(), "missing-proxmox-mail-forward")} + t.Cleanup(func() { pmfLookPathCandidates = origCandidates }) + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + _, _ = w.Write([]byte(`{"error":"temporary"}`)) + })) + defer server.Close() + + dir := t.TempDir() + capturePath := filepath.Join(t.TempDir(), "sendmail_capture.txt") + t.Setenv("SENDMAIL_CAPTURE_PATH", capturePath) + + sendmailPath := writeCmd(t, dir, "sendmail", `#!/bin/sh +set -eu +cat > "${SENDMAIL_CAPTURE_PATH}" +exit 0 +`) + writeCmd(t, dir, "mailq", "#!/bin/sh\necho \"Mail queue is empty\"\nexit 0\n") + writeCmd(t, dir, "tail", "#!/bin/sh\nexit 0\n") + writeCmd(t, dir, "journalctl", "#!/bin/sh\nexit 0\n") + writeCmd(t, dir, "systemctl", "#!/bin/sh\nexit 3\n") + origPath := os.Getenv("PATH") + t.Setenv("PATH", dir+string(os.PathListSeparator)+origPath) + + origSendmailPath := sendmailBinaryPath + sendmailBinaryPath = sendmailPath + t.Cleanup(func() { sendmailBinaryPath = origSendmailPath }) + + notifier, err := NewEmailNotifier(EmailConfig{ + Enabled: true, + DeliveryMethod: EmailDeliveryPMF, + FallbackSendmail: true, + Recipient: "admin@example.com", + From: "no-reply@proxmox.example.com", + CloudRelayConfig: CloudRelayConfig{ + WorkerURL: server.URL, + WorkerToken: "token", + HMACSecret: "secret", + Timeout: 5, + MaxRetries: 0, + RetryDelay: 0, + }, + }, types.ProxmoxBS, logger) + if err != nil { + t.Fatalf("NewEmailNotifier() error = %v", err) + } + + result, err := notifier.Send(context.Background(), createTestNotificationData()) + if err != nil { + t.Fatalf("Send() returned unexpected error: %v", err) + } + if !result.Success { + t.Fatalf("expected Success=true due to sendmail fallback, got false (err=%v)", result.Error) + } + if !result.UsedFallback { + t.Fatalf("expected UsedFallback=true") + } + if result.Method != "email-sendmail-fallback" { + t.Fatalf("expected Method=email-sendmail-fallback, got %q", result.Method) } if result.Error == nil { - t.Fatalf("expected original preflight cause preserved in result.Error") + t.Fatalf("expected original PMF error preserved in result.Error") } got, err := os.ReadFile(capturePath) if err != nil { - t.Fatalf("read pmf capture: %v", err) + t.Fatalf("read sendmail capture: %v", err) } - msg := string(got) - if !strings.Contains(msg, "To: root@example.com\n") { - t.Fatalf("expected To: root@example.com header in PMF message, got:\n%s", msg) + if !strings.Contains(string(got), "To: admin@example.com\n") { + t.Fatalf("expected To: admin@example.com header in sendmail message") } } @@ -414,6 +537,38 @@ func TestEmailNotifierBuildEmailMessage_EncodesUTF8BodiesAsSevenBitSafe(t *testi } } +func TestEmailNotifierBuildEmailMessageSanitizesAddressHeaders(t *testing.T) { + logger := logging.New(types.LogLevelDebug, false) + logger.SetOutput(io.Discard) + + notifier, err := NewEmailNotifier(EmailConfig{ + Enabled: true, + DeliveryMethod: EmailDeliveryPMF, + From: "sender@example.com\r\nBcc: injected@example.com", + }, types.ProxmoxBS, logger) + if err != nil { + t.Fatalf("NewEmailNotifier() error = %v", err) + } + + emailMessage, toHeader := notifier.buildEmailMessage( + "admin@example.com\r\nCc: injected@example.com", + "subject", + "html", + "text", + createTestNotificationData(), + ) + + if strings.Contains(emailMessage, "\r") { + t.Fatalf("email message contains raw carriage return:\n%s", emailMessage) + } + if strings.Contains(emailMessage, "\nCc: injected@example.com") || strings.Contains(emailMessage, "\nBcc: injected@example.com") { + t.Fatalf("email message contains injected header:\n%s", emailMessage) + } + if toHeader != "admin@example.comCc: injected@example.com" { + t.Fatalf("toHeader=%q", toHeader) + } +} + func TestEmailNotifierIsMTAServiceActive_SystemctlMissing(t *testing.T) { logger := logging.New(types.LogLevelDebug, false) logger.SetOutput(io.Discard) diff --git a/internal/notify/email_relay.go b/internal/notify/email_relay.go index 26cd84c3..50278211 100644 --- a/internal/notify/email_relay.go +++ b/internal/notify/email_relay.go @@ -10,12 +10,15 @@ import ( "fmt" "io" "net/http" + "regexp" "strings" "time" "github.com/tis24dev/proxsave/internal/logging" ) +var relayScriptVersionPattern = regexp.MustCompile(`^v?([0-9]+)(?:\.([0-9]+))?(?:\.([0-9]+))?`) + // CloudRelayConfig holds configuration for email cloud relay type CloudRelayConfig struct { WorkerURL string @@ -68,6 +71,7 @@ func sendViaCloudRelay( if config.WorkerURL == "" { config = DefaultCloudRelayConfig } + payload.ScriptVersion = normalizeRelayScriptVersion(payload.ScriptVersion) // Create HTTP client with timeout client := &http.Client{ @@ -234,6 +238,24 @@ func sendViaCloudRelay( return fmt.Errorf("cloud relay failed after %d attempts: %w", config.MaxRetries+1, lastErr) } +func normalizeRelayScriptVersion(version string) string { + version = strings.TrimSpace(version) + matches := relayScriptVersionPattern.FindStringSubmatch(version) + if matches == nil { + return "0.0.0" + } + major := matches[1] + minor := "0" + patch := "0" + if matches[2] != "" { + minor = matches[2] + } + if matches[3] != "" { + patch = matches[3] + } + return fmt.Sprintf("%s.%s.%s", major, minor, patch) +} + // generateHMACSignature generates an HMAC-SHA256 signature for the payload func generateHMACSignature(payload []byte, secret string) string { h := hmac.New(sha256.New, []byte(secret)) diff --git a/internal/notify/email_relay_test.go b/internal/notify/email_relay_test.go index 99467e4f..39f676a5 100644 --- a/internal/notify/email_relay_test.go +++ b/internal/notify/email_relay_test.go @@ -30,6 +30,59 @@ func TestGenerateHMACSignature(t *testing.T) { } } +func TestNormalizeRelayScriptVersion(t *testing.T) { + tests := map[string]string{ + "": "0.0.0", + "dev": "0.0.0", + "v1.2.3": "1.2.3", + "1.2.3": "1.2.3", + "1.2": "1.2.0", + "0.0.0-dev": "0.0.0", + " 2.10.4+abc": "2.10.4", + } + + for input, want := range tests { + if got := normalizeRelayScriptVersion(input); got != want { + t.Fatalf("normalizeRelayScriptVersion(%q)=%q, want %q", input, got, want) + } + } +} + +func TestSendViaCloudRelay_NormalizesScriptVersionHeader(t *testing.T) { + var gotHeader string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotHeader = r.Header.Get("X-Script-Version") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{"success":true}`)) + })) + defer server.Close() + + cfg := CloudRelayConfig{ + WorkerURL: server.URL, + WorkerToken: "token", + HMACSecret: "secret", + Timeout: 5, + MaxRetries: 0, + RetryDelay: 0, + } + logger := logging.New(types.LogLevelDebug, false) + err := sendViaCloudRelay(context.Background(), cfg, EmailRelayPayload{ + To: "dest@test.invalid", + Subject: "subject", + Report: map[string]interface{}{"ok": true}, + Timestamp: time.Now().Unix(), + ServerMAC: "00:11:22:33:44:55", + ScriptVersion: "0.0.0-dev", + ServerID: "server-id", + }, logger) + if err != nil { + t.Fatalf("sendViaCloudRelay() error = %v", err) + } + if gotHeader != "0.0.0" { + t.Fatalf("X-Script-Version=%q, want 0.0.0", gotHeader) + } +} + func TestIsQuotaLimit(t *testing.T) { cases := []struct { input string diff --git a/internal/notify/email_test.go b/internal/notify/email_test.go index cbc004b6..476f74cd 100644 --- a/internal/notify/email_test.go +++ b/internal/notify/email_test.go @@ -87,6 +87,8 @@ func TestDescribeEmailMethod(t *testing.T) { {"email-relay", "cloud relay"}, {"email-sendmail", "sendmail"}, {"email-pmf", "proxmox-mail-forward"}, + {"email-relay-fallback", "cloud relay fallback"}, + {"email-sendmail-fallback", "sendmail fallback"}, {"email-pmf-fallback", "proxmox-mail-forward fallback"}, {"custom", "custom"}, } diff --git a/internal/notify/notify.go b/internal/notify/notify.go index 8dd26022..94503eba 100644 --- a/internal/notify/notify.go +++ b/internal/notify/notify.go @@ -189,7 +189,7 @@ type StorageStatus struct { type NotificationResult struct { Success bool UsedFallback bool // True if fallback method was used after primary failed - Method string // "telegram", "email-relay", "email-sendmail" + Method string // "telegram", "email-relay", "email-pmf", "email-sendmail", or "*-fallback" Error error // Original error (even if fallback succeeded) Duration time.Duration Metadata map[string]interface{} // Additional info (HTTP status, etc.) diff --git a/internal/notify/notify_test.go b/internal/notify/notify_test.go index 7daf494f..92aba96d 100644 --- a/internal/notify/notify_test.go +++ b/internal/notify/notify_test.go @@ -134,6 +134,44 @@ func TestTemplateHelpers(t *testing.T) { } } +func TestBuildEmailHTMLEscapesHeaderStorageAndFooterValues(t *testing.T) { + data := createTestNotificationData() + data.Hostname = `` + data.LocalStatusSummary = `` + data.LocalUsed = `14 GB` + data.LocalFree = `12 GB` + data.LocalPercent = `53%` + data.SecondaryStatusSummary = `` + data.SecondaryUsed = `secondary used` + data.SecondaryFree = `secondary free` + data.SecondaryPercent = `secondary percent` + data.CloudStatusSummary = `` + data.ScriptVersion = `` + + html := BuildEmailHTML(data) + rawValues := []string{ + data.Hostname, + data.LocalStatusSummary, + data.LocalUsed, + data.LocalFree, + data.LocalPercent, + data.SecondaryStatusSummary, + data.SecondaryUsed, + data.SecondaryFree, + data.SecondaryPercent, + data.CloudStatusSummary, + data.ScriptVersion, + } + for _, raw := range rawValues { + if strings.Contains(html, raw) { + t.Fatalf("BuildEmailHTML emitted raw value %q\nHTML:\n%s", raw, html) + } + if !strings.Contains(html, escapeHTML(raw)) { + t.Fatalf("BuildEmailHTML missing escaped value %q", escapeHTML(raw)) + } + } +} + func TestValueHelpers(t *testing.T) { if got := valueOrNA(" "); got != "N/A" { t.Fatalf("valueOrNA blank = %s, want N/A", got) diff --git a/internal/notify/templates.go b/internal/notify/templates.go index 0bdbfb14..ef44ff3f 100644 --- a/internal/notify/templates.go +++ b/internal/notify/templates.go @@ -77,6 +77,21 @@ func BuildEmailHTML(data *NotificationData) string { statusColor := getStatusColor(data.Status) statusText := strings.ToUpper(data.Status.String()) proxmoxType := strings.ToUpper(data.ProxmoxType.String()) + backupDate := escapeHTML(data.BackupDate.Format("2006-01-02 15:04:05")) + hostname := escapeHTML(data.Hostname) + localEmoji := escapeHTML(GetStorageEmoji(data.LocalStatus)) + localStatusSummary := escapeHTML(data.LocalStatusSummary) + localUsed := escapeHTML(data.LocalUsed) + localFree := escapeHTML(data.LocalFree) + localPercent := escapeHTML(data.LocalPercent) + secondaryEmoji := escapeHTML(GetStorageEmoji(data.SecondaryStatus)) + secondaryStatusSummary := escapeHTML(data.SecondaryStatusSummary) + secondaryUsed := escapeHTML(data.SecondaryUsed) + secondaryFree := escapeHTML(data.SecondaryFree) + secondaryPercent := escapeHTML(data.SecondaryPercent) + cloudEmoji := escapeHTML(GetStorageEmoji(data.CloudStatus)) + cloudStatusSummary := escapeHTML(data.CloudStatusSummary) + scriptVersion := escapeHTML(data.ScriptVersion) // Determine backup paths sidebar color backupPathsColor := "#4CAF50" // Green by default @@ -113,7 +128,7 @@ func BuildEmailHTML(data *NotificationData) string { // Header fmt.Fprintf(&html, "
\n", statusColor) fmt.Fprintf(&html, "

%s Backup Report - %s

\n", proxmoxType, statusText) - fmt.Fprintf(&html, "

%s - %s

\n", data.Hostname, data.BackupDate.Format("2006-01-02 15:04:05")) + fmt.Fprintf(&html, "

%s - %s

\n", hostname, backupDate) html.WriteString("
\n") // Content @@ -126,7 +141,7 @@ func BuildEmailHTML(data *NotificationData) string { html.WriteString("
\n") html.WriteString("

Local Storage

\n") html.WriteString("
\n") - fmt.Fprintf(&html, " %s %s backups\n", GetStorageEmoji(data.LocalStatus), data.LocalStatusSummary) + fmt.Fprintf(&html, " %s %s backups\n", localEmoji, localStatusSummary) html.WriteString("
\n") if data.LocalFree != "" && data.LocalFree != "N/A" { barColor := "normal" @@ -136,11 +151,11 @@ func BuildEmailHTML(data *NotificationData) string { barColor = "warning" } html.WriteString("
\n") - fmt.Fprintf(&html, " %s\n", data.LocalUsed) + fmt.Fprintf(&html, " %s\n", localUsed) html.WriteString("
\n") fmt.Fprintf(&html, "
\n", barColor, data.LocalUsagePercent) html.WriteString("
\n") - fmt.Fprintf(&html, " %s free (%s used)\n", data.LocalFree, data.LocalPercent) + fmt.Fprintf(&html, " %s free (%s used)\n", localFree, localPercent) html.WriteString("
\n") } html.WriteString("
\n") @@ -150,7 +165,7 @@ func BuildEmailHTML(data *NotificationData) string { html.WriteString("
\n") html.WriteString("

Secondary Storage

\n") html.WriteString("
\n") - fmt.Fprintf(&html, " %s %s backups\n", GetStorageEmoji(data.SecondaryStatus), data.SecondaryStatusSummary) + fmt.Fprintf(&html, " %s %s backups\n", secondaryEmoji, secondaryStatusSummary) html.WriteString("
\n") if data.SecondaryEnabled && data.SecondaryFree != "" && data.SecondaryFree != "N/A" { barColor := "normal" @@ -160,11 +175,11 @@ func BuildEmailHTML(data *NotificationData) string { barColor = "warning" } html.WriteString("
\n") - fmt.Fprintf(&html, " %s\n", data.SecondaryUsed) + fmt.Fprintf(&html, " %s\n", secondaryUsed) html.WriteString("
\n") fmt.Fprintf(&html, "
\n", barColor, data.SecondaryUsagePercent) html.WriteString("
\n") - fmt.Fprintf(&html, " %s free (%s used)\n", data.SecondaryFree, data.SecondaryPercent) + fmt.Fprintf(&html, " %s free (%s used)\n", secondaryFree, secondaryPercent) html.WriteString("
\n") } html.WriteString("
\n") @@ -174,7 +189,7 @@ func BuildEmailHTML(data *NotificationData) string { html.WriteString("
\n") html.WriteString("

Cloud Storage

\n") html.WriteString("
\n") - fmt.Fprintf(&html, " %s %s backups\n", GetStorageEmoji(data.CloudStatus), data.CloudStatusSummary) + fmt.Fprintf(&html, " %s %s backups\n", cloudEmoji, cloudStatusSummary) html.WriteString("
\n") html.WriteString("
\n") @@ -259,7 +274,7 @@ func BuildEmailHTML(data *NotificationData) string { html.WriteString(" \n") html.WriteString("
\n") html.WriteString("

This is an automated message from the Proxmox Backup Script.

\n") - fmt.Fprintf(&html, "

Generated on %s by backup script v%s

\n", data.BackupDate.Format("2006-01-02 15:04:05"), data.ScriptVersion) + fmt.Fprintf(&html, "

Generated on %s by backup script v%s

\n", backupDate, scriptVersion) html.WriteString("
\n") html.WriteString(" \n") diff --git a/internal/orchestrator/backup_safety.go b/internal/orchestrator/backup_safety.go index 992b6996..0acc7680 100644 --- a/internal/orchestrator/backup_safety.go +++ b/internal/orchestrator/backup_safety.go @@ -448,11 +448,17 @@ func RestoreSafetyBackup(logger *logging.Logger, backupPath string, destRoot str if closeErr := outFile.Close(); closeErr != nil { logger.Warning("Cannot close partially restored file %s: %v", target, closeErr) } + if removeErr := safetyFS.Remove(target); removeErr != nil && !os.IsNotExist(removeErr) { + logger.Warning("Cannot remove partially restored file %s: %v", target, removeErr) + } logger.Warning("Cannot write file %s: %v", target, err) continue } if err := outFile.Close(); err != nil { logger.Warning("Cannot close restored file %s: %v", target, err) + if removeErr := safetyFS.Remove(target); removeErr != nil && !os.IsNotExist(removeErr) { + logger.Warning("Cannot remove partially restored file %s: %v", target, removeErr) + } continue } diff --git a/internal/orchestrator/backup_safety_test.go b/internal/orchestrator/backup_safety_test.go index 256e6bcf..9816028d 100644 --- a/internal/orchestrator/backup_safety_test.go +++ b/internal/orchestrator/backup_safety_test.go @@ -1152,6 +1152,40 @@ func TestRestoreSafetyBackup_CorruptedTar(t *testing.T) { } } +func TestRestoreSafetyBackupRemovesPartialFileOnCopyError(t *testing.T) { + logger := logging.New(types.LogLevelInfo, false) + var logBuf bytes.Buffer + logger.SetOutput(&logBuf) + + tmpDir := t.TempDir() + backupPath := filepath.Join(tmpDir, "truncated.tar.gz") + restoreDir := filepath.Join(tmpDir, "restore") + + var buf bytes.Buffer + gzw := gzip.NewWriter(&buf) + tw := tar.NewWriter(gzw) + if err := tw.WriteHeader(&tar.Header{Name: "partial.txt", Mode: 0o644, Size: 10}); err != nil { + t.Fatalf("write header: %v", err) + } + if _, err := tw.Write([]byte("abc")); err != nil { + t.Fatalf("write partial content: %v", err) + } + if err := gzw.Close(); err != nil { + t.Fatalf("close gzip: %v", err) + } + if err := os.WriteFile(backupPath, buf.Bytes(), 0o644); err != nil { + t.Fatalf("write archive: %v", err) + } + + err := RestoreSafetyBackup(logger, backupPath, restoreDir) + if err != nil && !strings.Contains(err.Error(), "read tar entry") { + t.Fatalf("unexpected restore error: %v", err) + } + if _, statErr := os.Stat(filepath.Join(restoreDir, "partial.txt")); !os.IsNotExist(statErr) { + t.Fatalf("expected partial file to be removed, stat err=%v", statErr) + } +} + func TestRestoreSafetyBackup_OpenError(t *testing.T) { logger := logging.New(types.LogLevelInfo, false) var logBuf bytes.Buffer diff --git a/internal/orchestrator/directory_recreation.go b/internal/orchestrator/directory_recreation.go index 060b6e6e..74b1a039 100644 --- a/internal/orchestrator/directory_recreation.go +++ b/internal/orchestrator/directory_recreation.go @@ -22,9 +22,11 @@ func RecreateStorageDirectories(logger *logging.Logger) error { } directoriesCreated := 0 + var errs []error for _, entry := range entries { if err := createPVEStorageStructure(entry.Path, entry.Type, logger); err != nil { logger.Warning("Failed to create storage structure for %s: %v", entry.Name, err) + errs = append(errs, fmt.Errorf("%s: %w", entry.Name, err)) continue } @@ -36,7 +38,7 @@ func RecreateStorageDirectories(logger *logging.Logger) error { logger.Info("Recreated %d storage directory structures", directoriesCreated) } - return nil + return errors.Join(errs...) } // RecreateDatastoreDirectories parses datastore.cfg and recreates datastore directories (PBS) @@ -47,10 +49,12 @@ func RecreateDatastoreDirectories(logger *logging.Logger) error { } directoriesCreated := 0 + var errs []error for _, entry := range entries { created, err := createPBSDatastoreStructure(entry.Path, entry.Name, logger) if err != nil { logger.Warning("Failed to create datastore structure for %s: %v", entry.Name, err) + errs = append(errs, fmt.Errorf("%s: %w", entry.Name, err)) continue } if created { @@ -63,7 +67,7 @@ func RecreateDatastoreDirectories(logger *logging.Logger) error { logger.Info("Recreated %d datastore directory structures", directoriesCreated) } - return nil + return errors.Join(errs...) } // RecreateDirectoriesFromConfig recreates storage/datastore directories based on system type diff --git a/internal/orchestrator/directory_recreation_config.go b/internal/orchestrator/directory_recreation_config.go index f1a8aba3..0927f08e 100644 --- a/internal/orchestrator/directory_recreation_config.go +++ b/internal/orchestrator/directory_recreation_config.go @@ -153,12 +153,9 @@ func parsePBSDatastoreHeader(line string) (pbsDatastoreEntry, bool) { } func parseConfigPath(line string) (string, bool) { - if !strings.HasPrefix(line, "path ") { + const pathPrefix = "path " + if !strings.HasPrefix(line, pathPrefix) { return "", false } - parts := strings.Fields(line) - if len(parts) < 2 { - return "", false - } - return parts[1], true + return strings.TrimSpace(line[len(pathPrefix):]), true } diff --git a/internal/orchestrator/directory_recreation_ownership.go b/internal/orchestrator/directory_recreation_ownership.go index 2a338479..40e24e71 100644 --- a/internal/orchestrator/directory_recreation_ownership.go +++ b/internal/orchestrator/directory_recreation_ownership.go @@ -18,11 +18,13 @@ func setDatastoreOwnership(path string, logger *logging.Logger) error { } uid, gid, found, err := lookupBackupOwnership(path, logger) - if err != nil || !found { + if err != nil { return err } - if err := chownDatastorePath(path, uid, gid, logger); err != nil { - return err + if found { + if err := chownDatastorePath(path, uid, gid, logger); err != nil { + return err + } } return ensureDatastoreDirectoryMode(path, logger) } diff --git a/internal/orchestrator/directory_recreation_paths.go b/internal/orchestrator/directory_recreation_paths.go index 4c377f51..42b287a0 100644 --- a/internal/orchestrator/directory_recreation_paths.go +++ b/internal/orchestrator/directory_recreation_paths.go @@ -148,9 +148,22 @@ func isLikelyZFSMountPoint(path string, logger *logging.Logger) bool { } func isCommonZFSMountPath(pathLower string) bool { + pathLower = filepath.Clean(strings.ToLower(pathLower)) + if !strings.HasPrefix(pathLower, "/") { + return false + } return strings.HasPrefix(pathLower, "/mnt/") || - strings.Contains(pathLower, "backup") || - strings.Contains(pathLower, "datastore") + hasPathSegment(pathLower, "backup") || + hasPathSegment(pathLower, "datastore") +} + +func hasPathSegment(path, segment string) bool { + for _, part := range strings.Split(path, "/") { + if part == segment { + return true + } + } + return false } func isIgnorableOwnershipError(err error) bool { diff --git a/internal/orchestrator/directory_recreation_pbs.go b/internal/orchestrator/directory_recreation_pbs.go index cb6b4875..e51bbb2b 100644 --- a/internal/orchestrator/directory_recreation_pbs.go +++ b/internal/orchestrator/directory_recreation_pbs.go @@ -176,7 +176,10 @@ func initializePBSDatastore(basePath, datastoreName string, logger *logging.Logg } changed := len(dirsToFix) > 0 - subdirChanged, dirsToFix := ensurePBSDatastoreSubdirs(basePath, dirsToFix, logger) + subdirChanged, dirsToFix, err := ensurePBSDatastoreSubdirs(basePath, dirsToFix) + if err != nil { + return false, fmt.Errorf("create datastore subdirectories: %w", err) + } applyPBSDatastoreOwnership(basePath, datastoreName, dirsToFix, logger) lockChanged, lockErr := ensurePBSDatastoreLockFile(basePath, logger) @@ -187,7 +190,7 @@ func initializePBSDatastore(basePath, datastoreName string, logger *logging.Logg return changed || subdirChanged || lockChanged, nil } -func ensurePBSDatastoreSubdirs(basePath string, dirsToFix []string, logger *logging.Logger) (bool, []string) { +func ensurePBSDatastoreSubdirs(basePath string, dirsToFix []string) (bool, []string, error) { changed := false for _, subdir := range pbsDatastoreSubdirs { path := filepath.Join(basePath, subdir) @@ -196,10 +199,10 @@ func ensurePBSDatastoreSubdirs(basePath string, dirsToFix []string, logger *logg dirsToFix = append(dirsToFix, path) } if err := os.MkdirAll(path, 0750); err != nil { - logger.Warning("Failed to create %s: %v", path, err) + return changed, dirsToFix, fmt.Errorf("create %s: %w", path, err) } } - return changed, dirsToFix + return changed, dirsToFix, nil } func applyPBSDatastoreOwnership(basePath, datastoreName string, dirsToFix []string, logger *logging.Logger) { diff --git a/internal/orchestrator/directory_recreation_pbs_config.go b/internal/orchestrator/directory_recreation_pbs_config.go index 72d59e6f..4c25928a 100644 --- a/internal/orchestrator/directory_recreation_pbs_config.go +++ b/internal/orchestrator/directory_recreation_pbs_config.go @@ -93,12 +93,31 @@ func writePBSDatastoreCfgAtomically(path string, data []byte, mode os.FileMode) if _, err = tmpFile.Write(data); err != nil { return err } + if err = tmpFile.Sync(); err != nil { + return fmt.Errorf("sync datastore.cfg temp file: %w", err) + } if err = tmpFile.Close(); err != nil { return err } if err = os.Rename(tmpPath, path); err != nil { return fmt.Errorf("replace datastore.cfg: %w", err) } + if err = syncPBSDatastoreCfgDir(path); err != nil { + return err + } + return nil +} + +func syncPBSDatastoreCfgDir(path string) (err error) { + dirFile, err := os.Open(filepath.Dir(path)) + if err != nil { + return fmt.Errorf("open datastore.cfg directory: %w", err) + } + defer closeIntoErr(&err, dirFile, "close datastore.cfg directory") + + if err = dirFile.Sync(); err != nil { + return fmt.Errorf("fsync datastore.cfg directory: %w", err) + } return nil } diff --git a/internal/orchestrator/directory_recreation_pve.go b/internal/orchestrator/directory_recreation_pve.go index 48a187de..a8080e79 100644 --- a/internal/orchestrator/directory_recreation_pve.go +++ b/internal/orchestrator/directory_recreation_pve.go @@ -2,6 +2,7 @@ package orchestrator import ( + "errors" "fmt" "os" "path/filepath" @@ -27,15 +28,19 @@ func createPVEStorageStructure(basePath, storageType string, logger *logging.Log return nil } - createStorageSubdirs(basePath, subdirs, logger) + if err := createStorageSubdirs(basePath, subdirs); err != nil { + return fmt.Errorf("create storage subdirectories: %w", err) + } return nil } -func createStorageSubdirs(basePath string, subdirs []string, logger *logging.Logger) { +func createStorageSubdirs(basePath string, subdirs []string) error { + var errs []error for _, subdir := range subdirs { path := filepath.Join(basePath, subdir) if err := os.MkdirAll(path, 0750); err != nil { - logger.Warning("Failed to create %s: %v", path, err) + errs = append(errs, fmt.Errorf("create %s: %w", path, err)) } } + return errors.Join(errs...) } diff --git a/internal/orchestrator/directory_recreation_test.go b/internal/orchestrator/directory_recreation_test.go index 0ea70867..2695b7c3 100644 --- a/internal/orchestrator/directory_recreation_test.go +++ b/internal/orchestrator/directory_recreation_test.go @@ -55,6 +55,30 @@ func TestRecreateStorageDirectoriesCreatesStructure(t *testing.T) { } } +func TestRecreateStorageDirectoriesReturnsSubdirError(t *testing.T) { + logger := newDirTestLogger() + baseDir := filepath.Join(t.TempDir(), "local") + if err := os.MkdirAll(baseDir, 0o750); err != nil { + t.Fatalf("mkdir base dir: %v", err) + } + if err := os.WriteFile(filepath.Join(baseDir, "dump"), []byte("not a directory"), 0o600); err != nil { + t.Fatalf("write blocking file: %v", err) + } + + cfg := fmt.Sprintf("dir: local\n path %s\n", baseDir) + cfgPath, restore := overridePath(t, &storageCfgPath, "storage.cfg") + defer restore() + writeFile(t, cfgPath, cfg) + + err := RecreateStorageDirectories(logger) + if err == nil { + t.Fatalf("expected subdirectory creation error") + } + if !strings.Contains(err.Error(), "dump") { + t.Fatalf("expected error to mention failed subdir, got: %v", err) + } +} + func TestCreatePVEStorageStructureHandlesVariousTypes(t *testing.T) { logger := newDirTestLogger() baseNFS := filepath.Join(t.TempDir(), "nfs") @@ -112,9 +136,34 @@ func TestRecreateDatastoreDirectoriesCreatesStructure(t *testing.T) { } } +func TestInitializePBSDatastoreReturnsSubdirError(t *testing.T) { + logger := newDirTestLogger() + baseDir := filepath.Join(t.TempDir(), "datastore") + if err := os.MkdirAll(baseDir, 0o750); err != nil { + t.Fatalf("mkdir base dir: %v", err) + } + if err := os.WriteFile(filepath.Join(baseDir, ".chunks"), []byte("not a directory"), 0o600); err != nil { + t.Fatalf("write blocking file: %v", err) + } + + changed, err := initializePBSDatastore(baseDir, "ds", logger) + if err == nil { + t.Fatalf("expected subdirectory creation error") + } + if changed { + t.Fatalf("changed=%t; want false on subdir error", changed) + } + if !strings.Contains(err.Error(), ".chunks") { + t.Fatalf("expected error to mention failed subdir, got: %v", err) + } + if _, statErr := os.Stat(filepath.Join(baseDir, ".index")); !os.IsNotExist(statErr) { + t.Fatalf("expected .index creation to be skipped after first error, stat err=%v", statErr) + } +} + func TestRecreateDatastoreDirectoriesSkipsZFSMountPoints(t *testing.T) { logger := newDirTestLogger() - baseDir := filepath.Join(t.TempDir(), "backup-ds") + baseDir := filepath.Join(t.TempDir(), "backup", "ds") cfg := fmt.Sprintf("datastore: ds\n path %s\n", baseDir) cfgPath, restore := overridePath(t, &datastoreCfgPath, "datastore.cfg") defer restore() @@ -187,6 +236,39 @@ func TestNormalizePBSDatastoreCfgContentNoChangesWhenValid(t *testing.T) { } } +func TestWritePBSDatastoreCfgAtomicallyWritesDataAndMode(t *testing.T) { + path := filepath.Join(t.TempDir(), "datastore.cfg") + + if err := writePBSDatastoreCfgAtomically(path, []byte("datastore: ds\n path /mnt/ds\n"), 0o640); err != nil { + t.Fatalf("writePBSDatastoreCfgAtomically: %v", err) + } + + got, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read datastore.cfg: %v", err) + } + if string(got) != "datastore: ds\n path /mnt/ds\n" { + t.Fatalf("unexpected content: %q", string(got)) + } + info, err := os.Stat(path) + if err != nil { + t.Fatalf("stat datastore.cfg: %v", err) + } + if info.Mode().Perm() != 0o640 { + t.Fatalf("mode=%#o; want 0640", info.Mode().Perm()) + } +} + +func TestParseConfigPathKeepsSpaces(t *testing.T) { + path, ok := parseConfigPath("path /mnt/my store") + if !ok { + t.Fatalf("expected path line to parse") + } + if path != "/mnt/my store" { + t.Fatalf("path=%q; want %q", path, "/mnt/my store") + } +} + func TestRecreateDirectoriesFromConfigRoutes(t *testing.T) { t.Run("PVE", testRecreateDirectoriesFromConfigPVE) t.Run("PBS", testRecreateDirectoriesFromConfigPBS) @@ -479,19 +561,46 @@ func TestIsLikelyZFSMountPointNoMatch(t *testing.T) { } } -// Test: isLikelyZFSMountPoint with a path containing "datastore" +// Test: isLikelyZFSMountPoint with a path containing a datastore segment func TestIsLikelyZFSMountPointDatastorePath(t *testing.T) { logger := newDirTestLogger() cachePath, restore := overridePath(t, &zpoolCachePath, "zpool.cache") defer restore() writeFile(t, cachePath, "cache") - // A path with "datastore" in the name should match + // A path with a "datastore" segment should match. if !isLikelyZFSMountPoint("/var/lib/datastore", logger) { - t.Fatalf("expected true for path containing 'datastore'") + t.Fatalf("expected true for path containing a 'datastore' segment") } if !isLikelyZFSMountPoint("/DATASTORE/pool", logger) { - t.Fatalf("expected true for path containing 'DATASTORE' (case insensitive)") + t.Fatalf("expected true for path containing a 'DATASTORE' segment (case insensitive)") + } +} + +func TestIsCommonZFSMountPathRequiresPathSegments(t *testing.T) { + tests := []struct { + name string + path string + want bool + }{ + {name: "mnt prefix", path: "/mnt/pbs", want: true}, + {name: "backup segment", path: "/backup/pbs", want: true}, + {name: "nested backup segment", path: "/srv/backup/pbs", want: true}, + {name: "datastore segment", path: "/var/lib/datastore", want: true}, + {name: "datastore segment case insensitive", path: "/DATASTORE/pool", want: true}, + {name: "backup substring", path: "/srv/mybackup/pbs", want: false}, + {name: "datastore substring", path: "/srv/mydatastore/pbs", want: false}, + {name: "backup suffix substring", path: "/srv/backup-old/pbs", want: false}, + {name: "datastore suffix substring", path: "/srv/datastore2/pbs", want: false}, + {name: "relative backup segment", path: "backup/pbs", want: false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := isCommonZFSMountPath(tt.path); got != tt.want { + t.Fatalf("isCommonZFSMountPath(%q)=%v; want %v", tt.path, got, tt.want) + } + }) } } diff --git a/internal/orchestrator/extensions.go b/internal/orchestrator/extensions.go index 02a0262f..df5c46a8 100644 --- a/internal/orchestrator/extensions.go +++ b/internal/orchestrator/extensions.go @@ -159,6 +159,7 @@ func (o *Orchestrator) DispatchEarlyErrorNotification(ctx context.Context, early // Try to populate version info from orchestrator if o.version != "" { stats.Version = o.version + stats.ScriptVersion = o.version } if o.envInfo != nil { stats.ProxmoxType = o.envInfo.Type @@ -170,6 +171,8 @@ func (o *Orchestrator) DispatchEarlyErrorNotification(ctx context.Context, early if o.proxmoxVersion != "" { stats.ProxmoxVersion = o.proxmoxVersion } + stats.ServerID = strings.TrimSpace(o.serverID) + stats.ServerMAC = strings.TrimSpace(o.serverMAC) // Set log file path if logger has one if logPath := o.logger.GetLogFilePath(); logPath != "" { diff --git a/internal/orchestrator/extensions_additional_test.go b/internal/orchestrator/extensions_additional_test.go index a8288eb7..61e90df5 100644 --- a/internal/orchestrator/extensions_additional_test.go +++ b/internal/orchestrator/extensions_additional_test.go @@ -41,6 +41,8 @@ func TestDispatchEarlyErrorNotification_PopulatesMinimalStats(t *testing.T) { logger: logger, version: "1.2.3", proxmoxVersion: "8.1", + serverID: " 1234567890123456 ", + serverMAC: " bc:24:11:41:0d:18 ", } early := &EarlyErrorState{ Phase: "config", @@ -62,8 +64,11 @@ func TestDispatchEarlyErrorNotification_PopulatesMinimalStats(t *testing.T) { if stats.StartTime != ts || stats.EndTime != ts || stats.Timestamp != ts { t.Fatalf("timestamps not propagated: start=%v end=%v ts=%v", stats.StartTime, stats.EndTime, stats.Timestamp) } - if stats.Version != "1.2.3" || stats.ProxmoxVersion != "8.1" { - t.Fatalf("version fields=%q/%q; want %q/%q", stats.Version, stats.ProxmoxVersion, "1.2.3", "8.1") + if stats.Version != "1.2.3" || stats.ScriptVersion != "1.2.3" || stats.ProxmoxVersion != "8.1" { + t.Fatalf("version fields=%q/%q/%q; want %q/%q/%q", stats.Version, stats.ScriptVersion, stats.ProxmoxVersion, "1.2.3", "1.2.3", "8.1") + } + if stats.ServerID != "1234567890123456" || stats.ServerMAC != "bc:24:11:41:0d:18" { + t.Fatalf("identity fields=%q/%q; want server ID and MAC", stats.ServerID, stats.ServerMAC) } if stats.LocalStatusSummary == "" || !strings.Contains(stats.LocalStatusSummary, "boom") { t.Fatalf("LocalStatusSummary=%q; want to contain error text", stats.LocalStatusSummary) diff --git a/internal/orchestrator/mount_guard.go b/internal/orchestrator/mount_guard.go index 836ab1e8..a99f8c00 100644 --- a/internal/orchestrator/mount_guard.go +++ b/internal/orchestrator/mount_guard.go @@ -34,9 +34,11 @@ func guardMountPoint(ctx context.Context, guardTarget string) error { if err != nil { return err } - if err := ensureGuardTargetUnmounted(target); err != nil { + mounted, err := ensureGuardTargetUnmounted(target) + if err != nil { return fmt.Errorf("check mount status: %w", err) - } else if isAlreadyMounted(target) { + } + if mounted { return nil } @@ -61,20 +63,12 @@ func normalizeGuardMountRequest(ctx context.Context, guardTarget string) (string return target, nil } -func ensureGuardTargetUnmounted(target string) error { +func ensureGuardTargetUnmounted(target string) (bool, error) { mounted, err := isMounted(target) if err != nil { - return err - } - if mounted { - return nil + return false, err } - return nil -} - -func isAlreadyMounted(target string) bool { - mounted, err := isMounted(target) - return err == nil && mounted + return mounted, nil } func ensureGuardDirectories(guardDir, target string) error { diff --git a/internal/orchestrator/mount_guard_more_test.go b/internal/orchestrator/mount_guard_more_test.go index cf3e7470..e1137819 100644 --- a/internal/orchestrator/mount_guard_more_test.go +++ b/internal/orchestrator/mount_guard_more_test.go @@ -332,10 +332,12 @@ func TestGuardMountPoint(t *testing.T) { }) t.Run("returns nil when already mounted", func(t *testing.T) { + readCalls := 0 mountGuardReadFile = func(path string) ([]byte, error) { if path != "/proc/self/mountinfo" { return nil, os.ErrNotExist } + readCalls++ return []byte("1 2 3:4 / /mnt/already rw - ext4 /dev/sda1 rw\n"), nil } mountGuardMkdirAll = func(string, os.FileMode) error { @@ -350,6 +352,9 @@ func TestGuardMountPoint(t *testing.T) { if err := guardMountPoint(context.Background(), "/mnt/already"); err != nil { t.Fatalf("unexpected error: %v", err) } + if readCalls != 1 { + t.Fatalf("readCalls=%d; want 1", readCalls) + } }) t.Run("bind mount failure", func(t *testing.T) { diff --git a/internal/orchestrator/network_apply.go b/internal/orchestrator/network_apply.go index ca5a6f7d..34d0c1db 100644 --- a/internal/orchestrator/network_apply.go +++ b/internal/orchestrator/network_apply.go @@ -16,7 +16,10 @@ import ( "github.com/tis24dev/proxsave/internal/logging" ) -const defaultNetworkRollbackTimeout = 180 * time.Second +const ( + defaultNetworkRollbackTimeout = 180 * time.Second + defaultNetworkApplyConfirmTimeout = 90 * time.Second +) var ErrNetworkApplyNotCommitted = errors.New("network configuration not committed") diff --git a/internal/orchestrator/network_apply_workflow_ui_prompt.go b/internal/orchestrator/network_apply_workflow_ui_prompt.go index 7dc50aa6..523a14b1 100644 --- a/internal/orchestrator/network_apply_workflow_ui_prompt.go +++ b/internal/orchestrator/network_apply_workflow_ui_prompt.go @@ -7,7 +7,6 @@ import ( "fmt" "os" "strings" - "time" "github.com/tis24dev/proxsave/internal/logging" ) @@ -159,7 +158,7 @@ func (f *networkConfigUIApplyFlow) confirmApplyNow() (bool, error) { sourceLine, int(defaultNetworkRollbackTimeout.Seconds()), ) - applyNow, err := f.ui.ConfirmAction(f.ctx, "Apply network configuration", message, "Apply now", "Skip apply", 90*time.Second, false) + applyNow, err := f.ui.ConfirmAction(f.ctx, "Apply network configuration", message, "Apply now", "Skip apply", defaultNetworkApplyConfirmTimeout, false) logging.DebugStep(f.logger, "network safe apply (ui)", "User choice: applyNow=%v", applyNow) return applyNow, err } diff --git a/internal/orchestrator/network_apply_workflow_ui_rollback.go b/internal/orchestrator/network_apply_workflow_ui_rollback.go index b28ddc8e..5fa9365f 100644 --- a/internal/orchestrator/network_apply_workflow_ui_rollback.go +++ b/internal/orchestrator/network_apply_workflow_ui_rollback.go @@ -272,7 +272,7 @@ func (f *networkRollbackUIApplyFlow) rollbackStagedPreflightFailure(preflight ne f.info("Network rollback log: %s", rollbackLog) } if rbErr != nil { - f.error("Network apply aborted: preflight validation failed (%s) and rollback failed: %v", preflight.CommandLine(), rbErr) + f.logError("Network apply aborted: preflight validation failed (%s) and rollback failed: %v", preflight.CommandLine(), rbErr) return fmt.Errorf("network preflight validation failed; rollback attempt failed: %w", rbErr) } f.captureAfterRollbackDiagnostics() @@ -502,7 +502,7 @@ func (f *networkRollbackUIApplyFlow) warning(format string, args ...interface{}) } } -func (f *networkRollbackUIApplyFlow) error(format string, args ...interface{}) { +func (f *networkRollbackUIApplyFlow) logError(format string, args ...interface{}) { if f.logger != nil { f.logger.Error(format, args...) } diff --git a/internal/orchestrator/notification_adapter_test.go b/internal/orchestrator/notification_adapter_test.go index e7b53ee1..fd648f1c 100644 --- a/internal/orchestrator/notification_adapter_test.go +++ b/internal/orchestrator/notification_adapter_test.go @@ -205,6 +205,8 @@ func TestConvertBackupStatsToNotificationData(t *testing.T) { ExitCode: 1, Hostname: "host", ProxmoxType: types.ProxmoxUnknown, + ServerID: "server-id", + ServerMAC: "bc:24:11:41:0d:18", ArchivePath: filepath.Join("/tmp", "backup.tar.xz"), ArchiveSize: 5000, CompressedSize: 4000, @@ -251,6 +253,9 @@ func TestConvertBackupStatsToNotificationData(t *testing.T) { if data.BackupFileName != "backup.tar.xz" { t.Fatalf("BackupFileName = %q; want backup.tar.xz", data.BackupFileName) } + if data.ServerID != "server-id" || data.ServerMAC != "bc:24:11:41:0d:18" { + t.Fatalf("identity fields=%q/%q; want server ID and MAC", data.ServerID, data.ServerMAC) + } if data.LocalStatus != "warning" { t.Fatalf("LocalStatus = %q; want warning", data.LocalStatus) } diff --git a/internal/orchestrator/pbs_notifications_api_apply.go b/internal/orchestrator/pbs_notifications_api_apply.go index a2b3651d..58d86d98 100644 --- a/internal/orchestrator/pbs_notifications_api_apply.go +++ b/internal/orchestrator/pbs_notifications_api_apply.go @@ -96,7 +96,9 @@ func buildPBSNotificationDesiredState(cfgSections, privSections []proxmoxNotific case "matcher": desired.matchers[name] = section default: - logger.Warning("PBS notifications API apply: unknown section %q (%s); skipping", typ, name) + if logger != nil { + logger.Warning("PBS notifications API apply: unknown section %q (%s); skipping", typ, name) + } } } @@ -167,7 +169,9 @@ func pbsEndpointPositionalArgs(typ, name string, entries []proxmoxNotificationEn func pbsEndpointSinglePositional(typ, name string, entries []proxmoxNotificationEntry, logger *logging.Logger, keys ...string) ([]string, []proxmoxNotificationEntry, bool) { value, remaining, ok := popEntryValue(entries, keys...) if !ok || strings.TrimSpace(value) == "" { - logger.Warning("PBS notifications API apply: %s endpoint %s missing %s; skipping", typ, name, keys[0]) + if logger != nil { + logger.Warning("PBS notifications API apply: %s endpoint %s missing %s; skipping", typ, name, keys[0]) + } return nil, entries, false } return []string{value}, remaining, true @@ -176,12 +180,16 @@ func pbsEndpointSinglePositional(typ, name string, entries []proxmoxNotification func pbsGotifyEndpointPositionals(name string, entries []proxmoxNotificationEntry, logger *logging.Logger) ([]string, []proxmoxNotificationEntry, bool) { server, remaining, ok := popEntryValue(entries, "server") if !ok || strings.TrimSpace(server) == "" { - logger.Warning("PBS notifications API apply: gotify endpoint %s missing server; skipping", name) + if logger != nil { + logger.Warning("PBS notifications API apply: gotify endpoint %s missing server; skipping", name) + } return nil, entries, false } token, remaining, ok := popEntryValue(remaining, "token") if !ok || strings.TrimSpace(token) == "" { - logger.Warning("PBS notifications API apply: gotify endpoint %s missing token; skipping", name) + if logger != nil { + logger.Warning("PBS notifications API apply: gotify endpoint %s missing token; skipping", name) + } return nil, entries, false } return []string{server, token}, remaining, true diff --git a/internal/orchestrator/pbs_notifications_api_apply_test.go b/internal/orchestrator/pbs_notifications_api_apply_test.go index 43dac960..ccc30511 100644 --- a/internal/orchestrator/pbs_notifications_api_apply_test.go +++ b/internal/orchestrator/pbs_notifications_api_apply_test.go @@ -60,3 +60,26 @@ func TestApplyPBSNotificationsViaAPI_CreatesEndpointAndMatcher(t *testing.T) { t.Fatalf("calls=%v want %v", runner.calls, want) } } + +func TestBuildPBSNotificationDesiredState_NilLoggerSkipsMalformedSections(t *testing.T) { + cfgSections := []proxmoxNotificationSection{ + {Type: "unknown", Name: "ignored"}, + {Type: "smtp", Name: "missing-recipient"}, + {Type: "gotify", Name: "missing-server"}, + { + Type: "gotify", + Name: "missing-token", + Entries: []proxmoxNotificationEntry{ + {Key: "server", Value: "https://gotify.example"}, + }, + }, + } + + desired := buildPBSNotificationDesiredState(cfgSections, nil, nil) + if len(desired.endpoints) != 0 { + t.Fatalf("expected malformed endpoints to be skipped, got=%v", desired.endpoints) + } + if len(desired.matchers) != 0 { + t.Fatalf("expected no matchers, got=%v", desired.matchers) + } +} diff --git a/internal/orchestrator/restore_workflow_ui.go b/internal/orchestrator/restore_workflow_ui.go index 97a2b20c..fc46f593 100644 --- a/internal/orchestrator/restore_workflow_ui.go +++ b/internal/orchestrator/restore_workflow_ui.go @@ -66,7 +66,7 @@ func normalizeRestoreWorkflowUIError(ctx context.Context, logger *logging.Logger if err == nil { return nil } - if err == io.EOF { + if errors.Is(err, io.EOF) { logger.Warning("Restore input closed unexpectedly (EOF). This usually means the interactive UI lost access to stdin/TTY (e.g., SSH disconnect or non-interactive execution). Re-run with --restore --cli from an interactive shell.") return ErrRestoreAborted } diff --git a/internal/orchestrator/restore_workflow_ui_backups_services.go b/internal/orchestrator/restore_workflow_ui_backups_services.go index f0f2702c..f5527625 100644 --- a/internal/orchestrator/restore_workflow_ui_backups_services.go +++ b/internal/orchestrator/restore_workflow_ui_backups_services.go @@ -130,21 +130,24 @@ func (w *restoreUIWorkflowRun) shouldCreateAccessControlRollbackBackup() bool { func (w *restoreUIWorkflowRun) prepareRestoreServices() (func(), error) { var cleanups []func() + runCleanups := func() { + for i := len(cleanups) - 1; i >= 0; i-- { + cleanups[i]() + } + } if cleanup, err := w.preparePVEClusterRestore(); err != nil { + runCleanups() return nil, err } else if cleanup != nil { cleanups = append(cleanups, cleanup) } if cleanup, err := w.preparePBSServices(); err != nil { + runCleanups() return nil, err } else if cleanup != nil { cleanups = append(cleanups, cleanup) } - return func() { - for i := len(cleanups) - 1; i >= 0; i-- { - cleanups[i]() - } - }, nil + return runCleanups, nil } func (w *restoreUIWorkflowRun) preparePVEClusterRestore() (func(), error) { diff --git a/internal/orchestrator/restore_workflow_ui_backups_services_test.go b/internal/orchestrator/restore_workflow_ui_backups_services_test.go new file mode 100644 index 00000000..7e354638 --- /dev/null +++ b/internal/orchestrator/restore_workflow_ui_backups_services_test.go @@ -0,0 +1,63 @@ +package orchestrator + +import ( + "context" + "errors" + "slices" + "testing" + + "github.com/tis24dev/proxsave/internal/config" +) + +func TestPrepareRestoreServicesCleansUpPreviousServicesOnLaterError(t *testing.T) { + origRestoreCmd := restoreCmd + t.Cleanup(func() { restoreCmd = origRestoreCmd }) + + cmd := &FakeCommandRunner{ + Outputs: map[string][]byte{ + "umount /etc/pve": []byte("not mounted\n"), + }, + Errors: map[string]error{ + "umount /etc/pve": errors.New("not mounted"), + "which systemctl": errors.New("missing"), + }, + } + for _, svc := range []string{"pve-cluster", "pvedaemon", "pveproxy", "pvestatd"} { + cmd.Outputs["systemctl stop --no-block "+svc] = []byte("ok") + cmd.Outputs["systemctl is-active "+svc] = []byte("inactive\n") + cmd.Errors["systemctl is-active "+svc] = errors.New("inactive") + cmd.Outputs["systemctl reset-failed "+svc] = []byte("ok") + cmd.Outputs["systemctl start "+svc] = []byte("ok") + } + restoreCmd = cmd + + w := &restoreUIWorkflowRun{ + ctx: context.Background(), + cfg: &config.Config{}, + logger: newTestLogger(), + ui: &fakeRestoreWorkflowUI{}, + plan: &RestorePlan{ + NeedsClusterRestore: true, + NeedsPBSServices: true, + }, + } + + cleanup, err := w.prepareRestoreServices() + if !errors.Is(err, ErrRestoreAborted) { + t.Fatalf("err=%v; want %v", err, ErrRestoreAborted) + } + if cleanup != nil { + t.Fatalf("expected nil cleanup on prepare error") + } + + for _, want := range []string{ + "systemctl start pve-cluster", + "systemctl start pvedaemon", + "systemctl start pveproxy", + "systemctl start pvestatd", + } { + if !slices.Contains(cmd.Calls, want) { + t.Fatalf("missing cleanup command %q; calls=%v", want, cmd.Calls) + } + } +} diff --git a/internal/orchestrator/restore_workflow_ui_plan.go b/internal/orchestrator/restore_workflow_ui_plan.go index 6275d37d..59b5a934 100644 --- a/internal/orchestrator/restore_workflow_ui_plan.go +++ b/internal/orchestrator/restore_workflow_ui_plan.go @@ -14,18 +14,11 @@ func (w *restoreUIWorkflowRun) prepareBundleAndPlan() (fallbackToFullRestore boo if err := w.prepareBundle(); err != nil { return false, err } - cleanupOnFailure := true - defer func() { - if cleanupOnFailure && w.prepared != nil { - w.prepared.Cleanup() - } - }() fallbackToFullRestore, err = w.planPreparedBundle() if err != nil { return fallbackToFullRestore, err } - cleanupOnFailure = false return fallbackToFullRestore, nil } diff --git a/internal/orchestrator/restore_workflow_ui_run.go b/internal/orchestrator/restore_workflow_ui_run.go index 837bf198..d6ac5af9 100644 --- a/internal/orchestrator/restore_workflow_ui_run.go +++ b/internal/orchestrator/restore_workflow_ui_run.go @@ -54,12 +54,12 @@ func newRestoreUIWorkflowRun(ctx context.Context, cfg *config.Config, logger *lo func (w *restoreUIWorkflowRun) run() error { fallbackToFullRestore, err := w.prepareBundleAndPlan() - if err != nil { - return err - } if w.prepared != nil { defer w.prepared.Cleanup() } + if err != nil { + return err + } if fallbackToFullRestore { return runFullRestoreWithUI(w.ctx, w.ui, w.candidate, w.prepared, w.destRoot, w.logger, w.cfg.DryRun) } diff --git a/internal/orchestrator/restore_workflow_ui_test.go b/internal/orchestrator/restore_workflow_ui_test.go new file mode 100644 index 00000000..e53b283c --- /dev/null +++ b/internal/orchestrator/restore_workflow_ui_test.go @@ -0,0 +1,27 @@ +package orchestrator + +import ( + "bytes" + "context" + "fmt" + "io" + "strings" + "testing" + + "github.com/tis24dev/proxsave/internal/logging" + "github.com/tis24dev/proxsave/internal/types" +) + +func TestNormalizeRestoreWorkflowUIErrorWrappedEOFAbortsWithWarning(t *testing.T) { + var buf bytes.Buffer + logger := logging.New(types.LogLevelWarning, false) + logger.SetOutput(&buf) + + err := normalizeRestoreWorkflowUIError(context.Background(), logger, fmt.Errorf("prompt failed: %w", io.EOF)) + if err != ErrRestoreAborted { + t.Fatalf("err=%v; want %v", err, ErrRestoreAborted) + } + if !strings.Contains(buf.String(), "Restore input closed unexpectedly (EOF).") { + t.Fatalf("expected EOF warning, got log output: %q", buf.String()) + } +} diff --git a/internal/orchestrator/restore_workflow_ui_tfa.go b/internal/orchestrator/restore_workflow_ui_tfa.go index 24c6fac6..ca9147d3 100644 --- a/internal/orchestrator/restore_workflow_ui_tfa.go +++ b/internal/orchestrator/restore_workflow_ui_tfa.go @@ -10,7 +10,7 @@ import ( ) func maybeAddRecommendedCategoriesForTFA(ctx context.Context, ui RestoreWorkflowUI, logger *logging.Logger, selected []Category, available []Category) ([]Category, error) { - if !shouldPromptForTFARecommendations(ui, logger, selected) { + if !shouldPromptForTFARecommendations(ui, selected) { return selected, nil } addCategories, addNames := tfaRecommendedCategories(selected, available) @@ -23,15 +23,16 @@ func maybeAddRecommendedCategoriesForTFA(ctx context.Context, ui RestoreWorkflow return nil, err } if !addNow { - logger.Warning("Access control selected without %s; WebAuthn users may require re-enrollment if the UI origin changes", strings.Join(addNames, ", ")) + if logger != nil { + logger.Warning("Access control selected without %s; WebAuthn users may require re-enrollment if the UI origin changes", strings.Join(addNames, ", ")) + } return selected, nil } return dedupeCategoriesByID(append(selected, addCategories...)), nil } -func shouldPromptForTFARecommendations(ui RestoreWorkflowUI, logger *logging.Logger, selected []Category) bool { +func shouldPromptForTFARecommendations(ui RestoreWorkflowUI, selected []Category) bool { return ui != nil && - logger != nil && (hasCategoryID(selected, "pve_access_control") || hasCategoryID(selected, "pbs_access_control")) } diff --git a/internal/orchestrator/restore_workflow_ui_tfa_test.go b/internal/orchestrator/restore_workflow_ui_tfa_test.go index cc9d298e..508664d7 100644 --- a/internal/orchestrator/restore_workflow_ui_tfa_test.go +++ b/internal/orchestrator/restore_workflow_ui_tfa_test.go @@ -49,3 +49,39 @@ func TestMaybeAddRecommendedCategoriesForTFA_DoesNotAddWhenDeclined(t *testing.T t.Fatalf("expected no categories to be added, got=%v", got) } } + +func TestMaybeAddRecommendedCategoriesForTFA_NilLoggerStillPrompts(t *testing.T) { + tests := []struct { + name string + confirmAction bool + wantAdded bool + }{ + {name: "confirmed", confirmAction: true, wantAdded: true}, + {name: "declined", confirmAction: false, wantAdded: false}, + } + + available := []Category{ + {ID: "pve_access_control", Name: "PVE Access Control", IsAvailable: true}, + {ID: "network", Name: "Network", IsAvailable: true}, + {ID: "ssl", Name: "SSL", IsAvailable: true}, + } + selected := []Category{ + {ID: "pve_access_control", Name: "PVE Access Control", IsAvailable: true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ui := &fakeRestoreWorkflowUI{confirmAction: tt.confirmAction} + + got, err := maybeAddRecommendedCategoriesForTFA(context.Background(), ui, nil, selected, available) + if err != nil { + t.Fatalf("maybeAddRecommendedCategoriesForTFA error: %v", err) + } + + gotAdded := hasCategoryID(got, "network") && hasCategoryID(got, "ssl") + if gotAdded != tt.wantAdded { + t.Fatalf("got added=%v; want %v; categories=%v", gotAdded, tt.wantAdded, got) + } + }) + } +} diff --git a/internal/orchestrator/telegram_setup_bootstrap.go b/internal/orchestrator/telegram_setup_bootstrap.go index 1e5429cf..1edd4e85 100644 --- a/internal/orchestrator/telegram_setup_bootstrap.go +++ b/internal/orchestrator/telegram_setup_bootstrap.go @@ -38,7 +38,7 @@ type TelegramSetupBootstrap struct { } var ( - telegramSetupBootstrapLoadConfig = config.LoadConfig + telegramSetupBootstrapLoadConfig = config.LoadConfigWithBaseDir telegramSetupBootstrapIdentityDetect = identity.Detect telegramSetupBootstrapStat = os.Stat ) @@ -46,7 +46,7 @@ var ( func BuildTelegramSetupBootstrap(configPath, baseDir string) (TelegramSetupBootstrap, error) { state := TelegramSetupBootstrap{} - cfg, err := telegramSetupBootstrapLoadConfig(configPath) + cfg, err := telegramSetupBootstrapLoadConfig(configPath, baseDir) if err != nil { state.Eligibility = TelegramSetupSkipConfigError state.ConfigError = err.Error() diff --git a/internal/orchestrator/telegram_setup_bootstrap_test.go b/internal/orchestrator/telegram_setup_bootstrap_test.go index ad655665..25758b49 100644 --- a/internal/orchestrator/telegram_setup_bootstrap_test.go +++ b/internal/orchestrator/telegram_setup_bootstrap_test.go @@ -28,7 +28,7 @@ func stubTelegramSetupBootstrapDeps(t *testing.T) { func TestBuildTelegramSetupBootstrap_ConfigLoadFailureSkips(t *testing.T) { stubTelegramSetupBootstrapDeps(t) - telegramSetupBootstrapLoadConfig = func(path string) (*config.Config, error) { + telegramSetupBootstrapLoadConfig = func(path, baseDir string) (*config.Config, error) { return nil, errors.New("parse failed") } telegramSetupBootstrapIdentityDetect = func(baseDir string, logger *logging.Logger) (*identity.Info, error) { @@ -54,7 +54,7 @@ func TestBuildTelegramSetupBootstrap_ConfigLoadFailureSkips(t *testing.T) { func TestBuildTelegramSetupBootstrap_DisabledSkips(t *testing.T) { stubTelegramSetupBootstrapDeps(t) - telegramSetupBootstrapLoadConfig = func(path string) (*config.Config, error) { + telegramSetupBootstrapLoadConfig = func(path, baseDir string) (*config.Config, error) { return &config.Config{TelegramEnabled: false}, nil } telegramSetupBootstrapIdentityDetect = func(baseDir string, logger *logging.Logger) (*identity.Info, error) { @@ -77,7 +77,7 @@ func TestBuildTelegramSetupBootstrap_DisabledSkips(t *testing.T) { func TestBuildTelegramSetupBootstrap_PersonalModeSkips(t *testing.T) { stubTelegramSetupBootstrapDeps(t) - telegramSetupBootstrapLoadConfig = func(path string) (*config.Config, error) { + telegramSetupBootstrapLoadConfig = func(path, baseDir string) (*config.Config, error) { return &config.Config{ TelegramEnabled: true, TelegramBotType: " Personal ", @@ -107,7 +107,7 @@ func TestBuildTelegramSetupBootstrap_PersonalModeSkips(t *testing.T) { func TestBuildTelegramSetupBootstrap_IdentityErrorSkips(t *testing.T) { stubTelegramSetupBootstrapDeps(t) - telegramSetupBootstrapLoadConfig = func(path string) (*config.Config, error) { + telegramSetupBootstrapLoadConfig = func(path, baseDir string) (*config.Config, error) { return &config.Config{ TelegramEnabled: true, TelegramBotType: "centralized", @@ -135,7 +135,7 @@ func TestBuildTelegramSetupBootstrap_IdentityErrorSkips(t *testing.T) { func TestBuildTelegramSetupBootstrap_EmptyServerIDSkips(t *testing.T) { stubTelegramSetupBootstrapDeps(t) - telegramSetupBootstrapLoadConfig = func(path string) (*config.Config, error) { + telegramSetupBootstrapLoadConfig = func(path, baseDir string) (*config.Config, error) { return &config.Config{ TelegramEnabled: true, TelegramBotType: "centralized", @@ -169,7 +169,7 @@ func TestBuildTelegramSetupBootstrap_EligibleCentralized(t *testing.T) { t.Fatalf("write identity file: %v", err) } - telegramSetupBootstrapLoadConfig = func(path string) (*config.Config, error) { + telegramSetupBootstrapLoadConfig = func(path, baseDir string) (*config.Config, error) { return &config.Config{ TelegramEnabled: true, TelegramBotType: " ", diff --git a/internal/safeexec/safeexec.go b/internal/safeexec/safeexec.go index 7d2fd455..7238d0a9 100644 --- a/internal/safeexec/safeexec.go +++ b/internal/safeexec/safeexec.go @@ -22,44 +22,66 @@ var allowedCommandFactories = map[string]commandFactory{ "apt-cache": func(ctx context.Context, args ...string) *exec.Cmd { return exec.CommandContext(ctx, "apt-cache", args...) }, - "blkid": func(ctx context.Context, args ...string) *exec.Cmd { return exec.CommandContext(ctx, "blkid", args...) }, + "blkid": func(ctx context.Context, args ...string) *exec.Cmd { + return exec.CommandContext(ctx, "blkid", args...) + }, "bridge": func(ctx context.Context, args ...string) *exec.Cmd { return exec.CommandContext(ctx, "bridge", args...) }, - "bzip2": func(ctx context.Context, args ...string) *exec.Cmd { return exec.CommandContext(ctx, "bzip2", args...) }, - "cat": func(ctx context.Context, args ...string) *exec.Cmd { return exec.CommandContext(ctx, "cat", args...) }, - "ceph": func(ctx context.Context, args ...string) *exec.Cmd { return exec.CommandContext(ctx, "ceph", args...) }, + "bzip2": func(ctx context.Context, args ...string) *exec.Cmd { + return exec.CommandContext(ctx, "bzip2", args...) + }, + "cat": func(ctx context.Context, args ...string) *exec.Cmd { + return exec.CommandContext(ctx, "cat", args...) + }, + "ceph": func(ctx context.Context, args ...string) *exec.Cmd { + return exec.CommandContext(ctx, "ceph", args...) + }, "chattr": func(ctx context.Context, args ...string) *exec.Cmd { return exec.CommandContext(ctx, "chattr", args...) }, "crontab": func(ctx context.Context, args ...string) *exec.Cmd { return exec.CommandContext(ctx, "crontab", args...) }, - "df": func(ctx context.Context, args ...string) *exec.Cmd { return exec.CommandContext(ctx, "df", args...) }, + "df": func(ctx context.Context, args ...string) *exec.Cmd { + return exec.CommandContext(ctx, "df", args...) + }, "dmidecode": func(ctx context.Context, args ...string) *exec.Cmd { return exec.CommandContext(ctx, "dmidecode", args...) }, - "dpkg": func(ctx context.Context, args ...string) *exec.Cmd { return exec.CommandContext(ctx, "dpkg", args...) }, + "dpkg": func(ctx context.Context, args ...string) *exec.Cmd { + return exec.CommandContext(ctx, "dpkg", args...) + }, "dpkg-query": func(ctx context.Context, args ...string) *exec.Cmd { return exec.CommandContext(ctx, "dpkg-query", args...) }, - "echo": func(ctx context.Context, args ...string) *exec.Cmd { return exec.CommandContext(ctx, "echo", args...) }, + "echo": func(ctx context.Context, args ...string) *exec.Cmd { + return exec.CommandContext(ctx, "echo", args...) + }, "ethtool": func(ctx context.Context, args ...string) *exec.Cmd { return exec.CommandContext(ctx, "ethtool", args...) }, - "false": func(ctx context.Context, args ...string) *exec.Cmd { return exec.CommandContext(ctx, "false", args...) }, + "false": func(ctx context.Context, args ...string) *exec.Cmd { + return exec.CommandContext(ctx, "false", args...) + }, "firewall-cmd": func(ctx context.Context, args ...string) *exec.Cmd { return exec.CommandContext(ctx, "firewall-cmd", args...) }, - "free": func(ctx context.Context, args ...string) *exec.Cmd { return exec.CommandContext(ctx, "free", args...) }, + "free": func(ctx context.Context, args ...string) *exec.Cmd { + return exec.CommandContext(ctx, "free", args...) + }, "hostname": func(ctx context.Context, args ...string) *exec.Cmd { return exec.CommandContext(ctx, "hostname", args...) }, "ifreload": func(ctx context.Context, args ...string) *exec.Cmd { return exec.CommandContext(ctx, "ifreload", args...) }, - "ifup": func(ctx context.Context, args ...string) *exec.Cmd { return exec.CommandContext(ctx, "ifup", args...) }, - "ip": func(ctx context.Context, args ...string) *exec.Cmd { return exec.CommandContext(ctx, "ip", args...) }, + "ifup": func(ctx context.Context, args ...string) *exec.Cmd { + return exec.CommandContext(ctx, "ifup", args...) + }, + "ip": func(ctx context.Context, args ...string) *exec.Cmd { + return exec.CommandContext(ctx, "ip", args...) + }, "iptables": func(ctx context.Context, args ...string) *exec.Cmd { return exec.CommandContext(ctx, "iptables", args...) }, @@ -75,26 +97,54 @@ var allowedCommandFactories = map[string]commandFactory{ "journalctl": func(ctx context.Context, args ...string) *exec.Cmd { return exec.CommandContext(ctx, "journalctl", args...) }, - "lsblk": func(ctx context.Context, args ...string) *exec.Cmd { return exec.CommandContext(ctx, "lsblk", args...) }, - "lspci": func(ctx context.Context, args ...string) *exec.Cmd { return exec.CommandContext(ctx, "lspci", args...) }, - "lscpu": func(ctx context.Context, args ...string) *exec.Cmd { return exec.CommandContext(ctx, "lscpu", args...) }, - "lsmod": func(ctx context.Context, args ...string) *exec.Cmd { return exec.CommandContext(ctx, "lsmod", args...) }, - "lsusb": func(ctx context.Context, args ...string) *exec.Cmd { return exec.CommandContext(ctx, "lsusb", args...) }, - "lvs": func(ctx context.Context, args ...string) *exec.Cmd { return exec.CommandContext(ctx, "lvs", args...) }, - "lzma": func(ctx context.Context, args ...string) *exec.Cmd { return exec.CommandContext(ctx, "lzma", args...) }, - "mailq": func(ctx context.Context, args ...string) *exec.Cmd { return exec.CommandContext(ctx, "mailq", args...) }, - "mount": func(ctx context.Context, args ...string) *exec.Cmd { return exec.CommandContext(ctx, "mount", args...) }, + "lsblk": func(ctx context.Context, args ...string) *exec.Cmd { + return exec.CommandContext(ctx, "lsblk", args...) + }, + "lspci": func(ctx context.Context, args ...string) *exec.Cmd { + return exec.CommandContext(ctx, "lspci", args...) + }, + "lscpu": func(ctx context.Context, args ...string) *exec.Cmd { + return exec.CommandContext(ctx, "lscpu", args...) + }, + "lsmod": func(ctx context.Context, args ...string) *exec.Cmd { + return exec.CommandContext(ctx, "lsmod", args...) + }, + "lsusb": func(ctx context.Context, args ...string) *exec.Cmd { + return exec.CommandContext(ctx, "lsusb", args...) + }, + "lvs": func(ctx context.Context, args ...string) *exec.Cmd { + return exec.CommandContext(ctx, "lvs", args...) + }, + "lzma": func(ctx context.Context, args ...string) *exec.Cmd { + return exec.CommandContext(ctx, "lzma", args...) + }, + "mailq": func(ctx context.Context, args ...string) *exec.Cmd { + return exec.CommandContext(ctx, "mailq", args...) + }, + "mount": func(ctx context.Context, args ...string) *exec.Cmd { + return exec.CommandContext(ctx, "mount", args...) + }, "mountpoint": func(ctx context.Context, args ...string) *exec.Cmd { return exec.CommandContext(ctx, "mountpoint", args...) }, - "nft": func(ctx context.Context, args ...string) *exec.Cmd { return exec.CommandContext(ctx, "nft", args...) }, + "nft": func(ctx context.Context, args ...string) *exec.Cmd { + return exec.CommandContext(ctx, "nft", args...) + }, "pbzip2": func(ctx context.Context, args ...string) *exec.Cmd { return exec.CommandContext(ctx, "pbzip2", args...) }, - "pgrep": func(ctx context.Context, args ...string) *exec.Cmd { return exec.CommandContext(ctx, "pgrep", args...) }, - "pigz": func(ctx context.Context, args ...string) *exec.Cmd { return exec.CommandContext(ctx, "pigz", args...) }, - "ping": func(ctx context.Context, args ...string) *exec.Cmd { return exec.CommandContext(ctx, "ping", args...) }, - "pvs": func(ctx context.Context, args ...string) *exec.Cmd { return exec.CommandContext(ctx, "pvs", args...) }, + "pgrep": func(ctx context.Context, args ...string) *exec.Cmd { + return exec.CommandContext(ctx, "pgrep", args...) + }, + "pigz": func(ctx context.Context, args ...string) *exec.Cmd { + return exec.CommandContext(ctx, "pigz", args...) + }, + "ping": func(ctx context.Context, args ...string) *exec.Cmd { + return exec.CommandContext(ctx, "ping", args...) + }, + "pvs": func(ctx context.Context, args ...string) *exec.Cmd { + return exec.CommandContext(ctx, "pvs", args...) + }, "proxmox-backup-client": func(ctx context.Context, args ...string) *exec.Cmd { return exec.CommandContext(ctx, "proxmox-backup-client", args...) }, @@ -107,17 +157,27 @@ var allowedCommandFactories = map[string]commandFactory{ "proxmox-tape": func(ctx context.Context, args ...string) *exec.Cmd { return exec.CommandContext(ctx, "proxmox-tape", args...) }, - "ps": func(ctx context.Context, args ...string) *exec.Cmd { return exec.CommandContext(ctx, "ps", args...) }, - "pvecm": func(ctx context.Context, args ...string) *exec.Cmd { return exec.CommandContext(ctx, "pvecm", args...) }, + "ps": func(ctx context.Context, args ...string) *exec.Cmd { + return exec.CommandContext(ctx, "ps", args...) + }, + "pvecm": func(ctx context.Context, args ...string) *exec.Cmd { + return exec.CommandContext(ctx, "pvecm", args...) + }, "pve-firewall": func(ctx context.Context, args ...string) *exec.Cmd { return exec.CommandContext(ctx, "pve-firewall", args...) }, "pvenode": func(ctx context.Context, args ...string) *exec.Cmd { return exec.CommandContext(ctx, "pvenode", args...) }, - "pvesh": func(ctx context.Context, args ...string) *exec.Cmd { return exec.CommandContext(ctx, "pvesh", args...) }, - "pvesm": func(ctx context.Context, args ...string) *exec.Cmd { return exec.CommandContext(ctx, "pvesm", args...) }, - "pveum": func(ctx context.Context, args ...string) *exec.Cmd { return exec.CommandContext(ctx, "pveum", args...) }, + "pvesh": func(ctx context.Context, args ...string) *exec.Cmd { + return exec.CommandContext(ctx, "pvesh", args...) + }, + "pvesm": func(ctx context.Context, args ...string) *exec.Cmd { + return exec.CommandContext(ctx, "pvesm", args...) + }, + "pveum": func(ctx context.Context, args ...string) *exec.Cmd { + return exec.CommandContext(ctx, "pveum", args...) + }, "pveversion": func(ctx context.Context, args ...string) *exec.Cmd { return exec.CommandContext(ctx, "pveversion", args...) }, @@ -130,11 +190,15 @@ var allowedCommandFactories = map[string]commandFactory{ "sensors": func(ctx context.Context, args ...string) *exec.Cmd { return exec.CommandContext(ctx, "sensors", args...) }, - "sh": func(ctx context.Context, args ...string) *exec.Cmd { return exec.CommandContext(ctx, "sh", args...) }, + "sh": func(ctx context.Context, args ...string) *exec.Cmd { + return exec.CommandContext(ctx, "sh", args...) + }, "smartctl": func(ctx context.Context, args ...string) *exec.Cmd { return exec.CommandContext(ctx, "smartctl", args...) }, - "ss": func(ctx context.Context, args ...string) *exec.Cmd { return exec.CommandContext(ctx, "ss", args...) }, + "ss": func(ctx context.Context, args ...string) *exec.Cmd { + return exec.CommandContext(ctx, "ss", args...) + }, "systemctl": func(ctx context.Context, args ...string) *exec.Cmd { return exec.CommandContext(ctx, "systemctl", args...) }, @@ -144,22 +208,42 @@ var allowedCommandFactories = map[string]commandFactory{ "sysctl": func(ctx context.Context, args ...string) *exec.Cmd { return exec.CommandContext(ctx, "sysctl", args...) }, - "tail": func(ctx context.Context, args ...string) *exec.Cmd { return exec.CommandContext(ctx, "tail", args...) }, - "tar": func(ctx context.Context, args ...string) *exec.Cmd { return exec.CommandContext(ctx, "tar", args...) }, + "tail": func(ctx context.Context, args ...string) *exec.Cmd { + return exec.CommandContext(ctx, "tail", args...) + }, + "tar": func(ctx context.Context, args ...string) *exec.Cmd { + return exec.CommandContext(ctx, "tar", args...) + }, "udevadm": func(ctx context.Context, args ...string) *exec.Cmd { return exec.CommandContext(ctx, "udevadm", args...) }, "umount": func(ctx context.Context, args ...string) *exec.Cmd { return exec.CommandContext(ctx, "umount", args...) }, - "uname": func(ctx context.Context, args ...string) *exec.Cmd { return exec.CommandContext(ctx, "uname", args...) }, - "ufw": func(ctx context.Context, args ...string) *exec.Cmd { return exec.CommandContext(ctx, "ufw", args...) }, - "vgs": func(ctx context.Context, args ...string) *exec.Cmd { return exec.CommandContext(ctx, "vgs", args...) }, - "which": func(ctx context.Context, args ...string) *exec.Cmd { return exec.CommandContext(ctx, "which", args...) }, - "xz": func(ctx context.Context, args ...string) *exec.Cmd { return exec.CommandContext(ctx, "xz", args...) }, - "zfs": func(ctx context.Context, args ...string) *exec.Cmd { return exec.CommandContext(ctx, "zfs", args...) }, - "zpool": func(ctx context.Context, args ...string) *exec.Cmd { return exec.CommandContext(ctx, "zpool", args...) }, - "zstd": func(ctx context.Context, args ...string) *exec.Cmd { return exec.CommandContext(ctx, "zstd", args...) }, + "uname": func(ctx context.Context, args ...string) *exec.Cmd { + return exec.CommandContext(ctx, "uname", args...) + }, + "ufw": func(ctx context.Context, args ...string) *exec.Cmd { + return exec.CommandContext(ctx, "ufw", args...) + }, + "vgs": func(ctx context.Context, args ...string) *exec.Cmd { + return exec.CommandContext(ctx, "vgs", args...) + }, + "which": func(ctx context.Context, args ...string) *exec.Cmd { + return exec.CommandContext(ctx, "which", args...) + }, + "xz": func(ctx context.Context, args ...string) *exec.Cmd { + return exec.CommandContext(ctx, "xz", args...) + }, + "zfs": func(ctx context.Context, args ...string) *exec.Cmd { + return exec.CommandContext(ctx, "zfs", args...) + }, + "zpool": func(ctx context.Context, args ...string) *exec.Cmd { + return exec.CommandContext(ctx, "zpool", args...) + }, + "zstd": func(ctx context.Context, args ...string) *exec.Cmd { + return exec.CommandContext(ctx, "zstd", args...) + }, } // CommandContext creates commands only for binaries that are intentionally diff --git a/internal/security/security.go b/internal/security/security.go index 5edf6f64..502e7d38 100644 --- a/internal/security/security.go +++ b/internal/security/security.go @@ -22,6 +22,7 @@ import ( "github.com/tis24dev/proxsave/internal/environment" "github.com/tis24dev/proxsave/internal/logging" "github.com/tis24dev/proxsave/internal/safeexec" + "github.com/tis24dev/proxsave/internal/storage" "github.com/tis24dev/proxsave/internal/types" ) @@ -86,6 +87,8 @@ type Checker struct { envInfo *environment.EnvironmentInfo result *Result lookPath func(string) (string, error) + + filesystemInfoLookup func(context.Context, string) (*storage.FilesystemInfo, error) } type dependencyEntry struct { @@ -232,31 +235,38 @@ func (c *Checker) buildDependencyList() []dependencyEntry { deps = append(deps, c.binaryDependency("rclone", []string{"rclone"}, false, "cloud storage uploads enabled")) } - emailMethod := strings.ToLower(strings.TrimSpace(c.cfg.EmailDeliveryMethod)) - if emailMethod == "" { - emailMethod = "relay" - } - if emailMethod == "pmf" { - deps = append(deps, c.binaryDependency( - "proxmox-mail-forward", - []string{"/usr/libexec/proxmox-mail-forward", "/usr/bin/proxmox-mail-forward", "proxmox-mail-forward"}, - true, - "email delivery method set to pmf (Proxmox Notifications via proxmox-mail-forward)", - )) - } else if emailMethod == "sendmail" { - deps = append(deps, c.binaryDependency( - "sendmail", - []string{"/usr/sbin/sendmail", "sendmail"}, - true, - "email delivery method set to sendmail (/usr/sbin/sendmail)", - )) - } else if emailMethod == "relay" && c.cfg.EmailFallbackSendmail { - deps = append(deps, c.binaryDependency( - "proxmox-mail-forward", - []string{"/usr/libexec/proxmox-mail-forward", "/usr/bin/proxmox-mail-forward", "proxmox-mail-forward"}, - false, - "email relay fallback to pmf enabled (uses proxmox-mail-forward)", - )) + if c.cfg.EmailEnabled { + emailMethod := config.NormalizeEmailDeliveryMethod(c.cfg.EmailDeliveryMethod) + if emailMethod == "pmf" { + deps = append(deps, c.binaryDependency( + "proxmox-mail-forward", + []string{"/usr/libexec/proxmox-mail-forward", "/usr/bin/proxmox-mail-forward", "proxmox-mail-forward"}, + true, + "email delivery method set to pmf (Proxmox Notifications via proxmox-mail-forward)", + )) + if c.cfg.EmailFallbackSendmail { + deps = append(deps, c.binaryDependency( + "sendmail", + []string{"/usr/sbin/sendmail", "sendmail"}, + false, + "email pmf fallback chain can use local sendmail if pmf and relay fail", + )) + } + } else if emailMethod == "sendmail" { + deps = append(deps, c.binaryDependency( + "sendmail", + []string{"/usr/sbin/sendmail", "sendmail"}, + true, + "email delivery method set to sendmail (/usr/sbin/sendmail)", + )) + } else if emailMethod == "relay" && c.cfg.EmailFallbackSendmail { + deps = append(deps, c.binaryDependency( + "sendmail", + []string{"/usr/sbin/sendmail", "sendmail"}, + false, + "email relay fallback to local sendmail enabled", + )) + } } if c.cfg.BackupCephConfig { @@ -518,6 +528,10 @@ func (c *Checker) verifyDirectories() { continue } + if dir.allowBackup && c.shouldSkipPOSIXDirectoryChecks(dir.path) { + continue + } + if dir.allowBackup && c.shouldSkipOwnershipChecks(dir.path) { c.logger.Debug("Security check: skipping root ownership enforcement for %s (managed by SET_BACKUP_PERMISSIONS)", dir.path) continue @@ -845,6 +859,42 @@ func (c *Checker) shouldSkipOwnershipChecks(path string) bool { return false } +func (c *Checker) shouldSkipPOSIXDirectoryChecks(path string) bool { + path = strings.TrimSpace(path) + if path == "" { + return false + } + + fsInfo, err := c.detectFilesystemInfo(context.Background(), path) + if err != nil { + if c.logger != nil { + c.logger.Debug("Security check: filesystem detection failed for %s; continuing with POSIX permission checks: %v", path, err) + } + return false + } + if fsInfo == nil { + return false + } + if fsInfo.Type.ShouldAutoExclude() || !fsInfo.SupportsOwnership { + if c.logger != nil { + c.logger.Info("Security check: skipping POSIX permissions for %s (filesystem %s does not support Unix ownership/permissions)", path, fsInfo.Type) + } + return true + } + return false +} + +func (c *Checker) detectFilesystemInfo(ctx context.Context, path string) (*storage.FilesystemInfo, error) { + if c != nil && c.filesystemInfoLookup != nil { + return c.filesystemInfoLookup(ctx, path) + } + logger := (*logging.Logger)(nil) + if c != nil { + logger = c.logger + } + return storage.NewFilesystemDetector(logger).DetectFilesystem(ctx, path) +} + type ssEntry struct { valid bool port int diff --git a/internal/security/security_test.go b/internal/security/security_test.go index d67a537c..a47a8623 100644 --- a/internal/security/security_test.go +++ b/internal/security/security_test.go @@ -13,6 +13,7 @@ import ( "github.com/tis24dev/proxsave/internal/config" "github.com/tis24dev/proxsave/internal/environment" "github.com/tis24dev/proxsave/internal/logging" + "github.com/tis24dev/proxsave/internal/storage" "github.com/tis24dev/proxsave/internal/types" ) @@ -146,12 +147,13 @@ func TestCheckDependenciesMissingRequiredAddsError(t *testing.T) { func TestCheckDependenciesMissingOptionalAddsWarning(t *testing.T) { cfg := &config.Config{ CompressionType: types.CompressionNone, // only tar required + EmailEnabled: true, EmailDeliveryMethod: "relay", - EmailFallbackSendmail: true, // pmf becomes optional dependency (relay fallback) + EmailFallbackSendmail: true, // sendmail becomes optional dependency (relay fallback) } checker := newCheckerForTest(cfg, stubLookPath(map[string]bool{ "tar": true, // present - // proxmox-mail-forward missing -> warning + // sendmail missing -> warning })) checker.checkDependencies() @@ -160,7 +162,7 @@ func TestCheckDependenciesMissingOptionalAddsWarning(t *testing.T) { t.Fatalf("expected 1 warning, got %d issues=%+v", got, checker.result.Issues) } msg := checker.result.Issues[0].Message - if !strings.Contains(msg, "Optional dependency") || !strings.Contains(msg, "proxmox-mail-forward") { + if !strings.Contains(msg, "Optional dependency") || !strings.Contains(msg, "sendmail") { t.Fatalf("unexpected warning message: %s", msg) } if checker.result.ErrorCount() != 0 { @@ -1422,7 +1424,7 @@ func TestBuildDependencyListEmailMethods(t *testing.T) { }{ {"pmf method", "pmf", false, "proxmox-mail-forward", true}, {"sendmail method", "sendmail", false, "sendmail", true}, - {"relay with fallback", "relay", true, "proxmox-mail-forward", false}, + {"relay with fallback", "relay", true, "sendmail", false}, {"relay without fallback", "relay", false, "", false}, {"empty defaults to relay", "", false, "", false}, } @@ -1432,6 +1434,7 @@ func TestBuildDependencyListEmailMethods(t *testing.T) { checker := &Checker{ logger: newSecurityTestLogger(), cfg: &config.Config{ + EmailEnabled: true, EmailDeliveryMethod: tc.method, EmailFallbackSendmail: tc.fallback, }, @@ -2310,6 +2313,53 @@ func TestVerifyDirectoriesSkipOwnershipForBackup(t *testing.T) { // Ownership warnings for backup path should not appear } +func TestVerifyDirectoriesSkipsPOSIXChecksOnCIFSBackupPath(t *testing.T) { + tmpDir := t.TempDir() + backupDir := filepath.Join(tmpDir, "backup") + if err := os.MkdirAll(backupDir, 0o777); err != nil { + t.Fatal(err) + } + if err := os.Chmod(backupDir, 0o777); err != nil { + t.Fatal(err) + } + + checker := &Checker{ + logger: newSecurityTestLogger(), + cfg: &config.Config{ + BaseDir: tmpDir, + BackupPath: backupDir, + AutoFixPermissions: false, + }, + result: &Result{}, + filesystemInfoLookup: func(ctx context.Context, path string) (*storage.FilesystemInfo, error) { + if filepath.Clean(path) == filepath.Clean(backupDir) { + return &storage.FilesystemInfo{ + Path: path, + Type: storage.FilesystemCIFS, + SupportsOwnership: false, + IsNetworkFS: true, + MountPoint: backupDir, + }, nil + } + return &storage.FilesystemInfo{ + Path: path, + Type: storage.FilesystemExt4, + SupportsOwnership: true, + MountPoint: tmpDir, + }, nil + }, + } + + checker.verifyDirectories() + + for _, issue := range checker.result.Issues { + if strings.Contains(issue.Message, backupDir) && + (strings.Contains(issue.Message, "permissions") || strings.Contains(issue.Message, "owned")) { + t.Fatalf("expected CIFS backup path POSIX warnings to be skipped, got issue: %s", issue.Message) + } + } +} + // ============================================================ // verifySecureAccountFiles additional tests // ============================================================ diff --git a/internal/storage/filesystem.go b/internal/storage/filesystem.go index 9fce63dd..937dbd95 100644 --- a/internal/storage/filesystem.go +++ b/internal/storage/filesystem.go @@ -202,11 +202,12 @@ func (d *FilesystemDetector) testOwnershipSupport(ctx context.Context, path stri d.logger.Debug("Cannot create test file for ownership check: %v", err) return false } + defer func() { _ = os.Remove(testFile) }() + if err := f.Close(); err != nil { d.logger.Debug("Cannot close test file for ownership check: %v", err) return false } - defer func() { _ = os.Remove(testFile) }() // Try to change ownership to current user (should be safe) uid := os.Getuid() @@ -241,6 +242,9 @@ func (d *FilesystemDetector) testOwnershipSupport(ctx context.Context, path stri // parseFilesystemType converts a filesystem type string to FilesystemType func parseFilesystemType(fsTypeStr string) FilesystemType { fsTypeStr = strings.ToLower(fsTypeStr) + if strings.HasPrefix(fsTypeStr, "fuse.") { + return FilesystemFUSE + } switch fsTypeStr { case "ext4": @@ -271,13 +275,13 @@ func parseFilesystemType(fsTypeStr string) FilesystemType { return FilesystemExFAT case "ntfs", "ntfs-3g": return FilesystemNTFS - case "fuse", "fuse.sshfs": + case "fuse": return FilesystemFUSE case "nfs": return FilesystemNFS case "nfs4": return FilesystemNFS4 - case "cifs", "smb", "smbfs": + case "cifs", "smb", "smbfs", "smb2", "smb3": return FilesystemCIFS default: return FilesystemUnknown diff --git a/internal/storage/filesystem_test.go b/internal/storage/filesystem_test.go index 2bafd5c0..7f9ec831 100644 --- a/internal/storage/filesystem_test.go +++ b/internal/storage/filesystem_test.go @@ -55,11 +55,13 @@ func TestParseFilesystemType_CoversKnownAndUnknownTypes(t *testing.T) { {"ntfs-3g", FilesystemNTFS}, {"fuse", FilesystemFUSE}, {"fuse.sshfs", FilesystemFUSE}, + {"fuse.smbnetfs", FilesystemFUSE}, {"nfs", FilesystemNFS}, {"nfs4", FilesystemNFS4}, {"cifs", FilesystemCIFS}, {"smb", FilesystemCIFS}, {"smbfs", FilesystemCIFS}, + {"smb3", FilesystemCIFS}, {"definitely-unknown", FilesystemUnknown}, } diff --git a/internal/storage/storage.go b/internal/storage/storage.go index 7b883445..6bebae55 100644 --- a/internal/storage/storage.go +++ b/internal/storage/storage.go @@ -187,7 +187,7 @@ func (f FilesystemType) IsNetworkFilesystem() bool { // from ownership operations (incompatible filesystems like FAT32/CIFS) func (f FilesystemType) ShouldAutoExclude() bool { switch f { - case FilesystemFAT32, FilesystemFAT, FilesystemExFAT, FilesystemNTFS, FilesystemCIFS: + case FilesystemFAT32, FilesystemFAT, FilesystemExFAT, FilesystemNTFS, FilesystemFUSE, FilesystemCIFS, FilesystemSMB: return true default: return false diff --git a/internal/storage/storage_test.go b/internal/storage/storage_test.go index 3234d1de..baad66a0 100644 --- a/internal/storage/storage_test.go +++ b/internal/storage/storage_test.go @@ -1112,8 +1112,8 @@ func TestFilesystemTypeHelpers(t *testing.T) { {FilesystemExFAT, false, false, true}, {FilesystemNFS4, false, true, false}, {FilesystemCIFS, false, true, true}, - {FilesystemSMB, false, true, false}, - {FilesystemFUSE, false, false, false}, + {FilesystemSMB, false, true, true}, + {FilesystemFUSE, false, false, true}, {FilesystemUnknown, false, false, false}, } diff --git a/internal/tui/components/form_test.go b/internal/tui/components/form_test.go index ce459b40..723c2913 100644 --- a/internal/tui/components/form_test.go +++ b/internal/tui/components/form_test.go @@ -74,7 +74,11 @@ func TestAddPasswordFieldRegistersValidators(t *testing.T) { if form.GetFormItemCount() != 1 { t.Fatalf("form item count=%d; want 1", form.GetFormItemCount()) } - if got := form.Form.GetFormItem(0).(*tview.InputField).GetLabel(); got != "Password" { + item, ok := form.GetFormItem(0).(*tview.InputField) + if !ok { + t.Fatalf("form item type=%T; want *tview.InputField", form.GetFormItem(0)) + } + if got := item.GetLabel(); got != "Password" { t.Fatalf("label=%q; want %q", got, "Password") } } diff --git a/internal/tui/wizard/install.go b/internal/tui/wizard/install.go index 33dc9ae2..b95fc675 100644 --- a/internal/tui/wizard/install.go +++ b/internal/tui/wizard/install.go @@ -19,16 +19,17 @@ import ( ) type installWizardPrefill struct { - SecondaryEnabled bool - SecondaryPath string - SecondaryLogPath string - CloudEnabled bool - CloudRemote string - CloudLogPath string - FirewallEnabled bool - TelegramEnabled bool - EmailEnabled bool - EncryptionEnabled bool + SecondaryEnabled bool + SecondaryPath string + SecondaryLogPath string + CloudEnabled bool + CloudRemote string + CloudLogPath string + FirewallEnabled bool + TelegramEnabled bool + EmailEnabled bool + EmailDeliveryMethod string + EncryptionEnabled bool } // InstallWizardData holds the collected installation data @@ -43,6 +44,8 @@ type InstallWizardData struct { RcloneLogRemote string BackupFirewallRules *bool NotificationMode string // "none", "telegram", "email", "both" + EmailDeliveryMethod string // "relay", "sendmail", or "pmf" + EmailFallbackSendmail *bool CronTime string // HH:MM EnableEncryption bool } @@ -58,6 +61,15 @@ const ( ) var ( + installEmailDeliveryOptions = []struct { + Label string + Method string + }{ + {Label: "Cloud relay (relay)", Method: "relay"}, + {Label: "Local sendmail (sendmail)", Method: "sendmail"}, + {Label: "Proxmox Notifications (pmf)", Method: "pmf"}, + } + // ErrInstallCancelled is returned when the user aborts the install wizard. ErrInstallCancelled = errors.New("installation aborted by user") // ErrNilInstallData is returned when ApplyInstallData or its validators receive a nil payload. @@ -77,12 +89,15 @@ var ( // RunInstallWizard runs the TUI-based installation wizard func RunInstallWizard(ctx context.Context, configPath string, baseDir string, buildSig string, baseTemplate string) (*InstallWizardData, error) { defaultFirewallRules := false + defaultEmailFallbackSendmail := true data := &InstallWizardData{ - BaseDir: baseDir, - ConfigPath: configPath, - CronTime: cronutil.DefaultTime, - EnableEncryption: false, // Default to disabled - BackupFirewallRules: &defaultFirewallRules, + BaseDir: baseDir, + ConfigPath: configPath, + CronTime: cronutil.DefaultTime, + EnableEncryption: false, // Default to disabled + BackupFirewallRules: &defaultFirewallRules, + EmailDeliveryMethod: "relay", + EmailFallbackSendmail: &defaultEmailFallbackSendmail, } app := tui.NewApp() @@ -232,6 +247,8 @@ func RunInstallWizard(ctx context.Context, configPath string, baseDir string, bu // Notifications (header + two toggles) telegramEnabled := prefill.TelegramEnabled emailEnabled := prefill.EmailEnabled + emailDeliveryMethod := installEmailDeliveryMethodOrDefault(prefill.EmailDeliveryMethod) + var emailMethodDropdown *tview.DropDown notificationHeader := tview.NewInputField(). SetLabel("Notifications"). SetFieldWidth(0). @@ -260,6 +277,9 @@ func RunInstallWizard(ctx context.Context, configPath string, baseDir string, bu SetLabel(" └─ Enable Email notifications"). SetOptions([]string{"No", "Yes"}, func(option string, index int) { emailEnabled = (option == "Yes") + if emailMethodDropdown != nil { + emailMethodDropdown.SetDisabled(!emailEnabled) + } dropdownOpen = false }). SetCurrentOption(boolToOptionIndex(emailEnabled)) @@ -273,6 +293,27 @@ func RunInstallWizard(ctx context.Context, configPath string, baseDir string, bu }) form.AddFormItem(emailDropdown) + emailMethodLabels := installEmailDeliveryMethodLabels() + emailMethodDropdown = tview.NewDropDown(). + SetLabel(" └─ Email delivery method"). + SetOptions(emailMethodLabels, func(_ string, index int) { + if index >= 0 && index < len(installEmailDeliveryOptions) { + emailDeliveryMethod = installEmailDeliveryOptions[index].Method + } + dropdownOpen = false + }). + SetCurrentOption(installEmailDeliveryMethodIndex(emailDeliveryMethod)) + emailMethodDropdown.SetDisabled(!emailEnabled) + emailMethodDropdown.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey { + if event.Key() == tcell.KeyEnter { + dropdownOpen = !dropdownOpen + } else if event.Key() == tcell.KeyEscape { + dropdownOpen = false + } + return event + }) + form.AddFormItem(emailMethodDropdown) + // Encryption encryptionDropdown := tview.NewDropDown(). SetLabel("Enable Backup Encryption (AGE)"). @@ -347,6 +388,8 @@ func RunInstallWizard(ctx context.Context, configPath string, baseDir string, bu default: data.NotificationMode = "none" } + data.EmailDeliveryMethod = installEmailDeliveryMethodOrDefault(emailDeliveryMethod) + data.EmailFallbackSendmail = &defaultEmailFallbackSendmail // Get encryption setting data.EnableEncryption = values["Enable Backup Encryption (AGE)"] == "Yes" @@ -447,12 +490,9 @@ func ApplyInstallData(baseTemplate string, data *InstallWizardData) (string, err return "", err } - // BASE_DIR is auto-detected at runtime from the executable/config location. - // Keep it out of backup.env to avoid pinning the installation to a specific path. - template = unsetEnvValue(template, "BASE_DIR") - template = unsetEnvValue(template, "CRON_SCHEDULE") - template = unsetEnvValue(template, "CRON_HOUR") - template = unsetEnvValue(template, "CRON_MINUTE") + // BASE_DIR and cron values are derived at runtime/finalization time. + // Keep them out of backup.env to avoid pinning the installation. + template = config.RemoveRuntimeDerivedEnvKeys(template) // Apply secondary storage template = config.ApplySecondaryStorageSettings( @@ -495,12 +535,24 @@ func ApplyInstallData(baseTemplate string, data *InstallWizardData) (string, err if data.NotificationMode == "email" || data.NotificationMode == "both" { template = setEnvValue(template, "EMAIL_ENABLED", "true") - // Preserve existing delivery preferences when editing an existing config. - if !editingExisting || strings.TrimSpace(existingValues["EMAIL_DELIVERY_METHOD"]) == "" { - template = setEnvValue(template, "EMAIL_DELIVERY_METHOD", "relay") + method := strings.TrimSpace(data.EmailDeliveryMethod) + if method == "" && editingExisting { + method = strings.TrimSpace(existingValues["EMAIL_DELIVERY_METHOD"]) } - if !editingExisting || strings.TrimSpace(existingValues["EMAIL_FALLBACK_SENDMAIL"]) == "" { + method = installEmailDeliveryMethodOrDefault(method) + template = setEnvValue(template, "EMAIL_DELIVERY_METHOD", method) + + fallbackRaw := readTemplateString(existingValues, "EMAIL_FALLBACK_SENDMAIL", "EMAIL_FALLBACK_PMF") + switch { + case data.EmailFallbackSendmail != nil: + template = unsetEnvValue(template, "EMAIL_FALLBACK_PMF") + template = setEnvValue(template, "EMAIL_FALLBACK_SENDMAIL", fmt.Sprintf("%t", *data.EmailFallbackSendmail)) + case fallbackRaw == "": + template = unsetEnvValue(template, "EMAIL_FALLBACK_PMF") template = setEnvValue(template, "EMAIL_FALLBACK_SENDMAIL", "true") + case strings.TrimSpace(existingValues["EMAIL_FALLBACK_SENDMAIL"]) == "": + template = unsetEnvValue(template, "EMAIL_FALLBACK_PMF") + template = setEnvValue(template, "EMAIL_FALLBACK_SENDMAIL", fmt.Sprintf("%t", utils.ParseBool(fallbackRaw))) } } else { template = setEnvValue(template, "EMAIL_ENABLED", "false") @@ -598,6 +650,7 @@ func deriveInstallWizardPrefill(baseTemplate string) installWizardPrefill { out.TelegramEnabled = readTemplateBool(values, "TELEGRAM_ENABLED") out.EmailEnabled = readTemplateBool(values, "EMAIL_ENABLED") + out.EmailDeliveryMethod = installEmailDeliveryMethodOrDefault(readTemplateString(values, "EMAIL_DELIVERY_METHOD")) out.EncryptionEnabled = readTemplateBool(values, "ENCRYPT_ARCHIVE") @@ -653,6 +706,37 @@ func readTemplateBool(values map[string]string, keys ...string) bool { return utils.ParseBool(raw) } +func installEmailDeliveryMethodOrDefault(method string) string { + if strings.TrimSpace(method) == "" { + return "relay" + } + normalized := config.NormalizeEmailDeliveryMethod(method) + switch normalized { + case "relay", "sendmail", "pmf": + return normalized + default: + return "relay" + } +} + +func installEmailDeliveryMethodLabels() []string { + labels := make([]string, 0, len(installEmailDeliveryOptions)) + for _, option := range installEmailDeliveryOptions { + labels = append(labels, option.Label) + } + return labels +} + +func installEmailDeliveryMethodIndex(method string) int { + method = installEmailDeliveryMethodOrDefault(method) + for i, option := range installEmailDeliveryOptions { + if option.Method == method { + return i + } + } + return 0 +} + // CheckExistingConfig checks if config file exists and asks how to proceed func CheckExistingConfig(ctx context.Context, configPath string, buildSig string) (ExistingConfigAction, error) { if info, err := os.Stat(configPath); err == nil { diff --git a/internal/tui/wizard/install_test.go b/internal/tui/wizard/install_test.go index 1e22a4dc..641f68de 100644 --- a/internal/tui/wizard/install_test.go +++ b/internal/tui/wizard/install_test.go @@ -78,7 +78,7 @@ func TestApplyInstallDataRespectsBaseTemplate(t *testing.T) { } assertContains("MARKER", "1") - if strings.Contains(result, "BASE_DIR=") { + if _, ok := parseEnvTemplate(result)["BASE_DIR"]; ok { t.Fatalf("expected BASE_DIR to be removed, got:\n%s", result) } assertContains("SECONDARY_ENABLED", "true") @@ -101,7 +101,7 @@ func TestApplyInstallDataDefaultsBaseTemplate(t *testing.T) { if err != nil { t.Fatalf("ApplyInstallData returned error: %v", err) } - if strings.Contains(result, "BASE_DIR=") { + if _, ok := parseEnvTemplate(result)["BASE_DIR"]; ok { t.Fatalf("expected BASE_DIR not to be written in default template") } } @@ -234,12 +234,40 @@ func TestApplyInstallDataCronAndNotifications(t *testing.T) { assertContains("TELEGRAM_ENABLED", "false") assertContains("EMAIL_ENABLED", "true") assertContains("EMAIL_DELIVERY_METHOD", "relay") + assertContains("EMAIL_FALLBACK_SENDMAIL", "true") if strings.Contains(result, "CRON_SCHEDULE=") || strings.Contains(result, "CRON_HOUR=") || strings.Contains(result, "CRON_MINUTE=") { t.Fatalf("expected CRON_* keys to be removed from backup.env, got:\n%s", result) } assertContains("ENCRYPT_ARCHIVE", "false") } +func TestApplyInstallDataPreservesExistingEmailDeliveryMethod(t *testing.T) { + baseTemplate := strings.Join([]string{ + "EMAIL_ENABLED=false", + "EMAIL_DELIVERY_METHOD=relay", + "EMAIL_FALLBACK_SENDMAIL=false", + "", + }, "\n") + data := &InstallWizardData{ + BaseDir: "/data", + NotificationMode: "email", + } + + result, err := ApplyInstallData(baseTemplate, data) + if err != nil { + t.Fatalf("ApplyInstallData returned error: %v", err) + } + if !strings.Contains(result, "EMAIL_DELIVERY_METHOD=relay") { + t.Fatalf("expected existing relay method to be preserved:\n%s", result) + } + if !strings.Contains(result, "EMAIL_FALLBACK_SENDMAIL=false") { + t.Fatalf("expected existing sendmail fallback key to be preserved:\n%s", result) + } + if strings.Contains(result, "EMAIL_FALLBACK_PMF") { + t.Fatalf("expected transitional EMAIL_FALLBACK_PMF key to be removed:\n%s", result) + } +} + func TestRunInstallWizardBlankCronIgnoresEnvOverride(t *testing.T) { t.Setenv("CRON_SCHEDULE", "5 1 * * *") diff --git a/internal/tui/wizard/post_install_audit_tui.go b/internal/tui/wizard/post_install_audit_tui.go index 534bd76e..5f52e606 100644 --- a/internal/tui/wizard/post_install_audit_tui.go +++ b/internal/tui/wizard/post_install_audit_tui.go @@ -110,7 +110,7 @@ func RunPostInstallAuditWizard(ctx context.Context, execPath, configPath, buildS "ProxSave - Post-install Check\n\n"+ "Detect optional components that are enabled but not configured on this node.\n"+ "This helps reduce WARNING noise and exit code 1 runs when features are unused.\n", - "[yellow]Navigation:[white] ↑↓ to move | ENTER/SPACE to toggle | ←→ on buttons | ENTER to select", + "[yellow]Navigation:[white] ↑↓ to move | ENTER/SPACE to toggle | TAB to actions | ←→ on buttons | ENTER to select", configPath, buildSig, pages, @@ -221,23 +221,14 @@ func showAuditReview(app *tui.App, pages *tview.Pages, configPath string, sugges list.SetSelectedFunc(func(index int, _ string, _ string, _ rune) { toggle(index) }) - list.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey { - switch event.Key() { - case tcell.KeyEnter: - // Let SetSelectedFunc handle it. - return event - } - if event.Rune() == ' ' { - toggle(list.GetCurrentItem()) - return nil - } - return event - }) - if len(suggestions) > 0 { updateDetails(0) } + applyKeys := func(keys []string) { + applyAuditDisableSelection(app, pages, configPath, keys, applied) + } + buttons := tview.NewForm(). AddButton("Disable selected", func() { keys := make([]string, 0, len(suggestions)) @@ -246,35 +237,68 @@ func showAuditReview(app *tui.App, pages *tview.Pages, configPath string, sugges keys = append(keys, s.Key) } } - sort.Strings(keys) - if len(keys) == 0 { - showAuditDoneModal(app, pages, "No changes selected.\n\nNothing was modified.") - return - } - if err := applyAuditDisables(configPath, keys); err != nil { - showAuditDoneModal(app, pages, "Failed to update configuration:\n\n"+err.Error()) - return - } - *applied = keys - showAuditDoneModal(app, pages, fmt.Sprintf("Configuration updated successfully.\n\nDisabled %d feature(s).", len(keys))) + applyKeys(keys) }). AddButton("Disable all", func() { - for i := range suggestions { - selected[suggestions[i].Key] = true - updateListItem(i) + keys := make([]string, 0, len(suggestions)) + for _, s := range suggestions { + keys = append(keys, s.Key) } - updateDetails(list.GetCurrentItem()) + applyKeys(keys) }). AddButton("Skip", func() { app.Stop() }) + list.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey { + if event == nil { + return event + } + switch event.Key() { + case tcell.KeyEnter: + // Let SetSelectedFunc handle it. + return event + case tcell.KeyTab: + app.SetFocus(buttons) + return nil + case tcell.KeyDown: + if len(suggestions) == 0 || list.GetCurrentItem() >= len(suggestions)-1 { + app.SetFocus(buttons) + return nil + } + } + if event.Rune() == ' ' { + toggle(list.GetCurrentItem()) + return nil + } + return event + }) + buttons.SetBorder(true). SetTitle(" Actions "). SetTitleAlign(tview.AlignCenter). SetTitleColor(tui.ProxmoxOrange). SetBorderColor(tui.ProxmoxOrange). SetBackgroundColor(tcell.ColorBlack) + buttons.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey { + if event == nil { + return event + } + formItemIndex, buttonIndex := buttons.GetFocusedItemIndex() + if formItemIndex >= 0 || buttonIndex < 0 { + return event + } + switch event.Key() { + case tcell.KeyLeft: + return tcell.NewEventKey(tcell.KeyBacktab, 0, tcell.ModNone) + case tcell.KeyRight, tcell.KeyDown: + return tcell.NewEventKey(tcell.KeyTab, 0, tcell.ModNone) + case tcell.KeyUp, tcell.KeyEscape: + app.SetFocus(list) + return nil + } + return event + }) list.SetBorder(true). SetTitle(" Suggestions "). @@ -305,6 +329,29 @@ func showAuditReview(app *tui.App, pages *tview.Pages, configPath string, sugges app.SetFocus(list) } +func applyAuditDisableSelection(app *tui.App, pages *tview.Pages, configPath string, keys []string, applied *[]string) { + normalized := make([]string, 0, len(keys)) + for _, key := range keys { + key = strings.ToUpper(strings.TrimSpace(key)) + if key != "" { + normalized = append(normalized, key) + } + } + sort.Strings(normalized) + if len(normalized) == 0 { + showAuditDoneModal(app, pages, "No changes selected.\n\nNothing was modified.") + return + } + if err := applyAuditDisables(configPath, normalized); err != nil { + showAuditDoneModal(app, pages, "Failed to update configuration:\n\n"+err.Error()) + return + } + if applied != nil { + *applied = append([]string(nil), normalized...) + } + showAuditDoneModal(app, pages, fmt.Sprintf("Configuration updated successfully.\n\nDisabled %d feature(s).", len(normalized))) +} + func applyAuditDisables(configPath string, keys []string) error { if strings.TrimSpace(configPath) == "" { return fmt.Errorf("config path cannot be empty") diff --git a/internal/tui/wizard/post_install_audit_tui_test.go b/internal/tui/wizard/post_install_audit_tui_test.go index 4fd992cb..c1f256d0 100644 --- a/internal/tui/wizard/post_install_audit_tui_test.go +++ b/internal/tui/wizard/post_install_audit_tui_test.go @@ -2,8 +2,13 @@ package wizard import ( "context" + "os" + "path/filepath" + "reflect" + "strings" "testing" + "github.com/gdamore/tcell/v2" "github.com/rivo/tview" "github.com/tis24dev/proxsave/internal/tui" @@ -31,3 +36,183 @@ func TestRunPostInstallAuditWizard_PassesContextToRunner(t *testing.T) { t.Fatalf("expected Ran=false when runner exits without selecting an action") } } + +func TestShowAuditReviewDisableAllAppliesAllSuggestions(t *testing.T) { + configPath := writePostInstallAuditConfig(t, + "BACKUP_CLUSTER_CONFIG=true\n"+ + "BACKUP_PBS_DATASTORE=true\n"+ + "BACKUP_FIREWALL_RULES=true\n") + suggestions := []PostInstallAuditSuggestion{ + {Key: "BACKUP_PBS_DATASTORE", Messages: []string{"pbs missing; set BACKUP_PBS_DATASTORE=false to disable"}}, + {Key: "BACKUP_CLUSTER_CONFIG", Messages: []string{"cluster missing; set BACKUP_CLUSTER_CONFIG=false to disable"}}, + } + + _, pages, _, buttons, applied := buildPostInstallAuditReview(t, configPath, suggestions) + pressFormButton(t, buttons, "Disable all") + + values := readPostInstallAuditConfigValues(t, configPath) + if got := values["BACKUP_CLUSTER_CONFIG"]; got != "false" { + t.Fatalf("BACKUP_CLUSTER_CONFIG=%q, want false", got) + } + if got := values["BACKUP_PBS_DATASTORE"]; got != "false" { + t.Fatalf("BACKUP_PBS_DATASTORE=%q, want false", got) + } + if got := values["BACKUP_FIREWALL_RULES"]; got != "true" { + t.Fatalf("BACKUP_FIREWALL_RULES=%q, want true", got) + } + + wantApplied := []string{"BACKUP_CLUSTER_CONFIG", "BACKUP_PBS_DATASTORE"} + if !reflect.DeepEqual(*applied, wantApplied) { + t.Fatalf("applied=%v, want %v", *applied, wantApplied) + } + assertPostInstallAuditDonePage(t, pages) +} + +func TestShowAuditReviewDisableSelectedAppliesSelectedSuggestion(t *testing.T) { + configPath := writePostInstallAuditConfig(t, + "BACKUP_CLUSTER_CONFIG=true\n"+ + "BACKUP_PBS_DATASTORE=true\n") + suggestions := []PostInstallAuditSuggestion{ + {Key: "BACKUP_CLUSTER_CONFIG", Messages: []string{"cluster missing; set BACKUP_CLUSTER_CONFIG=false to disable"}}, + {Key: "BACKUP_PBS_DATASTORE", Messages: []string{"pbs missing; set BACKUP_PBS_DATASTORE=false to disable"}}, + } + + _, pages, list, buttons, applied := buildPostInstallAuditReview(t, configPath, suggestions) + list.InputHandler()(tcell.NewEventKey(tcell.KeyRune, ' ', tcell.ModNone), func(tview.Primitive) {}) + main, _ := list.GetItemText(0) + if !strings.Contains(main, "[x] BACKUP_CLUSTER_CONFIG") { + t.Fatalf("selected list item text=%q, want checked BACKUP_CLUSTER_CONFIG", main) + } + + pressFormButton(t, buttons, "Disable selected") + + values := readPostInstallAuditConfigValues(t, configPath) + if got := values["BACKUP_CLUSTER_CONFIG"]; got != "false" { + t.Fatalf("BACKUP_CLUSTER_CONFIG=%q, want false", got) + } + if got := values["BACKUP_PBS_DATASTORE"]; got != "true" { + t.Fatalf("BACKUP_PBS_DATASTORE=%q, want true", got) + } + + wantApplied := []string{"BACKUP_CLUSTER_CONFIG"} + if !reflect.DeepEqual(*applied, wantApplied) { + t.Fatalf("applied=%v, want %v", *applied, wantApplied) + } + assertPostInstallAuditDonePage(t, pages) +} + +func TestShowAuditReviewNavigationMovesBetweenListAndActions(t *testing.T) { + configPath := writePostInstallAuditConfig(t, "BACKUP_CLUSTER_CONFIG=true\nBACKUP_PBS_DATASTORE=true\n") + suggestions := []PostInstallAuditSuggestion{ + {Key: "BACKUP_CLUSTER_CONFIG", Messages: []string{"cluster missing"}}, + {Key: "BACKUP_PBS_DATASTORE", Messages: []string{"pbs missing"}}, + } + + app, _, list, buttons, _ := buildPostInstallAuditReview(t, configPath, suggestions) + setFocus := func(p tview.Primitive) { app.SetFocus(p) } + + if !list.HasFocus() { + t.Fatalf("expected suggestions list to start focused") + } + + list.InputHandler()(tcell.NewEventKey(tcell.KeyTab, 0, tcell.ModNone), setFocus) + _, buttonIndex := buttons.GetFocusedItemIndex() + if buttonIndex != 0 { + t.Fatalf("after list TAB, focused button=%d, want 0", buttonIndex) + } + + buttons.InputHandler()(tcell.NewEventKey(tcell.KeyRight, 0, tcell.ModNone), setFocus) + _, buttonIndex = buttons.GetFocusedItemIndex() + if buttonIndex != 1 { + t.Fatalf("after button RIGHT, focused button=%d, want 1", buttonIndex) + } + + buttons.InputHandler()(tcell.NewEventKey(tcell.KeyUp, 0, tcell.ModNone), setFocus) + if !list.HasFocus() { + t.Fatalf("expected button UP to return focus to suggestions list") + } + + list.SetCurrentItem(len(suggestions) - 1) + list.InputHandler()(tcell.NewEventKey(tcell.KeyDown, 0, tcell.ModNone), setFocus) + _, buttonIndex = buttons.GetFocusedItemIndex() + if buttonIndex < 0 { + t.Fatalf("expected list DOWN at the last item to focus an action button") + } + + buttons.InputHandler()(tcell.NewEventKey(tcell.KeyEscape, 0, tcell.ModNone), setFocus) + if !list.HasFocus() { + t.Fatalf("expected button ESC to return focus to suggestions list") + } +} + +func buildPostInstallAuditReview(t *testing.T, configPath string, suggestions []PostInstallAuditSuggestion) (*tui.App, *tview.Pages, *tview.List, *tview.Form, *[]string) { + t.Helper() + + app := tui.NewApp() + pages := tview.NewPages() + applied := []string{} + showAuditReview(app, pages, configPath, suggestions, &applied) + list, buttons := extractPostInstallAuditReviewControls(t, pages) + return app, pages, list, buttons, &applied +} + +func extractPostInstallAuditReviewControls(t *testing.T, pages *tview.Pages) (*tview.List, *tview.Form) { + t.Helper() + + page := pages.GetPage("review") + if page == nil { + t.Fatalf("review page not found") + } + review, ok := page.(*tview.Flex) + if !ok { + t.Fatalf("review page type=%T, want *tview.Flex", page) + } + if review.GetItemCount() < 3 { + t.Fatalf("review item count=%d, want at least 3", review.GetItemCount()) + } + mid, ok := review.GetItem(1).(*tview.Flex) + if !ok { + t.Fatalf("review middle item type=%T, want *tview.Flex", review.GetItem(1)) + } + list, ok := mid.GetItem(0).(*tview.List) + if !ok { + t.Fatalf("suggestions item type=%T, want *tview.List", mid.GetItem(0)) + } + buttons, ok := review.GetItem(2).(*tview.Form) + if !ok { + t.Fatalf("actions item type=%T, want *tview.Form", review.GetItem(2)) + } + return list, buttons +} + +func writePostInstallAuditConfig(t *testing.T, content string) string { + t.Helper() + + path := filepath.Join(t.TempDir(), "backup.env") + if err := os.WriteFile(path, []byte(content), 0o600); err != nil { + t.Fatalf("write config: %v", err) + } + return path +} + +func readPostInstallAuditConfigValues(t *testing.T, configPath string) map[string]string { + t.Helper() + + content, err := os.ReadFile(configPath) + if err != nil { + t.Fatalf("read config: %v", err) + } + return parseEnvTemplate(string(content)) +} + +func assertPostInstallAuditDonePage(t *testing.T, pages *tview.Pages) { + t.Helper() + + name, primitive := pages.GetFrontPage() + if name != "done" { + t.Fatalf("front page=%q, want done", name) + } + if _, ok := primitive.(*tview.Modal); !ok { + t.Fatalf("done page type=%T, want *tview.Modal", primitive) + } +}