diff --git a/cmd/proxsave/encryption_setup.go b/cmd/proxsave/encryption_setup.go new file mode 100644 index 0000000..0d493c3 --- /dev/null +++ b/cmd/proxsave/encryption_setup.go @@ -0,0 +1,60 @@ +package main + +import ( + "context" + "errors" + "fmt" + "io" + + "github.com/tis24dev/proxsave/internal/config" + "github.com/tis24dev/proxsave/internal/logging" + "github.com/tis24dev/proxsave/internal/orchestrator" + "github.com/tis24dev/proxsave/internal/types" +) + +type encryptionSetupResult struct { + Config *config.Config + RecipientPath string + WroteRecipientFile bool + ReusedExistingRecipients bool +} + +func runInitialEncryptionSetupWithUI(ctx context.Context, configPath string, ui orchestrator.AgeSetupUI) (*encryptionSetupResult, error) { + cfg, err := config.LoadConfig(configPath) + if err != nil { + return nil, fmt.Errorf("failed to reload configuration after install: %w", err) + } + + logger := logging.New(types.LogLevelError, false) + logger.SetOutput(io.Discard) + + orch := orchestrator.New(logger, false) + orch.SetConfig(cfg) + + var setupResult *orchestrator.AgeRecipientSetupResult + if ui != nil { + setupResult, err = orch.EnsureAgeRecipientsReadyWithUIDetails(ctx, ui) + } else { + setupResult, err = orch.EnsureAgeRecipientsReadyWithDetails(ctx) + } + if err != nil { + if errors.Is(err, orchestrator.ErrAgeRecipientSetupAborted) { + return nil, fmt.Errorf("encryption setup aborted by user: %w", errInteractiveAborted) + } + return nil, fmt.Errorf("encryption setup failed: %w", err) + } + + result := &encryptionSetupResult{Config: cfg} + if setupResult != nil { + result.RecipientPath = setupResult.RecipientPath + result.WroteRecipientFile = setupResult.WroteRecipientFile + result.ReusedExistingRecipients = setupResult.ReusedExistingRecipients + } + + return result, nil +} + +func runInitialEncryptionSetup(ctx context.Context, configPath string) error { + _, err := runInitialEncryptionSetupWithUI(ctx, configPath, nil) + return err +} diff --git a/cmd/proxsave/encryption_setup_test.go b/cmd/proxsave/encryption_setup_test.go new file mode 100644 index 0000000..78dfacb --- /dev/null +++ b/cmd/proxsave/encryption_setup_test.go @@ -0,0 +1,250 @@ +package main + +import ( + "context" + "os" + "path/filepath" + "testing" + + "filippo.io/age" + + "github.com/tis24dev/proxsave/internal/orchestrator" +) + +type testAgeSetupUI struct { + overwrite bool + drafts []*orchestrator.AgeRecipientDraft + addMore []bool +} + +func (u *testAgeSetupUI) ConfirmOverwriteExistingRecipient(ctx context.Context, recipientPath string) (bool, error) { + return u.overwrite, nil +} + +func (u *testAgeSetupUI) CollectRecipientDraft(ctx context.Context, recipientPath string) (*orchestrator.AgeRecipientDraft, error) { + if len(u.drafts) == 0 { + return nil, orchestrator.ErrAgeRecipientSetupAborted + } + draft := u.drafts[0] + u.drafts = u.drafts[1:] + return draft, nil +} + +func (u *testAgeSetupUI) ConfirmAddAnotherRecipient(ctx context.Context, currentCount int) (bool, error) { + if len(u.addMore) == 0 { + return false, nil + } + next := u.addMore[0] + u.addMore = u.addMore[1:] + return next, nil +} + +func TestRunInitialEncryptionSetupWithUIReloadsConfig(t *testing.T) { + id, err := age.GenerateX25519Identity() + if err != nil { + t.Fatalf("GenerateX25519Identity: %v", err) + } + + baseDir := t.TempDir() + configPath := filepath.Join(baseDir, "env", "backup.env") + if err := os.MkdirAll(filepath.Dir(configPath), 0o700); err != nil { + t.Fatalf("MkdirAll: %v", err) + } + + content := "BASE_DIR=" + baseDir + "\nENCRYPT_ARCHIVE=true\nAGE_RECIPIENT=" + id.Recipient().String() + "\n" + if err := os.WriteFile(configPath, []byte(content), 0o600); err != nil { + t.Fatalf("WriteFile: %v", err) + } + + result, err := runInitialEncryptionSetupWithUI(context.Background(), configPath, nil) + if err != nil { + t.Fatalf("runInitialEncryptionSetupWithUI error: %v", err) + } + if result == nil || result.Config == nil { + t.Fatalf("expected config result") + } + if len(result.Config.AgeRecipients) != 1 || result.Config.AgeRecipients[0] != id.Recipient().String() { + t.Fatalf("AgeRecipients=%v; want [%s]", result.Config.AgeRecipients, id.Recipient().String()) + } + if !result.ReusedExistingRecipients { + t.Fatalf("expected ReusedExistingRecipients=true") + } + if result.WroteRecipientFile { + t.Fatalf("expected WroteRecipientFile=false") + } + if result.RecipientPath != "" { + t.Fatalf("RecipientPath=%q; want empty for reuse-only result", result.RecipientPath) + } +} + +func TestRunInitialEncryptionSetupWithUIUsesProvidedUI(t *testing.T) { + id, err := age.GenerateX25519Identity() + if err != nil { + t.Fatalf("GenerateX25519Identity: %v", err) + } + + baseDir := t.TempDir() + configPath := filepath.Join(baseDir, "env", "backup.env") + if err := os.MkdirAll(filepath.Dir(configPath), 0o700); err != nil { + t.Fatalf("MkdirAll: %v", err) + } + + content := "BASE_DIR=" + baseDir + "\nENCRYPT_ARCHIVE=true\n" + if err := os.WriteFile(configPath, []byte(content), 0o600); err != nil { + t.Fatalf("WriteFile: %v", err) + } + + ui := &testAgeSetupUI{ + drafts: []*orchestrator.AgeRecipientDraft{ + {Kind: orchestrator.AgeRecipientInputExisting, PublicKey: id.Recipient().String()}, + }, + addMore: []bool{false}, + } + + result, err := runInitialEncryptionSetupWithUI(context.Background(), configPath, ui) + if err != nil { + t.Fatalf("runInitialEncryptionSetupWithUI error: %v", err) + } + + expectedPath := filepath.Join(baseDir, "identity", "age", "recipient.txt") + if result == nil || result.Config == nil { + t.Fatalf("expected setup result with config") + } + if result.RecipientPath != expectedPath { + t.Fatalf("RecipientPath=%q; want %q", result.RecipientPath, expectedPath) + } + if !result.WroteRecipientFile { + t.Fatalf("expected WroteRecipientFile=true") + } + if result.ReusedExistingRecipients { + t.Fatalf("expected ReusedExistingRecipients=false") + } + if _, err := os.Stat(expectedPath); err != nil { + t.Fatalf("expected recipient file at %s: %v", expectedPath, err) + } +} + +func TestRunInitialEncryptionSetupWithUIReusesExistingFileWithoutReportingWrite(t *testing.T) { + id, err := age.GenerateX25519Identity() + if err != nil { + t.Fatalf("GenerateX25519Identity: %v", err) + } + + baseDir := t.TempDir() + recipientPath := filepath.Join(baseDir, "identity", "age", "recipient.txt") + if err := os.MkdirAll(filepath.Dir(recipientPath), 0o700); err != nil { + t.Fatalf("MkdirAll: %v", err) + } + if err := os.WriteFile(recipientPath, []byte(id.Recipient().String()+"\n"), 0o600); err != nil { + t.Fatalf("WriteFile(%s): %v", recipientPath, err) + } + + configPath := filepath.Join(baseDir, "env", "backup.env") + if err := os.MkdirAll(filepath.Dir(configPath), 0o700); err != nil { + t.Fatalf("MkdirAll(%s): %v", filepath.Dir(configPath), err) + } + content := "BASE_DIR=" + baseDir + "\nENCRYPT_ARCHIVE=true\nAGE_RECIPIENT_FILE=" + recipientPath + "\n" + if err := os.WriteFile(configPath, []byte(content), 0o600); err != nil { + t.Fatalf("WriteFile(%s): %v", configPath, err) + } + + result, err := runInitialEncryptionSetupWithUI(context.Background(), configPath, nil) + if err != nil { + t.Fatalf("runInitialEncryptionSetupWithUI error: %v", err) + } + + if result == nil || result.Config == nil { + t.Fatalf("expected setup result with config") + } + if !result.ReusedExistingRecipients { + t.Fatalf("expected ReusedExistingRecipients=true") + } + if result.WroteRecipientFile { + t.Fatalf("expected WroteRecipientFile=false") + } + if result.RecipientPath != "" { + t.Fatalf("RecipientPath=%q; want empty for reuse-only result", result.RecipientPath) + } +} + +func TestRunNewKeySetupKeepsDefaultRecipientPathContract(t *testing.T) { + id, err := age.GenerateX25519Identity() + if err != nil { + t.Fatalf("GenerateX25519Identity: %v", err) + } + + baseDir := t.TempDir() + configPath := filepath.Join(baseDir, "env", "backup.env") + ui := &testAgeSetupUI{ + overwrite: true, + drafts: []*orchestrator.AgeRecipientDraft{ + {Kind: orchestrator.AgeRecipientInputExisting, PublicKey: id.Recipient().String()}, + }, + addMore: []bool{false}, + } + + recipientPath, err := runNewKeySetup(context.Background(), configPath, baseDir, nil, ui) + if err != nil { + t.Fatalf("runNewKeySetup error: %v", err) + } + + target := filepath.Join(baseDir, "identity", "age", "recipient.txt") + if recipientPath != target { + t.Fatalf("recipientPath=%q; want %q", recipientPath, target) + } + content, err := os.ReadFile(target) + if err != nil { + t.Fatalf("ReadFile(%s): %v", target, err) + } + if got := string(content); got != id.Recipient().String()+"\n" { + t.Fatalf("content=%q; want %q", got, id.Recipient().String()+"\n") + } +} + +func TestRunNewKeySetupUsesConfiguredRecipientFile(t *testing.T) { + id, err := age.GenerateX25519Identity() + if err != nil { + t.Fatalf("GenerateX25519Identity: %v", err) + } + + baseDir := t.TempDir() + configPath := filepath.Join(baseDir, "env", "backup.env") + if err := os.MkdirAll(filepath.Dir(configPath), 0o700); err != nil { + t.Fatalf("MkdirAll(%s): %v", filepath.Dir(configPath), err) + } + + customPath := filepath.Join(baseDir, "custom", "recipient.txt") + content := "BASE_DIR=" + baseDir + "\nENCRYPT_ARCHIVE=true\nAGE_RECIPIENT_FILE=" + customPath + "\n" + if err := os.WriteFile(configPath, []byte(content), 0o600); err != nil { + t.Fatalf("WriteFile(%s): %v", configPath, err) + } + + ui := &testAgeSetupUI{ + overwrite: true, + drafts: []*orchestrator.AgeRecipientDraft{ + {Kind: orchestrator.AgeRecipientInputExisting, PublicKey: id.Recipient().String()}, + }, + addMore: []bool{false}, + } + + recipientPath, err := runNewKeySetup(context.Background(), configPath, baseDir, nil, ui) + if err != nil { + t.Fatalf("runNewKeySetup error: %v", err) + } + if recipientPath != customPath { + t.Fatalf("recipientPath=%q; want %q", recipientPath, customPath) + } + + customContent, err := os.ReadFile(customPath) + if err != nil { + t.Fatalf("ReadFile(%s): %v", customPath, err) + } + if got := string(customContent); got != id.Recipient().String()+"\n" { + t.Fatalf("content=%q; want %q", got, id.Recipient().String()+"\n") + } + + defaultPath := filepath.Join(baseDir, "identity", "age", "recipient.txt") + if _, err := os.Stat(defaultPath); !os.IsNotExist(err) { + t.Fatalf("default path %s should not be written, stat err=%v", defaultPath, err) + } +} diff --git a/cmd/proxsave/helpers_test.go b/cmd/proxsave/helpers_test.go index e27d735..dd3490f 100644 --- a/cmd/proxsave/helpers_test.go +++ b/cmd/proxsave/helpers_test.go @@ -411,8 +411,55 @@ func TestInputMapInputError(t *testing.T) { func TestValidateFutureFeatures_SecondaryWithoutPath(t *testing.T) { cfg := &config.Config{SecondaryEnabled: true} - if err := validateFutureFeatures(cfg); err == nil { - t.Error("expected error for secondary enabled without path") + err := validateFutureFeatures(cfg) + if err == nil { + t.Fatal("expected error for secondary enabled without path") + } + if got, want := err.Error(), "SECONDARY_PATH is required when SECONDARY_ENABLED=true"; got != want { + t.Fatalf("validateFutureFeatures error = %q, want %q", got, want) + } +} + +func TestValidateFutureFeatures_SecondaryRejectsRemotePath(t *testing.T) { + cfg := &config.Config{ + SecondaryEnabled: true, + SecondaryPath: "remote:path", + } + + err := validateFutureFeatures(cfg) + if err == nil { + t.Fatal("expected error for remote-style secondary path") + } + if got, want := err.Error(), "SECONDARY_PATH must be an absolute local filesystem path"; got != want { + t.Fatalf("validateFutureFeatures error = %q, want %q", got, want) + } +} + +func TestValidateFutureFeatures_SecondaryAllowsEmptyLogPath(t *testing.T) { + cfg := &config.Config{ + SecondaryEnabled: true, + SecondaryPath: "/backup/secondary", + SecondaryLogPath: "", + } + + if err := validateFutureFeatures(cfg); err != nil { + t.Fatalf("expected empty secondary log path to be allowed, got %v", err) + } +} + +func TestValidateFutureFeatures_SecondaryRejectsInvalidLogPath(t *testing.T) { + cfg := &config.Config{ + SecondaryEnabled: true, + SecondaryPath: "/backup/secondary", + SecondaryLogPath: "remote:/logs", + } + + err := validateFutureFeatures(cfg) + if err == nil { + t.Fatal("expected error for invalid secondary log path") + } + if got, want := err.Error(), "SECONDARY_LOG_PATH must be an absolute local filesystem path"; got != want { + t.Fatalf("validateFutureFeatures error = %q, want %q", got, want) } } diff --git a/cmd/proxsave/install.go b/cmd/proxsave/install.go index 5560623..d6d49a6 100644 --- a/cmd/proxsave/install.go +++ b/cmd/proxsave/install.go @@ -5,7 +5,6 @@ import ( "context" "errors" "fmt" - "io" "os" "os/exec" "path/filepath" @@ -13,15 +12,28 @@ import ( "strings" "github.com/tis24dev/proxsave/internal/config" + cronutil "github.com/tis24dev/proxsave/internal/cron" "github.com/tis24dev/proxsave/internal/identity" "github.com/tis24dev/proxsave/internal/logging" - "github.com/tis24dev/proxsave/internal/notify" - "github.com/tis24dev/proxsave/internal/orchestrator" "github.com/tis24dev/proxsave/internal/tui/wizard" - "github.com/tis24dev/proxsave/internal/types" buildinfo "github.com/tis24dev/proxsave/internal/version" ) +var ( + newInstallEnsureInteractiveStdin = ensureInteractiveStdin + newInstallConfirmCLI = confirmNewInstallCLI + newInstallConfirmTUI = wizard.ConfirmNewInstall + newInstallRunInstall = runInstall + newInstallRunInstallTUI = runInstallTUI + configureCronTimeFunc = configureCronTime +) + +type installConfigResult struct { + EnableEncryption bool + SkipConfigWizard bool + CronSchedule string +} + func runInstall(ctx context.Context, configPath string, bootstrap *logging.BootstrapLogger) (err error) { logging.DebugStepBootstrap(bootstrap, "install workflow (cli)", "resolving configuration path") resolvedPath, err := resolveInstallConfigPath(configPath) @@ -74,11 +86,18 @@ func runInstall(ctx context.Context, configPath string, bootstrap *logging.Boots } logging.DebugStepBootstrap(bootstrap, "install workflow (cli)", "running config wizard") - enableEncryption, skipConfigWizard, err := runConfigWizardCLI(ctx, reader, configPath, tmpConfigPath, baseDir, bootstrap) + configResult, err := runConfigWizardCLI(ctx, reader, configPath, tmpConfigPath, baseDir, bootstrap) if err != nil { return err } - logging.DebugStepBootstrap(bootstrap, "install workflow (cli)", "config wizard done (encryption=%v skip=%v)", enableEncryption, skipConfigWizard) + logging.DebugStepBootstrap( + bootstrap, + "install workflow (cli)", + "config wizard done (encryption=%v skip=%v cron=%s)", + configResult.EnableEncryption, + configResult.SkipConfigWizard, + configResult.CronSchedule, + ) logging.DebugStepBootstrap(bootstrap, "install workflow (cli)", "installing support docs") if err := installSupportDocs(baseDir, bootstrap); err != nil { @@ -86,12 +105,12 @@ func runInstall(ctx context.Context, configPath string, bootstrap *logging.Boots } logging.DebugStepBootstrap(bootstrap, "install workflow (cli)", "running encryption setup if needed") - if err := runEncryptionSetupIfNeeded(ctx, configPath, enableEncryption, skipConfigWizard, bootstrap); err != nil { + if err := runEncryptionSetupIfNeeded(ctx, configPath, configResult.EnableEncryption, configResult.SkipConfigWizard, bootstrap); err != nil { return err } // Optional post-install audit: run a dry-run and offer to disable unused collectors. - if !skipConfigWizard { + if !configResult.SkipConfigWizard { logging.DebugStepBootstrap(bootstrap, "install workflow (cli)", "post-install audit") if err := runPostInstallAuditCLI(ctx, reader, execInfo.ExecPath, configPath, bootstrap); err != nil { return err @@ -106,7 +125,13 @@ func runInstall(ctx context.Context, configPath string, bootstrap *logging.Boots } logging.DebugStepBootstrap(bootstrap, "install workflow (cli)", "finalizing symlinks and cron") - runPostInstallSymlinksAndCron(ctx, baseDir, execInfo, bootstrap) + runPostInstallSymlinksAndCron( + ctx, + baseDir, + execInfo, + bootstrap, + buildInstallCronSchedule(configResult.SkipConfigWizard, configResult.CronSchedule), + ) logging.DebugStepBootstrap(bootstrap, "install workflow (cli)", "detecting telegram identity") telegramCode = detectTelegramCode(baseDir) @@ -125,123 +150,6 @@ func runInstall(ctx context.Context, configPath string, bootstrap *logging.Boots return nil } -func runTelegramSetupCLI(ctx context.Context, reader *bufio.Reader, baseDir, configPath string, bootstrap *logging.BootstrapLogger) error { - cfg, err := config.LoadConfig(configPath) - if err != nil { - if bootstrap != nil { - bootstrap.Warning("Telegram setup: unable to load config (skipping): %v", err) - } - return nil - } - if cfg == nil || !cfg.TelegramEnabled { - return nil - } - - mode := strings.ToLower(strings.TrimSpace(cfg.TelegramBotType)) - if mode == "" { - mode = "centralized" - } - if mode == "personal" { - // No centralized pairing check exists for personal mode. - if bootstrap != nil { - bootstrap.Info("Telegram setup: personal mode selected (no centralized pairing check)") - } - return nil - } - - fmt.Println("\n--- Telegram setup (optional) ---") - fmt.Println("You enabled Telegram notifications (centralized bot).") - - info, idErr := identity.Detect(baseDir, nil) - if idErr != nil { - fmt.Printf("WARNING: Unable to compute server identity (non-blocking): %v\n", idErr) - if bootstrap != nil { - bootstrap.Warning("Telegram setup: identity detection failed (non-blocking): %v", idErr) - } - return nil - } - - serverID := "" - if info != nil { - serverID = strings.TrimSpace(info.ServerID) - } - if serverID == "" { - fmt.Println("WARNING: Server ID unavailable; skipping Telegram setup.") - if bootstrap != nil { - bootstrap.Warning("Telegram setup: server ID unavailable; skipping") - } - return nil - } - - fmt.Printf("Server ID: %s\n", serverID) - if info != nil && strings.TrimSpace(info.IdentityFile) != "" { - fmt.Printf("Identity file: %s\n", strings.TrimSpace(info.IdentityFile)) - } - fmt.Println() - fmt.Println("1) Open Telegram and start @ProxmoxAN_bot") - fmt.Println("2) Send the Server ID above (digits only)") - fmt.Println("3) Verify pairing (recommended)") - fmt.Println() - - check, err := promptYesNo(ctx, reader, "Check Telegram pairing now? [Y/n]: ", true) - if err != nil { - return wrapInstallError(err) - } - if !check { - fmt.Println("Skipped verification. You can verify later by running proxsave.") - if bootstrap != nil { - bootstrap.Info("Telegram setup: verification skipped by user") - } - return nil - } - - serverHost := strings.TrimSpace(cfg.TelegramServerAPIHost) - if serverHost == "" { - serverHost = "https://bot.tis24.it:1443" - } - - attempts := 0 - for { - attempts++ - status := notify.CheckTelegramRegistration(ctx, serverHost, serverID, nil) - if status.Code == 200 && status.Error == nil { - fmt.Println("✓ Telegram linked successfully.") - if bootstrap != nil { - bootstrap.Info("Telegram setup: verified (attempts=%d)", attempts) - } - return nil - } - - msg := strings.TrimSpace(status.Message) - if msg == "" { - msg = "Registration not active yet" - } - fmt.Printf("Telegram: %s\n", msg) - switch status.Code { - case 403, 409: - fmt.Println("Hint: Start the bot, send the Server ID, then retry.") - case 422: - fmt.Println("Hint: The Server ID appears invalid. If this persists, re-run the installer.") - default: - if status.Error != nil { - fmt.Printf("Hint: Check failed: %v\n", status.Error) - } - } - - retry, err := promptYesNo(ctx, reader, "Check again? [y/N]: ", false) - if err != nil { - return wrapInstallError(err) - } - if !retry { - fmt.Println("Verification not completed. You can retry later by running proxsave.") - if bootstrap != nil { - bootstrap.Info("Telegram setup: not verified (attempts=%d last=%d %s)", attempts, status.Code, msg) - } - return nil - } - } -} - func runPostInstallAuditCLI(ctx context.Context, reader *bufio.Reader, execPath, configPath string, bootstrap *logging.BootstrapLogger) error { fmt.Println("\n--- Post-install check (optional) ---") run, err := promptYesNo(ctx, reader, "Run a dry-run to detect unused components and reduce warnings? [Y/n]: ", true) @@ -361,25 +269,25 @@ func runPostInstallAuditCLI(ctx context.Context, reader *bufio.Reader, execPath, func runNewInstall(ctx context.Context, configPath string, bootstrap *logging.BootstrapLogger, useCLI bool) (err error) { done := logging.DebugStartBootstrap(bootstrap, "new-install workflow", "config=%s", configPath) defer func() { done(err) }() - resolvedPath, err := resolveInstallConfigPath(configPath) - if err != nil { - return err - } - - baseDir := deriveBaseDirFromConfig(resolvedPath) logging.DebugStepBootstrap(bootstrap, "new-install workflow", "ensuring interactive stdin") - if err := ensureInteractiveStdin(); err != nil { + if err := newInstallEnsureInteractiveStdin(); err != nil { return err } - buildSig := buildSignature() - if strings.TrimSpace(buildSig) == "" { - buildSig = "n/a" + logging.DebugStepBootstrap(bootstrap, "new-install workflow", "building reset plan") + plan, err := buildNewInstallPlan(configPath) + if err != nil { + return err } logging.DebugStepBootstrap(bootstrap, "new-install workflow", "confirming reset") - confirm, err := wizard.ConfirmNewInstall(baseDir, buildSig) + var confirm bool + if useCLI { + confirm, err = newInstallConfirmCLI(ctx, bufio.NewReader(os.Stdin), plan) + } else { + confirm, err = newInstallConfirmTUI(plan.BaseDir, plan.BuildSignature, plan.PreservedEntries) + } if err != nil { return wrapInstallError(err) } @@ -387,16 +295,18 @@ func runNewInstall(ctx context.Context, configPath string, bootstrap *logging.Bo return wrapInstallError(errInteractiveAborted) } - bootstrap.Info("Resetting %s (preserving env/ and identity/)", baseDir) + if bootstrap != nil { + bootstrap.Info("Resetting %s (preserving %s)", plan.BaseDir, formatNewInstallPreservedEntries(plan.PreservedEntries)) + } logging.DebugStepBootstrap(bootstrap, "new-install workflow", "resetting base dir") - if err := resetInstallBaseDir(baseDir, bootstrap); err != nil { + if err := resetInstallBaseDir(plan.BaseDir, bootstrap); err != nil { return err } if useCLI { - return runInstall(ctx, resolvedPath, bootstrap) + return newInstallRunInstall(ctx, plan.ResolvedConfigPath, bootstrap) } - return runInstallTUI(ctx, resolvedPath, bootstrap) + return newInstallRunInstallTUI(ctx, plan.ResolvedConfigPath, bootstrap) } func printInstallFooter(installErr error, configPath, baseDir, telegramCode, permStatus, permMessage string) { @@ -472,7 +382,7 @@ func printInstallFooter(installErr error, configPath, baseDir, telegramCode, per fmt.Println(" --help - Show all options") fmt.Println(" --dry-run - Test without changes") fmt.Println(" --install - Re-run interactive installation/setup") - fmt.Println(" --new-install - Wipe installation directory (keep env/identity) then run installer") + fmt.Println(" --new-install - Wipe installation directory (keep build/env/identity) then run installer") fmt.Println(" --upgrade - Update proxsave binary to latest release (also adds missing keys to backup.env)") fmt.Println(" --newkey - Generate a new encryption key for backups") fmt.Println(" --decrypt - Decrypt an existing backup archive") @@ -537,53 +447,65 @@ func handleLegacyInstall(ctx context.Context, reader *bufio.Reader, baseDir stri return nil } -func runConfigWizardCLI(ctx context.Context, reader *bufio.Reader, configPath, tmpConfigPath, baseDir string, bootstrap *logging.BootstrapLogger) (enableEncryption bool, skipConfigWizard bool, err error) { +func runConfigWizardCLI(ctx context.Context, reader *bufio.Reader, configPath, tmpConfigPath, baseDir string, bootstrap *logging.BootstrapLogger) (result installConfigResult, err error) { done := logging.DebugStartBootstrap(bootstrap, "install config wizard (cli)", "config=%s", configPath) defer func() { done(err) }() logging.DebugStepBootstrap(bootstrap, "install config wizard (cli)", "preparing base template") template, skipConfigWizard, err := prepareBaseTemplate(ctx, reader, configPath) if err != nil { - return false, false, wrapInstallError(err) + return installConfigResult{}, wrapInstallError(err) } if skipConfigWizard { - return false, true, nil + return installConfigResult{SkipConfigWizard: true}, nil } logging.DebugStepBootstrap(bootstrap, "install config wizard (cli)", "configuring secondary storage") if template, err = configureSecondaryStorage(ctx, reader, template); err != nil { - return false, false, wrapInstallError(err) + return installConfigResult{}, wrapInstallError(err) } logging.DebugStepBootstrap(bootstrap, "install config wizard (cli)", "configuring cloud storage") if template, err = configureCloudStorage(ctx, reader, template); err != nil { - return false, false, wrapInstallError(err) + return installConfigResult{}, wrapInstallError(err) } logging.DebugStepBootstrap(bootstrap, "install config wizard (cli)", "configuring firewall rules") if template, err = configureFirewallRules(ctx, reader, template); err != nil { - return false, false, wrapInstallError(err) + return installConfigResult{}, wrapInstallError(err) } logging.DebugStepBootstrap(bootstrap, "install config wizard (cli)", "configuring notifications") if template, err = configureNotifications(ctx, reader, template); err != nil { - return false, false, wrapInstallError(err) + return installConfigResult{}, wrapInstallError(err) } logging.DebugStepBootstrap(bootstrap, "install config wizard (cli)", "configuring encryption") - enableEncryption, err = configureEncryption(ctx, reader, &template) + result.EnableEncryption, err = configureEncryption(ctx, reader, &template) if err != nil { - return false, false, wrapInstallError(err) + return installConfigResult{}, wrapInstallError(err) + } + + logging.DebugStepBootstrap(bootstrap, "install config wizard (cli)", "configuring cron time") + cronTime, err := configureCronTimeFunc(ctx, reader, cronutil.DefaultTime) + if err != nil { + return installConfigResult{}, wrapInstallError(err) + } + result.CronSchedule = cronutil.TimeToSchedule(cronTime) + result.SkipConfigWizard = false + + if bootstrap != nil { + bootstrap.Info("Cron schedule selected: %s", cronTime) } logging.DebugStepBootstrap(bootstrap, "install config wizard (cli)", "writing configuration") if err := writeConfigFile(configPath, tmpConfigPath, template); err != nil { - return false, false, err + return installConfigResult{}, err } if bootstrap != nil { bootstrap.Info("✓ Configuration saved at %s", configPath) } - return enableEncryption, false, nil + return result, nil } func runEncryptionSetupIfNeeded(ctx context.Context, configPath string, enableEncryption, skipConfigWizard bool, bootstrap *logging.BootstrapLogger) (err error) { @@ -605,7 +527,7 @@ func runEncryptionSetupIfNeeded(ctx context.Context, configPath string, enableEn return nil } -func runPostInstallSymlinksAndCron(ctx context.Context, baseDir string, execInfo ExecInfo, bootstrap *logging.BootstrapLogger) { +func runPostInstallSymlinksAndCron(ctx context.Context, baseDir string, execInfo ExecInfo, bootstrap *logging.BootstrapLogger, cronSchedule string) { done := logging.DebugStartBootstrap(bootstrap, "post-install setup", "base=%s", baseDir) defer func() { done(nil) }() // Clean up legacy bash-based symlinks that point to the old installer scripts. @@ -624,7 +546,9 @@ func runPostInstallSymlinksAndCron(ctx context.Context, baseDir string, execInfo // Migrate legacy cron entries pointing to the bash script to the Go binary. // If no cron entry exists at all, create a default one at 02:00 every day. - cronSchedule := resolveCronSchedule(nil) + if strings.TrimSpace(cronSchedule) == "" { + cronSchedule = resolveCronScheduleFromEnv() + } logging.DebugStepBootstrap(bootstrap, "post-install setup", "migrating cron entries") migrateLegacyCronEntries(ctx, baseDir, execInfo.ExecPath, bootstrap, cronSchedule) } @@ -655,11 +579,7 @@ func resetInstallBaseDir(baseDir string, bootstrap *logging.BootstrapLogger) (er return fmt.Errorf("failed to list base directory %s: %w", baseDir, err) } - preserve := map[string]struct{}{ - "env": {}, - "identity": {}, - "build": {}, - } + preserve := newInstallPreserveSet() for _, entry := range entries { name := entry.Name() @@ -700,22 +620,18 @@ func printInstallBanner(configPath string) { } func prepareBaseTemplate(ctx context.Context, reader *bufio.Reader, configPath string) (string, bool, error) { - if info, err := os.Stat(configPath); err == nil { - if info.Mode().IsRegular() { - overwrite, err := promptYesNo(ctx, reader, fmt.Sprintf("%s already exists. Overwrite? [y/N]: ", configPath), false) - if err != nil { - return "", false, err - } - if !overwrite { - fmt.Println("Existing configuration detected, keeping current backup.env and skipping configuration wizard.") - return "", true, nil - } - } - } else if !os.IsNotExist(err) { - return "", false, fmt.Errorf("failed to access configuration file: %w", err) + decision, err := prepareExistingConfigDecisionCLI(ctx, reader, configPath) + if err != nil { + return "", false, err } - - return config.DefaultEnvTemplate(), false, nil + if decision.AbortInstall { + return "", false, errInteractiveAborted + } + if decision.SkipConfigWizard { + fmt.Println("Existing configuration detected, keeping current backup.env and skipping configuration wizard.") + return "", true, nil + } + return decision.BaseTemplate, false, nil } func configureSecondaryStorage(ctx context.Context, reader *bufio.Reader, template string) (string, error) { @@ -730,23 +646,35 @@ func configureSecondaryStorage(ctx context.Context, reader *bufio.Reader, templa return "", err } if enableSecondary { - secondaryPath, err := promptNonEmpty(ctx, reader, "Secondary backup path (SECONDARY_PATH): ") - if err != nil { - return "", err + var secondaryPath string + for { + secondaryPath, err = promptNonEmpty(ctx, reader, "Secondary backup path (SECONDARY_PATH): ") + if err != nil { + return "", err + } + secondaryPath = sanitizeEnvValue(secondaryPath) + if err := config.ValidateRequiredSecondaryPath(secondaryPath); err != nil { + fmt.Printf("%v\n", err) + continue + } + break } - secondaryPath = sanitizeEnvValue(secondaryPath) - secondaryLog, err := promptNonEmpty(ctx, reader, "Secondary log path (SECONDARY_LOG_PATH): ") - if err != nil { - return "", err + var secondaryLog string + for { + secondaryLog, err = promptOptional(ctx, reader, "Secondary log path (SECONDARY_LOG_PATH, optional - press Enter to skip): ") + if err != nil { + return "", err + } + secondaryLog = sanitizeEnvValue(secondaryLog) + if err := config.ValidateOptionalSecondaryLogPath(secondaryLog); err != nil { + fmt.Printf("%v\n", err) + continue + } + break } - secondaryLog = sanitizeEnvValue(secondaryLog) - template = setEnvValue(template, "SECONDARY_ENABLED", "true") - template = setEnvValue(template, "SECONDARY_PATH", secondaryPath) - template = setEnvValue(template, "SECONDARY_LOG_PATH", secondaryLog) + template = config.ApplySecondaryStorageSettings(template, true, secondaryPath, secondaryLog) } else { - template = setEnvValue(template, "SECONDARY_ENABLED", "false") - template = setEnvValue(template, "SECONDARY_PATH", "") - template = setEnvValue(template, "SECONDARY_LOG_PATH", "") + template = config.ApplySecondaryStorageSettings(template, false, "", "") } return template, nil } @@ -838,6 +766,22 @@ func configureEncryption(ctx context.Context, reader *bufio.Reader, template *st return enableEncryption, nil } +func configureCronTime(ctx context.Context, reader *bufio.Reader, defaultCron string) (string, error) { + fmt.Println("\n--- Schedule ---") + for { + cronTime, err := promptOptional(ctx, reader, fmt.Sprintf("Cron time for daily proxsave job (HH:MM) [%s]: ", defaultCron)) + if err != nil { + return "", err + } + normalized, err := cronutil.NormalizeTime(cronTime, defaultCron) + if err != nil { + fmt.Printf("%v\n", err) + continue + } + return normalized, nil + } +} + func writeConfigFile(configPath, tmpConfigPath, content string) error { dir := filepath.Dir(configPath) if err := os.MkdirAll(dir, 0o700); err != nil { @@ -852,25 +796,6 @@ func writeConfigFile(configPath, tmpConfigPath, content string) error { return nil } -func runInitialEncryptionSetup(ctx context.Context, configPath string) error { - cfg, err := config.LoadConfig(configPath) - if err != nil { - return fmt.Errorf("failed to reload configuration after install: %w", err) - } - logger := logging.New(types.LogLevelError, false) - logger.SetOutput(io.Discard) - orch := orchestrator.New(logger, false) - orch.SetConfig(cfg) - if err := orch.EnsureAgeRecipientsReady(ctx); err != nil { - if errors.Is(err, orchestrator.ErrAgeRecipientSetupAborted) { - // Treat AGE wizard abort as an interactive abort for install UX - return fmt.Errorf("encryption setup aborted by user: %w", errInteractiveAborted) - } - return fmt.Errorf("encryption setup failed: %w", err) - } - return nil -} - func wrapInstallError(err error) error { if err == nil { return nil diff --git a/cmd/proxsave/install_existing_config.go b/cmd/proxsave/install_existing_config.go new file mode 100644 index 0000000..a243d1f --- /dev/null +++ b/cmd/proxsave/install_existing_config.go @@ -0,0 +1,110 @@ +package main + +import ( + "bufio" + "context" + "fmt" + "os" + "strings" + + "github.com/tis24dev/proxsave/internal/config" +) + +type existingConfigMode int + +const ( + existingConfigOverwrite existingConfigMode = iota + existingConfigEdit + existingConfigKeepContinue + existingConfigCancel +) + +type existingConfigDecision struct { + BaseTemplate string + SkipConfigWizard bool + AbortInstall bool +} + +func promptExistingConfigModeCLI(ctx context.Context, reader *bufio.Reader, configPath string) (existingConfigMode, error) { + info, err := os.Stat(configPath) + if err != nil { + if os.IsNotExist(err) { + return existingConfigOverwrite, nil + } + return existingConfigCancel, fmt.Errorf("failed to access configuration file: %w", err) + } + if !info.Mode().IsRegular() { + return existingConfigCancel, fmt.Errorf("configuration file path is not a regular file: %s", configPath) + } + + fmt.Printf("%s already exists.\n", configPath) + fmt.Println("Choose how to proceed:") + fmt.Println(" [1] Overwrite (start from embedded template)") + fmt.Println(" [2] Edit existing (use current file as base)") + fmt.Println(" [3] Keep existing & continue (skip configuration wizard)") + fmt.Println(" [0] Cancel installation") + + for { + choice, err := promptOptional(ctx, reader, "Choice [3]: ") + if err != nil { + return existingConfigCancel, err + } + switch strings.TrimSpace(choice) { + case "": + fallthrough + case "3": + return existingConfigKeepContinue, nil + case "1": + return existingConfigOverwrite, nil + case "2": + return existingConfigEdit, nil + case "0": + return existingConfigCancel, nil + default: + fmt.Println("Please enter 1, 2, 3 or 0.") + } + } +} + +func resolveExistingConfigDecision(mode existingConfigMode, configPath string) (existingConfigDecision, error) { + switch mode { + case existingConfigOverwrite: + return existingConfigDecision{ + BaseTemplate: config.DefaultEnvTemplate(), + SkipConfigWizard: false, + AbortInstall: false, + }, nil + case existingConfigEdit: + content, err := os.ReadFile(configPath) + if err != nil { + return existingConfigDecision{}, fmt.Errorf("read existing configuration: %w", err) + } + return existingConfigDecision{ + BaseTemplate: string(content), + SkipConfigWizard: false, + AbortInstall: false, + }, nil + case existingConfigKeepContinue: + return existingConfigDecision{ + BaseTemplate: "", + SkipConfigWizard: true, + AbortInstall: false, + }, nil + case existingConfigCancel: + return existingConfigDecision{ + BaseTemplate: "", + SkipConfigWizard: false, + AbortInstall: true, + }, nil + default: + return existingConfigDecision{}, fmt.Errorf("unsupported existing configuration mode: %d", mode) + } +} + +func prepareExistingConfigDecisionCLI(ctx context.Context, reader *bufio.Reader, configPath string) (existingConfigDecision, error) { + mode, err := promptExistingConfigModeCLI(ctx, reader, configPath) + if err != nil { + return existingConfigDecision{}, err + } + return resolveExistingConfigDecision(mode, configPath) +} diff --git a/cmd/proxsave/install_existing_config_test.go b/cmd/proxsave/install_existing_config_test.go new file mode 100644 index 0000000..8de7a58 --- /dev/null +++ b/cmd/proxsave/install_existing_config_test.go @@ -0,0 +1,170 @@ +package main + +import ( + "bufio" + "context" + "errors" + "os" + "path/filepath" + "strings" + "testing" +) + +func TestPromptExistingConfigModeCLIMissingFileDefaultsToOverwrite(t *testing.T) { + missing := filepath.Join(t.TempDir(), "missing.env") + mode, err := promptExistingConfigModeCLI(context.Background(), bufio.NewReader(strings.NewReader("")), missing) + if err != nil { + t.Fatalf("promptExistingConfigModeCLI error: %v", err) + } + if mode != existingConfigOverwrite { + t.Fatalf("expected overwrite mode, got %v", mode) + } +} + +func TestPromptExistingConfigModeCLIOptions(t *testing.T) { + cfgFile := createTempFile(t, "EXISTING=1\n") + tests := []struct { + name string + input string + want existingConfigMode + }{ + {name: "default keep continue", input: "\n", want: existingConfigKeepContinue}, + {name: "overwrite", input: "1\n", want: existingConfigOverwrite}, + {name: "edit", input: "2\n", want: existingConfigEdit}, + {name: "keep continue", input: "3\n", want: existingConfigKeepContinue}, + {name: "cancel", input: "0\n", want: existingConfigCancel}, + {name: "invalid then overwrite", input: "x\n1\n", want: existingConfigOverwrite}, + } + + for _, tc := range tests { + tc := tc + t.Run(tc.name, func(t *testing.T) { + reader := bufio.NewReader(strings.NewReader(tc.input)) + var mode existingConfigMode + var err error + captureStdout(t, func() { + mode, err = promptExistingConfigModeCLI(context.Background(), reader, cfgFile) + }) + if err != nil { + t.Fatalf("promptExistingConfigModeCLI error: %v", err) + } + if mode != tc.want { + t.Fatalf("mode = %v, want %v", mode, tc.want) + } + }) + } +} + +func TestResolveExistingConfigDecision(t *testing.T) { + cfgFile := createTempFile(t, "EXISTING=1\n") + + overwrite, err := resolveExistingConfigDecision(existingConfigOverwrite, cfgFile) + if err != nil { + t.Fatalf("overwrite decision error: %v", err) + } + if overwrite.SkipConfigWizard || overwrite.AbortInstall { + t.Fatalf("overwrite decision flags are invalid: %+v", overwrite) + } + if strings.TrimSpace(overwrite.BaseTemplate) == "" { + t.Fatalf("overwrite base template should not be empty") + } + + edit, err := resolveExistingConfigDecision(existingConfigEdit, cfgFile) + if err != nil { + t.Fatalf("edit decision error: %v", err) + } + if edit.SkipConfigWizard || edit.AbortInstall { + t.Fatalf("edit decision flags are invalid: %+v", edit) + } + if !strings.Contains(edit.BaseTemplate, "EXISTING=1") { + t.Fatalf("expected existing content, got %q", edit.BaseTemplate) + } + + keep, err := resolveExistingConfigDecision(existingConfigKeepContinue, cfgFile) + if err != nil { + t.Fatalf("keep decision error: %v", err) + } + if !keep.SkipConfigWizard || keep.AbortInstall { + t.Fatalf("keep decision flags are invalid: %+v", keep) + } + + cancel, err := resolveExistingConfigDecision(existingConfigCancel, cfgFile) + if err != nil { + t.Fatalf("cancel decision error: %v", err) + } + if cancel.SkipConfigWizard || !cancel.AbortInstall { + t.Fatalf("cancel decision flags are invalid: %+v", cancel) + } +} + +func TestPrepareExistingConfigDecisionCLICancel(t *testing.T) { + cfgFile := createTempFile(t, "EXISTING=1\n") + reader := bufio.NewReader(strings.NewReader("0\n")) + decision, err := prepareExistingConfigDecisionCLI(context.Background(), reader, cfgFile) + if err != nil { + t.Fatalf("prepareExistingConfigDecisionCLI error: %v", err) + } + if !decision.AbortInstall { + t.Fatalf("expected abort decision, got %+v", decision) + } +} + +func TestResolveExistingConfigDecisionEditReadError(t *testing.T) { + cfgFile := filepath.Join(t.TempDir(), "missing.env") + _, err := resolveExistingConfigDecision(existingConfigEdit, cfgFile) + if err == nil { + t.Fatalf("expected read error for missing file") + } +} + +func TestPromptExistingConfigModeCLIPropagatesReadError(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + cancel() + cfgFile := createTempFile(t, "EXISTING=1\n") + _, err := promptExistingConfigModeCLI(ctx, bufio.NewReader(strings.NewReader("1\n")), cfgFile) + if !errors.Is(err, errInteractiveAborted) { + t.Fatalf("expected interactive aborted error, got %v", err) + } +} + +func TestPromptExistingConfigModeCLINonRegularFile(t *testing.T) { + dirPath := t.TempDir() + _, err := promptExistingConfigModeCLI(context.Background(), bufio.NewReader(strings.NewReader("1\n")), dirPath) + if err == nil { + t.Fatalf("expected error for non-regular file") + } + if !strings.Contains(err.Error(), "not a regular file") { + t.Fatalf("unexpected error message: %v", err) + } +} + +func TestResolveExistingConfigDecisionUnsupportedMode(t *testing.T) { + cfgFile := createTempFile(t, "EXISTING=1\n") + _, err := resolveExistingConfigDecision(existingConfigMode(99), cfgFile) + if err == nil { + t.Fatalf("expected unsupported mode error") + } +} + +func TestPromptExistingConfigModeCLIStatError(t *testing.T) { + pathWithNul := string([]byte{0}) + _, err := promptExistingConfigModeCLI(context.Background(), bufio.NewReader(strings.NewReader("1\n")), pathWithNul) + if err == nil { + t.Fatalf("expected stat error") + } +} + +func TestResolveExistingConfigDecisionEditExistingContentExact(t *testing.T) { + cfg := filepath.Join(t.TempDir(), "backup.env") + content := "KEY=VALUE\nANOTHER=1\n" + if err := os.WriteFile(cfg, []byte(content), 0o600); err != nil { + t.Fatalf("write config: %v", err) + } + decision, err := resolveExistingConfigDecision(existingConfigEdit, cfg) + if err != nil { + t.Fatalf("resolveExistingConfigDecision error: %v", err) + } + if decision.BaseTemplate != content { + t.Fatalf("expected exact content, got %q", decision.BaseTemplate) + } +} diff --git a/cmd/proxsave/install_test.go b/cmd/proxsave/install_test.go index fc9b835..4f3f83d 100644 --- a/cmd/proxsave/install_test.go +++ b/cmd/proxsave/install_test.go @@ -9,6 +9,7 @@ import ( "strings" "testing" + cronutil "github.com/tis24dev/proxsave/internal/cron" "github.com/tis24dev/proxsave/internal/logging" ) @@ -105,7 +106,7 @@ func TestIsInstallAbortedError(t *testing.T) { } } -func TestResetInstallBaseDirPreservesEnvAndIdentity(t *testing.T) { +func TestResetInstallBaseDirPreservesCoreDirectories(t *testing.T) { base := t.TempDir() // setup contents @@ -134,6 +135,15 @@ func TestResetInstallBaseDirPreservesEnvAndIdentity(t *testing.T) { t.Fatalf("setup identity file: %v", err) } + buildDir := filepath.Join(base, "build") + if err := os.Mkdir(buildDir, 0o755); err != nil { + t.Fatalf("setup build: %v", err) + } + buildFile := filepath.Join(buildDir, "keep.txt") + if err := os.WriteFile(buildFile, []byte("build"), 0o600); err != nil { + t.Fatalf("setup build file: %v", err) + } + logger := logging.NewBootstrapLogger() if err := resetInstallBaseDir(base, logger); err != nil { t.Fatalf("resetInstallBaseDir returned error: %v", err) @@ -157,6 +167,44 @@ func TestResetInstallBaseDirPreservesEnvAndIdentity(t *testing.T) { if _, err := os.Stat(idFile); err != nil { t.Fatalf("identity file should remain: %v", err) } + if _, err := os.Stat(buildDir); err != nil { + t.Fatalf("build dir should remain: %v", err) + } + if _, err := os.Stat(buildFile); err != nil { + t.Fatalf("build file should remain: %v", err) + } +} + +func TestResetInstallBaseDirRespectsSharedPreserveSet(t *testing.T) { + base := t.TempDir() + for _, entry := range newInstallPreservedEntries() { + dirPath := filepath.Join(base, entry) + if err := os.MkdirAll(dirPath, 0o755); err != nil { + t.Fatalf("setup %s: %v", entry, err) + } + filePath := filepath.Join(dirPath, "keep.txt") + if err := os.WriteFile(filePath, []byte(entry), 0o600); err != nil { + t.Fatalf("setup %s file: %v", entry, err) + } + } + if err := os.WriteFile(filepath.Join(base, "drop.txt"), []byte("drop"), 0o600); err != nil { + t.Fatalf("setup drop file: %v", err) + } + + logger := logging.NewBootstrapLogger() + if err := resetInstallBaseDir(base, logger); err != nil { + t.Fatalf("resetInstallBaseDir returned error: %v", err) + } + + for _, entry := range newInstallPreservedEntries() { + filePath := filepath.Join(base, entry, "keep.txt") + if _, err := os.Stat(filePath); err != nil { + t.Fatalf("expected preserved file for %s, got %v", entry, err) + } + } + if _, err := os.Stat(filepath.Join(base, "drop.txt")); !os.IsNotExist(err) { + t.Fatalf("expected drop.txt removed, got err=%v", err) + } } func TestResetInstallBaseDirRefusesRoot(t *testing.T) { @@ -168,7 +216,7 @@ func TestResetInstallBaseDirRefusesRoot(t *testing.T) { func TestPrepareBaseTemplateExistingSkip(t *testing.T) { cfgFile := createTempFile(t, "existing config") - reader := bufio.NewReader(strings.NewReader("n\n")) + reader := bufio.NewReader(strings.NewReader("3\n")) var tmpl string var skip bool var err error @@ -188,7 +236,7 @@ func TestPrepareBaseTemplateExistingSkip(t *testing.T) { func TestPrepareBaseTemplateOverwrite(t *testing.T) { cfgFile := createTempFile(t, "old") - reader := bufio.NewReader(strings.NewReader("y\n")) + reader := bufio.NewReader(strings.NewReader("1\n")) var tmpl string var skip bool var err error @@ -206,6 +254,35 @@ func TestPrepareBaseTemplateOverwrite(t *testing.T) { } } +func TestPrepareBaseTemplateEditExisting(t *testing.T) { + cfgFile := createTempFile(t, "EXISTING=1\n") + reader := bufio.NewReader(strings.NewReader("2\n")) + var tmpl string + var skip bool + var err error + captureStdout(t, func() { + tmpl, skip, err = prepareBaseTemplate(context.Background(), reader, cfgFile) + }) + if err != nil { + t.Fatalf("prepareBaseTemplate error: %v", err) + } + if skip { + t.Fatalf("expected skip=false for edit existing") + } + if !strings.Contains(tmpl, "EXISTING=1") { + t.Fatalf("expected existing template content, got %q", tmpl) + } +} + +func TestPrepareBaseTemplateCancel(t *testing.T) { + cfgFile := createTempFile(t, "EXISTING=1\n") + reader := bufio.NewReader(strings.NewReader("0\n")) + _, _, err := prepareBaseTemplate(context.Background(), reader, cfgFile) + if !errors.Is(err, errInteractiveAborted) { + t.Fatalf("expected interactive abort, got %v", err) + } +} + func TestConfigureSecondaryStorageEnabled(t *testing.T) { var result string var err error @@ -228,6 +305,60 @@ func TestConfigureSecondaryStorageEnabled(t *testing.T) { } } +func TestConfigureSecondaryStorageEnabledWithEmptyLogPath(t *testing.T) { + var result string + var err error + ctx := context.Background() + reader := bufio.NewReader(strings.NewReader("y\n/mnt/secondary\n\n")) + captureStdout(t, func() { + result, err = configureSecondaryStorage(ctx, reader, "") + }) + if err != nil { + t.Fatalf("configureSecondaryStorage error: %v", err) + } + if !strings.Contains(result, "SECONDARY_ENABLED=true") { + t.Fatalf("expected SECONDARY_ENABLED=true in template: %q", result) + } + if !strings.Contains(result, "SECONDARY_PATH=/mnt/secondary") { + t.Fatalf("expected secondary path in template: %q", result) + } + if !strings.Contains(result, "SECONDARY_LOG_PATH=") { + t.Fatalf("expected empty secondary log path in template: %q", result) + } +} + +func TestConfigureSecondaryStorageRejectsInvalidBackupPath(t *testing.T) { + var result string + var err error + ctx := context.Background() + reader := bufio.NewReader(strings.NewReader("y\nrelative/path\n/mnt/secondary\n\n")) + captureStdout(t, func() { + result, err = configureSecondaryStorage(ctx, reader, "") + }) + if err != nil { + t.Fatalf("configureSecondaryStorage error: %v", err) + } + if !strings.Contains(result, "SECONDARY_PATH=/mnt/secondary") { + t.Fatalf("expected corrected secondary path in template: %q", result) + } +} + +func TestConfigureSecondaryStorageRejectsInvalidLogPath(t *testing.T) { + var result string + var err error + ctx := context.Background() + reader := bufio.NewReader(strings.NewReader("y\n/mnt/secondary\nremote:/logs\n\n")) + captureStdout(t, func() { + result, err = configureSecondaryStorage(ctx, reader, "") + }) + if err != nil { + t.Fatalf("configureSecondaryStorage error: %v", err) + } + if !strings.Contains(result, "SECONDARY_LOG_PATH=") { + t.Fatalf("expected empty secondary log path in template: %q", result) + } +} + func TestConfigureSecondaryStorageDisabled(t *testing.T) { var result string var err error @@ -242,6 +373,38 @@ func TestConfigureSecondaryStorageDisabled(t *testing.T) { if !strings.Contains(result, "SECONDARY_ENABLED=false") { t.Fatalf("expected disabled flag in template: %q", result) } + if !strings.Contains(result, "SECONDARY_PATH=") { + t.Fatalf("expected cleared secondary path in template: %q", result) + } + if !strings.Contains(result, "SECONDARY_LOG_PATH=") { + t.Fatalf("expected cleared secondary log path in template: %q", result) + } +} + +func TestConfigureSecondaryStorageDisabledClearsExistingValues(t *testing.T) { + var result string + var err error + ctx := context.Background() + reader := bufio.NewReader(strings.NewReader("n\n")) + template := "SECONDARY_ENABLED=true\nSECONDARY_PATH=/mnt/old-secondary\nSECONDARY_LOG_PATH=/mnt/old-secondary/logs\n" + captureStdout(t, func() { + result, err = configureSecondaryStorage(ctx, reader, template) + }) + if err != nil { + t.Fatalf("configureSecondaryStorage error: %v", err) + } + for _, needle := range []string{ + "SECONDARY_ENABLED=false", + "SECONDARY_PATH=", + "SECONDARY_LOG_PATH=", + } { + if !strings.Contains(result, needle) { + t.Fatalf("expected %q in template: %q", needle, result) + } + } + if strings.Contains(result, "/mnt/old-secondary") { + t.Fatalf("expected old secondary values to be cleared: %q", result) + } } func TestConfigureCloudStorageEnabled(t *testing.T) { @@ -367,6 +530,130 @@ func TestConfigureEncryption(t *testing.T) { } } +func TestConfigureCronTime(t *testing.T) { + t.Run("empty input uses default", func(t *testing.T) { + var cronTime string + var err error + reader := bufio.NewReader(strings.NewReader("\n")) + captureStdout(t, func() { + cronTime, err = configureCronTime(context.Background(), reader, cronutil.DefaultTime) + }) + if err != nil { + t.Fatalf("configureCronTime returned error: %v", err) + } + if cronTime != cronutil.DefaultTime { + t.Fatalf("configureCronTime default = %q, want %q", cronTime, cronutil.DefaultTime) + } + }) + + t.Run("invalid input re-prompts until valid", func(t *testing.T) { + var cronTime string + var err error + reader := bufio.NewReader(strings.NewReader("24:00\n3:7\n")) + output := captureStdout(t, func() { + cronTime, err = configureCronTime(context.Background(), reader, cronutil.DefaultTime) + }) + if err != nil { + t.Fatalf("configureCronTime returned error: %v", err) + } + if cronTime != "03:07" { + t.Fatalf("configureCronTime normalized = %q, want %q", cronTime, "03:07") + } + if !strings.Contains(output, "cron hour must be between 00 and 23") { + t.Fatalf("expected validation error in output, got %q", output) + } + }) + + t.Run("aborted input returns sentinel", func(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + cancel() + reader := bufio.NewReader(strings.NewReader("03:15\n")) + _, err := configureCronTime(ctx, reader, cronutil.DefaultTime) + if !errors.Is(err, errInteractiveAborted) { + t.Fatalf("expected errInteractiveAborted, got %v", err) + } + }) +} + +func TestRunConfigWizardCLIReturnsCronSchedule(t *testing.T) { + cfgDir := t.TempDir() + configPath := filepath.Join(cfgDir, "env", "backup.env") + tmpConfigPath := configPath + ".tmp" + reader := bufio.NewReader(strings.NewReader("n\nn\nn\nn\nn\nn\n03:15\n")) + + var result installConfigResult + var err error + captureStdout(t, func() { + result, err = runConfigWizardCLI(context.Background(), reader, configPath, tmpConfigPath, "/opt/proxsave", nil) + }) + if err != nil { + t.Fatalf("runConfigWizardCLI returned error: %v", err) + } + if result.SkipConfigWizard { + t.Fatal("expected SkipConfigWizard=false") + } + if result.EnableEncryption { + t.Fatal("expected EnableEncryption=false") + } + if result.CronSchedule != "15 03 * * *" { + t.Fatalf("CronSchedule = %q, want %q", result.CronSchedule, "15 03 * * *") + } + + content, readErr := os.ReadFile(configPath) + if readErr != nil { + t.Fatalf("expected config file to be written: %v", readErr) + } + if !strings.Contains(string(content), "ENCRYPT_ARCHIVE=false") { + t.Fatalf("expected config content to be written, got %q", string(content)) + } +} + +func TestRunConfigWizardCLISkipLeavesCronScheduleEmpty(t *testing.T) { + cfgFile := createTempFile(t, "EXISTING=1\n") + tmpConfigPath := cfgFile + ".tmp" + reader := bufio.NewReader(strings.NewReader("3\n")) + + var result installConfigResult + var err error + captureStdout(t, func() { + result, err = runConfigWizardCLI(context.Background(), reader, cfgFile, tmpConfigPath, "/opt/proxsave", nil) + }) + if err != nil { + t.Fatalf("runConfigWizardCLI returned error: %v", err) + } + if !result.SkipConfigWizard { + t.Fatal("expected SkipConfigWizard=true") + } + if result.CronSchedule != "" { + t.Fatalf("expected empty CronSchedule when skipping wizard, got %q", result.CronSchedule) + } +} + +func TestRunConfigWizardCLIAbortAtCronPromptDoesNotWriteConfig(t *testing.T) { + configPath := filepath.Join(t.TempDir(), "env", "backup.env") + tmpConfigPath := configPath + ".tmp" + + originalConfigureCronTime := configureCronTimeFunc + t.Cleanup(func() { configureCronTimeFunc = originalConfigureCronTime }) + + configureCronTimeFunc = func(ctx context.Context, reader *bufio.Reader, defaultCron string) (string, error) { + return "", errInteractiveAborted + } + + reader := bufio.NewReader(strings.NewReader("n\nn\nn\nn\nn\nn\n")) + + _, err := runConfigWizardCLI(context.Background(), reader, configPath, tmpConfigPath, "/opt/proxsave", nil) + if !errors.Is(err, errInteractiveAborted) { + t.Fatalf("expected errInteractiveAborted, got %v", err) + } + if _, statErr := os.Stat(configPath); !os.IsNotExist(statErr) { + t.Fatalf("expected config file not to exist, got err=%v", statErr) + } + if _, statErr := os.Stat(tmpConfigPath); !os.IsNotExist(statErr) { + t.Fatalf("expected temp config file not to exist, got err=%v", statErr) + } +} + func createTempFile(t *testing.T, content string) string { t.Helper() f, err := os.CreateTemp(t.TempDir(), "config-*.env") diff --git a/cmd/proxsave/install_tui.go b/cmd/proxsave/install_tui.go index 93fb39c..8a177b3 100644 --- a/cmd/proxsave/install_tui.go +++ b/cmd/proxsave/install_tui.go @@ -5,14 +5,11 @@ import ( "errors" "fmt" "os" - "path/filepath" "strings" - "filippo.io/age" - + cronutil "github.com/tis24dev/proxsave/internal/cron" "github.com/tis24dev/proxsave/internal/identity" "github.com/tis24dev/proxsave/internal/logging" - "github.com/tis24dev/proxsave/internal/orchestrator" "github.com/tis24dev/proxsave/internal/tui/wizard" ) @@ -75,9 +72,12 @@ func runInstallTUI(ctx context.Context, configPath string, bootstrap *logging.Bo baseTemplate := "" switch existingAction { - case wizard.ExistingConfigSkip: - logging.DebugStepBootstrap(bootstrap, "install workflow (tui)", "user skipped configuration") + case wizard.ExistingConfigCancel: + logging.DebugStepBootstrap(bootstrap, "install workflow (tui)", "user cancelled installation") return wrapInstallError(errInteractiveAborted) + case wizard.ExistingConfigKeepContinue: + logging.DebugStepBootstrap(bootstrap, "install workflow (tui)", "using existing configuration and skipping wizard") + skipConfigWizard = true case wizard.ExistingConfigEdit: logging.DebugStepBootstrap(bootstrap, "install workflow (tui)", "editing existing configuration") content, readErr := os.ReadFile(configPath) @@ -122,7 +122,9 @@ func runInstallTUI(ctx context.Context, configPath string, bootstrap *logging.Bo return err } - bootstrap.Debug("Configuration saved at %s", configPath) + if bootstrap != nil { + bootstrap.Debug("Configuration saved at %s", configPath) + } } // Install support docs @@ -136,73 +138,49 @@ func runInstallTUI(ctx context.Context, configPath string, bootstrap *logging.Bo if bootstrap != nil { bootstrap.Info("Running initial encryption setup (AGE recipients)") } - logging.DebugStepBootstrap(bootstrap, "install workflow (tui)", "running AGE setup wizard") - recipientPath := filepath.Join(baseDir, "identity", "age", "recipient.txt") - ageData, err := wizard.RunAgeSetupWizard(ctx, recipientPath, configPath, buildSig) + logging.DebugStepBootstrap(bootstrap, "install workflow (tui)", "running AGE setup via orchestrator") + setupResult, err := runInitialEncryptionSetupWithUI(ctx, configPath, wizard.NewAgeSetupUI(configPath, buildSig)) if err != nil { - if errors.Is(err, wizard.ErrAgeSetupCancelled) { - return fmt.Errorf("encryption setup aborted by user: %w", errInteractiveAborted) - } else { - return fmt.Errorf("AGE setup failed: %w", err) - } + return err } - // Process the AGE data based on setup type - var recipientKey string - switch ageData.SetupType { - case "existing": - recipientKey = ageData.PublicKey - case "passphrase": - // Derive recipient from passphrase - recipient, err := deriveRecipientFromPassphrase(ageData.Passphrase) - if err != nil { - return fmt.Errorf("failed to derive recipient from passphrase: %w", err) - } - recipientKey = recipient - case "privatekey": - // Derive recipient from private key - recipient, err := deriveRecipientFromPrivateKey(ageData.PrivateKey) - if err != nil { - return fmt.Errorf("failed to derive recipient from private key: %w", err) + if bootstrap != nil { + bootstrap.Info("AGE encryption configured successfully") + if setupResult.WroteRecipientFile && setupResult.RecipientPath != "" { + bootstrap.Info("Recipient saved to: %s", setupResult.RecipientPath) + } else if setupResult.ReusedExistingRecipients { + bootstrap.Info("Using existing AGE recipient configuration") } - recipientKey = recipient + bootstrap.Info("IMPORTANT: Keep your passphrase/private key offline and secure!") } - - // Save the recipient - logging.DebugStepBootstrap(bootstrap, "install workflow (tui)", "saving AGE recipient") - if err := wizard.SaveAgeRecipient(recipientPath, recipientKey); err != nil { - return fmt.Errorf("failed to save AGE recipient: %w", err) - } - - bootstrap.Info("AGE encryption configured successfully") - bootstrap.Info("Recipient saved to: %s", recipientPath) - bootstrap.Info("IMPORTANT: Keep your passphrase/private key offline and secure!") } // Optional post-install audit: run a dry-run and offer to disable unused collectors // based on actionable warning hints like "set BACKUP_*=false to disable". - auditRes, auditErr := wizard.RunPostInstallAuditWizard(ctx, execInfo.ExecPath, configPath, buildSig) - if bootstrap != nil { - if auditErr != nil { - bootstrap.Warning("Post-install check failed (non-blocking): %v", auditErr) - } else { - switch { - case !auditRes.Ran: - bootstrap.Info("Post-install audit: skipped by user") - case auditRes.CollectErr != nil: - bootstrap.Warning("Post-install audit failed (non-blocking): %v", auditRes.CollectErr) - case len(auditRes.Suggestions) == 0: - bootstrap.Info("Post-install audit: no unused components detected") - default: - keys := make([]string, 0, len(auditRes.Suggestions)) - for _, s := range auditRes.Suggestions { - keys = append(keys, s.Key) - } - bootstrap.Info("Post-install audit: suggested disables (%d): %s", len(keys), strings.Join(keys, ", ")) - if len(auditRes.AppliedKeys) > 0 { - bootstrap.Info("Post-install audit: disabled (%d): %s", len(auditRes.AppliedKeys), strings.Join(auditRes.AppliedKeys, ", ")) - } else { - bootstrap.Info("Post-install audit: no disables applied") + if !skipConfigWizard { + auditRes, auditErr := wizard.RunPostInstallAuditWizard(ctx, execInfo.ExecPath, configPath, buildSig) + if bootstrap != nil { + if auditErr != nil { + bootstrap.Warning("Post-install check failed (non-blocking): %v", auditErr) + } else { + switch { + case !auditRes.Ran: + bootstrap.Info("Post-install audit: skipped by user") + case auditRes.CollectErr != nil: + bootstrap.Warning("Post-install audit failed (non-blocking): %v", auditRes.CollectErr) + case len(auditRes.Suggestions) == 0: + bootstrap.Info("Post-install audit: no unused components detected") + default: + keys := make([]string, 0, len(auditRes.Suggestions)) + for _, s := range auditRes.Suggestions { + keys = append(keys, s.Key) + } + bootstrap.Info("Post-install audit: suggested disables (%d): %s", len(keys), strings.Join(keys, ", ")) + if len(auditRes.AppliedKeys) > 0 { + bootstrap.Info("Post-install audit: disabled (%d): %s", len(auditRes.AppliedKeys), strings.Join(auditRes.AppliedKeys, ", ")) + } else { + bootstrap.Info("Post-install audit: no disables applied") + } } } } @@ -210,21 +188,16 @@ func runInstallTUI(ctx context.Context, configPath string, bootstrap *logging.Bo // Telegram setup (centralized bot): if enabled during install, guide the user through // pairing and allow an explicit verification step with retry + skip. - if wizardData != nil && (wizardData.NotificationMode == "telegram" || wizardData.NotificationMode == "both") { + if !skipConfigWizard && wizardData != nil && (wizardData.NotificationMode == "telegram" || wizardData.NotificationMode == "both") { telegramRes, telegramErr := wizard.RunTelegramSetupWizard(ctx, baseDir, configPath, buildSig) if telegramErr != nil && bootstrap != nil { bootstrap.Warning("Telegram setup failed (non-blocking): %v", telegramErr) } + if bootstrap != nil && telegramErr == nil { + logTelegramSetupBootstrapOutcome(bootstrap, telegramRes.TelegramSetupBootstrap) + } if bootstrap != nil && telegramRes.Shown { - if telegramRes.ConfigError != "" { - bootstrap.Warning("Telegram setup: failed to load config (non-blocking): %s", telegramRes.ConfigError) - } - if telegramRes.IdentityDetectError != "" { - bootstrap.Warning("Telegram setup: identity detection issue (non-blocking): %s", telegramRes.IdentityDetectError) - } - if telegramRes.TelegramMode == "personal" { - bootstrap.Info("Telegram setup: personal mode selected (no centralized pairing check)") - } else if telegramRes.Verified { + if telegramRes.Verified { bootstrap.Info("Telegram setup: verified (code=%d)", telegramRes.LastStatusCode) } else if telegramRes.SkippedVerification { bootstrap.Info("Telegram setup: verification skipped by user") @@ -251,7 +224,11 @@ func runInstallTUI(ctx context.Context, configPath string, bootstrap *logging.Bo ensureGoSymlink(execInfo.ExecPath, bootstrap) // Migrate legacy cron entries - cronSchedule := resolveCronSchedule(wizardData) + wizardCronSchedule := "" + if wizardData != nil { + wizardCronSchedule = cronutil.TimeToSchedule(wizardData.CronTime) + } + cronSchedule := buildInstallCronSchedule(skipConfigWizard, wizardCronSchedule) logging.DebugStepBootstrap(bootstrap, "install workflow (tui)", "migrating cron entries") migrateLegacyCronEntries(ctx, baseDir, execInfo.ExecPath, bootstrap, cronSchedule) @@ -275,23 +252,3 @@ func runInstallTUI(ctx context.Context, configPath string, bootstrap *logging.Bo return nil } - -// deriveRecipientFromPassphrase derives a deterministic AGE recipient from a passphrase -func deriveRecipientFromPassphrase(passphrase string) (string, error) { - return orchestrator.DeriveDeterministicRecipientFromPassphrase(passphrase) -} - -// deriveRecipientFromPrivateKey derives the recipient (public key) from an AGE private key -func deriveRecipientFromPrivateKey(privateKey string) (string, error) { - privateKey = strings.TrimSpace(privateKey) - if privateKey == "" { - return "", fmt.Errorf("private key cannot be empty") - } - - identity, err := age.ParseX25519Identity(privateKey) - if err != nil { - return "", fmt.Errorf("invalid AGE private key: %w", err) - } - - return identity.Recipient().String(), nil -} diff --git a/cmd/proxsave/main.go b/cmd/proxsave/main.go index 73d06bd..26dfa4f 100644 --- a/cmd/proxsave/main.go +++ b/cmd/proxsave/main.go @@ -1566,10 +1566,8 @@ func printNetworkRollbackCountdown(abortInfo *orchestrator.RestoreAbortInfo) { } fmt.Printf("\r Remaining: %ds ", int(remaining.Seconds())) - select { - case <-ticker.C: - continue - } + <-ticker.C + continue } fmt.Printf("%s===========================================%s\n", color, colorReset) @@ -1637,7 +1635,7 @@ func printFinalSummary(finalExitCode int) { fmt.Println(" --help - Show all options") fmt.Println(" --dry-run - Test without changes") fmt.Println(" --install - Re-run interactive installation/setup") - fmt.Println(" --new-install - Wipe installation directory (keep env/identity) then run installer") + fmt.Println(" --new-install - Wipe installation directory (keep build/env/identity) then run installer") fmt.Println(" --env-migration - Run installer and migrate legacy Bash backup.env to Go template") fmt.Println(" --env-migration-dry-run - Preview installer/migration without writing files") fmt.Println(" --upgrade - Update proxsave binary to latest release (also adds missing keys to backup.env)") diff --git a/cmd/proxsave/new_install.go b/cmd/proxsave/new_install.go new file mode 100644 index 0000000..4ff0fdc --- /dev/null +++ b/cmd/proxsave/new_install.go @@ -0,0 +1,81 @@ +package main + +import ( + "bufio" + "context" + "fmt" + "os" + "sort" + "strings" +) + +type newInstallPlan struct { + ResolvedConfigPath string + BaseDir string + BuildSignature string + PreservedEntries []string +} + +func buildNewInstallPlan(configPath string) (newInstallPlan, error) { + resolvedPath, err := resolveInstallConfigPath(configPath) + if err != nil { + return newInstallPlan{}, err + } + + buildSig := strings.TrimSpace(buildSignature()) + if buildSig == "" { + buildSig = "n/a" + } + + return newInstallPlan{ + ResolvedConfigPath: resolvedPath, + BaseDir: deriveBaseDirFromConfig(resolvedPath), + BuildSignature: buildSig, + PreservedEntries: newInstallPreservedEntries(), + }, nil +} + +func newInstallPreservedEntries() []string { + preserved := []string{"env", "identity", "build"} + sort.Strings(preserved) + return preserved +} + +func newInstallPreserveSet() map[string]struct{} { + preserved := newInstallPreservedEntries() + result := make(map[string]struct{}, len(preserved)) + for _, entry := range preserved { + result[entry] = struct{}{} + } + return result +} + +func formatNewInstallPreservedEntries(entries []string) string { + formatted := make([]string, 0, len(entries)) + for _, entry := range entries { + trimmed := strings.TrimSpace(entry) + if trimmed == "" { + continue + } + formatted = append(formatted, trimmed+"/") + } + if len(formatted) == 0 { + return "(none)" + } + return strings.Join(formatted, " ") +} + +func confirmNewInstallCLI(ctx context.Context, reader *bufio.Reader, plan newInstallPlan) (bool, error) { + if reader == nil { + reader = bufio.NewReader(os.Stdin) + } + + fmt.Println() + fmt.Println("--- New installation reset ---") + fmt.Printf("Base directory: %s\n", plan.BaseDir) + fmt.Printf("Build signature: %s\n", plan.BuildSignature) + fmt.Printf("Preserved entries: %s\n", formatNewInstallPreservedEntries(plan.PreservedEntries)) + fmt.Println("Everything else under the base directory will be removed.") + + return promptYesNo(ctx, reader, "Continue? [y/N]: ", false) +} diff --git a/cmd/proxsave/new_install_test.go b/cmd/proxsave/new_install_test.go new file mode 100644 index 0000000..441a463 --- /dev/null +++ b/cmd/proxsave/new_install_test.go @@ -0,0 +1,188 @@ +package main + +import ( + "bufio" + "context" + "errors" + "os" + "path/filepath" + "reflect" + "strings" + "testing" + + "github.com/tis24dev/proxsave/internal/logging" +) + +func TestNewInstallPreservedEntries(t *testing.T) { + got := newInstallPreservedEntries() + want := []string{"build", "env", "identity"} + if !reflect.DeepEqual(got, want) { + t.Fatalf("newInstallPreservedEntries() = %#v, want %#v", got, want) + } +} + +func TestBuildNewInstallPlan(t *testing.T) { + baseDir := t.TempDir() + configPath := filepath.Join(baseDir, "env", "backup.env") + + plan, err := buildNewInstallPlan(configPath) + if err != nil { + t.Fatalf("buildNewInstallPlan error: %v", err) + } + 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 strings.TrimSpace(plan.BuildSignature) == "" { + t.Fatalf("build signature should not be empty") + } + if !reflect.DeepEqual(plan.PreservedEntries, newInstallPreservedEntries()) { + t.Fatalf("preserved entries = %#v, want %#v", plan.PreservedEntries, newInstallPreservedEntries()) + } +} + +func TestConfirmNewInstallCLIContinue(t *testing.T) { + plan := newInstallPlan{ + BaseDir: "/opt/proxsave", + BuildSignature: "sig-123", + PreservedEntries: []string{"build", "env", "identity"}, + } + + reader := bufio.NewReader(strings.NewReader("y\n")) + var confirmed bool + var err error + output := captureStdout(t, func() { + confirmed, err = confirmNewInstallCLI(context.Background(), reader, plan) + }) + if err != nil { + t.Fatalf("confirmNewInstallCLI error: %v", err) + } + if !confirmed { + t.Fatalf("expected confirmation=true") + } + if !strings.Contains(output, "Preserved entries: build/ env/ identity/") { + t.Fatalf("expected preserved entries output, got %q", output) + } +} + +func TestConfirmNewInstallCLIContextCancelled(t *testing.T) { + plan := newInstallPlan{ + BaseDir: "/opt/proxsave", + BuildSignature: "sig-123", + PreservedEntries: []string{"build", "env", "identity"}, + } + + ctx, cancel := context.WithCancel(context.Background()) + cancel() + _, err := confirmNewInstallCLI(ctx, bufio.NewReader(strings.NewReader("y\n")), plan) + if !errors.Is(err, errInteractiveAborted) { + t.Fatalf("expected errInteractiveAborted, got %v", err) + } +} + +func TestRunNewInstallCLIUsesCLIConfirmOnly(t *testing.T) { + originalEnsure := newInstallEnsureInteractiveStdin + originalConfirmCLI := newInstallConfirmCLI + originalConfirmTUI := newInstallConfirmTUI + originalRunInstall := newInstallRunInstall + originalRunInstallTUI := newInstallRunInstallTUI + defer func() { + newInstallEnsureInteractiveStdin = originalEnsure + newInstallConfirmCLI = originalConfirmCLI + newInstallConfirmTUI = originalConfirmTUI + newInstallRunInstall = originalRunInstall + newInstallRunInstallTUI = originalRunInstallTUI + }() + + baseDir := t.TempDir() + configPath := filepath.Join(baseDir, "env", "backup.env") + stalePath := filepath.Join(baseDir, "stale.txt") + if err := os.WriteFile(stalePath, []byte("stale"), 0o600); err != nil { + t.Fatalf("write stale marker: %v", err) + } + + newInstallEnsureInteractiveStdin = func() error { return nil } + + cliConfirmCalled := false + newInstallConfirmCLI = func(ctx context.Context, reader *bufio.Reader, plan newInstallPlan) (bool, error) { + cliConfirmCalled = true + if plan.BaseDir != baseDir { + t.Fatalf("plan base dir = %q, want %q", plan.BaseDir, baseDir) + } + return true, nil + } + + newInstallConfirmTUI = func(baseDirArg, buildSig string, preservedEntries []string) (bool, error) { + t.Fatalf("TUI confirmation must not be called in --cli mode") + return false, nil + } + + runInstallCalled := false + newInstallRunInstall = func(ctx context.Context, cfg string, bootstrap *logging.BootstrapLogger) error { + runInstallCalled = true + if cfg != configPath { + t.Fatalf("runInstall config path = %q, want %q", cfg, configPath) + } + return nil + } + newInstallRunInstallTUI = func(ctx context.Context, cfg string, bootstrap *logging.BootstrapLogger) error { + t.Fatalf("runInstallTUI must not be called in --cli mode") + return nil + } + + if err := runNewInstall(context.Background(), configPath, logging.NewBootstrapLogger(), true); err != nil { + t.Fatalf("runNewInstall error: %v", err) + } + if !cliConfirmCalled { + t.Fatalf("expected CLI confirmation to be called") + } + if !runInstallCalled { + t.Fatalf("expected runInstall to be called") + } + if _, err := os.Stat(stalePath); !os.IsNotExist(err) { + t.Fatalf("expected stale marker to be removed by reset, got err=%v", err) + } +} + +func TestRunNewInstallCancelSkipsReset(t *testing.T) { + originalEnsure := newInstallEnsureInteractiveStdin + originalConfirmCLI := newInstallConfirmCLI + originalRunInstall := newInstallRunInstall + originalRunInstallTUI := newInstallRunInstallTUI + defer func() { + newInstallEnsureInteractiveStdin = originalEnsure + newInstallConfirmCLI = originalConfirmCLI + newInstallRunInstall = originalRunInstall + newInstallRunInstallTUI = originalRunInstallTUI + }() + + baseDir := t.TempDir() + configPath := filepath.Join(baseDir, "env", "backup.env") + markerPath := filepath.Join(baseDir, "marker.txt") + if err := os.WriteFile(markerPath, []byte("keep"), 0o600); err != nil { + t.Fatalf("write marker: %v", err) + } + + newInstallEnsureInteractiveStdin = func() error { return nil } + newInstallConfirmCLI = func(ctx context.Context, reader *bufio.Reader, plan newInstallPlan) (bool, error) { + return false, nil + } + newInstallRunInstall = func(ctx context.Context, cfg string, bootstrap *logging.BootstrapLogger) error { + t.Fatalf("runInstall must not be called on cancel") + return nil + } + newInstallRunInstallTUI = func(ctx context.Context, cfg string, bootstrap *logging.BootstrapLogger) error { + t.Fatalf("runInstallTUI must not be called on cancel") + return nil + } + + err := runNewInstall(context.Background(), configPath, logging.NewBootstrapLogger(), true) + if !errors.Is(err, errInteractiveAborted) { + t.Fatalf("expected interactive abort, got %v", err) + } + if _, statErr := os.Stat(markerPath); statErr != nil { + t.Fatalf("expected marker to remain after cancel, got %v", statErr) + } +} diff --git a/cmd/proxsave/newkey.go b/cmd/proxsave/newkey.go index 9099f65..5adbf85 100644 --- a/cmd/proxsave/newkey.go +++ b/cmd/proxsave/newkey.go @@ -75,99 +75,89 @@ func runNewKey(ctx context.Context, configPath string, logLevel types.LogLevel, } func runNewKeyTUI(ctx context.Context, configPath, baseDir string, bootstrap *logging.BootstrapLogger) (err error) { - recipientPath := filepath.Join(baseDir, "identity", "age", "recipient.txt") sig := buildSignature() if strings.TrimSpace(sig) == "" { sig = "n/a" } - done := logging.DebugStartBootstrap(bootstrap, "newkey workflow (tui)", "recipient=%s", recipientPath) + done := logging.DebugStartBootstrap(bootstrap, "newkey workflow (tui)", "config=%s", configPath) defer func() { done(err) }() - // If a recipient already exists, ask for confirmation before overwriting - if _, err := os.Stat(recipientPath); err == nil { - logging.DebugStepBootstrap(bootstrap, "newkey workflow (tui)", "existing recipient found") - confirm, err := wizard.ConfirmRecipientOverwrite(recipientPath, configPath, sig) - if err != nil { - return err - } - if !confirm { - return wrapInstallError(errInteractiveAborted) - } - if err := orchestrator.BackupAgeRecipientFile(recipientPath); err != nil && bootstrap != nil { - bootstrap.Warning("WARNING: %v", err) - } + logging.DebugStepBootstrap(bootstrap, "newkey workflow (tui)", "running AGE setup via orchestrator") + recipientPath, err := runNewKeySetup(ctx, configPath, baseDir, logging.GetDefaultLogger(), wizard.NewAgeSetupUI(configPath, sig)) + if err != nil { + return err } - recipients := make([]string, 0, 2) - for { - logging.DebugStepBootstrap(bootstrap, "newkey workflow (tui)", "running AGE setup wizard") - ageData, err := wizard.RunAgeSetupWizard(ctx, recipientPath, configPath, sig) - if err != nil { - if errors.Is(err, wizard.ErrAgeSetupCancelled) { - return wrapInstallError(errInteractiveAborted) - } - return fmt.Errorf("AGE setup failed: %w", err) - } - - // Process the AGE data based on setup type - var recipientKey string - switch ageData.SetupType { - case "existing": - recipientKey = ageData.PublicKey - case "passphrase": - recipient, err := deriveRecipientFromPassphrase(ageData.Passphrase) - if err != nil { - return fmt.Errorf("failed to derive recipient from passphrase: %w", err) - } - recipientKey = recipient - case "privatekey": - recipient, err := deriveRecipientFromPrivateKey(ageData.PrivateKey) - if err != nil { - return fmt.Errorf("failed to derive recipient from private key: %w", err) - } - recipientKey = recipient - default: - return fmt.Errorf("unknown AGE setup type: %s", ageData.SetupType) - } + logNewKeySuccess(recipientPath, bootstrap) - if err := orchestrator.ValidateRecipientString(recipientKey); err != nil { - return fmt.Errorf("invalid recipient: %w", err) - } - recipients = append(recipients, recipientKey) + return nil +} - logging.DebugStepBootstrap(bootstrap, "newkey workflow (tui)", "recipient count=%d", len(recipients)) - addMore, err := wizard.ConfirmAddRecipient(configPath, sig, len(recipients)) - if err != nil { - return err - } - if !addMore { - break - } +func runNewKeyCLI(ctx context.Context, configPath, baseDir string, logger *logging.Logger, bootstrap *logging.BootstrapLogger) error { + recipientPath, err := runNewKeySetup(ctx, configPath, baseDir, logger, nil) + if err != nil { + return err } - recipients = orchestrator.DedupeRecipientStrings(recipients) - if len(recipients) == 0 { - return fmt.Errorf("no AGE recipients provided") - } - logging.DebugStepBootstrap(bootstrap, "newkey workflow (tui)", "saving recipients") - if err := orchestrator.WriteRecipientFile(recipientPath, recipients); err != nil { - return fmt.Errorf("failed to save AGE recipients: %w", err) + logNewKeySuccess(recipientPath, bootstrap) + + return nil +} + +func logNewKeySuccess(recipientPath string, bootstrap *logging.BootstrapLogger) { + if bootstrap != nil { + bootstrap.Info("✓ New AGE recipient(s) generated and saved to %s", recipientPath) + bootstrap.Info("IMPORTANT: Keep your passphrase/private key offline and secure!") + return } - bootstrap.Info("✓ New AGE recipient(s) generated and saved to %s", recipientPath) - bootstrap.Info("IMPORTANT: Keep your passphrase/private key offline and secure!") + fmt.Printf("✓ New AGE recipient(s) generated and saved to %s\n", recipientPath) + fmt.Println("IMPORTANT: Keep your passphrase/private key offline and secure!") +} - return nil +func modeLabel(useCLI bool) string { + if useCLI { + return "cli" + } + return "tui" } -func runNewKeyCLI(ctx context.Context, configPath, baseDir string, logger *logging.Logger, bootstrap *logging.BootstrapLogger) error { - recipientPath := filepath.Join(baseDir, "identity", "age", "recipient.txt") +func loadNewKeyConfig(configPath, baseDir string) (*config.Config, string, error) { + defaultRecipientPath := filepath.Join(baseDir, "identity", "age", "recipient.txt") cfg := &config.Config{ BaseDir: baseDir, ConfigPath: configPath, EncryptArchive: true, - AgeRecipientFile: recipientPath, + AgeRecipientFile: defaultRecipientPath, + } + + if _, err := os.Stat(configPath); err == nil { + loaded, err := config.LoadConfig(configPath) + if err != nil { + return nil, "", fmt.Errorf("load configuration for newkey: %w", err) + } + cfg = loaded + cfg.BaseDir = baseDir + cfg.ConfigPath = configPath + cfg.EncryptArchive = true + } else if !errors.Is(err, os.ErrNotExist) { + return nil, "", fmt.Errorf("inspect configuration for newkey: %w", err) + } + + recipientPath := strings.TrimSpace(cfg.AgeRecipientFile) + if recipientPath == "" { + recipientPath = defaultRecipientPath + } + cfg.AgeRecipientFile = recipientPath + + return cfg, recipientPath, nil +} + +func runNewKeySetup(ctx context.Context, configPath, baseDir string, logger *logging.Logger, ui orchestrator.AgeSetupUI) (string, error) { + cfg, recipientPath, err := loadNewKeyConfig(configPath, baseDir) + if err != nil { + return "", err } if logger == nil { @@ -179,22 +169,17 @@ func runNewKeyCLI(ctx context.Context, configPath, baseDir string, logger *loggi orch.SetConfig(cfg) orch.SetForceNewAgeRecipient(true) - if err := orch.EnsureAgeRecipientsReady(ctx); err != nil { + if ui != nil { + err = orch.EnsureAgeRecipientsReadyWithUI(ctx, ui) + } else { + err = orch.EnsureAgeRecipientsReady(ctx) + } + if err != nil { if errors.Is(err, orchestrator.ErrAgeRecipientSetupAborted) { - return wrapInstallError(errInteractiveAborted) + return "", wrapInstallError(errInteractiveAborted) } - return fmt.Errorf("AGE setup failed: %w", err) + return "", fmt.Errorf("AGE setup failed: %w", err) } - bootstrap.Info("✓ New AGE recipient(s) generated and saved to %s", recipientPath) - bootstrap.Info("IMPORTANT: Keep your passphrase/private key offline and secure!") - - return nil -} - -func modeLabel(useCLI bool) string { - if useCLI { - return "cli" - } - return "tui" + return recipientPath, nil } diff --git a/cmd/proxsave/newkey_test.go b/cmd/proxsave/newkey_test.go new file mode 100644 index 0000000..c68624f --- /dev/null +++ b/cmd/proxsave/newkey_test.go @@ -0,0 +1,140 @@ +package main + +import ( + "bytes" + "io" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/tis24dev/proxsave/internal/logging" + "github.com/tis24dev/proxsave/internal/types" +) + +func captureNewKeyStdout(t *testing.T, fn func()) string { + t.Helper() + orig := os.Stdout + r, w, err := os.Pipe() + if err != nil { + t.Fatalf("pipe: %v", err) + } + os.Stdout = w + + var buf bytes.Buffer + done := make(chan struct{}) + go func() { + _, _ = io.Copy(&buf, r) + close(done) + }() + + fn() + + _ = w.Close() + os.Stdout = orig + <-done + return buf.String() +} + +func TestLogNewKeySuccessWithoutBootstrapFallsBackToStdout(t *testing.T) { + recipientPath := filepath.Join("/tmp", "identity", "age", "recipient.txt") + + output := captureNewKeyStdout(t, func() { + logNewKeySuccess(recipientPath, nil) + }) + + if !strings.Contains(output, "✓ New AGE recipient(s) generated and saved to "+recipientPath) { + t.Fatalf("expected recipient success message, got %q", output) + } + if !strings.Contains(output, "IMPORTANT: Keep your passphrase/private key offline and secure!") { + t.Fatalf("expected security reminder, got %q", output) + } +} + +func TestLogNewKeySuccessWithBootstrapUsesBootstrapLogger(t *testing.T) { + recipientPath := filepath.Join("/tmp", "identity", "age", "recipient.txt") + bootstrap := logging.NewBootstrapLogger() + bootstrap.SetLevel(types.LogLevelInfo) + + var mirrorBuf bytes.Buffer + mirror := logging.New(types.LogLevelDebug, false) + mirror.SetOutput(&mirrorBuf) + bootstrap.SetMirrorLogger(mirror) + + output := captureNewKeyStdout(t, func() { + logNewKeySuccess(recipientPath, bootstrap) + }) + + if !strings.Contains(output, "✓ New AGE recipient(s) generated and saved to "+recipientPath) { + t.Fatalf("expected bootstrap stdout success message, got %q", output) + } + if !strings.Contains(output, "IMPORTANT: Keep your passphrase/private key offline and secure!") { + t.Fatalf("expected bootstrap stdout security reminder, got %q", output) + } + + mirrorOutput := mirrorBuf.String() + if !strings.Contains(mirrorOutput, "New AGE recipient(s) generated and saved to "+recipientPath) { + t.Fatalf("expected mirror logger success message, got %q", mirrorOutput) + } + if !strings.Contains(mirrorOutput, "IMPORTANT: Keep your passphrase/private key offline and secure!") { + t.Fatalf("expected mirror logger security reminder, got %q", mirrorOutput) + } +} + +func TestLoadNewKeyConfigUsesConfiguredRecipientFile(t *testing.T) { + baseDir := t.TempDir() + configPath := filepath.Join(baseDir, "env", "backup.env") + if err := os.MkdirAll(filepath.Dir(configPath), 0o700); err != nil { + t.Fatalf("MkdirAll(%s): %v", filepath.Dir(configPath), err) + } + + customPath := filepath.Join(baseDir, "custom", "recipient.txt") + content := "BASE_DIR=" + baseDir + "\nENCRYPT_ARCHIVE=false\nAGE_RECIPIENT_FILE=" + customPath + "\n" + if err := os.WriteFile(configPath, []byte(content), 0o600); err != nil { + t.Fatalf("WriteFile(%s): %v", configPath, err) + } + + cfg, recipientPath, err := loadNewKeyConfig(configPath, baseDir) + if err != nil { + t.Fatalf("loadNewKeyConfig error: %v", err) + } + if recipientPath != customPath { + t.Fatalf("recipientPath=%q; want %q", recipientPath, customPath) + } + if cfg == nil { + t.Fatalf("expected config") + } + if cfg.BaseDir != baseDir { + t.Fatalf("BaseDir=%q; want %q", cfg.BaseDir, baseDir) + } + if cfg.ConfigPath != configPath { + t.Fatalf("ConfigPath=%q; want %q", cfg.ConfigPath, configPath) + } + if cfg.AgeRecipientFile != customPath { + t.Fatalf("AgeRecipientFile=%q; want %q", cfg.AgeRecipientFile, customPath) + } + if !cfg.EncryptArchive { + t.Fatalf("EncryptArchive=false; want true") + } +} + +func TestLoadNewKeyConfigFailsForInvalidExistingConfig(t *testing.T) { + baseDir := t.TempDir() + configPath := filepath.Join(baseDir, "env", "backup.env") + if err := os.MkdirAll(filepath.Dir(configPath), 0o700); err != nil { + t.Fatalf("MkdirAll(%s): %v", filepath.Dir(configPath), err) + } + + content := "BASE_DIR=" + baseDir + "\nCUSTOM_BACKUP_PATHS=\"\nunterminated\n" + if err := os.WriteFile(configPath, []byte(content), 0o600); err != nil { + t.Fatalf("WriteFile(%s): %v", configPath, err) + } + + _, _, err := loadNewKeyConfig(configPath, baseDir) + if err == nil { + t.Fatalf("expected loadNewKeyConfig to fail for invalid config") + } + if !strings.Contains(err.Error(), "load configuration for newkey") { + t.Fatalf("expected wrapped configuration load error, got %v", err) + } +} diff --git a/cmd/proxsave/prompts.go b/cmd/proxsave/prompts.go index 15b906a..fe9ca65 100644 --- a/cmd/proxsave/prompts.go +++ b/cmd/proxsave/prompts.go @@ -49,18 +49,25 @@ func promptYesNo(ctx context.Context, reader *bufio.Reader, question string, def func promptNonEmpty(ctx context.Context, reader *bufio.Reader, question string) (string, error) { for { - if err := ctx.Err(); err != nil { - return "", errInteractiveAborted - } - fmt.Print(question) - resp, err := input.ReadLineWithContext(ctx, reader) + resp, err := promptOptional(ctx, reader, question) if err != nil { return "", err } - resp = strings.TrimSpace(resp) if resp != "" { return resp, nil } fmt.Println("Value cannot be empty.") } } + +func promptOptional(ctx context.Context, reader *bufio.Reader, question string) (string, error) { + if err := ctx.Err(); err != nil { + return "", errInteractiveAborted + } + fmt.Print(question) + resp, err := input.ReadLineWithContext(ctx, reader) + if err != nil { + return "", err + } + return strings.TrimSpace(resp), nil +} diff --git a/cmd/proxsave/runtime_helpers.go b/cmd/proxsave/runtime_helpers.go index b95d90e..2cb4f48 100644 --- a/cmd/proxsave/runtime_helpers.go +++ b/cmd/proxsave/runtime_helpers.go @@ -239,8 +239,13 @@ func resolveHostname() string { } func validateFutureFeatures(cfg *config.Config) error { - if cfg.SecondaryEnabled && cfg.SecondaryPath == "" { - return fmt.Errorf("secondary backup enabled but SECONDARY_PATH is empty") + if cfg.SecondaryEnabled { + if err := config.ValidateRequiredSecondaryPath(cfg.SecondaryPath); err != nil { + return err + } + if err := config.ValidateOptionalSecondaryLogPath(cfg.SecondaryLogPath); err != nil { + return err + } } if cfg.CloudEnabled && cfg.CloudRemote == "" { logging.Warning("Cloud backup enabled but CLOUD_REMOTE is empty – disabling cloud storage for this run") diff --git a/cmd/proxsave/schedule_helpers.go b/cmd/proxsave/schedule_helpers.go index 4b7da8a..d8d5b27 100644 --- a/cmd/proxsave/schedule_helpers.go +++ b/cmd/proxsave/schedule_helpers.go @@ -3,49 +3,35 @@ package main import ( "fmt" "os" - "strconv" "strings" - "github.com/tis24dev/proxsave/internal/tui/wizard" + cronutil "github.com/tis24dev/proxsave/internal/cron" ) -// resolveCronSchedule returns a cron schedule string (e.g. "0 2 * * *") derived from -// wizard data or environment variables, falling back to 02:00 if unavailable. -func resolveCronSchedule(data *wizard.InstallWizardData) string { - // Try wizard data first - if data != nil { - cron := strings.TrimSpace(data.CronTime) - if cron != "" { - if schedule := cronToSchedule(cron); schedule != "" { - return schedule - } - } - } - - // Environment overrides +// resolveCronScheduleFromEnv returns a cron schedule string derived from the +// legacy environment overrides, falling back to 02:00 if unavailable. +func resolveCronScheduleFromEnv() string { if s := strings.TrimSpace(os.Getenv("CRON_SCHEDULE")); s != "" { return s } + hour := strings.TrimSpace(os.Getenv("CRON_HOUR")) min := strings.TrimSpace(os.Getenv("CRON_MINUTE")) if hour != "" && min != "" { return fmt.Sprintf("%s %s * * *", min, hour) } - // Default: 02:00 - return "0 2 * * *" + return cronutil.TimeToSchedule(cronutil.DefaultTime) } -// cronToSchedule converts HH:MM into "MM HH * * *". -func cronToSchedule(cron string) string { - parts := strings.Split(cron, ":") - if len(parts) != 2 { - return "" - } - hour, errH := strconv.Atoi(parts[0]) - min, errM := strconv.Atoi(parts[1]) - if errH != nil || errM != nil || hour < 0 || hour > 23 || min < 0 || min > 59 { - return "" +// buildInstallCronSchedule keeps wizard-driven installs independent from +// env-based overrides while preserving the existing skip-wizard behavior. +func buildInstallCronSchedule(skipConfigWizard bool, cronSchedule string) string { + if !skipConfigWizard { + if schedule := strings.TrimSpace(cronSchedule); schedule != "" { + return schedule + } + return cronutil.TimeToSchedule(cronutil.DefaultTime) } - return fmt.Sprintf("%02d %02d * * *", min, hour) + return resolveCronScheduleFromEnv() } diff --git a/cmd/proxsave/schedule_helpers_test.go b/cmd/proxsave/schedule_helpers_test.go index 1e906af..fd268f5 100644 --- a/cmd/proxsave/schedule_helpers_test.go +++ b/cmd/proxsave/schedule_helpers_test.go @@ -3,45 +3,14 @@ package main import ( "testing" - "github.com/tis24dev/proxsave/internal/tui/wizard" + cronutil "github.com/tis24dev/proxsave/internal/cron" ) -func TestCronToSchedule(t *testing.T) { - tests := []struct { - name string - in string - want string - }{ - {"valid with padding", "2:5", "05 02 * * *"}, - {"valid already padded", "02:05", "05 02 * * *"}, - {"invalid format", "0205", ""}, - {"invalid hour", "24:00", ""}, - {"invalid minute", "00:60", ""}, - {"non numeric", "aa:bb", ""}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - if got := cronToSchedule(tt.in); got != tt.want { - t.Fatalf("cronToSchedule(%q) = %q, want %q", tt.in, got, tt.want) - } - }) - } -} - -func TestResolveCronSchedule(t *testing.T) { - t.Run("wizard data takes precedence", func(t *testing.T) { - t.Setenv("CRON_SCHEDULE", "0 4 * * *") - data := &wizard.InstallWizardData{CronTime: "03:15"} - if got := resolveCronSchedule(data); got != "15 03 * * *" { - t.Fatalf("resolveCronSchedule(wizard) = %q, want %q", got, "15 03 * * *") - } - }) - +func TestResolveCronScheduleFromEnv(t *testing.T) { t.Run("env CRON_SCHEDULE overrides", func(t *testing.T) { t.Setenv("CRON_SCHEDULE", "5 1 * * *") - if got := resolveCronSchedule(nil); got != "5 1 * * *" { - t.Fatalf("resolveCronSchedule(env) = %q, want %q", got, "5 1 * * *") + if got := resolveCronScheduleFromEnv(); got != "5 1 * * *" { + t.Fatalf("resolveCronScheduleFromEnv() = %q, want %q", got, "5 1 * * *") } }) @@ -49,8 +18,8 @@ func TestResolveCronSchedule(t *testing.T) { t.Setenv("CRON_SCHEDULE", "") t.Setenv("CRON_HOUR", "22") t.Setenv("CRON_MINUTE", "10") - if got := resolveCronSchedule(nil); got != "10 22 * * *" { - t.Fatalf("resolveCronSchedule(hour/minute) = %q, want %q", got, "10 22 * * *") + if got := resolveCronScheduleFromEnv(); got != "10 22 * * *" { + t.Fatalf("resolveCronScheduleFromEnv() = %q, want %q", got, "10 22 * * *") } }) @@ -58,8 +27,31 @@ func TestResolveCronSchedule(t *testing.T) { t.Setenv("CRON_SCHEDULE", "") t.Setenv("CRON_HOUR", "") t.Setenv("CRON_MINUTE", "") - if got := resolveCronSchedule(nil); got != "0 2 * * *" { - t.Fatalf("resolveCronSchedule(default) = %q, want %q", got, "0 2 * * *") + if got := resolveCronScheduleFromEnv(); got != cronutil.TimeToSchedule(cronutil.DefaultTime) { + t.Fatalf("resolveCronScheduleFromEnv() = %q, want %q", got, cronutil.TimeToSchedule(cronutil.DefaultTime)) + } + }) +} + +func TestBuildInstallCronSchedule(t *testing.T) { + t.Run("wizard schedule takes precedence over env", func(t *testing.T) { + t.Setenv("CRON_SCHEDULE", "5 1 * * *") + if got := buildInstallCronSchedule(false, "15 03 * * *"); got != "15 03 * * *" { + t.Fatalf("buildInstallCronSchedule(false, schedule) = %q, want %q", got, "15 03 * * *") + } + }) + + t.Run("wizard run with empty schedule falls back to default time not env", func(t *testing.T) { + t.Setenv("CRON_SCHEDULE", "5 1 * * *") + if got := buildInstallCronSchedule(false, ""); got != cronutil.TimeToSchedule(cronutil.DefaultTime) { + t.Fatalf("buildInstallCronSchedule(false, \"\") = %q, want %q", got, cronutil.TimeToSchedule(cronutil.DefaultTime)) + } + }) + + t.Run("skip wizard uses env fallback", func(t *testing.T) { + t.Setenv("CRON_SCHEDULE", "5 1 * * *") + if got := buildInstallCronSchedule(true, "15 03 * * *"); got != "5 1 * * *" { + t.Fatalf("buildInstallCronSchedule(true, schedule) = %q, want %q", got, "5 1 * * *") } }) } diff --git a/cmd/proxsave/telegram_setup_cli.go b/cmd/proxsave/telegram_setup_cli.go new file mode 100644 index 0000000..9511565 --- /dev/null +++ b/cmd/proxsave/telegram_setup_cli.go @@ -0,0 +1,107 @@ +package main + +import ( + "bufio" + "context" + "fmt" + "strings" + + "github.com/tis24dev/proxsave/internal/logging" + "github.com/tis24dev/proxsave/internal/notify" + "github.com/tis24dev/proxsave/internal/orchestrator" +) + +var ( + telegramSetupBuildBootstrap = orchestrator.BuildTelegramSetupBootstrap + telegramSetupCheckRegistration = notify.CheckTelegramRegistration + telegramSetupPromptYesNo = promptYesNo +) + +func logTelegramSetupBootstrapOutcome(bootstrap *logging.BootstrapLogger, state orchestrator.TelegramSetupBootstrap) { + switch state.Eligibility { + case orchestrator.TelegramSetupSkipConfigError: + if strings.TrimSpace(state.ConfigError) != "" { + logBootstrapWarning(bootstrap, "Telegram setup: unable to load config (skipping): %s", state.ConfigError) + } + case orchestrator.TelegramSetupSkipPersonalMode: + logBootstrapInfo(bootstrap, "Telegram setup: personal mode selected (no centralized pairing check)") + case orchestrator.TelegramSetupSkipIdentityUnavailable: + if strings.TrimSpace(state.IdentityDetectError) != "" { + logBootstrapWarning(bootstrap, "Telegram setup: identity detection failed (non-blocking): %s", state.IdentityDetectError) + return + } + logBootstrapWarning(bootstrap, "Telegram setup: server ID unavailable; skipping") + } +} + +func runTelegramSetupCLI(ctx context.Context, reader *bufio.Reader, baseDir, configPath string, bootstrap *logging.BootstrapLogger) error { + state, err := telegramSetupBuildBootstrap(configPath, baseDir) + if err != nil { + logBootstrapWarning(bootstrap, "Telegram setup bootstrap failed (non-blocking): %v", err) + return nil + } + + logTelegramSetupBootstrapOutcome(bootstrap, state) + if state.Eligibility != orchestrator.TelegramSetupEligibleCentralized { + return nil + } + + fmt.Println("\n--- Telegram setup (optional) ---") + fmt.Println("You enabled Telegram notifications (centralized bot).") + fmt.Printf("Server ID: %s\n", state.ServerID) + if strings.TrimSpace(state.IdentityFile) != "" { + fmt.Printf("Identity file: %s\n", strings.TrimSpace(state.IdentityFile)) + } + fmt.Println() + fmt.Println("1) Open Telegram and start @ProxmoxAN_bot") + fmt.Println("2) Send the Server ID above (digits only)") + fmt.Println("3) Verify pairing (recommended)") + fmt.Println() + + check, err := telegramSetupPromptYesNo(ctx, reader, "Check Telegram pairing now? [Y/n]: ", true) + if err != nil { + return wrapInstallError(err) + } + if !check { + fmt.Println("Skipped verification. You can verify later by running proxsave.") + logBootstrapInfo(bootstrap, "Telegram setup: verification skipped by user") + return nil + } + + attempts := 0 + for { + attempts++ + status := telegramSetupCheckRegistration(ctx, state.ServerAPIHost, state.ServerID, nil) + if status.Code == 200 && status.Error == nil { + fmt.Println("✓ Telegram linked successfully.") + logBootstrapInfo(bootstrap, "Telegram setup: verified (attempts=%d)", attempts) + return nil + } + + msg := strings.TrimSpace(status.Message) + if msg == "" { + msg = "Registration not active yet" + } + fmt.Printf("Telegram: %s\n", msg) + switch status.Code { + case 403, 409: + fmt.Println("Hint: Start the bot, send the Server ID, then retry.") + case 422: + fmt.Println("Hint: The Server ID appears invalid. If this persists, re-run the installer.") + default: + if status.Error != nil { + fmt.Printf("Hint: Check failed: %v\n", status.Error) + } + } + + retry, err := telegramSetupPromptYesNo(ctx, reader, "Check again? [y/N]: ", false) + if err != nil { + return wrapInstallError(err) + } + if !retry { + fmt.Println("Verification not completed. You can retry later by running proxsave.") + logBootstrapInfo(bootstrap, "Telegram setup: not verified (attempts=%d last=%d %s)", attempts, status.Code, msg) + return nil + } + } +} diff --git a/cmd/proxsave/telegram_setup_cli_test.go b/cmd/proxsave/telegram_setup_cli_test.go new file mode 100644 index 0000000..bd01835 --- /dev/null +++ b/cmd/proxsave/telegram_setup_cli_test.go @@ -0,0 +1,184 @@ +package main + +import ( + "bufio" + "context" + "errors" + "strings" + "testing" + + "github.com/tis24dev/proxsave/internal/logging" + "github.com/tis24dev/proxsave/internal/notify" + "github.com/tis24dev/proxsave/internal/orchestrator" +) + +func stubTelegramSetupCLIDeps(t *testing.T) { + t.Helper() + + origBuildBootstrap := telegramSetupBuildBootstrap + origCheckRegistration := telegramSetupCheckRegistration + origPromptYesNo := telegramSetupPromptYesNo + + t.Cleanup(func() { + telegramSetupBuildBootstrap = origBuildBootstrap + telegramSetupCheckRegistration = origCheckRegistration + telegramSetupPromptYesNo = origPromptYesNo + }) +} + +func TestRunTelegramSetupCLI_SkipOnConfigError(t *testing.T) { + stubTelegramSetupCLIDeps(t) + + telegramSetupBuildBootstrap = func(configPath, baseDir string) (orchestrator.TelegramSetupBootstrap, error) { + return orchestrator.TelegramSetupBootstrap{ + Eligibility: orchestrator.TelegramSetupSkipConfigError, + ConfigError: "parse failed", + }, nil + } + telegramSetupPromptYesNo = func(ctx context.Context, reader *bufio.Reader, question string, defaultYes bool) (bool, error) { + t.Fatalf("prompt should not run for config skip") + return false, nil + } + telegramSetupCheckRegistration = func(ctx context.Context, serverAPIHost, serverID string, logger *logging.Logger) notify.TelegramRegistrationStatus { + t.Fatalf("registration check should not run for config skip") + return notify.TelegramRegistrationStatus{} + } + + if err := runTelegramSetupCLI(context.Background(), bufio.NewReader(strings.NewReader("")), t.TempDir(), "/fake/backup.env", logging.NewBootstrapLogger()); err != nil { + t.Fatalf("runTelegramSetupCLI error: %v", err) + } +} + +func TestRunTelegramSetupCLI_SkipOnPersonalMode(t *testing.T) { + stubTelegramSetupCLIDeps(t) + + telegramSetupBuildBootstrap = func(configPath, baseDir string) (orchestrator.TelegramSetupBootstrap, error) { + return orchestrator.TelegramSetupBootstrap{ + Eligibility: orchestrator.TelegramSetupSkipPersonalMode, + ConfigLoaded: true, + TelegramEnabled: true, + TelegramMode: "personal", + }, nil + } + telegramSetupPromptYesNo = func(ctx context.Context, reader *bufio.Reader, question string, defaultYes bool) (bool, error) { + t.Fatalf("prompt should not run for personal mode") + return false, nil + } + + if err := runTelegramSetupCLI(context.Background(), bufio.NewReader(strings.NewReader("")), t.TempDir(), "/fake/backup.env", logging.NewBootstrapLogger()); err != nil { + t.Fatalf("runTelegramSetupCLI error: %v", err) + } +} + +func TestRunTelegramSetupCLI_SkipOnMissingIdentity(t *testing.T) { + stubTelegramSetupCLIDeps(t) + + telegramSetupBuildBootstrap = func(configPath, baseDir string) (orchestrator.TelegramSetupBootstrap, error) { + return orchestrator.TelegramSetupBootstrap{ + Eligibility: orchestrator.TelegramSetupSkipIdentityUnavailable, + ConfigLoaded: true, + TelegramEnabled: true, + TelegramMode: "centralized", + IdentityDetectError: "detect failed", + }, nil + } + telegramSetupPromptYesNo = func(ctx context.Context, reader *bufio.Reader, question string, defaultYes bool) (bool, error) { + t.Fatalf("prompt should not run when identity is unavailable") + return false, nil + } + + if err := runTelegramSetupCLI(context.Background(), bufio.NewReader(strings.NewReader("")), t.TempDir(), "/fake/backup.env", logging.NewBootstrapLogger()); err != nil { + t.Fatalf("runTelegramSetupCLI error: %v", err) + } +} + +func TestRunTelegramSetupCLI_DeclineVerification(t *testing.T) { + stubTelegramSetupCLIDeps(t) + + telegramSetupBuildBootstrap = func(configPath, baseDir string) (orchestrator.TelegramSetupBootstrap, error) { + return orchestrator.TelegramSetupBootstrap{ + Eligibility: orchestrator.TelegramSetupEligibleCentralized, + ConfigLoaded: true, + TelegramEnabled: true, + TelegramMode: "centralized", + ServerAPIHost: "https://api.example.test", + ServerID: "123456789", + IdentityFile: "/tmp/.server_identity", + }, nil + } + telegramSetupPromptYesNo = func(ctx context.Context, reader *bufio.Reader, question string, defaultYes bool) (bool, error) { + if !strings.Contains(question, "Check Telegram pairing now?") { + t.Fatalf("unexpected question: %s", question) + } + return false, nil + } + telegramSetupCheckRegistration = func(ctx context.Context, serverAPIHost, serverID string, logger *logging.Logger) notify.TelegramRegistrationStatus { + t.Fatalf("registration check should not run when user declines") + return notify.TelegramRegistrationStatus{} + } + + if err := runTelegramSetupCLI(context.Background(), bufio.NewReader(strings.NewReader("")), t.TempDir(), "/fake/backup.env", logging.NewBootstrapLogger()); err != nil { + t.Fatalf("runTelegramSetupCLI error: %v", err) + } +} + +func TestRunTelegramSetupCLI_VerifiesSuccessfully(t *testing.T) { + stubTelegramSetupCLIDeps(t) + + var promptCalls int + var checkCalls int + telegramSetupBuildBootstrap = func(configPath, baseDir string) (orchestrator.TelegramSetupBootstrap, error) { + return orchestrator.TelegramSetupBootstrap{ + Eligibility: orchestrator.TelegramSetupEligibleCentralized, + ConfigLoaded: true, + TelegramEnabled: true, + TelegramMode: "centralized", + ServerAPIHost: "https://api.example.test", + ServerID: "123456789", + IdentityFile: "/tmp/.server_identity", + }, nil + } + telegramSetupPromptYesNo = func(ctx context.Context, reader *bufio.Reader, question string, defaultYes bool) (bool, error) { + promptCalls++ + if promptCalls != 1 { + t.Fatalf("unexpected prompt call count: %d", promptCalls) + } + return true, nil + } + telegramSetupCheckRegistration = func(ctx context.Context, serverAPIHost, serverID string, logger *logging.Logger) notify.TelegramRegistrationStatus { + checkCalls++ + if serverAPIHost != "https://api.example.test" { + t.Fatalf("serverAPIHost=%q, want https://api.example.test", serverAPIHost) + } + if serverID != "123456789" { + t.Fatalf("serverID=%q, want 123456789", serverID) + } + return notify.TelegramRegistrationStatus{Code: 200, Message: "ok"} + } + + if err := runTelegramSetupCLI(context.Background(), bufio.NewReader(strings.NewReader("")), t.TempDir(), "/fake/backup.env", logging.NewBootstrapLogger()); err != nil { + t.Fatalf("runTelegramSetupCLI error: %v", err) + } + if promptCalls != 1 { + t.Fatalf("promptCalls=%d, want 1", promptCalls) + } + if checkCalls != 1 { + t.Fatalf("checkCalls=%d, want 1", checkCalls) + } +} + +func TestRunTelegramSetupCLI_BootstrapErrorNonBlocking(t *testing.T) { + stubTelegramSetupCLIDeps(t) + + telegramSetupBuildBootstrap = func(configPath, baseDir string) (orchestrator.TelegramSetupBootstrap, error) { + return orchestrator.TelegramSetupBootstrap{}, errors.New("boom") + } + telegramSetupPromptYesNo = func(ctx context.Context, reader *bufio.Reader, question string, defaultYes bool) (bool, error) { + t.Fatalf("prompt should not run on bootstrap error") + return false, nil + } + + if err := runTelegramSetupCLI(context.Background(), bufio.NewReader(strings.NewReader("")), t.TempDir(), "/fake/backup.env", logging.NewBootstrapLogger()); err != nil { + t.Fatalf("runTelegramSetupCLI error: %v", err) + } +} diff --git a/cmd/proxsave/upgrade.go b/cmd/proxsave/upgrade.go index 9f4ff1f..8b89e36 100644 --- a/cmd/proxsave/upgrade.go +++ b/cmd/proxsave/upgrade.go @@ -177,7 +177,7 @@ func runUpgrade(ctx context.Context, args *cli.Args, bootstrap *logging.Bootstra cleanupLegacyBashSymlinks(baseDir, bootstrap) ensureGoSymlink(execPath, bootstrap) - cronSchedule := resolveCronSchedule(nil) + cronSchedule := resolveCronScheduleFromEnv() logging.DebugStepBootstrap(bootstrap, "upgrade workflow", "migrating cron entries") migrateLegacyCronEntries(ctx, baseDir, execPath, bootstrap, cronSchedule) @@ -631,7 +631,7 @@ func printUpgradeFooter(upgradeErr error, version, configPath, baseDir, telegram fmt.Println(" proxsave (alias: proxmox-backup) - Start backup") fmt.Println(" --upgrade - Update proxsave binary to latest release (also adds missing keys to backup.env)") fmt.Println(" --install - Re-run interactive installation/setup") - fmt.Println(" --new-install - Wipe installation directory (keep env/identity) then run installer") + fmt.Println(" --new-install - Wipe installation directory (keep build/env/identity) then run installer") fmt.Println(" --upgrade-config - Upgrade configuration file using the embedded template (run after installing a new binary)") fmt.Println() diff --git a/docs/CLI_REFERENCE.md b/docs/CLI_REFERENCE.md index c985046..5ed74a5 100644 --- a/docs/CLI_REFERENCE.md +++ b/docs/CLI_REFERENCE.md @@ -131,19 +131,23 @@ Some interactive commands support two interface modes: **Use `--cli` when**: TUI rendering issues occur or advanced debugging is needed. **Existing configuration**: -- If the configuration file already exists, the **TUI wizard** prompts you to **Overwrite**, **Edit existing** (uses the current file as base and pre-fills the wizard fields), or **Keep & exit**. -- In **CLI mode** (`--cli`), you will be prompted to overwrite; choosing "No" keeps the file and skips the configuration wizard. +- If the configuration file already exists, **both TUI and CLI** now offer the same choices: + - **Overwrite** (start from embedded template) + - **Edit existing** (use current file as base and pre-fill wizard fields) + - **Keep existing & continue** (leave file untouched and skip configuration wizard) + - **Cancel** (abort installation) +- In **Keep existing & continue** mode, config-dependent post-steps are skipped (encryption setup, post-install audit, Telegram pairing), while finalization steps still run (docs install, symlink/cron finalization, permissions normalization). **Wizard workflow**: 1. Generates/updates the configuration file (`configs/backup.env` by default) -2. Optionally configures secondary storage +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`) 6. Optionally configures encryption (AGE setup) -7. (TUI) Optionally selects a cron time (HH:MM) for the `proxsave` cron entry +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`) -9. (If Telegram enabled) Shows Server ID and offers pairing verification (retry/skip supported) +9. (If Telegram centralized mode is enabled and config + Server ID resolve successfully) Shows Server ID and offers pairing verification (retry/skip supported); otherwise install continues and logs why pairing was skipped 10. Finalizes installation (symlinks, cron migration, permission checks) **Install log**: The installer writes a session log under `/tmp/proxsave/install-*.log` (includes audit results and Telegram pairing outcome). @@ -346,21 +350,21 @@ Next step: ./build/proxsave --dry-run # TUI mode (default) - terminal interface ./build/proxsave --newkey -# CLI mode - text prompts (for debugging) +# CLI mode - text prompts (for debugging or when TUI rendering is unavailable) ./build/proxsave --newkey --cli ``` **Use `--cli` when**: TUI rendering issues occur or advanced debugging is needed. **`--newkey` workflow**: -1. Uses the default recipient file: `${BASE_DIR}/identity/age/recipient.txt` (same as `AGE_RECIPIENT_FILE` in the template) +1. Uses the configured `AGE_RECIPIENT_FILE` when present; otherwise falls back to `${BASE_DIR}/identity/age/recipient.txt` 2. Prompts for one of: - **Existing public recipient**: paste an `age1...` recipient - **Passphrase-derived**: enter a passphrase (proxsave derives the recipient; the passphrase is **not stored**) - **Private key-derived**: paste an `AGE-SECRET-KEY-...` key (not stored; proxsave stores only the derived public recipient) 3. Writes/overwrites the recipient file after confirmation -**Note**: In `--cli` mode (text prompts), you can add multiple recipients. The default TUI flow saves a single recipient; you can always add more by editing the recipient file (one per line). +**Note**: Both CLI and TUI `--newkey` flows support adding multiple recipients and de-duplicate repeated entries before saving. **For complete encryption guide**, see: **[Encryption Guide](ENCRYPTION.md)** diff --git a/docs/CLOUD_STORAGE.md b/docs/CLOUD_STORAGE.md index 240b112..3e4161b 100644 --- a/docs/CLOUD_STORAGE.md +++ b/docs/CLOUD_STORAGE.md @@ -815,7 +815,7 @@ cp -a /restore/* / A: No, currently only one `CLOUD_REMOTE` is supported. Workaround: Use `rclone union` to combine multiple backends. **Q: Can I use a network address like "192.168.0.10/folder" for SECONDARY_PATH?** -A: **No**. `SECONDARY_PATH` and `BACKUP_PATH` require **filesystem-mounted paths only**. Network shares must be mounted first using NFS/CIFS/SMB mount commands, then you use the local mount point path (e.g., `/mnt/nas-backup`). +A: **No**. `SECONDARY_PATH` and `BACKUP_PATH` require **absolute local filesystem paths**. For network shares, mount them first using NFS/CIFS/SMB, then use the local mount point path (e.g., `/mnt/nas-backup`). If you want to use a direct network address without mounting, configure it as `CLOUD_REMOTE` using rclone with an S3-compatible backend (like MinIO) or appropriate protocol. diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index e0f3d82..6ae7669 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -408,10 +408,10 @@ BACKUP_EXCLUDE_PATTERNS="*/cache/**, /var/tmp/**, *.log" # Enable secondary storage SECONDARY_ENABLED=false # true | false -# Secondary backup path +# Secondary backup path (required when SECONDARY_ENABLED=true) SECONDARY_PATH=/mnt/secondary/backup -# Secondary log path +# Secondary log path (optional) SECONDARY_LOG_PATH=/mnt/secondary/log ``` @@ -421,7 +421,9 @@ Additional local storage for redundant backup copies - mounted NAS, USB drives, ### IMPORTANT PATH REQUIREMENTS -- `SECONDARY_PATH` **must be a filesystem-mounted path** (e.g., `/mnt/nas-backup`, `/media/usb-drive`) +- `SECONDARY_PATH` **must be an absolute local filesystem path** (e.g., `/mnt/nas-backup`, `/media/usb-drive`) +- `SECONDARY_LOG_PATH`, when set, must follow the **same absolute local path rules** +- `SECONDARY_LOG_PATH` is optional; when empty, secondary backup copies still run, but secondary log copy/cleanup is disabled - `SECONDARY_PATH` **CANNOT** be a network address (e.g., `192.168.0.10/folder`, `//server/share`) - Network shares **must be mounted first** using standard Linux mounting (NFS/CIFS/SMB) @@ -443,6 +445,7 @@ sudo mount -t cifs //192.168.0.10/backup /mnt/nas-backup -o credentials=/root/.s **2. Then configure SECONDARY_PATH**: ```bash SECONDARY_PATH=/mnt/nas-backup # ✓ Correct - uses mounted path +SECONDARY_LOG_PATH=/mnt/nas-logs # Optional ``` ### What NOT to Do @@ -460,6 +463,7 @@ SECONDARY_PATH=\\192.168.0.10\backup # ✗ WRONG - Windows path - Secondary storage is **non-critical** (failures log warnings, don't abort backup) - Files copied via native Go (no dependency on rclone) - Same retention policy as primary storage +- Invalid configured secondary paths fail fast during configuration loading --- @@ -800,8 +804,8 @@ TELEGRAM_CHAT_ID= # Chat ID (your user ID or group ID) 3. Open Telegram and start `@ProxmoxAN_bot` 4. Send the Server ID to the bot 5. Verify pairing: - - **TUI installer**: press `Check` (retry supported). `Continue` appears only after success; use `Skip` (or `ESC`) to proceed without verification. - - **CLI installer**: opt into the check and retry when prompted. + - **TUI installer**: the Telegram setup screen is shown only when config loads successfully, centralized mode is active, and a Server ID is available. When shown, press `Check` (retry supported). `Continue` appears only after success; use `Skip` (or `ESC`) to proceed without verification. + - **CLI installer**: the same eligibility rules apply, then you can opt into the check and retry when prompted. - Normal runs also verify automatically and will skip Telegram if not paired yet. **Setup personal bot**: diff --git a/docs/ENCRYPTION.md b/docs/ENCRYPTION.md index 63dcad1..99fffc3 100644 --- a/docs/ENCRYPTION.md +++ b/docs/ENCRYPTION.md @@ -133,7 +133,7 @@ You can create/update recipients in two ways: # Dedicated wizard (TUI by default) ./build/proxsave --newkey -# Use CLI prompts instead of TUI (useful for debugging and multi-recipient setups) +# Use CLI prompts instead of TUI (useful for debugging or when TUI rendering is unavailable) ./build/proxsave --newkey --cli ``` @@ -147,7 +147,7 @@ If `ENCRYPT_ARCHIVE=true` and no recipients are configured, proxsave will start **Notes**: - Proxsave stores **only recipients** (public keys) in `${BASE_DIR}/identity/age/recipient.txt`. Keep private keys and passphrases offline. - `AGE_RECIPIENT` and `AGE_RECIPIENT_FILE` are **merged and de-duplicated**. -- The CLI setup supports multiple recipients; otherwise you can add multiple recipients by editing the file (one per line). +- Both TUI and CLI setup flows support multiple recipients and de-duplicate repeated entries before saving. --- diff --git a/docs/INSTALL.md b/docs/INSTALL.md index 43b7954..e09b6b7 100644 --- a/docs/INSTALL.md +++ b/docs/INSTALL.md @@ -207,28 +207,45 @@ The installation wizard creates your configuration file interactively: ./build/proxsave --new-install ``` -If the configuration file already exists, the **TUI wizard** will ask whether to: +If the configuration file already exists, **both TUI and CLI** ask whether to: - **Overwrite** (start from the embedded template) - **Edit existing** (use the current file as base and pre-fill the wizard fields) -- **Keep & exit** (leave the file untouched and exit) +- **Keep existing & continue** (leave the file untouched and skip the configuration wizard) +- **Cancel** (exit installation) + +In **Keep existing & continue** mode, config-dependent post-steps are skipped: +- AGE setup +- Post-install check wizard +- Telegram pairing wizard + +Final install steps still run: +- Support docs installation +- Symlink and cron finalization +- Permission normalization **Wizard prompts:** 1. **Configuration file path**: Default `configs/backup.env` (accepts absolute or relative paths within repo) -2. **Secondary storage**: Optional path for backup/log copies +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) 6. **Encryption**: AGE encryption setup (runs sub-wizard immediately if enabled) -7. **Cron schedule**: Choose cron time (HH:MM) for the `proxsave` cron entry (TUI mode only) +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 -9. **Telegram pairing (optional)**: If Telegram (centralized) is enabled, shows your Server ID and lets you verify pairing with the bot (retry/skip supported) +9. **Telegram pairing (optional)**: If Telegram centralized mode is enabled and the installer can load a valid config plus a Server ID, it shows your Server ID and lets you verify pairing with the bot (retry/skip supported). Otherwise installation continues and logs why pairing was skipped. #### Telegram pairing wizard (TUI) -If you enable Telegram notifications during `--install` (centralized bot), the installer opens an additional **Telegram Setup** screen after the post-install check. +If you enable Telegram notifications during `--install`, the installer opens an additional **Telegram Setup** screen only when all of these are true: +- `TELEGRAM_ENABLED=true` +- `BOT_TELEGRAM_TYPE=centralized` (or left empty, which defaults to centralized) +- `backup.env` loads successfully +- a Server ID can be resolved from `/identity/.server_identity` + +If any of those checks fail, installation continues without this screen and logs the skip reason (for example config load failure, personal mode, or missing server identity). -It does **not** modify your `backup.env`. It only: +When shown, it does **not** modify your `backup.env`. It only: - Computes/loads the **Server ID** and persists it (identity file) - Guides you through pairing with the centralized bot - Lets you verify pairing immediately (retry supported) @@ -239,7 +256,7 @@ It does **not** modify your `backup.env`. It only: - **Status**: live feedback from the pairing check - **Actions**: - `Check`: verify pairing (press again to retry) - - `Continue`: available only after a successful check (centralized mode), or immediately in personal mode / when the Server ID is unavailable + - `Continue`: available only after a successful check - `Skip`: leave without verification (in centralized mode, `ESC` behaves like Skip when not verified) **Where the Server ID is stored:** @@ -251,7 +268,7 @@ It does **not** modify your `backup.env`. It only: - Other errors: temporary server/network issue; retry or skip and pair later **CLI mode:** -- With `--install --cli`, the installer prints the Server ID and asks whether to run the check now (with a retry loop). +- With `--install --cli`, the installer follows the same eligibility rules, then prints the Server ID and asks whether to run the check now (with a retry loop). **Features:** @@ -260,7 +277,7 @@ It does **not** modify your `backup.env`. It only: - Creates all necessary directories with proper permissions (0700) - Immediate AGE key generation if encryption is enabled - Optional post-install audit to disable unused collectors (keeps changes explicit; nothing is disabled silently) -- Optional Telegram pairing wizard (centralized mode) that displays Server ID and verifies the bot registration (retry/skip supported) +- Optional Telegram pairing wizard (centralized mode, valid config, Server ID available) that displays Server ID and verifies the bot registration (retry/skip supported) - Install session log under `/tmp/proxsave/install-*.log` (includes audit results and Telegram pairing outcome) After completion, edit `configs/backup.env` manually for advanced options. diff --git a/internal/config/config.go b/internal/config/config.go index bb35388..c2918df 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -344,10 +344,28 @@ func (c *Config) parse() error { if err := c.parseCollectionSettings(); err != nil { return err } + if err := c.validateSecondarySettings(); err != nil { + return err + } c.autoDetectPBSAuth() return nil } +func (c *Config) validateSecondarySettings() error { + if err := ValidateOptionalSecondaryPath(c.SecondaryPath); err != nil { + return err + } + if c.SecondaryEnabled { + if err := ValidateRequiredSecondaryPath(c.SecondaryPath); err != nil { + return err + } + } + if err := ValidateOptionalSecondaryLogPath(c.SecondaryLogPath); err != nil { + return err + } + return nil +} + func (c *Config) parseGeneralSettings() { c.BackupEnabled = c.getBool("BACKUP_ENABLED", true) c.DryRun = c.getBool("DRY_RUN", false) diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 3fb7ab0..b3611ee 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -288,6 +288,48 @@ func TestLoadConfigNotFound(t *testing.T) { } } +func TestLoadConfigRejectsInvalidSecondaryPathEvenWhenDisabled(t *testing.T) { + tmpDir := t.TempDir() + configPath := filepath.Join(tmpDir, "invalid-secondary.env") + content := `BACKUP_PATH=/test/backup +LOG_PATH=/test/log +SECONDARY_ENABLED=false +SECONDARY_PATH=remote:path +` + if err := os.WriteFile(configPath, []byte(content), 0o644); err != nil { + t.Fatalf("Failed to create config file: %v", err) + } + + _, err := LoadConfig(configPath) + if err == nil { + t.Fatal("expected LoadConfig to fail") + } + if got, want := err.Error(), "SECONDARY_PATH must be an absolute local filesystem path"; !strings.Contains(got, want) { + t.Fatalf("LoadConfig() error = %q, want substring %q", got, want) + } +} + +func TestLoadConfigRejectsInvalidSecondaryLogPathWhenConfigured(t *testing.T) { + tmpDir := t.TempDir() + configPath := filepath.Join(tmpDir, "invalid-secondary-log.env") + content := `BACKUP_PATH=/test/backup +LOG_PATH=/test/log +SECONDARY_ENABLED=false +SECONDARY_LOG_PATH=remote:/logs +` + if err := os.WriteFile(configPath, []byte(content), 0o644); err != nil { + t.Fatalf("Failed to create config file: %v", err) + } + + _, err := LoadConfig(configPath) + if err == nil { + t.Fatal("expected LoadConfig to fail") + } + if got, want := err.Error(), "SECONDARY_LOG_PATH must be an absolute local filesystem path"; !strings.Contains(got, want) { + t.Fatalf("LoadConfig() error = %q, want substring %q", got, want) + } +} + func TestLoadConfigWithQuotes(t *testing.T) { tmpDir := t.TempDir() configPath := filepath.Join(tmpDir, "test_quotes.env") diff --git a/internal/config/env_mutation.go b/internal/config/env_mutation.go new file mode 100644 index 0000000..5ade607 --- /dev/null +++ b/internal/config/env_mutation.go @@ -0,0 +1,24 @@ +package config + +import ( + "strings" + + "github.com/tis24dev/proxsave/pkg/utils" +) + +// ApplySecondaryStorageSettings writes the canonical secondary-storage state +// into an env template. Disabled secondary storage always clears both +// SECONDARY_PATH and SECONDARY_LOG_PATH so the saved config matches user intent. +func ApplySecondaryStorageSettings(template string, enabled bool, secondaryPath string, secondaryLogPath string) string { + if enabled { + template = utils.SetEnvValue(template, "SECONDARY_ENABLED", "true") + template = utils.SetEnvValue(template, "SECONDARY_PATH", strings.TrimSpace(secondaryPath)) + template = utils.SetEnvValue(template, "SECONDARY_LOG_PATH", strings.TrimSpace(secondaryLogPath)) + return template + } + + template = utils.SetEnvValue(template, "SECONDARY_ENABLED", "false") + template = utils.SetEnvValue(template, "SECONDARY_PATH", "") + template = utils.SetEnvValue(template, "SECONDARY_LOG_PATH", "") + return template +} diff --git a/internal/config/env_mutation_test.go b/internal/config/env_mutation_test.go new file mode 100644 index 0000000..6b434b6 --- /dev/null +++ b/internal/config/env_mutation_test.go @@ -0,0 +1,74 @@ +package config + +import ( + "strings" + "testing" +) + +func TestApplySecondaryStorageSettingsEnabled(t *testing.T) { + template := "SECONDARY_ENABLED=false\nSECONDARY_PATH=\nSECONDARY_LOG_PATH=\n" + + got := ApplySecondaryStorageSettings(template, true, " /mnt/secondary ", " /mnt/secondary/log ") + + for _, needle := range []string{ + "SECONDARY_ENABLED=true", + "SECONDARY_PATH=/mnt/secondary", + "SECONDARY_LOG_PATH=/mnt/secondary/log", + } { + if !strings.Contains(got, needle) { + t.Fatalf("expected %q in template:\n%s", needle, got) + } + } +} + +func TestApplySecondaryStorageSettingsEnabledWithEmptyLogPath(t *testing.T) { + template := "SECONDARY_ENABLED=false\nSECONDARY_PATH=\nSECONDARY_LOG_PATH=/old/log\n" + + got := ApplySecondaryStorageSettings(template, true, "/mnt/secondary", "") + + for _, needle := range []string{ + "SECONDARY_ENABLED=true", + "SECONDARY_PATH=/mnt/secondary", + "SECONDARY_LOG_PATH=", + } { + if !strings.Contains(got, needle) { + t.Fatalf("expected %q in template:\n%s", needle, got) + } + } +} + +func TestApplySecondaryStorageSettingsDisabledClearsValues(t *testing.T) { + template := "SECONDARY_ENABLED=true\nSECONDARY_PATH=/mnt/old-secondary\nSECONDARY_LOG_PATH=/mnt/old-secondary/logs\n" + + got := ApplySecondaryStorageSettings(template, false, "/ignored", "/ignored/logs") + + for _, needle := range []string{ + "SECONDARY_ENABLED=false", + "SECONDARY_PATH=", + "SECONDARY_LOG_PATH=", + } { + if !strings.Contains(got, needle) { + t.Fatalf("expected %q in template:\n%s", needle, got) + } + } + if strings.Contains(got, "/mnt/old-secondary") { + t.Fatalf("expected old secondary values to be cleared:\n%s", got) + } +} + +func TestApplySecondaryStorageSettingsDisabledAppendsCanonicalState(t *testing.T) { + template := "BACKUP_ENABLED=true\n" + + got := ApplySecondaryStorageSettings(template, false, "", "") + + for _, needle := range []string{ + "BACKUP_ENABLED=true", + "SECONDARY_ENABLED=false", + "SECONDARY_PATH=", + "SECONDARY_LOG_PATH=", + } { + if !strings.Contains(got, needle) { + t.Fatalf("expected %q in template:\n%s", needle, got) + } + } +} diff --git a/internal/config/migration.go b/internal/config/migration.go index 653ddd4..d48b9b7 100644 --- a/internal/config/migration.go +++ b/internal/config/migration.go @@ -206,8 +206,13 @@ func validateMigratedConfig(cfg *Config) error { if strings.TrimSpace(cfg.LogPath) == "" { return fmt.Errorf("LOG_PATH cannot be empty") } - if cfg.SecondaryEnabled && strings.TrimSpace(cfg.SecondaryPath) == "" { - return fmt.Errorf("SECONDARY_PATH required when SECONDARY_ENABLED=true") + if cfg.SecondaryEnabled { + if err := ValidateRequiredSecondaryPath(cfg.SecondaryPath); err != nil { + return err + } + if err := ValidateOptionalSecondaryLogPath(cfg.SecondaryLogPath); err != nil { + return err + } } if cfg.CloudEnabled && strings.TrimSpace(cfg.CloudRemote) == "" { return fmt.Errorf("CLOUD_REMOTE required when CLOUD_ENABLED=true") diff --git a/internal/config/migration_test.go b/internal/config/migration_test.go index 450d770..eb30065 100644 --- a/internal/config/migration_test.go +++ b/internal/config/migration_test.go @@ -159,6 +159,8 @@ const baseInstallTemplate = `BACKUP_ENABLED=true BACKUP_PATH=/default/backup LOG_PATH=/default/log SECONDARY_ENABLED=false +SECONDARY_PATH= +SECONDARY_LOG_PATH= CLOUD_ENABLED=false SET_BACKUP_PERMISSIONS=false BACKUP_USER=backup @@ -192,6 +194,29 @@ func TestMigrateLegacyEnvCreatesConfigAndKeepsValues(t *testing.T) { }) } +func TestMigrateLegacyEnvRejectsInvalidSecondaryPath(t *testing.T) { + withTemplate(t, baseInstallTemplate, func() { + tmpDir := t.TempDir() + legacyPath := filepath.Join(tmpDir, "legacy.env") + outputPath := filepath.Join(tmpDir, "backup.env") + legacyContent := strings.Join([]string{ + "ENABLE_SECONDARY_BACKUP=true", + "SECONDARY_BACKUP_PATH=remote:path", + }, "\n") + "\n" + if err := os.WriteFile(legacyPath, []byte(legacyContent), 0600); err != nil { + t.Fatalf("failed to write legacy env: %v", err) + } + + _, err := MigrateLegacyEnv(legacyPath, outputPath) + if err == nil { + t.Fatal("expected migration to fail") + } + if got, want := err.Error(), "SECONDARY_PATH must be an absolute local filesystem path"; !strings.Contains(got, want) { + t.Fatalf("MigrateLegacyEnv error = %q, want substring %q", got, want) + } + }) +} + func TestMigrateLegacyEnvCreatesBackupWhenOverwriting(t *testing.T) { withTemplate(t, baseInstallTemplate, func() { tmpDir := t.TempDir() diff --git a/internal/config/templates/backup.env b/internal/config/templates/backup.env index ad5a9b0..8369ff6 100644 --- a/internal/config/templates/backup.env +++ b/internal/config/templates/backup.env @@ -93,7 +93,7 @@ LOG_PATH=${BASE_DIR}/log # Primary log storage path # ---------------------------------------------------------------------- # Secondary storage # ---------------------------------------------------------------------- -# IMPORTANT: SECONDARY_PATH must be a filesystem-mounted path (e.g., /mnt/nas-backup) +# IMPORTANT: SECONDARY_PATH must be an absolute local filesystem path (e.g., /mnt/nas-backup) # It CANNOT be a network address like "192.168.0.10/folder" or "//server/share" # # For local network storage (NAS): @@ -111,8 +111,8 @@ LOG_PATH=${BASE_DIR}/log # Primary log storage path # For direct network access without mounting, use CLOUD_REMOTE with rclone instead. # ---------------------------------------------------------------------- SECONDARY_ENABLED=false # true-false = enable disable copy backup on secondary path -SECONDARY_PATH= # Secondary backup storage path -SECONDARY_LOG_PATH= # Secondary log storage path +SECONDARY_PATH= # Required absolute secondary backup path when secondary storage is enabled +SECONDARY_LOG_PATH= # Optional absolute secondary log path (same rules as SECONDARY_PATH) # ---------------------------------------------------------------------- # Cloud storage (rclone) diff --git a/internal/config/validation_secondary.go b/internal/config/validation_secondary.go new file mode 100644 index 0000000..0656420 --- /dev/null +++ b/internal/config/validation_secondary.go @@ -0,0 +1,58 @@ +package config + +import ( + "fmt" + "path/filepath" + "strings" +) + +const secondaryPathFormatMessage = "must be an absolute local filesystem path" + +// ValidateRequiredSecondaryPath validates SECONDARY_PATH when secondary storage is enabled. +func ValidateRequiredSecondaryPath(path string) error { + return validateSecondaryLocalPath(path, "SECONDARY_PATH", true) +} + +// ValidateOptionalSecondaryPath validates SECONDARY_PATH when configured but not required. +func ValidateOptionalSecondaryPath(path string) error { + return validateSecondaryLocalPath(path, "SECONDARY_PATH", false) +} + +// ValidateOptionalSecondaryLogPath validates SECONDARY_LOG_PATH when provided. +func ValidateOptionalSecondaryLogPath(path string) error { + return validateSecondaryLocalPath(path, "SECONDARY_LOG_PATH", false) +} + +func validateSecondaryLocalPath(path, fieldName string, required bool) error { + clean := strings.TrimSpace(path) + if clean == "" { + if required { + return fmt.Errorf("%s is required when SECONDARY_ENABLED=true", fieldName) + } + return nil + } + + if isUNCStylePath(clean) { + return fmt.Errorf("%s %s", fieldName, secondaryPathFormatMessage) + } + + if strings.Contains(clean, ":") && !filepath.IsAbs(clean) { + return fmt.Errorf("%s %s", fieldName, secondaryPathFormatMessage) + } + + if !filepath.IsAbs(clean) { + return fmt.Errorf("%s %s", fieldName, secondaryPathFormatMessage) + } + + return nil +} + +func isUNCStylePath(path string) bool { + if strings.HasPrefix(path, `\\`) { + return true + } + if strings.HasPrefix(path, "//") { + return len(path) == 2 || path[2] != '/' + } + return false +} diff --git a/internal/config/validation_secondary_test.go b/internal/config/validation_secondary_test.go new file mode 100644 index 0000000..b1aeb75 --- /dev/null +++ b/internal/config/validation_secondary_test.go @@ -0,0 +1,99 @@ +package config + +import "testing" + +func TestValidateRequiredSecondaryPath(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + path string + wantErr string + }{ + {name: "valid mount path", path: "/mnt/secondary"}, + {name: "valid subdirectory", path: "/mnt/secondary/log"}, + {name: "valid absolute with colon", path: "/mnt/data:archive"}, + {name: "empty", path: "", wantErr: "SECONDARY_PATH is required when SECONDARY_ENABLED=true"}, + {name: "relative", path: "relative/path", wantErr: "SECONDARY_PATH must be an absolute local filesystem path"}, + {name: "rclone remote", path: "gdrive:backups", wantErr: "SECONDARY_PATH must be an absolute local filesystem path"}, + {name: "host remote", path: "host:/backup", wantErr: "SECONDARY_PATH must be an absolute local filesystem path"}, + {name: "unc share", path: "//server/share", wantErr: "SECONDARY_PATH must be an absolute local filesystem path"}, + {name: "windows unc share", path: `\\server\share`, wantErr: "SECONDARY_PATH must be an absolute local filesystem path"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := ValidateRequiredSecondaryPath(tt.path) + if tt.wantErr == "" { + if err != nil { + t.Fatalf("ValidateRequiredSecondaryPath(%q) error = %v", tt.path, err) + } + return + } + if err == nil || err.Error() != tt.wantErr { + t.Fatalf("ValidateRequiredSecondaryPath(%q) error = %v, want %q", tt.path, err, tt.wantErr) + } + }) + } +} + +func TestValidateOptionalSecondaryLogPath(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + path string + wantErr string + }{ + {name: "empty allowed", path: ""}, + {name: "valid path", path: "/mnt/secondary/log"}, + {name: "relative", path: "logs", wantErr: "SECONDARY_LOG_PATH must be an absolute local filesystem path"}, + {name: "remote style", path: "remote:/logs", wantErr: "SECONDARY_LOG_PATH must be an absolute local filesystem path"}, + {name: "unc share", path: "//server/logs", wantErr: "SECONDARY_LOG_PATH must be an absolute local filesystem path"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := ValidateOptionalSecondaryLogPath(tt.path) + if tt.wantErr == "" { + if err != nil { + t.Fatalf("ValidateOptionalSecondaryLogPath(%q) error = %v", tt.path, err) + } + return + } + if err == nil || err.Error() != tt.wantErr { + t.Fatalf("ValidateOptionalSecondaryLogPath(%q) error = %v, want %q", tt.path, err, tt.wantErr) + } + }) + } +} + +func TestValidateOptionalSecondaryPath(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + path string + wantErr string + }{ + {name: "empty allowed", path: ""}, + {name: "valid path", path: "/mnt/secondary"}, + {name: "relative", path: "relative/path", wantErr: "SECONDARY_PATH must be an absolute local filesystem path"}, + {name: "remote style", path: "remote:/backup", wantErr: "SECONDARY_PATH must be an absolute local filesystem path"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := ValidateOptionalSecondaryPath(tt.path) + if tt.wantErr == "" { + if err != nil { + t.Fatalf("ValidateOptionalSecondaryPath(%q) error = %v", tt.path, err) + } + return + } + if err == nil || err.Error() != tt.wantErr { + t.Fatalf("ValidateOptionalSecondaryPath(%q) error = %v, want %q", tt.path, err, tt.wantErr) + } + }) + } +} diff --git a/internal/cron/cron.go b/internal/cron/cron.go new file mode 100644 index 0000000..4c6c519 --- /dev/null +++ b/internal/cron/cron.go @@ -0,0 +1,51 @@ +package cron + +import ( + "fmt" + "strconv" + "strings" +) + +const DefaultTime = "02:00" + +// NormalizeTime validates a cron time in HH:MM form and returns a normalized, +// zero-padded value. Empty input falls back to defaultValue. +func NormalizeTime(input string, defaultValue string) (string, error) { + value := strings.TrimSpace(input) + if value == "" { + value = strings.TrimSpace(defaultValue) + } + hour, minute, err := parseTime(value) + if err != nil { + return "", err + } + return fmt.Sprintf("%02d:%02d", hour, minute), nil +} + +// TimeToSchedule converts HH:MM into "MM HH * * *". Invalid input returns "". +func TimeToSchedule(cronTime string) string { + hour, minute, err := parseTime(strings.TrimSpace(cronTime)) + if err != nil { + return "" + } + return fmt.Sprintf("%02d %02d * * *", minute, hour) +} + +func parseTime(value string) (int, int, error) { + parts := strings.Split(strings.TrimSpace(value), ":") + if len(parts) != 2 { + return 0, 0, fmt.Errorf("cron time must be in HH:MM format") + } + + hour, err := strconv.Atoi(strings.TrimSpace(parts[0])) + if err != nil || hour < 0 || hour > 23 { + return 0, 0, fmt.Errorf("cron hour must be between 00 and 23") + } + + minute, err := strconv.Atoi(strings.TrimSpace(parts[1])) + if err != nil || minute < 0 || minute > 59 { + return 0, 0, fmt.Errorf("cron minute must be between 00 and 59") + } + + return hour, minute, nil +} diff --git a/internal/cron/cron_test.go b/internal/cron/cron_test.go new file mode 100644 index 0000000..4007750 --- /dev/null +++ b/internal/cron/cron_test.go @@ -0,0 +1,61 @@ +package cron + +import "testing" + +func TestNormalizeTime(t *testing.T) { + tests := []struct { + name string + input string + defaultValue string + want string + wantErr string + }{ + {name: "default fallback", input: "", defaultValue: DefaultTime, want: DefaultTime}, + {name: "normalize short values", input: "3:7", defaultValue: DefaultTime, want: "03:07"}, + {name: "trim whitespace", input: " 03:15 ", defaultValue: DefaultTime, want: "03:15"}, + {name: "invalid format", input: "0315", defaultValue: DefaultTime, wantErr: "cron time must be in HH:MM format"}, + {name: "invalid hour", input: "24:00", defaultValue: DefaultTime, wantErr: "cron hour must be between 00 and 23"}, + {name: "invalid minute", input: "00:60", defaultValue: DefaultTime, wantErr: "cron minute must be between 00 and 59"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := NormalizeTime(tt.input, tt.defaultValue) + if tt.wantErr != "" { + if err == nil { + t.Fatal("expected error") + } + if err.Error() != tt.wantErr { + t.Fatalf("NormalizeTime(%q) error = %q, want %q", tt.input, err.Error(), tt.wantErr) + } + return + } + if err != nil { + t.Fatalf("NormalizeTime(%q) returned error: %v", tt.input, err) + } + if got != tt.want { + t.Fatalf("NormalizeTime(%q) = %q, want %q", tt.input, got, tt.want) + } + }) + } +} + +func TestTimeToSchedule(t *testing.T) { + tests := []struct { + name string + in string + want string + }{ + {name: "valid", in: "02:05", want: "05 02 * * *"}, + {name: "normalized short", in: "2:5", want: "05 02 * * *"}, + {name: "invalid", in: "bad", want: ""}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := TimeToSchedule(tt.in); got != tt.want { + t.Fatalf("TimeToSchedule(%q) = %q, want %q", tt.in, got, tt.want) + } + }) + } +} diff --git a/internal/orchestrator/age_setup_ui.go b/internal/orchestrator/age_setup_ui.go new file mode 100644 index 0000000..172d2f0 --- /dev/null +++ b/internal/orchestrator/age_setup_ui.go @@ -0,0 +1,24 @@ +package orchestrator + +import "context" + +type AgeRecipientInputKind int + +const ( + AgeRecipientInputExisting AgeRecipientInputKind = iota + AgeRecipientInputPassphrase + AgeRecipientInputPrivateKey +) + +type AgeRecipientDraft struct { + Kind AgeRecipientInputKind + PublicKey string + Passphrase string + PrivateKey string +} + +type AgeSetupUI interface { + ConfirmOverwriteExistingRecipient(ctx context.Context, recipientPath string) (bool, error) + CollectRecipientDraft(ctx context.Context, recipientPath string) (*AgeRecipientDraft, error) + ConfirmAddAnotherRecipient(ctx context.Context, currentCount int) (bool, error) +} diff --git a/internal/orchestrator/age_setup_ui_cli.go b/internal/orchestrator/age_setup_ui_cli.go new file mode 100644 index 0000000..b6a9b4a --- /dev/null +++ b/internal/orchestrator/age_setup_ui_cli.go @@ -0,0 +1,86 @@ +package orchestrator + +import ( + "bufio" + "context" + "fmt" + "os" + + "github.com/tis24dev/proxsave/internal/logging" +) + +type cliAgeSetupUI struct { + reader *bufio.Reader + logger *logging.Logger +} + +func newCLIAgeSetupUI(reader *bufio.Reader, logger *logging.Logger) AgeSetupUI { + if reader == nil { + reader = bufio.NewReader(os.Stdin) + } + return &cliAgeSetupUI{ + reader: reader, + logger: logger, + } +} + +func (u *cliAgeSetupUI) ConfirmOverwriteExistingRecipient(ctx context.Context, recipientPath string) (bool, error) { + fmt.Printf("WARNING: this will remove the existing AGE recipients stored at %s. Existing backups remain decryptable with your old private key.\n", recipientPath) + return promptYesNoAge(ctx, u.reader, fmt.Sprintf("Delete %s and enter a new recipient? [y/N]: ", recipientPath)) +} + +func (u *cliAgeSetupUI) CollectRecipientDraft(ctx context.Context, recipientPath string) (*AgeRecipientDraft, error) { + for { + fmt.Println("\n[1] Use an existing AGE public key") + fmt.Println("[2] Generate an AGE public key using a personal passphrase/password - not stored on the server") + fmt.Println("[3] Generate an AGE public key from an existing personal private key - not stored on the server") + fmt.Println("[4] Exit setup") + + option, err := promptOptionAge(ctx, u.reader, "Select an option [1-4]: ") + if err != nil { + return nil, err + } + if option == "4" { + return nil, ErrAgeRecipientSetupAborted + } + + switch option { + case "1": + value, err := promptPublicRecipientAge(ctx, u.reader) + if err != nil { + u.warn(err) + continue + } + return &AgeRecipientDraft{Kind: AgeRecipientInputExisting, PublicKey: value}, nil + case "2": + passphrase, err := promptAndConfirmPassphraseAge(ctx) + if err != nil { + u.warn(err) + continue + } + return &AgeRecipientDraft{Kind: AgeRecipientInputPassphrase, Passphrase: passphrase}, nil + case "3": + privateKey, err := promptPrivateKeyValueAge(ctx) + if err != nil { + u.warn(err) + continue + } + return &AgeRecipientDraft{Kind: AgeRecipientInputPrivateKey, PrivateKey: privateKey}, nil + } + } +} + +func (u *cliAgeSetupUI) ConfirmAddAnotherRecipient(ctx context.Context, currentCount int) (bool, error) { + return promptYesNoAge(ctx, u.reader, "Add another recipient? [y/N]: ") +} + +func (u *cliAgeSetupUI) warn(err error) { + if err == nil { + return + } + if u.logger != nil { + u.logger.Warning("Encryption setup: %v", err) + return + } + fmt.Printf("WARNING: %v\n", err) +} diff --git a/internal/orchestrator/age_setup_workflow.go b/internal/orchestrator/age_setup_workflow.go new file mode 100644 index 0000000..e4a344e --- /dev/null +++ b/internal/orchestrator/age_setup_workflow.go @@ -0,0 +1,214 @@ +package orchestrator + +import ( + "context" + "errors" + "fmt" + "os" + "strings" + + "filippo.io/age" +) + +type AgeRecipientSetupResult struct { + RecipientPath string + WroteRecipientFile bool + ReusedExistingRecipients bool +} + +func (o *Orchestrator) EnsureAgeRecipientsReadyWithUI(ctx context.Context, ui AgeSetupUI) error { + if o == nil || o.cfg == nil || !o.cfg.EncryptArchive { + return nil + } + _, _, err := o.prepareAgeRecipientsWithUI(ctx, ui) + return err +} + +func (o *Orchestrator) EnsureAgeRecipientsReadyWithUIDetails(ctx context.Context, ui AgeSetupUI) (*AgeRecipientSetupResult, error) { + if o == nil || o.cfg == nil || !o.cfg.EncryptArchive { + return nil, nil + } + _, result, err := o.prepareAgeRecipientsWithUI(ctx, ui) + return result, err +} + +func (o *Orchestrator) EnsureAgeRecipientsReadyWithDetails(ctx context.Context) (*AgeRecipientSetupResult, error) { + return o.EnsureAgeRecipientsReadyWithUIDetails(ctx, nil) +} + +func (o *Orchestrator) prepareAgeRecipientsWithUI(ctx context.Context, ui AgeSetupUI) ([]age.Recipient, *AgeRecipientSetupResult, error) { + if o.cfg == nil || !o.cfg.EncryptArchive { + return nil, nil, nil + } + + if o.ageRecipientCache != nil && !o.forceNewAgeRecipient { + return cloneRecipients(o.ageRecipientCache), &AgeRecipientSetupResult{ReusedExistingRecipients: true}, nil + } + + recipients, candidatePath, err := o.collectRecipientStrings() + if err != nil { + return nil, nil, err + } + + result := &AgeRecipientSetupResult{} + if len(recipients) > 0 && !o.forceNewAgeRecipient { + result.ReusedExistingRecipients = true + } + + if len(recipients) == 0 { + if ui == nil { + if !o.isInteractiveShell() { + if o.logger != nil { + o.logger.Error("Encryption setup requires interaction. Run the script interactively to complete the AGE recipient setup, then re-run in automated mode.") + o.logger.Debug("HINT Set AGE_RECIPIENT or AGE_RECIPIENT_FILE to bypass the interactive setup and re-run.") + } + return nil, nil, fmt.Errorf("age recipients not configured") + } + ui = newCLIAgeSetupUI(nil, o.logger) + } + + wizardRecipients, setupResult, err := o.runAgeSetupWorkflow(ctx, candidatePath, ui) + if err != nil { + return nil, nil, err + } + recipients = append(recipients, wizardRecipients...) + result = setupResult + if o.cfg.AgeRecipientFile == "" { + o.cfg.AgeRecipientFile = setupResult.RecipientPath + } + } + + if len(recipients) == 0 { + return nil, nil, fmt.Errorf("no AGE recipients configured after setup") + } + + parsed, err := parseRecipientStrings(recipients) + if err != nil { + return nil, nil, err + } + o.ageRecipientCache = cloneRecipients(parsed) + o.forceNewAgeRecipient = false + return cloneRecipients(parsed), result, nil +} + +func (o *Orchestrator) runAgeSetupWorkflow(ctx context.Context, candidatePath string, ui AgeSetupUI) ([]string, *AgeRecipientSetupResult, error) { + targetPath := strings.TrimSpace(candidatePath) + if targetPath == "" { + targetPath = o.defaultAgeRecipientFile() + } + if targetPath == "" { + return nil, nil, fmt.Errorf("unable to determine default path for AGE recipients") + } + + if o.logger != nil { + o.logger.Info("Encryption setup: no AGE recipients found, starting interactive wizard") + } + + if o.forceNewAgeRecipient { + if _, err := os.Stat(targetPath); err == nil { + confirm, err := ui.ConfirmOverwriteExistingRecipient(ctx, targetPath) + if err != nil { + return nil, nil, mapAgeSetupAbort(err) + } + if !confirm { + return nil, nil, ErrAgeRecipientSetupAborted + } + if err := backupExistingRecipientFile(targetPath); err != nil && o.logger != nil { + o.logger.Warning("NOTE: %v", err) + } + } else if !errors.Is(err, os.ErrNotExist) { + return nil, nil, fmt.Errorf("failed to inspect existing AGE recipients at %s: %w", targetPath, err) + } + } + + recipients := make([]string, 0) + for { + draft, err := ui.CollectRecipientDraft(ctx, targetPath) + if err != nil { + return nil, nil, mapAgeSetupAbort(err) + } + if draft == nil { + return nil, nil, ErrAgeRecipientSetupAborted + } + + value, err := resolveAgeRecipientDraft(draft) + if err != nil { + if o.logger != nil { + o.logger.Warning("Encryption setup: %v", err) + } + continue + } + recipients = append(recipients, value) + + more, err := ui.ConfirmAddAnotherRecipient(ctx, len(recipients)) + if err != nil { + return nil, nil, mapAgeSetupAbort(err) + } + if !more { + break + } + } + + recipients = dedupeRecipientStrings(recipients) + if len(recipients) == 0 { + return nil, nil, fmt.Errorf("no recipients provided") + } + + if err := writeRecipientFile(targetPath, recipients); err != nil { + return nil, nil, err + } + + if o.logger != nil { + o.logger.Info("Saved AGE recipient to %s", targetPath) + o.logger.Info("Reminder: keep the AGE private key offline; the server stores only recipients.") + } + return recipients, &AgeRecipientSetupResult{ + RecipientPath: targetPath, + WroteRecipientFile: true, + }, nil +} + +func resolveAgeRecipientDraft(draft *AgeRecipientDraft) (string, error) { + if draft == nil { + return "", fmt.Errorf("recipient draft is required") + } + + switch draft.Kind { + case AgeRecipientInputExisting: + value := strings.TrimSpace(draft.PublicKey) + if err := ValidateRecipientString(value); err != nil { + return "", err + } + return value, nil + case AgeRecipientInputPassphrase: + passphrase := strings.TrimSpace(draft.Passphrase) + defer resetString(&passphrase) + if passphrase == "" { + return "", fmt.Errorf("passphrase cannot be empty") + } + if err := validatePassphraseStrength([]byte(passphrase)); err != nil { + return "", err + } + recipient, err := deriveDeterministicRecipientFromPassphrase(passphrase) + if err != nil { + return "", err + } + return recipient, nil + case AgeRecipientInputPrivateKey: + privateKey := strings.TrimSpace(draft.PrivateKey) + defer resetString(&privateKey) + return ParseAgePrivateKeyRecipient(privateKey) + default: + return "", fmt.Errorf("unsupported AGE setup input kind: %d", draft.Kind) + } +} + +func mapAgeSetupAbort(err error) error { + if err == nil { + return nil + } + if errors.Is(err, ErrAgeRecipientSetupAborted) { + return ErrAgeRecipientSetupAborted + } + return err +} diff --git a/internal/orchestrator/age_setup_workflow_test.go b/internal/orchestrator/age_setup_workflow_test.go new file mode 100644 index 0000000..33a6730 --- /dev/null +++ b/internal/orchestrator/age_setup_workflow_test.go @@ -0,0 +1,135 @@ +package orchestrator + +import ( + "context" + "errors" + "os" + "path/filepath" + "testing" + + "filippo.io/age" + + "github.com/tis24dev/proxsave/internal/config" +) + +type mockAgeSetupUI struct { + overwrite bool + drafts []*AgeRecipientDraft + addMore []bool + + overwriteCalls int + collectCalls int + addCalls int +} + +func (m *mockAgeSetupUI) ConfirmOverwriteExistingRecipient(ctx context.Context, recipientPath string) (bool, error) { + m.overwriteCalls++ + return m.overwrite, nil +} + +func (m *mockAgeSetupUI) CollectRecipientDraft(ctx context.Context, recipientPath string) (*AgeRecipientDraft, error) { + m.collectCalls++ + if len(m.drafts) == 0 { + return nil, ErrAgeRecipientSetupAborted + } + draft := m.drafts[0] + m.drafts = m.drafts[1:] + return draft, nil +} + +func (m *mockAgeSetupUI) ConfirmAddAnotherRecipient(ctx context.Context, currentCount int) (bool, error) { + m.addCalls++ + if len(m.addMore) == 0 { + return false, nil + } + next := m.addMore[0] + m.addMore = m.addMore[1:] + return next, nil +} + +func TestEnsureAgeRecipientsReadyWithUI_ReusesConfiguredRecipientsWithoutPrompting(t *testing.T) { + id, err := age.GenerateX25519Identity() + if err != nil { + t.Fatalf("GenerateX25519Identity: %v", err) + } + + ui := &mockAgeSetupUI{} + orch := newEncryptionTestOrchestrator(&config.Config{ + EncryptArchive: true, + BaseDir: t.TempDir(), + AgeRecipients: []string{id.Recipient().String()}, + }) + + if err := orch.EnsureAgeRecipientsReadyWithUI(context.Background(), ui); err != nil { + t.Fatalf("EnsureAgeRecipientsReadyWithUI error: %v", err) + } + if ui.collectCalls != 0 || ui.overwriteCalls != 0 || ui.addCalls != 0 { + t.Fatalf("UI should not have been used when recipients already exist: %#v", ui) + } +} + +func TestEnsureAgeRecipientsReadyWithUI_ConfiguresRecipientsWithoutTTY(t *testing.T) { + id, err := age.GenerateX25519Identity() + if err != nil { + t.Fatalf("GenerateX25519Identity: %v", err) + } + + tmp := t.TempDir() + ui := &mockAgeSetupUI{ + drafts: []*AgeRecipientDraft{ + {Kind: AgeRecipientInputExisting, PublicKey: id.Recipient().String()}, + }, + addMore: []bool{false}, + } + cfg := &config.Config{EncryptArchive: true, BaseDir: tmp} + orch := newEncryptionTestOrchestrator(cfg) + + if err := orch.EnsureAgeRecipientsReadyWithUI(context.Background(), ui); err != nil { + t.Fatalf("EnsureAgeRecipientsReadyWithUI error: %v", err) + } + + target := filepath.Join(tmp, "identity", "age", "recipient.txt") + content, err := os.ReadFile(target) + if err != nil { + t.Fatalf("ReadFile(%s): %v", target, err) + } + if got := string(content); got != id.Recipient().String()+"\n" { + t.Fatalf("content=%q; want %q", got, id.Recipient().String()+"\n") + } + if cfg.AgeRecipientFile != target { + t.Fatalf("AgeRecipientFile=%q; want %q", cfg.AgeRecipientFile, target) + } +} + +func TestEnsureAgeRecipientsReadyWithUI_ForceNewRecipientDeclineReturnsAbort(t *testing.T) { + tmp := t.TempDir() + target := filepath.Join(tmp, "identity", "age", "recipient.txt") + if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil { + t.Fatalf("MkdirAll: %v", err) + } + if err := os.WriteFile(target, []byte("old\n"), 0o600); err != nil { + t.Fatalf("WriteFile: %v", err) + } + + ui := &mockAgeSetupUI{overwrite: false} + orch := newEncryptionTestOrchestrator(&config.Config{ + EncryptArchive: true, + BaseDir: tmp, + AgeRecipientFile: target, + }) + orch.SetForceNewAgeRecipient(true) + + err := orch.EnsureAgeRecipientsReadyWithUI(context.Background(), ui) + if !errors.Is(err, ErrAgeRecipientSetupAborted) { + t.Fatalf("err=%v; want %v", err, ErrAgeRecipientSetupAborted) + } + if ui.overwriteCalls != 1 { + t.Fatalf("overwriteCalls=%d; want 1", ui.overwriteCalls) + } + if ui.collectCalls != 0 { + t.Fatalf("collectCalls=%d; want 0", ui.collectCalls) + } + if _, statErr := os.Stat(target); statErr != nil { + t.Fatalf("recipient file should remain in place, stat err=%v", statErr) + } +} diff --git a/internal/orchestrator/decrypt_tui.go b/internal/orchestrator/decrypt_tui.go index 7602d8a..5da7250 100644 --- a/internal/orchestrator/decrypt_tui.go +++ b/internal/orchestrator/decrypt_tui.go @@ -4,11 +4,8 @@ import ( "context" "errors" "fmt" - "os" - "path/filepath" "strings" - "filippo.io/age" "github.com/gdamore/tcell/v2" "github.com/rivo/tview" @@ -16,21 +13,11 @@ import ( "github.com/tis24dev/proxsave/internal/config" "github.com/tis24dev/proxsave/internal/logging" "github.com/tis24dev/proxsave/internal/tui" - "github.com/tis24dev/proxsave/internal/tui/components" ) const ( decryptWizardSubtitle = "Decrypt Backup Workflow" decryptNavText = "[yellow]Navigation:[white] TAB/↑↓ to move | ENTER to select | ESC to exit screens | Mouse clicks enabled" - - pathActionOverwrite = "overwrite" - pathActionNew = "new" - pathActionCancel = "cancel" -) - -var ( - promptOverwriteActionFunc = promptOverwriteAction - promptNewPathInputFunc = promptNewPathInput ) // RunDecryptWorkflowTUI runs the decrypt workflow using a TUI flow. @@ -104,276 +91,6 @@ func filterEncryptedCandidates(candidates []*decryptCandidate) []*decryptCandida return filtered } -func ensureWritablePathTUI(path, description, configPath, buildSig string) (string, error) { - current := filepath.Clean(path) - if description == "" { - description = "file" - } - var failureMessage string - - for { - if _, err := restoreFS.Stat(current); errors.Is(err, os.ErrNotExist) { - return current, nil - } else if err != nil && !errors.Is(err, os.ErrExist) { - return "", fmt.Errorf("stat %s: %w", current, err) - } - - action, err := promptOverwriteActionFunc(current, description, failureMessage, configPath, buildSig) - if err != nil { - return "", err - } - failureMessage = "" - - switch action { - case pathActionOverwrite: - if err := restoreFS.Remove(current); err != nil && !errors.Is(err, os.ErrNotExist) { - failureMessage = fmt.Sprintf("Failed to remove existing %s: %v", description, err) - continue - } - return current, nil - case pathActionNew: - newPath, err := promptNewPathInputFunc(current, configPath, buildSig) - if err != nil { - if errors.Is(err, ErrDecryptAborted) { - return "", ErrDecryptAborted - } - failureMessage = err.Error() - continue - } - current = filepath.Clean(newPath) - default: - return "", ErrDecryptAborted - } - } -} - -func promptOverwriteAction(path, description, failureMessage, configPath, buildSig string) (string, error) { - app := newTUIApp() - var choice string - - message := fmt.Sprintf("The %s [yellow]%s[white] already exists.\nSelect how you want to proceed.", description, path) - if strings.TrimSpace(failureMessage) != "" { - message = fmt.Sprintf("%s\n\n[red]%s[white]", message, failureMessage) - } - message += "\n\n[yellow]Use ←→ or TAB to switch buttons | ENTER to confirm[white]" - - modal := tview.NewModal(). - SetText(message). - AddButtons([]string{"Overwrite", "Use different path", "Cancel"}). - SetDoneFunc(func(buttonIndex int, buttonLabel string) { - switch buttonLabel { - case "Overwrite": - choice = pathActionOverwrite - case "Use different path": - choice = pathActionNew - default: - choice = pathActionCancel - } - app.Stop() - }) - - modal.SetBorder(true). - SetTitle(" Existing file "). - SetTitleAlign(tview.AlignCenter). - SetTitleColor(tui.WarningYellow). - SetBorderColor(tui.WarningYellow). - SetBackgroundColor(tcell.ColorBlack) - - wrapped := buildWizardPage("Destination path", configPath, buildSig, modal) - if err := app.SetRoot(wrapped, true).SetFocus(modal).Run(); err != nil { - return "", err - } - return choice, nil -} - -func promptNewPathInput(defaultPath, configPath, buildSig string) (string, error) { - app := newTUIApp() - var newPath string - var cancelled bool - - form := components.NewForm(app) - label := "New path" - form.AddInputFieldWithValidation(label, defaultPath, 64, func(value string) error { - if strings.TrimSpace(value) == "" { - return fmt.Errorf("path cannot be empty") - } - return nil - }) - form.SetOnSubmit(func(values map[string]string) error { - newPath = strings.TrimSpace(values[label]) - return nil - }) - form.SetOnCancel(func() { - cancelled = true - }) - form.AddSubmitButton("Continue") - form.AddCancelButton("Cancel") - - helper := tview.NewTextView(). - SetText("Provide a writable filesystem path for the decrypted files."). - SetWrap(true). - SetTextColor(tcell.ColorWhite). - SetDynamicColors(true) - - content := tview.NewFlex(). - SetDirection(tview.FlexRow). - AddItem(helper, 3, 0, false). - AddItem(form.Form, 0, 1, true) - - page := buildWizardPage("Choose destination path", configPath, buildSig, content) - form.SetParentView(page) - - if err := app.SetRoot(page, true).SetFocus(form.Form).Run(); err != nil { - return "", err - } - if cancelled { - return "", ErrDecryptAborted - } - return filepath.Clean(newPath), nil -} - -func preparePlainBundleTUI(ctx context.Context, cand *decryptCandidate, version string, logger *logging.Logger, configPath, buildSig string) (*preparedBundle, error) { - return preparePlainBundleCommon(ctx, cand, version, logger, func(ctx context.Context, encryptedPath, outputPath, displayName string) error { - return decryptArchiveWithTUIPrompts(ctx, encryptedPath, outputPath, displayName, configPath, buildSig, logger) - }) -} - -func decryptArchiveWithTUIPrompts(ctx context.Context, encryptedPath, outputPath, displayName, configPath, buildSig string, logger *logging.Logger) error { - var promptError string - if ctx == nil { - ctx = context.Background() - } - for { - if err := ctx.Err(); err != nil { - return err - } - identities, err := promptDecryptIdentity(displayName, configPath, buildSig, promptError) - if err != nil { - return err - } - - if err := ctx.Err(); err != nil { - return err - } - if err := decryptWithIdentity(encryptedPath, outputPath, identities...); err != nil { - var noMatch *age.NoIdentityMatchError - if errors.Is(err, age.ErrIncorrectIdentity) || errors.As(err, &noMatch) { - promptError = "Provided key or passphrase does not match this archive." - logger.Warning("Incorrect key or passphrase for %s", filepath.Base(encryptedPath)) - continue - } - return err - } - return nil - } -} - -func promptDecryptIdentity(displayName, configPath, buildSig, errorMessage string) ([]age.Identity, error) { - app := newTUIApp() - var ( - chosenIdentity []age.Identity - cancelled bool - ) - - name := displayName - if strings.TrimSpace(name) == "" { - name = "selected backup" - } - infoMessage := fmt.Sprintf("Provide the AGE secret key or passphrase used for [yellow]%s[white].", name) - if strings.TrimSpace(errorMessage) != "" { - infoMessage = fmt.Sprintf("%s\n\n[red]%s[white]", infoMessage, errorMessage) - } - infoText := tview.NewTextView(). - SetText(infoMessage). - SetWrap(true). - SetTextColor(tcell.ColorWhite). - SetDynamicColors(true) - - form := components.NewForm(app) - label := "Key or passphrase:" - form.AddPasswordField(label, 64) - form.SetOnSubmit(func(values map[string]string) error { - raw := strings.TrimSpace(values[label]) - if raw == "" { - return fmt.Errorf("key or passphrase cannot be empty") - } - identity, err := parseIdentityInput(raw) - resetString(&raw) - if err != nil { - return fmt.Errorf("invalid key or passphrase: %w", err) - } - chosenIdentity = identity - return nil - }) - form.SetOnCancel(func() { - cancelled = true - }) - // Buttons: Continue, Cancel - form.AddSubmitButton("Continue") - form.AddCancelButton("Cancel") - - content := tview.NewFlex(). - SetDirection(tview.FlexRow). - AddItem(infoText, 3, 0, false). - AddItem(form.Form, 0, 1, true) - - page := buildWizardPage("Enter decryption secret", configPath, buildSig, content) - form.SetParentView(page) - - if err := app.SetRoot(page, true).SetFocus(form.Form).Run(); err != nil { - return nil, err - } - if cancelled { - return nil, ErrDecryptAborted - } - if len(chosenIdentity) == 0 { - return nil, fmt.Errorf("missing identity") - } - return chosenIdentity, nil -} - -func enableFormNavigation(form *components.Form, dropdownOpen *bool) { - if form == nil || form.Form == nil { - return - } - form.Form.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey { - if event == nil { - return event - } - if dropdownOpen != nil && *dropdownOpen { - return event - } - - formItemIndex, buttonIndex := form.Form.GetFocusedItemIndex() - isOnButton := formItemIndex < 0 && buttonIndex >= 0 - isOnField := formItemIndex >= 0 - - if isOnButton { - switch event.Key() { - case tcell.KeyLeft, tcell.KeyUp: - return tcell.NewEventKey(tcell.KeyBacktab, 0, tcell.ModNone) - case tcell.KeyRight, tcell.KeyDown: - return tcell.NewEventKey(tcell.KeyTab, 0, tcell.ModNone) - } - } else if isOnField { - // If focused item is a ListFormItem, let it handle navigation internally - if formItemIndex >= 0 { - if _, ok := form.Form.GetFormItem(formItemIndex).(*components.ListFormItem); ok { - return event - } - } - // For other form fields, convert arrows to tab navigation - switch event.Key() { - case tcell.KeyUp: - return tcell.NewEventKey(tcell.KeyBacktab, 0, tcell.ModNone) - case tcell.KeyDown: - return tcell.NewEventKey(tcell.KeyTab, 0, tcell.ModNone) - } - } - return event - }) -} - func buildWizardPage(title, configPath, buildSig string, content tview.Primitive) tview.Primitive { welcomeText := tview.NewTextView(). SetText(fmt.Sprintf("ProxSave - By TIS24DEV\n%s\n", decryptWizardSubtitle)). diff --git a/internal/orchestrator/decrypt_tui_e2e_helpers_test.go b/internal/orchestrator/decrypt_tui_e2e_helpers_test.go new file mode 100644 index 0000000..df56478 --- /dev/null +++ b/internal/orchestrator/decrypt_tui_e2e_helpers_test.go @@ -0,0 +1,275 @@ +package orchestrator + +import ( + "archive/tar" + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "io" + "os" + "path/filepath" + "sync" + "testing" + "time" + + "filippo.io/age" + "github.com/gdamore/tcell/v2" + + "github.com/tis24dev/proxsave/internal/backup" + "github.com/tis24dev/proxsave/internal/config" + "github.com/tis24dev/proxsave/internal/logging" + "github.com/tis24dev/proxsave/internal/tui" + "github.com/tis24dev/proxsave/internal/types" +) + +var decryptTUIE2EMu sync.Mutex + +type timedSimKey struct { + Key tcell.Key + R rune + Mod tcell.ModMask + Wait time.Duration +} + +type decryptTUIFixture struct { + Config *config.Config + ConfigPath string + BackupDir string + BaseDir string + DestinationDir string + ArchivePlaintext []byte + Secret string + EncryptedArchive string + ExpectedBundlePath string + ExpectedArchiveName string + ExpectedChecksum string +} + +func lockDecryptTUIE2E(t *testing.T) { + t.Helper() + + decryptTUIE2EMu.Lock() + t.Cleanup(decryptTUIE2EMu.Unlock) +} + +func withTimedSimAppSequence(t *testing.T, keys []timedSimKey) { + t.Helper() + + orig := newTUIApp + screen := tcell.NewSimulationScreen("UTF-8") + if err := screen.Init(); err != nil { + t.Fatalf("screen.Init: %v", err) + } + screen.SetSize(120, 40) + + var once sync.Once + newTUIApp = func() *tui.App { + app := tui.NewApp() + app.SetScreen(screen) + + once.Do(func() { + go func() { + for _, k := range keys { + if k.Wait > 0 { + time.Sleep(k.Wait) + } + mod := k.Mod + if mod == 0 { + mod = tcell.ModNone + } + screen.InjectKey(k.Key, k.R, mod) + } + }() + }) + + return app + } + + t.Cleanup(func() { + newTUIApp = orig + }) +} + +func createDecryptTUIEncryptedFixture(t *testing.T) *decryptTUIFixture { + t.Helper() + + backupDir := t.TempDir() + baseDir := t.TempDir() + configPath := filepath.Join(baseDir, "backup.env") + if err := os.WriteFile(configPath, []byte("BACKUP_PATH="+backupDir+"\nBASE_DIR="+baseDir+"\n"), 0o600); err != nil { + t.Fatalf("write config placeholder: %v", err) + } + + passphrase := "Decrypt123!" + recipientStr, err := deriveDeterministicRecipientFromPassphrase(passphrase) + if err != nil { + t.Fatalf("deriveDeterministicRecipientFromPassphrase: %v", err) + } + recipient, err := age.ParseX25519Recipient(recipientStr) + if err != nil { + t.Fatalf("age.ParseX25519Recipient: %v", err) + } + + plaintext := []byte("proxsave decrypt tui e2e plaintext\n") + archivePath := filepath.Join(backupDir, "backup.tar.xz.age") + archiveFile, err := os.Create(archivePath) + if err != nil { + t.Fatalf("create encrypted archive: %v", err) + } + + encWriter, err := age.Encrypt(archiveFile, recipient) + if err != nil { + _ = archiveFile.Close() + t.Fatalf("age.Encrypt: %v", err) + } + if _, err := encWriter.Write(plaintext); err != nil { + _ = encWriter.Close() + _ = archiveFile.Close() + t.Fatalf("write plaintext to age writer: %v", err) + } + if err := encWriter.Close(); err != nil { + _ = archiveFile.Close() + t.Fatalf("close age writer: %v", err) + } + if err := archiveFile.Close(); err != nil { + t.Fatalf("close encrypted archive: %v", err) + } + + encryptedBytes, err := os.ReadFile(archivePath) + if err != nil { + t.Fatalf("read encrypted archive: %v", err) + } + + createdAt := time.Date(2025, 1, 2, 3, 4, 5, 0, time.UTC) + manifest := &backup.Manifest{ + ArchivePath: archivePath, + CreatedAt: createdAt, + Hostname: "node1", + EncryptionMode: "age", + ProxmoxType: "pve", + } + manifestData, err := json.Marshal(manifest) + if err != nil { + t.Fatalf("marshal manifest: %v", err) + } + if err := os.WriteFile(archivePath+".metadata", manifestData, 0o640); err != nil { + t.Fatalf("write manifest sidecar: %v", err) + } + if err := os.WriteFile(archivePath+".sha256", checksumLineForBytes(filepath.Base(archivePath), encryptedBytes), 0o640); err != nil { + t.Fatalf("write checksum sidecar: %v", err) + } + + checksum := sha256.Sum256(plaintext) + expectedArchiveName := "backup.tar.xz" + destinationDir := filepath.Join(baseDir, "decrypt") + + return &decryptTUIFixture{ + Config: &config.Config{ + BackupPath: backupDir, + BaseDir: baseDir, + SecondaryEnabled: false, + CloudEnabled: false, + }, + ConfigPath: configPath, + BackupDir: backupDir, + BaseDir: baseDir, + DestinationDir: destinationDir, + ArchivePlaintext: plaintext, + Secret: passphrase, + EncryptedArchive: archivePath, + ExpectedBundlePath: filepath.Join(destinationDir, expectedArchiveName+".decrypted.bundle.tar"), + ExpectedArchiveName: expectedArchiveName, + ExpectedChecksum: hex.EncodeToString(checksum[:]), + } +} + +func successDecryptTUISequence(secret string) []timedSimKey { + keys := []timedSimKey{ + {Key: tcell.KeyEnter, Wait: 250 * time.Millisecond}, + {Key: tcell.KeyEnter, Wait: 500 * time.Millisecond}, + } + + for _, r := range secret { + keys = append(keys, timedSimKey{ + Key: tcell.KeyRune, + R: r, + Wait: 35 * time.Millisecond, + }) + } + + keys = append(keys, + timedSimKey{Key: tcell.KeyTab, Wait: 150 * time.Millisecond}, + timedSimKey{Key: tcell.KeyEnter, Wait: 100 * time.Millisecond}, + timedSimKey{Key: tcell.KeyTab, Wait: 500 * time.Millisecond}, + timedSimKey{Key: tcell.KeyEnter, Wait: 100 * time.Millisecond}, + ) + + return keys +} + +func abortDecryptTUISequence() []timedSimKey { + return []timedSimKey{ + {Key: tcell.KeyEnter, Wait: 250 * time.Millisecond}, + {Key: tcell.KeyEnter, Wait: 500 * time.Millisecond}, + {Key: tcell.KeyRune, R: '0', Wait: 500 * time.Millisecond}, + {Key: tcell.KeyTab, Wait: 150 * time.Millisecond}, + {Key: tcell.KeyEnter, Wait: 100 * time.Millisecond}, + } +} + +func runDecryptWorkflowTUIForTest(t *testing.T, ctx context.Context, cfg *config.Config, configPath string) error { + t.Helper() + + logger := logging.New(types.LogLevelError, false) + logger.SetOutput(io.Discard) + + errCh := make(chan error, 1) + go func() { + errCh <- RunDecryptWorkflowTUI(ctx, cfg, logger, "1.0.0", configPath, "test-build") + }() + + select { + case err := <-errCh: + return err + case <-ctx.Done(): + t.Fatalf("RunDecryptWorkflowTUI context expired: %v", ctx.Err()) + return nil + case <-time.After(20 * time.Second): + t.Fatalf("RunDecryptWorkflowTUI did not complete within 20s") + return nil + } +} + +func readTarEntries(t *testing.T, tarPath string) map[string][]byte { + t.Helper() + + file, err := os.Open(tarPath) + if err != nil { + t.Fatalf("open tar %s: %v", tarPath, err) + } + defer file.Close() + + tr := tar.NewReader(file) + entries := make(map[string][]byte) + for { + hdr, err := tr.Next() + if err == io.EOF { + break + } + if err != nil { + t.Fatalf("read tar header from %s: %v", tarPath, err) + } + data, err := io.ReadAll(tr) + if err != nil { + t.Fatalf("read tar entry %s: %v", hdr.Name, err) + } + entries[hdr.Name] = data + } + return entries +} + +func checksumLineForArchiveHex(filename, checksumHex string) string { + return fmt.Sprintf("%s %s\n", checksumHex, filename) +} diff --git a/internal/orchestrator/decrypt_tui_e2e_test.go b/internal/orchestrator/decrypt_tui_e2e_test.go new file mode 100644 index 0000000..6f471eb --- /dev/null +++ b/internal/orchestrator/decrypt_tui_e2e_test.go @@ -0,0 +1,99 @@ +package orchestrator + +import ( + "context" + "encoding/json" + "errors" + "os" + "path/filepath" + "testing" + "time" + + "github.com/tis24dev/proxsave/internal/backup" +) + +func TestRunDecryptWorkflowTUI_SuccessLocalEncrypted(t *testing.T) { + lockDecryptTUIE2E(t) + + origFS := restoreFS + restoreFS = osFS{} + t.Cleanup(func() { restoreFS = origFS }) + + fixture := createDecryptTUIEncryptedFixture(t) + withTimedSimAppSequence(t, successDecryptTUISequence(fixture.Secret)) + + ctx, cancel := context.WithTimeout(context.Background(), 18*time.Second) + defer cancel() + + if err := runDecryptWorkflowTUIForTest(t, ctx, fixture.Config, fixture.ConfigPath); err != nil { + t.Fatalf("RunDecryptWorkflowTUI error: %v", err) + } + + if _, err := os.Stat(fixture.ExpectedBundlePath); err != nil { + t.Fatalf("expected decrypted bundle at %s: %v", fixture.ExpectedBundlePath, err) + } + + entries := readTarEntries(t, fixture.ExpectedBundlePath) + + archiveData, ok := entries[fixture.ExpectedArchiveName] + if !ok { + t.Fatalf("bundle missing archive entry %s", fixture.ExpectedArchiveName) + } + if string(archiveData) != string(fixture.ArchivePlaintext) { + t.Fatalf("archive entry content mismatch: got %q want %q", string(archiveData), string(fixture.ArchivePlaintext)) + } + + metadataName := fixture.ExpectedArchiveName + ".metadata" + metadataData, ok := entries[metadataName] + if !ok { + t.Fatalf("bundle missing metadata entry %s", metadataName) + } + + var manifest backup.Manifest + if err := json.Unmarshal(metadataData, &manifest); err != nil { + t.Fatalf("unmarshal metadata entry %s: %v", metadataName, err) + } + if manifest.EncryptionMode != "none" { + t.Fatalf("metadata EncryptionMode=%q; want %q", manifest.EncryptionMode, "none") + } + expectedArchivePath := filepath.Join(fixture.DestinationDir, fixture.ExpectedArchiveName) + if manifest.ArchivePath != expectedArchivePath { + t.Fatalf("metadata ArchivePath=%q; want %q", manifest.ArchivePath, expectedArchivePath) + } + if manifest.SHA256 != fixture.ExpectedChecksum { + t.Fatalf("metadata SHA256=%q; want %q", manifest.SHA256, fixture.ExpectedChecksum) + } + + checksumName := fixture.ExpectedArchiveName + ".sha256" + checksumData, ok := entries[checksumName] + if !ok { + t.Fatalf("bundle missing checksum entry %s", checksumName) + } + expectedChecksumLine := checksumLineForArchiveHex(fixture.ExpectedArchiveName, fixture.ExpectedChecksum) + if string(checksumData) != expectedChecksumLine { + t.Fatalf("checksum entry=%q; want %q", string(checksumData), expectedChecksumLine) + } +} + +func TestRunDecryptWorkflowTUI_AbortAtSecretPrompt(t *testing.T) { + lockDecryptTUIE2E(t) + + origFS := restoreFS + restoreFS = osFS{} + t.Cleanup(func() { restoreFS = origFS }) + + fixture := createDecryptTUIEncryptedFixture(t) + withTimedSimAppSequence(t, abortDecryptTUISequence()) + + ctx, cancel := context.WithTimeout(context.Background(), 18*time.Second) + defer cancel() + + err := runDecryptWorkflowTUIForTest(t, ctx, fixture.Config, fixture.ConfigPath) + if !errors.Is(err, ErrDecryptAborted) { + t.Fatalf("RunDecryptWorkflowTUI error=%v; want %v", err, ErrDecryptAborted) + } + + if _, err := os.Stat(fixture.ExpectedBundlePath); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("expected no decrypted bundle at %s, stat err=%v", fixture.ExpectedBundlePath, err) + } +} diff --git a/internal/orchestrator/decrypt_tui_simulation_test.go b/internal/orchestrator/decrypt_tui_simulation_test.go index 9a65f1c..d574a13 100644 --- a/internal/orchestrator/decrypt_tui_simulation_test.go +++ b/internal/orchestrator/decrypt_tui_simulation_test.go @@ -1,22 +1,23 @@ package orchestrator import ( + "context" "testing" "github.com/gdamore/tcell/v2" ) -func TestPromptDecryptIdentity_CancelReturnsAborted(t *testing.T) { - // Focus starts on the password field; tab to Cancel and submit. +func TestTUIWorkflowUIPromptDecryptSecret_CancelReturnsAborted(t *testing.T) { withSimApp(t, []tcell.Key{tcell.KeyTab, tcell.KeyTab, tcell.KeyEnter}) - _, err := promptDecryptIdentity("backup", "/tmp/config.env", "sig", "") + ui := newTUIWorkflowUI("/tmp/config.env", "sig", nil) + _, err := ui.PromptDecryptSecret(context.Background(), "backup", "") if err != ErrDecryptAborted { t.Fatalf("err=%v; want %v", err, ErrDecryptAborted) } } -func TestPromptDecryptIdentity_PassphraseReturnsIdentity(t *testing.T) { +func TestTUIWorkflowUIPromptDecryptSecret_PassphraseReturnsSecret(t *testing.T) { passphrase := "test passphrase" var seq []simKey @@ -26,11 +27,26 @@ func TestPromptDecryptIdentity_PassphraseReturnsIdentity(t *testing.T) { seq = append(seq, simKey{Key: tcell.KeyTab}, simKey{Key: tcell.KeyEnter}) withSimAppSequence(t, seq) - ids, err := promptDecryptIdentity("backup", "/tmp/config.env", "sig", "") + ui := newTUIWorkflowUI("/tmp/config.env", "sig", nil) + secret, err := ui.PromptDecryptSecret(context.Background(), "backup", "") if err != nil { - t.Fatalf("promptDecryptIdentity error: %v", err) + t.Fatalf("PromptDecryptSecret error: %v", err) } - if len(ids) == 0 { - t.Fatalf("expected at least one identity") + if secret != passphrase { + t.Fatalf("secret=%q; want %q", secret, passphrase) + } +} + +func TestTUIWorkflowUIPromptDecryptSecret_ZeroInputAborts(t *testing.T) { + withSimAppSequence(t, []simKey{ + {Key: tcell.KeyRune, R: '0'}, + {Key: tcell.KeyTab}, + {Key: tcell.KeyEnter}, + }) + + ui := newTUIWorkflowUI("/tmp/config.env", "sig", nil) + _, err := ui.PromptDecryptSecret(context.Background(), "backup", "") + if err != ErrDecryptAborted { + t.Fatalf("err=%v; want %v", err, ErrDecryptAborted) } } diff --git a/internal/orchestrator/decrypt_tui_test.go b/internal/orchestrator/decrypt_tui_test.go index f08f9a0..d94b78e 100644 --- a/internal/orchestrator/decrypt_tui_test.go +++ b/internal/orchestrator/decrypt_tui_test.go @@ -1,18 +1,12 @@ package orchestrator import ( - "context" - "errors" - "os" - "path/filepath" "testing" "time" "github.com/rivo/tview" "github.com/tis24dev/proxsave/internal/backup" - "github.com/tis24dev/proxsave/internal/logging" - "github.com/tis24dev/proxsave/internal/types" ) func TestNormalizeProxmoxVersion(t *testing.T) { @@ -66,194 +60,6 @@ func TestFilterEncryptedCandidates(t *testing.T) { } } -func TestEnsureWritablePathTUI_ReturnsCleanMissingPath(t *testing.T) { - originalFS := restoreFS - restoreFS = osFS{} - defer func() { restoreFS = originalFS }() - - tmp := t.TempDir() - target := filepath.Join(tmp, "subdir", "file.txt") - dirty := target + string(filepath.Separator) + ".." + string(filepath.Separator) + "file.txt" - - path, err := ensureWritablePathTUI(dirty, "test file", "cfg", "sig") - if err != nil { - t.Fatalf("ensureWritablePathTUI returned error: %v", err) - } - if path != target { - t.Fatalf("ensureWritablePathTUI path=%q, want %q", path, target) - } -} - -func TestEnsureWritablePathTUIOverwriteExisting(t *testing.T) { - tmp := t.TempDir() - target := filepath.Join(tmp, "existing.tar") - if err := os.WriteFile(target, []byte("payload"), 0o640); err != nil { - t.Fatalf("write existing file: %v", err) - } - - restore := stubPromptOverwriteAction(func(path, desc, failure, configPath, buildSig string) (string, error) { - if failure != "" { - t.Fatalf("unexpected failure message: %s", failure) - } - return pathActionOverwrite, nil - }) - defer restore() - - got, err := ensureWritablePathTUI(target, "archive", "cfg", "sig") - if err != nil { - t.Fatalf("ensureWritablePathTUI error: %v", err) - } - if got != target { - t.Fatalf("path = %q, want %q", got, target) - } - if _, err := os.Stat(target); !errors.Is(err, os.ErrNotExist) { - t.Fatalf("existing file should be removed, stat err=%v", err) - } -} - -func TestEnsureWritablePathTUINewPath(t *testing.T) { - tmp := t.TempDir() - existing := filepath.Join(tmp, "current.tar") - if err := os.WriteFile(existing, []byte("payload"), 0o640); err != nil { - t.Fatalf("write existing file: %v", err) - } - nextPath := filepath.Join(tmp, "new.tar") - - var promptCalls int - restorePrompt := stubPromptOverwriteAction(func(path, desc, failure, configPath, buildSig string) (string, error) { - promptCalls++ - if failure != "" { - t.Fatalf("unexpected failure message: %s", failure) - } - return pathActionNew, nil - }) - defer restorePrompt() - - restoreNew := stubPromptNewPath(func(current, configPath, buildSig string) (string, error) { - if filepath.Clean(current) != filepath.Clean(existing) { - t.Fatalf("promptNewPath received %q, want %q", current, existing) - } - return nextPath, nil - }) - defer restoreNew() - - got, err := ensureWritablePathTUI(existing, "bundle", "cfg", "sig") - if err != nil { - t.Fatalf("ensureWritablePathTUI error: %v", err) - } - if got != filepath.Clean(nextPath) { - t.Fatalf("path=%q, want %q", got, nextPath) - } - if promptCalls != 1 { - t.Fatalf("expected 1 prompt call, got %d", promptCalls) - } -} - -func TestEnsureWritablePathTUIAbortOnCancel(t *testing.T) { - path := mustCreateExistingFile(t) - restore := stubPromptOverwriteAction(func(path, desc, failure, configPath, buildSig string) (string, error) { - return pathActionCancel, nil - }) - defer restore() - - if _, err := ensureWritablePathTUI(path, "bundle", "cfg", "sig"); !errors.Is(err, ErrDecryptAborted) { - t.Fatalf("expected ErrDecryptAborted, got %v", err) - } -} - -func TestEnsureWritablePathTUIPropagatesPromptErrors(t *testing.T) { - path := mustCreateExistingFile(t) - wantErr := errors.New("boom") - restore := stubPromptOverwriteAction(func(path, desc, failure, configPath, buildSig string) (string, error) { - return "", wantErr - }) - defer restore() - - if _, err := ensureWritablePathTUI(path, "bundle", "cfg", "sig"); !errors.Is(err, wantErr) { - t.Fatalf("expected %v, got %v", wantErr, err) - } -} - -func TestEnsureWritablePathTUINewPathAbort(t *testing.T) { - path := mustCreateExistingFile(t) - restorePrompt := stubPromptOverwriteAction(func(path, desc, failure, configPath, buildSig string) (string, error) { - return pathActionNew, nil - }) - defer restorePrompt() - - restoreNew := stubPromptNewPath(func(current, configPath, buildSig string) (string, error) { - return "", ErrDecryptAborted - }) - defer restoreNew() - - if _, err := ensureWritablePathTUI(path, "bundle", "cfg", "sig"); !errors.Is(err, ErrDecryptAborted) { - t.Fatalf("expected ErrDecryptAborted, got %v", err) - } -} - -func TestPreparePlainBundleTUICopiesRawArtifacts(t *testing.T) { - logger := logging.New(types.LogLevelError, false) - tmp := t.TempDir() - rawArchive := filepath.Join(tmp, "backup.tar") - rawMetadata := rawArchive + ".metadata" - rawChecksum := rawArchive + ".sha256" - - if err := os.WriteFile(rawArchive, []byte("payload-data"), 0o640); err != nil { - t.Fatalf("write archive: %v", err) - } - if err := os.WriteFile(rawMetadata, []byte(`{"manifest":true}`), 0o640); err != nil { - t.Fatalf("write metadata: %v", err) - } - if err := os.WriteFile(rawChecksum, checksumLineForBytes("backup.tar", []byte("payload-data")), 0o640); err != nil { - t.Fatalf("write checksum: %v", err) - } - - cand := &decryptCandidate{ - Manifest: &backup.Manifest{ - ArchivePath: rawArchive, - EncryptionMode: "none", - CreatedAt: time.Now(), - Hostname: "node1", - }, - Source: sourceRaw, - RawArchivePath: rawArchive, - RawMetadataPath: rawMetadata, - RawChecksumPath: rawChecksum, - DisplayBase: "test-backup", - } - - ctx := context.Background() - prepared, err := preparePlainBundleTUI(ctx, cand, "1.0.0", logger, "cfg", "sig") - if err != nil { - t.Fatalf("preparePlainBundleTUI error: %v", err) - } - defer prepared.Cleanup() - - if prepared.ArchivePath == "" { - t.Fatalf("expected archive path to be set") - } - if prepared.Manifest.EncryptionMode != "none" { - t.Fatalf("expected manifest encryption mode none, got %s", prepared.Manifest.EncryptionMode) - } - if prepared.Manifest.ScriptVersion != "1.0.0" { - t.Fatalf("expected script version to propagate, got %s", prepared.Manifest.ScriptVersion) - } - if _, err := os.Stat(prepared.ArchivePath); err != nil { - t.Fatalf("expected staged archive to exist: %v", err) - } - if prepared.Checksum == "" { - t.Fatalf("expected checksum to be computed") - } -} - -func TestPreparePlainBundleTUIRejectsInvalidCandidate(t *testing.T) { - logger := logging.New(types.LogLevelError, false) - ctx := context.Background() - if _, err := preparePlainBundleTUI(ctx, nil, "", logger, "cfg", "sig"); err == nil { - t.Fatalf("expected error for nil candidate") - } -} - func TestBuildWizardPageReturnsFlex(t *testing.T) { content := tview.NewBox() page := buildWizardPage("Title", "/etc/proxsave/backup.env", "sig", content) @@ -264,25 +70,3 @@ func TestBuildWizardPageReturnsFlex(t *testing.T) { t.Fatalf("expected *tview.Flex, got %T", page) } } - -func stubPromptOverwriteAction(fn func(path, description, failureMessage, configPath, buildSig string) (string, error)) func() { - orig := promptOverwriteActionFunc - promptOverwriteActionFunc = fn - return func() { promptOverwriteActionFunc = orig } -} - -func stubPromptNewPath(fn func(current, configPath, buildSig string) (string, error)) func() { - orig := promptNewPathInputFunc - promptNewPathInputFunc = fn - return func() { promptNewPathInputFunc = orig } -} - -func mustCreateExistingFile(t *testing.T) string { - t.Helper() - tmp := t.TempDir() - path := filepath.Join(tmp, "existing.dat") - if err := os.WriteFile(path, []byte("data"), 0o640); err != nil { - t.Fatalf("write %s: %v", path, err) - } - return path -} diff --git a/internal/orchestrator/decrypt_workflow_ui.go b/internal/orchestrator/decrypt_workflow_ui.go index 7de3f69..f03a413 100644 --- a/internal/orchestrator/decrypt_workflow_ui.go +++ b/internal/orchestrator/decrypt_workflow_ui.go @@ -177,6 +177,13 @@ func decryptArchiveWithSecretPrompt(ctx context.Context, encryptedPath, outputPa func preparePlainBundleWithUI(ctx context.Context, cand *decryptCandidate, version string, logger *logging.Logger, ui interface { PromptDecryptSecret(ctx context.Context, displayName, previousError string) (string, error) }) (bundle *preparedBundle, err error) { + if cand == nil || cand.Manifest == nil { + return nil, fmt.Errorf("invalid backup candidate") + } + if ui == nil { + return nil, fmt.Errorf("decrypt workflow UI not available") + } + done := logging.DebugStart(logger, "prepare plain bundle (ui)", "source=%v rclone=%v", cand.Source, cand.IsRclone) defer func() { done(err) }() return preparePlainBundleCommon(ctx, cand, version, logger, func(ctx context.Context, encryptedPath, outputPath, displayName string) error { diff --git a/internal/orchestrator/decrypt_workflow_ui_test.go b/internal/orchestrator/decrypt_workflow_ui_test.go new file mode 100644 index 0000000..069b55c --- /dev/null +++ b/internal/orchestrator/decrypt_workflow_ui_test.go @@ -0,0 +1,284 @@ +package orchestrator + +import ( + "context" + "errors" + "os" + "path/filepath" + "testing" + "time" + + "github.com/tis24dev/proxsave/internal/backup" + "github.com/tis24dev/proxsave/internal/logging" + "github.com/tis24dev/proxsave/internal/types" +) + +type fakeDecryptWorkflowUI struct { + resolveExistingPathFn func(ctx context.Context, path, description, failure string) (ExistingPathDecision, string, error) +} + +func (f *fakeDecryptWorkflowUI) RunTask(ctx context.Context, title, initialMessage string, run func(ctx context.Context, report ProgressReporter) error) error { + panic("unexpected RunTask call") +} + +func (f *fakeDecryptWorkflowUI) ShowMessage(ctx context.Context, title, message string) error { + panic("unexpected ShowMessage call") +} + +func (f *fakeDecryptWorkflowUI) ShowError(ctx context.Context, title, message string) error { + panic("unexpected ShowError call") +} + +func (f *fakeDecryptWorkflowUI) SelectBackupSource(ctx context.Context, options []decryptPathOption) (decryptPathOption, error) { + panic("unexpected SelectBackupSource call") +} + +func (f *fakeDecryptWorkflowUI) SelectBackupCandidate(ctx context.Context, candidates []*decryptCandidate) (*decryptCandidate, error) { + panic("unexpected SelectBackupCandidate call") +} + +func (f *fakeDecryptWorkflowUI) PromptDestinationDir(ctx context.Context, defaultDir string) (string, error) { + panic("unexpected PromptDestinationDir call") +} + +func (f *fakeDecryptWorkflowUI) ResolveExistingPath(ctx context.Context, path, description, failure string) (ExistingPathDecision, string, error) { + if f.resolveExistingPathFn == nil { + panic("unexpected ResolveExistingPath call") + } + return f.resolveExistingPathFn(ctx, path, description, failure) +} + +func (f *fakeDecryptWorkflowUI) PromptDecryptSecret(ctx context.Context, displayName, previousError string) (string, error) { + panic("unexpected PromptDecryptSecret call") +} + +type countingSecretPrompter struct { + calls int +} + +func (c *countingSecretPrompter) PromptDecryptSecret(ctx context.Context, displayName, previousError string) (string, error) { + c.calls++ + return "unused", nil +} + +func TestEnsureWritablePathWithUI_ReturnsCleanMissingPath(t *testing.T) { + originalFS := restoreFS + restoreFS = osFS{} + defer func() { restoreFS = originalFS }() + + tmp := t.TempDir() + target := filepath.Join(tmp, "subdir", "file.txt") + dirty := target + string(filepath.Separator) + ".." + string(filepath.Separator) + "file.txt" + + got, err := ensureWritablePathWithUI(context.Background(), &fakeDecryptWorkflowUI{}, dirty, "test file") + if err != nil { + t.Fatalf("ensureWritablePathWithUI error: %v", err) + } + if got != target { + t.Fatalf("ensureWritablePathWithUI path=%q, want %q", got, target) + } +} + +func TestEnsureWritablePathWithUI_OverwriteExisting(t *testing.T) { + tmp := t.TempDir() + target := filepath.Join(tmp, "existing.tar") + if err := os.WriteFile(target, []byte("payload"), 0o640); err != nil { + t.Fatalf("write existing file: %v", err) + } + + ui := &fakeDecryptWorkflowUI{ + resolveExistingPathFn: func(ctx context.Context, path, description, failure string) (ExistingPathDecision, string, error) { + if path != target { + t.Fatalf("path=%q, want %q", path, target) + } + if failure != "" { + t.Fatalf("unexpected failure message: %s", failure) + } + return PathDecisionOverwrite, "", nil + }, + } + + got, err := ensureWritablePathWithUI(context.Background(), ui, target, "archive") + if err != nil { + t.Fatalf("ensureWritablePathWithUI error: %v", err) + } + if got != target { + t.Fatalf("path=%q, want %q", got, target) + } + if _, err := os.Stat(target); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("existing file should be removed, stat err=%v", err) + } +} + +func TestEnsureWritablePathWithUI_NewPath(t *testing.T) { + tmp := t.TempDir() + existing := filepath.Join(tmp, "current.tar") + if err := os.WriteFile(existing, []byte("payload"), 0o640); err != nil { + t.Fatalf("write existing file: %v", err) + } + nextPath := filepath.Join(tmp, "next.tar") + + var calls int + ui := &fakeDecryptWorkflowUI{ + resolveExistingPathFn: func(ctx context.Context, path, description, failure string) (ExistingPathDecision, string, error) { + calls++ + if path != existing { + t.Fatalf("path=%q, want %q", path, existing) + } + return PathDecisionNewPath, nextPath, nil + }, + } + + got, err := ensureWritablePathWithUI(context.Background(), ui, existing, "bundle") + if err != nil { + t.Fatalf("ensureWritablePathWithUI error: %v", err) + } + if got != filepath.Clean(nextPath) { + t.Fatalf("path=%q, want %q", got, filepath.Clean(nextPath)) + } + if calls != 1 { + t.Fatalf("expected 1 ResolveExistingPath call, got %d", calls) + } +} + +func TestEnsureWritablePathWithUI_AbortOnCancelDecision(t *testing.T) { + path := mustCreateExistingFile(t) + ui := &fakeDecryptWorkflowUI{ + resolveExistingPathFn: func(ctx context.Context, path, description, failure string) (ExistingPathDecision, string, error) { + return PathDecisionCancel, "", nil + }, + } + + if _, err := ensureWritablePathWithUI(context.Background(), ui, path, "bundle"); !errors.Is(err, ErrDecryptAborted) { + t.Fatalf("expected ErrDecryptAborted, got %v", err) + } +} + +func TestEnsureWritablePathWithUI_PropagatesPromptErrors(t *testing.T) { + path := mustCreateExistingFile(t) + wantErr := errors.New("boom") + ui := &fakeDecryptWorkflowUI{ + resolveExistingPathFn: func(ctx context.Context, path, description, failure string) (ExistingPathDecision, string, error) { + return PathDecisionCancel, "", wantErr + }, + } + + if _, err := ensureWritablePathWithUI(context.Background(), ui, path, "bundle"); !errors.Is(err, wantErr) { + t.Fatalf("expected %v, got %v", wantErr, err) + } +} + +func TestPreparePlainBundleWithUICopiesRawArtifacts(t *testing.T) { + logger := logging.New(types.LogLevelError, false) + tmp := t.TempDir() + rawArchive := filepath.Join(tmp, "backup.tar") + rawMetadata := rawArchive + ".metadata" + rawChecksum := rawArchive + ".sha256" + + if err := os.WriteFile(rawArchive, []byte("payload-data"), 0o640); err != nil { + t.Fatalf("write archive: %v", err) + } + if err := os.WriteFile(rawMetadata, []byte(`{"manifest":true}`), 0o640); err != nil { + t.Fatalf("write metadata: %v", err) + } + if err := os.WriteFile(rawChecksum, checksumLineForBytes("backup.tar", []byte("payload-data")), 0o640); err != nil { + t.Fatalf("write checksum: %v", err) + } + + cand := &decryptCandidate{ + Manifest: &backup.Manifest{ + ArchivePath: rawArchive, + EncryptionMode: "none", + CreatedAt: time.Now(), + Hostname: "node1", + }, + Source: sourceRaw, + RawArchivePath: rawArchive, + RawMetadataPath: rawMetadata, + RawChecksumPath: rawChecksum, + DisplayBase: "test-backup", + } + + ctx := context.Background() + prompter := &countingSecretPrompter{} + prepared, err := preparePlainBundleWithUI(ctx, cand, "1.0.0", logger, prompter) + if err != nil { + t.Fatalf("preparePlainBundleWithUI error: %v", err) + } + defer prepared.Cleanup() + + if prepared.ArchivePath == "" { + t.Fatalf("expected archive path to be set") + } + if prepared.Manifest.EncryptionMode != "none" { + t.Fatalf("expected manifest encryption mode none, got %s", prepared.Manifest.EncryptionMode) + } + if prepared.Manifest.ScriptVersion != "1.0.0" { + t.Fatalf("expected script version to propagate, got %s", prepared.Manifest.ScriptVersion) + } + if _, err := os.Stat(prepared.ArchivePath); err != nil { + t.Fatalf("expected staged archive to exist: %v", err) + } + if prepared.Checksum == "" { + t.Fatalf("expected checksum to be computed") + } + if prompter.calls != 0 { + t.Fatalf("PromptDecryptSecret should not be called for plain backups, got %d calls", prompter.calls) + } +} + +func TestPreparePlainBundleWithUIRejectsInvalidCandidate(t *testing.T) { + logger := logging.New(types.LogLevelError, false) + ctx := context.Background() + prompter := &countingSecretPrompter{} + if _, err := preparePlainBundleWithUI(ctx, nil, "", logger, prompter); err == nil { + t.Fatalf("expected error for nil candidate") + } +} + +func TestPreparePlainBundleWithUIRejectsMissingUI(t *testing.T) { + logger := logging.New(types.LogLevelError, false) + tmp := t.TempDir() + rawArchive := filepath.Join(tmp, "backup.tar") + rawMetadata := rawArchive + ".metadata" + rawChecksum := rawArchive + ".sha256" + + if err := os.WriteFile(rawArchive, []byte("payload-data"), 0o640); err != nil { + t.Fatalf("write archive: %v", err) + } + if err := os.WriteFile(rawMetadata, []byte(`{"manifest":true}`), 0o640); err != nil { + t.Fatalf("write metadata: %v", err) + } + if err := os.WriteFile(rawChecksum, checksumLineForBytes("backup.tar", []byte("payload-data")), 0o640); err != nil { + t.Fatalf("write checksum: %v", err) + } + + cand := &decryptCandidate{ + Manifest: &backup.Manifest{ + ArchivePath: rawArchive, + EncryptionMode: "none", + CreatedAt: time.Now(), + Hostname: "node1", + }, + Source: sourceRaw, + RawArchivePath: rawArchive, + RawMetadataPath: rawMetadata, + RawChecksumPath: rawChecksum, + DisplayBase: "test-backup", + } + + if _, err := preparePlainBundleWithUI(context.Background(), cand, "1.0.0", logger, nil); err == nil { + t.Fatalf("expected error for missing UI") + } +} + +func mustCreateExistingFile(t *testing.T) string { + t.Helper() + + tmp := t.TempDir() + path := filepath.Join(tmp, "existing.dat") + if err := os.WriteFile(path, []byte("data"), 0o640); err != nil { + t.Fatalf("write %s: %v", path, err) + } + return path +} diff --git a/internal/orchestrator/encryption.go b/internal/orchestrator/encryption.go index aacfbb4..4d15676 100644 --- a/internal/orchestrator/encryption.go +++ b/internal/orchestrator/encryption.go @@ -58,47 +58,8 @@ func (o *Orchestrator) EnsureAgeRecipientsReady(ctx context.Context) error { } func (o *Orchestrator) prepareAgeRecipients(ctx context.Context) ([]age.Recipient, error) { - if o.cfg == nil || !o.cfg.EncryptArchive { - return nil, nil - } - - if o.ageRecipientCache != nil && !o.forceNewAgeRecipient { - return cloneRecipients(o.ageRecipientCache), nil - } - - recipients, candidatePath, err := o.collectRecipientStrings() - if err != nil { - return nil, err - } - - if len(recipients) == 0 { - if !o.isInteractiveShell() { - o.logger.Error("Encryption setup requires interaction. Run the script interactively to complete the AGE recipient setup, then re-run in automated mode.") - o.logger.Debug("HINT Set AGE_RECIPIENT or AGE_RECIPIENT_FILE to bypass the interactive setup and re-run.") - return nil, fmt.Errorf("age recipients not configured") - } - - wizardRecipients, savedPath, err := o.runAgeSetupWizard(ctx, candidatePath) - if err != nil { - return nil, err - } - recipients = append(recipients, wizardRecipients...) - if o.cfg.AgeRecipientFile == "" { - o.cfg.AgeRecipientFile = savedPath - } - } - - if len(recipients) == 0 { - return nil, fmt.Errorf("no AGE recipients configured after setup") - } - - parsed, err := parseRecipientStrings(recipients) - if err != nil { - return nil, err - } - o.ageRecipientCache = cloneRecipients(parsed) - o.forceNewAgeRecipient = false - return cloneRecipients(parsed), nil + recipients, _, err := o.prepareAgeRecipientsWithUI(ctx, nil) + return recipients, err } func (o *Orchestrator) collectRecipientStrings() ([]string, string, error) { @@ -129,94 +90,22 @@ func (o *Orchestrator) collectRecipientStrings() ([]string, string, error) { // runAgeSetupWizard collects AGE recipients interactively. // Returns (fileRecipients, savedPath, error) func (o *Orchestrator) runAgeSetupWizard(ctx context.Context, candidatePath string) ([]string, string, error) { - reader := bufio.NewReader(os.Stdin) - targetPath := candidatePath - if targetPath == "" { - targetPath = o.defaultAgeRecipientFile() - } - - o.logger.Info("Encryption setup: no AGE recipients found, starting interactive wizard") - if targetPath == "" { - return nil, "", fmt.Errorf("unable to determine default path for AGE recipients") + if o == nil { + return nil, "", fmt.Errorf("orchestrator is required") } - // Create a child context for the wizard to handle Ctrl+C locally wizardCtx, wizardCancel := context.WithCancel(ctx) defer wizardCancel() - recipientPath := targetPath - if o.forceNewAgeRecipient && recipientPath != "" { - if _, err := os.Stat(recipientPath); err == nil { - fmt.Printf("WARNING: this will remove the existing AGE recipients stored at %s. Existing backups remain decryptable with your old private key.\n", recipientPath) - confirm, errPrompt := promptYesNoAge(wizardCtx, reader, fmt.Sprintf("Delete %s and enter a new recipient? [y/N]: ", recipientPath)) - if errPrompt != nil { - return nil, "", errPrompt - } - if !confirm { - return nil, "", fmt.Errorf("operation aborted by user") - } - if err := backupExistingRecipientFile(recipientPath); err != nil { - fmt.Printf("NOTE: %v\n", err) - } - } else if !errors.Is(err, os.ErrNotExist) { - return nil, "", fmt.Errorf("failed to inspect existing AGE recipients at %s: %w", recipientPath, err) - } - } - - recipients := make([]string, 0) - for { - fmt.Println("\n[1] Use an existing AGE public key") - fmt.Println("[2] Generate an AGE public key using a personal passphrase/password — not stored on the server") - fmt.Println("[3] Generate an AGE public key from an existing personal private key — not stored on the server") - fmt.Println("[4] Exit setup") - option, err := promptOptionAge(wizardCtx, reader, "Select an option [1-4]: ") - if err != nil { - return nil, "", err - } - if option == "4" { - return nil, "", ErrAgeRecipientSetupAborted - } - - var value string - switch option { - case "1": - value, err = promptPublicRecipientAge(wizardCtx, reader) - case "2": - value, err = promptPassphraseRecipientAge(wizardCtx) - if err == nil { - o.logger.Info("Derived deterministic AGE public key from passphrase (no secrets stored)") - } - case "3": - value, err = promptPrivateKeyRecipientAge(wizardCtx) - } - if err != nil { - o.logger.Warning("Encryption setup: %v", err) - continue - } - if value != "" { - recipients = append(recipients, value) - } - - more, err := promptYesNoAge(wizardCtx, reader, "Add another recipient? [y/N]: ") - if err != nil { - return nil, "", err - } - if !more { - break - } - } - - if len(recipients) == 0 { - return nil, "", fmt.Errorf("no recipients provided") - } - - if err := writeRecipientFile(targetPath, dedupeRecipientStrings(recipients)); err != nil { + recipients, result, err := o.runAgeSetupWorkflow(wizardCtx, candidatePath, newCLIAgeSetupUI(bufio.NewReader(os.Stdin), o.logger)) + if err != nil { return nil, "", err } - - o.logger.Info("Saved AGE recipient to %s", targetPath) - o.logger.Info("Reminder: keep the AGE private key offline; the server stores only recipients.") - return recipients, targetPath, nil + savedPath := "" + if result != nil { + savedPath = result.RecipientPath + } + return recipients, savedPath, nil } func (o *Orchestrator) defaultAgeRecipientFile() string { @@ -262,6 +151,16 @@ func promptPublicRecipientAge(ctx context.Context, reader *bufio.Reader) (string } func promptPrivateKeyRecipientAge(ctx context.Context) (string, error) { + secret, err := promptPrivateKeyValueAge(ctx) + if err != nil { + return "", err + } + defer resetString(&secret) + + return ParseAgePrivateKeyRecipient(secret) +} + +func promptPrivateKeyValueAge(ctx context.Context) (string, error) { fmt.Print("Paste your AGE private key (not stored; input is not echoed). Press Enter when done: ") secretBytes, err := input.ReadPasswordWithContext(ctx, readPassword, int(os.Stdin.Fd())) fmt.Println() @@ -271,15 +170,14 @@ func promptPrivateKeyRecipientAge(ctx context.Context) (string, error) { defer zeroBytes(secretBytes) secret := strings.TrimSpace(string(secretBytes)) - defer resetString(&secret) if secret == "" { return "", fmt.Errorf("private key cannot be empty") } - identity, err := age.ParseX25519Identity(secret) - if err != nil { - return "", fmt.Errorf("invalid AGE private key: %w", err) + if err := ValidateAgePrivateKeyString(secret); err != nil { + resetString(&secret) + return "", err } - return identity.Recipient().String(), nil + return secret, nil } // promptPassphraseRecipient derives a deterministic AGE public key from a passphrase @@ -495,6 +393,25 @@ func ValidateRecipientString(value string) error { return err } +// ValidateAgePrivateKeyString checks whether a private AGE identity is valid. +func ValidateAgePrivateKeyString(value string) error { + _, err := ParseAgePrivateKeyRecipient(value) + return err +} + +// ParseAgePrivateKeyRecipient validates a private AGE identity and returns its public recipient. +func ParseAgePrivateKeyRecipient(value string) (string, error) { + trimmed := strings.TrimSpace(value) + if trimmed == "" { + return "", fmt.Errorf("private key cannot be empty") + } + identity, err := age.ParseX25519Identity(trimmed) + if err != nil { + return "", fmt.Errorf("invalid AGE private key: %w", err) + } + return identity.Recipient().String(), nil +} + // DedupeRecipientStrings removes empty values and duplicates from recipient strings. func DedupeRecipientStrings(values []string) []string { return dedupeRecipientStrings(values) diff --git a/internal/orchestrator/encryption_exported_test.go b/internal/orchestrator/encryption_exported_test.go index 912f991..096b8f4 100644 --- a/internal/orchestrator/encryption_exported_test.go +++ b/internal/orchestrator/encryption_exported_test.go @@ -249,9 +249,13 @@ func TestRunAgeSetupWizard_ExitReturnsAborted(t *testing.T) { func TestRunAgeSetupWizard_Option1WritesFile(t *testing.T) { tmp := t.TempDir() + id, err := age.GenerateX25519Identity() + if err != nil { + t.Fatalf("GenerateX25519Identity: %v", err) + } inputFile := filepath.Join(tmp, "stdin.txt") // Option 1 -> recipient -> no more recipients. - if err := os.WriteFile(inputFile, []byte("1\nage1alpha\nn\n"), 0o600); err != nil { + if err := os.WriteFile(inputFile, []byte("1\n"+id.Recipient().String()+"\nn\n"), 0o600); err != nil { t.Fatalf("write stdin: %v", err) } f, err := os.Open(inputFile) @@ -272,14 +276,14 @@ func TestRunAgeSetupWizard_Option1WritesFile(t *testing.T) { if savedPath == "" { t.Fatalf("expected saved path") } - if len(out) != 1 || out[0] != "age1alpha" { - t.Fatalf("out=%v; want %v", out, []string{"age1alpha"}) + if len(out) != 1 || out[0] != id.Recipient().String() { + t.Fatalf("out=%v; want %v", out, []string{id.Recipient().String()}) } data, err := os.ReadFile(savedPath) if err != nil { t.Fatalf("read saved: %v", err) } - if string(data) != "age1alpha\n" { - t.Fatalf("saved content=%q; want %q", string(data), "age1alpha\n") + if string(data) != id.Recipient().String()+"\n" { + t.Fatalf("saved content=%q; want %q", string(data), id.Recipient().String()+"\n") } } diff --git a/internal/orchestrator/telegram_setup_bootstrap.go b/internal/orchestrator/telegram_setup_bootstrap.go new file mode 100644 index 0000000..1e5429c --- /dev/null +++ b/internal/orchestrator/telegram_setup_bootstrap.go @@ -0,0 +1,104 @@ +package orchestrator + +import ( + "os" + "strings" + + "github.com/tis24dev/proxsave/internal/config" + "github.com/tis24dev/proxsave/internal/identity" +) + +const defaultTelegramServerAPIHost = "https://bot.tis24.it:1443" + +type TelegramSetupEligibility int + +const ( + TelegramSetupEligibilityUnknown TelegramSetupEligibility = iota + TelegramSetupEligibleCentralized + TelegramSetupSkipDisabled + TelegramSetupSkipConfigError + TelegramSetupSkipPersonalMode + TelegramSetupSkipIdentityUnavailable +) + +type TelegramSetupBootstrap struct { + Eligibility TelegramSetupEligibility + + ConfigLoaded bool + ConfigError string + + TelegramEnabled bool + TelegramMode string + ServerAPIHost string + + ServerID string + IdentityFile string + IdentityPersisted bool + IdentityDetectError string +} + +var ( + telegramSetupBootstrapLoadConfig = config.LoadConfig + telegramSetupBootstrapIdentityDetect = identity.Detect + telegramSetupBootstrapStat = os.Stat +) + +func BuildTelegramSetupBootstrap(configPath, baseDir string) (TelegramSetupBootstrap, error) { + state := TelegramSetupBootstrap{} + + cfg, err := telegramSetupBootstrapLoadConfig(configPath) + if err != nil { + state.Eligibility = TelegramSetupSkipConfigError + state.ConfigError = err.Error() + return state, nil + } + + state.ConfigLoaded = true + if cfg != nil { + state.TelegramEnabled = cfg.TelegramEnabled + state.TelegramMode = strings.ToLower(strings.TrimSpace(cfg.TelegramBotType)) + state.ServerAPIHost = strings.TrimSpace(cfg.TelegramServerAPIHost) + } + + if !state.TelegramEnabled { + state.Eligibility = TelegramSetupSkipDisabled + return state, nil + } + + if state.TelegramMode == "" { + state.TelegramMode = "centralized" + } + if state.ServerAPIHost == "" { + state.ServerAPIHost = defaultTelegramServerAPIHost + } + + if state.TelegramMode == "personal" { + state.Eligibility = TelegramSetupSkipPersonalMode + return state, nil + } + + info, err := telegramSetupBootstrapIdentityDetect(baseDir, nil) + if err != nil { + state.Eligibility = TelegramSetupSkipIdentityUnavailable + state.IdentityDetectError = err.Error() + return state, nil + } + + if info != nil { + state.ServerID = strings.TrimSpace(info.ServerID) + state.IdentityFile = strings.TrimSpace(info.IdentityFile) + if state.IdentityFile != "" { + if _, statErr := telegramSetupBootstrapStat(state.IdentityFile); statErr == nil { + state.IdentityPersisted = true + } + } + } + + if state.ServerID == "" { + state.Eligibility = TelegramSetupSkipIdentityUnavailable + return state, nil + } + + state.Eligibility = TelegramSetupEligibleCentralized + return state, nil +} diff --git a/internal/orchestrator/telegram_setup_bootstrap_test.go b/internal/orchestrator/telegram_setup_bootstrap_test.go new file mode 100644 index 0000000..ad65566 --- /dev/null +++ b/internal/orchestrator/telegram_setup_bootstrap_test.go @@ -0,0 +1,212 @@ +package orchestrator + +import ( + "errors" + "os" + "path/filepath" + "testing" + + "github.com/tis24dev/proxsave/internal/config" + "github.com/tis24dev/proxsave/internal/identity" + "github.com/tis24dev/proxsave/internal/logging" +) + +func stubTelegramSetupBootstrapDeps(t *testing.T) { + t.Helper() + + origLoadConfig := telegramSetupBootstrapLoadConfig + origIdentityDetect := telegramSetupBootstrapIdentityDetect + origStat := telegramSetupBootstrapStat + + t.Cleanup(func() { + telegramSetupBootstrapLoadConfig = origLoadConfig + telegramSetupBootstrapIdentityDetect = origIdentityDetect + telegramSetupBootstrapStat = origStat + }) +} + +func TestBuildTelegramSetupBootstrap_ConfigLoadFailureSkips(t *testing.T) { + stubTelegramSetupBootstrapDeps(t) + + telegramSetupBootstrapLoadConfig = func(path string) (*config.Config, error) { + return nil, errors.New("parse failed") + } + telegramSetupBootstrapIdentityDetect = func(baseDir string, logger *logging.Logger) (*identity.Info, error) { + t.Fatalf("identity detect should not run on config failure") + return nil, nil + } + + state, err := BuildTelegramSetupBootstrap("/fake/backup.env", t.TempDir()) + if err != nil { + t.Fatalf("BuildTelegramSetupBootstrap error: %v", err) + } + if state.Eligibility != TelegramSetupSkipConfigError { + t.Fatalf("Eligibility=%v, want %v", state.Eligibility, TelegramSetupSkipConfigError) + } + if state.ConfigError == "" { + t.Fatalf("expected ConfigError to be set") + } + if state.ConfigLoaded { + t.Fatalf("expected ConfigLoaded=false") + } +} + +func TestBuildTelegramSetupBootstrap_DisabledSkips(t *testing.T) { + stubTelegramSetupBootstrapDeps(t) + + telegramSetupBootstrapLoadConfig = func(path string) (*config.Config, error) { + return &config.Config{TelegramEnabled: false}, nil + } + telegramSetupBootstrapIdentityDetect = func(baseDir string, logger *logging.Logger) (*identity.Info, error) { + t.Fatalf("identity detect should not run when telegram is disabled") + return nil, nil + } + + state, err := BuildTelegramSetupBootstrap("/fake/backup.env", t.TempDir()) + if err != nil { + t.Fatalf("BuildTelegramSetupBootstrap error: %v", err) + } + if state.Eligibility != TelegramSetupSkipDisabled { + t.Fatalf("Eligibility=%v, want %v", state.Eligibility, TelegramSetupSkipDisabled) + } + if state.TelegramEnabled { + t.Fatalf("expected TelegramEnabled=false") + } +} + +func TestBuildTelegramSetupBootstrap_PersonalModeSkips(t *testing.T) { + stubTelegramSetupBootstrapDeps(t) + + telegramSetupBootstrapLoadConfig = func(path string) (*config.Config, error) { + return &config.Config{ + TelegramEnabled: true, + TelegramBotType: " Personal ", + TelegramServerAPIHost: "", + }, nil + } + telegramSetupBootstrapIdentityDetect = func(baseDir string, logger *logging.Logger) (*identity.Info, error) { + t.Fatalf("identity detect should not run in personal mode") + return nil, nil + } + + state, err := BuildTelegramSetupBootstrap("/fake/backup.env", t.TempDir()) + if err != nil { + t.Fatalf("BuildTelegramSetupBootstrap error: %v", err) + } + if state.Eligibility != TelegramSetupSkipPersonalMode { + t.Fatalf("Eligibility=%v, want %v", state.Eligibility, TelegramSetupSkipPersonalMode) + } + if state.TelegramMode != "personal" { + t.Fatalf("TelegramMode=%q, want personal", state.TelegramMode) + } + if state.ServerAPIHost != defaultTelegramServerAPIHost { + t.Fatalf("ServerAPIHost=%q, want %q", state.ServerAPIHost, defaultTelegramServerAPIHost) + } +} + +func TestBuildTelegramSetupBootstrap_IdentityErrorSkips(t *testing.T) { + stubTelegramSetupBootstrapDeps(t) + + telegramSetupBootstrapLoadConfig = func(path string) (*config.Config, error) { + return &config.Config{ + TelegramEnabled: true, + TelegramBotType: "centralized", + }, nil + } + telegramSetupBootstrapIdentityDetect = func(baseDir string, logger *logging.Logger) (*identity.Info, error) { + return nil, errors.New("detect failed") + } + + state, err := BuildTelegramSetupBootstrap("/fake/backup.env", t.TempDir()) + if err != nil { + t.Fatalf("BuildTelegramSetupBootstrap error: %v", err) + } + if state.Eligibility != TelegramSetupSkipIdentityUnavailable { + t.Fatalf("Eligibility=%v, want %v", state.Eligibility, TelegramSetupSkipIdentityUnavailable) + } + if state.IdentityDetectError == "" { + t.Fatalf("expected IdentityDetectError to be set") + } + if state.ServerAPIHost != defaultTelegramServerAPIHost { + t.Fatalf("ServerAPIHost=%q, want %q", state.ServerAPIHost, defaultTelegramServerAPIHost) + } +} + +func TestBuildTelegramSetupBootstrap_EmptyServerIDSkips(t *testing.T) { + stubTelegramSetupBootstrapDeps(t) + + telegramSetupBootstrapLoadConfig = func(path string) (*config.Config, error) { + return &config.Config{ + TelegramEnabled: true, + TelegramBotType: "centralized", + TelegramServerAPIHost: "https://api.example.test", + }, nil + } + telegramSetupBootstrapIdentityDetect = func(baseDir string, logger *logging.Logger) (*identity.Info, error) { + return &identity.Info{ServerID: " ", IdentityFile: " /tmp/id "}, nil + } + + state, err := BuildTelegramSetupBootstrap("/fake/backup.env", t.TempDir()) + if err != nil { + t.Fatalf("BuildTelegramSetupBootstrap error: %v", err) + } + if state.Eligibility != TelegramSetupSkipIdentityUnavailable { + t.Fatalf("Eligibility=%v, want %v", state.Eligibility, TelegramSetupSkipIdentityUnavailable) + } + if state.ServerID != "" { + t.Fatalf("ServerID=%q, want empty", state.ServerID) + } + if state.IdentityFile != "/tmp/id" { + t.Fatalf("IdentityFile=%q, want /tmp/id", state.IdentityFile) + } +} + +func TestBuildTelegramSetupBootstrap_EligibleCentralized(t *testing.T) { + stubTelegramSetupBootstrapDeps(t) + + identityFile := filepath.Join(t.TempDir(), ".server_identity") + if err := os.WriteFile(identityFile, []byte("id"), 0o600); err != nil { + t.Fatalf("write identity file: %v", err) + } + + telegramSetupBootstrapLoadConfig = func(path string) (*config.Config, error) { + return &config.Config{ + TelegramEnabled: true, + TelegramBotType: " ", + TelegramServerAPIHost: " https://api.example.test ", + }, nil + } + telegramSetupBootstrapIdentityDetect = func(baseDir string, logger *logging.Logger) (*identity.Info, error) { + return &identity.Info{ + ServerID: " 123456789 ", + IdentityFile: " " + identityFile + " ", + }, nil + } + telegramSetupBootstrapStat = os.Stat + + state, err := BuildTelegramSetupBootstrap("/fake/backup.env", t.TempDir()) + if err != nil { + t.Fatalf("BuildTelegramSetupBootstrap error: %v", err) + } + if state.Eligibility != TelegramSetupEligibleCentralized { + t.Fatalf("Eligibility=%v, want %v", state.Eligibility, TelegramSetupEligibleCentralized) + } + if !state.ConfigLoaded { + t.Fatalf("expected ConfigLoaded=true") + } + if state.TelegramMode != "centralized" { + t.Fatalf("TelegramMode=%q, want centralized", state.TelegramMode) + } + if state.ServerAPIHost != "https://api.example.test" { + t.Fatalf("ServerAPIHost=%q, want https://api.example.test", state.ServerAPIHost) + } + if state.ServerID != "123456789" { + t.Fatalf("ServerID=%q, want 123456789", state.ServerID) + } + if state.IdentityFile != identityFile { + t.Fatalf("IdentityFile=%q, want %q", state.IdentityFile, identityFile) + } + if !state.IdentityPersisted { + t.Fatalf("expected IdentityPersisted=true") + } +} diff --git a/internal/orchestrator/tui_simulation_test.go b/internal/orchestrator/tui_simulation_test.go index 27dd3d0..f40d594 100644 --- a/internal/orchestrator/tui_simulation_test.go +++ b/internal/orchestrator/tui_simulation_test.go @@ -61,25 +61,34 @@ func withSimApp(t *testing.T, keys []tcell.Key) { func TestPromptOverwriteAction_SelectsOverwrite(t *testing.T) { withSimApp(t, []tcell.Key{tcell.KeyEnter}) - got, err := promptOverwriteAction("/tmp/existing", "file", "", "/tmp/config.env", "sig") + decision, newPath, err := promptExistingPathDecisionTUI("/tmp/existing", "file", "", "/tmp/config.env", "sig") if err != nil { - t.Fatalf("promptOverwriteAction error: %v", err) + t.Fatalf("promptExistingPathDecisionTUI error: %v", err) } - if got != pathActionOverwrite { - t.Fatalf("choice=%q; want %q", got, pathActionOverwrite) + if decision != PathDecisionOverwrite { + t.Fatalf("decision=%v; want %v", decision, PathDecisionOverwrite) + } + if newPath != "" { + t.Fatalf("newPath=%q; want empty", newPath) } } -func TestPromptNewPathInput_ContinueReturnsDefault(t *testing.T) { - // Move focus to Continue button then submit. - withSimApp(t, []tcell.Key{tcell.KeyTab, tcell.KeyEnter}) +func TestPromptNewPathInput_ContinueReturnsEditedPath(t *testing.T) { + withSimAppSequence(t, []simKey{ + {Key: tcell.KeyRune, R: '/'}, + {Key: tcell.KeyRune, R: 'a'}, + {Key: tcell.KeyRune, R: 'l'}, + {Key: tcell.KeyRune, R: 't'}, + {Key: tcell.KeyTab}, + {Key: tcell.KeyEnter}, + }) - got, err := promptNewPathInput("/tmp/newpath", "/tmp/config.env", "sig") + got, err := promptNewPathInputTUI("/tmp/newpath", "/tmp/config.env", "sig") if err != nil { - t.Fatalf("promptNewPathInput error: %v", err) + t.Fatalf("promptNewPathInputTUI error: %v", err) } - if got != "/tmp/newpath" { - t.Fatalf("path=%q; want %q", got, "/tmp/newpath") + if got != "/tmp/newpath/alt" { + t.Fatalf("path=%q; want %q", got, "/tmp/newpath/alt") } } diff --git a/internal/orchestrator/workflow_ui_cli.go b/internal/orchestrator/workflow_ui_cli.go index 1d303c7..ec73455 100644 --- a/internal/orchestrator/workflow_ui_cli.go +++ b/internal/orchestrator/workflow_ui_cli.go @@ -133,8 +133,9 @@ func (u *cliWorkflowUI) ResolveExistingPath(ctx context.Context, path, descripti if err != nil { return PathDecisionCancel, "", err } - trimmed := strings.TrimSpace(newPath) - if trimmed == "" { + trimmed, err := validateDistinctNewPathInput(newPath, current) + if err != nil { + fmt.Println(err.Error()) continue } return PathDecisionNewPath, filepath.Clean(trimmed), nil diff --git a/internal/orchestrator/workflow_ui_cli_test.go b/internal/orchestrator/workflow_ui_cli_test.go new file mode 100644 index 0000000..4cf1798 --- /dev/null +++ b/internal/orchestrator/workflow_ui_cli_test.go @@ -0,0 +1,83 @@ +package orchestrator + +import ( + "bufio" + "bytes" + "context" + "io" + "os" + "strings" + "testing" +) + +func captureCLIStdout(t *testing.T, fn func()) string { + t.Helper() + + oldStdout := os.Stdout + r, w, err := os.Pipe() + if err != nil { + t.Fatalf("os.Pipe: %v", err) + } + os.Stdout = w + t.Cleanup(func() { + os.Stdout = oldStdout + }) + + var buf bytes.Buffer + done := make(chan struct{}) + go func() { + _, _ = io.Copy(&buf, r) + close(done) + }() + + fn() + + _ = w.Close() + <-done + _ = r.Close() + + os.Stdout = oldStdout + return buf.String() +} + +func TestCLIWorkflowUIResolveExistingPath_RejectsEquivalentNormalizedPath(t *testing.T) { + reader := bufio.NewReader(strings.NewReader("2\n/tmp/out/\n2\n /tmp/out/../alt \n")) + ui := newCLIWorkflowUI(reader, nil) + + var ( + decision ExistingPathDecision + newPath string + err error + ) + output := captureCLIStdout(t, func() { + decision, newPath, err = ui.ResolveExistingPath(context.Background(), "/tmp/out", "archive", "") + }) + if err != nil { + t.Fatalf("ResolveExistingPath error: %v", err) + } + if decision != PathDecisionNewPath { + t.Fatalf("decision=%v, want %v", decision, PathDecisionNewPath) + } + if newPath != "/tmp/alt" { + t.Fatalf("newPath=%q, want %q", newPath, "/tmp/alt") + } + if !strings.Contains(output, "path must be different from existing path") { + t.Fatalf("expected validation message in output, got %q", output) + } +} + +func TestCLIWorkflowUIResolveExistingPath_EmptyPathRetriesUntilValid(t *testing.T) { + reader := bufio.NewReader(strings.NewReader("2\n \n2\n/tmp/next\n")) + ui := newCLIWorkflowUI(reader, nil) + + decision, newPath, err := ui.ResolveExistingPath(context.Background(), "/tmp/out", "archive", "") + if err != nil { + t.Fatalf("ResolveExistingPath error: %v", err) + } + if decision != PathDecisionNewPath { + t.Fatalf("decision=%v, want %v", decision, PathDecisionNewPath) + } + if newPath != "/tmp/next" { + t.Fatalf("newPath=%q, want %q", newPath, "/tmp/next") + } +} diff --git a/internal/orchestrator/workflow_ui_tui_decrypt.go b/internal/orchestrator/workflow_ui_tui_decrypt.go index 88c83b3..8849a04 100644 --- a/internal/orchestrator/workflow_ui_tui_decrypt.go +++ b/internal/orchestrator/workflow_ui_tui_decrypt.go @@ -375,83 +375,18 @@ func (u *tuiWorkflowUI) PromptDestinationDir(ctx context.Context, defaultDir str } func (u *tuiWorkflowUI) ResolveExistingPath(ctx context.Context, path, description, failure string) (ExistingPathDecision, string, error) { - action, err := promptOverwriteActionFunc(path, description, failure, u.configPath, u.buildSig) + decision, newPath, err := tuiPromptExistingPathDecision(path, description, failure, u.configPath, u.buildSig) if err != nil { return PathDecisionCancel, "", err } - switch action { - case pathActionOverwrite: - return PathDecisionOverwrite, "", nil - case pathActionNew: - newPath, err := promptNewPathInputFunc(path, u.configPath, u.buildSig) - if err != nil { - return PathDecisionCancel, "", err - } - return PathDecisionNewPath, filepath.Clean(newPath), nil - default: - return PathDecisionCancel, "", ErrDecryptAborted + if decision != PathDecisionNewPath { + return decision, "", nil } + return decision, filepath.Clean(newPath), nil } func (u *tuiWorkflowUI) PromptDecryptSecret(ctx context.Context, displayName, previousError string) (string, error) { - app := newTUIApp() - var ( - secret string - cancelled bool - ) - - name := strings.TrimSpace(displayName) - if name == "" { - name = "selected backup" - } - - infoMessage := fmt.Sprintf("Provide the AGE secret key or passphrase used for [yellow]%s[white].", name) - if strings.TrimSpace(previousError) != "" { - infoMessage = fmt.Sprintf("%s\n\n[red]%s[white]", infoMessage, strings.TrimSpace(previousError)) - } - - infoText := tview.NewTextView(). - SetText(infoMessage). - SetWrap(true). - SetTextColor(tcell.ColorWhite). - SetDynamicColors(true) - - form := components.NewForm(app) - label := "Key or passphrase:" - form.AddPasswordField(label, 64) - form.SetOnSubmit(func(values map[string]string) error { - raw := strings.TrimSpace(values[label]) - if raw == "" { - return fmt.Errorf("key or passphrase cannot be empty") - } - if raw == "0" { - cancelled = true - return nil - } - secret = raw - return nil - }) - form.SetOnCancel(func() { - cancelled = true - }) - form.AddSubmitButton("Continue") - form.AddCancelButton("Cancel") - enableFormNavigation(form, nil) - - content := tview.NewFlex(). - SetDirection(tview.FlexRow). - AddItem(infoText, 0, 2, false). - AddItem(form.Form, 0, 1, true) - - page := u.buildPage("Decrypt key", u.configPath, u.buildSig, content) - form.SetParentView(page) - if err := app.SetRoot(page, true).SetFocus(form.Form).Run(); err != nil { - return "", err - } - if cancelled { - return "", ErrDecryptAborted - } - return secret, nil + return tuiPromptDecryptSecret(u.configPath, u.buildSig, displayName, previousError) } func backupSummaryForUI(cand *decryptCandidate) string { diff --git a/internal/orchestrator/workflow_ui_tui_decrypt_prompts.go b/internal/orchestrator/workflow_ui_tui_decrypt_prompts.go new file mode 100644 index 0000000..55bc35c --- /dev/null +++ b/internal/orchestrator/workflow_ui_tui_decrypt_prompts.go @@ -0,0 +1,196 @@ +package orchestrator + +import ( + "fmt" + "path/filepath" + "strings" + + "github.com/gdamore/tcell/v2" + "github.com/rivo/tview" + + "github.com/tis24dev/proxsave/internal/tui" + "github.com/tis24dev/proxsave/internal/tui/components" +) + +var ( + tuiPromptExistingPathDecision = promptExistingPathDecisionTUI + tuiPromptDecryptSecret = promptDecryptSecretTUI +) + +func promptExistingPathDecisionTUI(path, description, failureMessage, configPath, buildSig string) (ExistingPathDecision, string, error) { + app := newTUIApp() + decision := PathDecisionCancel + + message := fmt.Sprintf("The %s [yellow]%s[white] already exists.\nSelect how you want to proceed.", description, path) + if strings.TrimSpace(failureMessage) != "" { + message = fmt.Sprintf("%s\n\n[red]%s[white]", message, failureMessage) + } + message += "\n\n[yellow]Use ←→ or TAB to switch buttons | ENTER to confirm[white]" + + modal := tview.NewModal(). + SetText(message). + AddButtons([]string{"Overwrite", "Use different path", "Cancel"}). + SetDoneFunc(func(buttonIndex int, buttonLabel string) { + switch buttonLabel { + case "Overwrite": + decision = PathDecisionOverwrite + case "Use different path": + decision = PathDecisionNewPath + default: + decision = PathDecisionCancel + } + app.Stop() + }) + + modal.SetBorder(true). + SetTitle(" Existing file "). + SetTitleAlign(tview.AlignCenter). + SetTitleColor(tui.WarningYellow). + SetBorderColor(tui.WarningYellow). + SetBackgroundColor(tcell.ColorBlack) + + page := buildWizardPage("Destination path", configPath, buildSig, modal) + if err := app.SetRoot(page, true).SetFocus(modal).Run(); err != nil { + return PathDecisionCancel, "", err + } + if decision != PathDecisionNewPath { + return decision, "", nil + } + + newPath, err := promptNewPathInputTUI(path, configPath, buildSig) + if err != nil { + if err == ErrDecryptAborted { + return PathDecisionCancel, "", nil + } + return PathDecisionCancel, "", err + } + return PathDecisionNewPath, filepath.Clean(newPath), nil +} + +func promptNewPathInputTUI(defaultPath, configPath, buildSig string) (string, error) { + app := newTUIApp() + var newPath string + var cancelled bool + + form := components.NewForm(app) + label := "New path" + form.AddInputFieldWithValidation(label, defaultPath, 64, func(value string) error { + _, err := validateDistinctNewPathInput(value, defaultPath) + return err + }) + form.SetOnSubmit(func(values map[string]string) error { + trimmed, err := validateDistinctNewPathInput(values[label], defaultPath) + if err != nil { + return err + } + newPath = trimmed + return nil + }) + form.SetOnCancel(func() { + cancelled = true + }) + form.AddSubmitButton("Continue") + form.AddCancelButton("Cancel") + enableFormNavigation(form, nil) + + helper := tview.NewTextView(). + SetText("Provide a writable filesystem path for the decrypted files."). + SetWrap(true). + SetTextColor(tcell.ColorWhite). + SetDynamicColors(true) + + content := tview.NewFlex(). + SetDirection(tview.FlexRow). + AddItem(helper, 3, 0, false). + AddItem(form.Form, 0, 1, true) + + page := buildWizardPage("Choose destination path", configPath, buildSig, content) + form.SetParentView(page) + + if err := app.SetRoot(page, true).SetFocus(form.Form).Run(); err != nil { + return "", err + } + if cancelled { + return "", ErrDecryptAborted + } + return filepath.Clean(newPath), nil +} + +func validateDistinctNewPathInput(value, defaultPath string) (string, error) { + trimmed := strings.TrimSpace(value) + if trimmed == "" { + return "", fmt.Errorf("path cannot be empty") + } + + trimmedDefault := strings.TrimSpace(defaultPath) + if trimmedDefault != "" && filepath.Clean(trimmed) == filepath.Clean(trimmedDefault) { + return "", fmt.Errorf("path must be different from existing path") + } + + return trimmed, nil +} + +func promptDecryptSecretTUI(configPath, buildSig, displayName, previousError string) (string, error) { + app := newTUIApp() + var ( + secret string + cancelled bool + ) + + name := strings.TrimSpace(displayName) + if name == "" { + name = "selected backup" + } + + infoMessage := fmt.Sprintf( + "Provide the AGE secret key or passphrase used for [yellow]%s[white].\n\n"+ + "Enter [yellow]0[white] to exit or use [yellow]Cancel[white].", + name, + ) + if strings.TrimSpace(previousError) != "" { + infoMessage = fmt.Sprintf("%s\n\n[red]%s[white]", infoMessage, strings.TrimSpace(previousError)) + } + + infoText := tview.NewTextView(). + SetText(infoMessage). + SetWrap(true). + SetTextColor(tcell.ColorWhite). + SetDynamicColors(true) + + form := components.NewForm(app) + label := "Key or passphrase:" + form.AddPasswordField(label, 64) + form.SetOnSubmit(func(values map[string]string) error { + raw := strings.TrimSpace(values[label]) + if raw == "" { + return fmt.Errorf("key or passphrase cannot be empty") + } + if raw == "0" { + cancelled = true + return nil + } + secret = raw + return nil + }) + form.SetOnCancel(func() { + cancelled = true + }) + form.AddSubmitButton("Continue") + form.AddCancelButton("Cancel") + enableFormNavigation(form, nil) + + content := tview.NewFlex(). + SetDirection(tview.FlexRow). + AddItem(infoText, 0, 2, false). + AddItem(form.Form, 0, 1, true) + + page := buildWizardPage("Decrypt key", configPath, buildSig, content) + form.SetParentView(page) + if err := app.SetRoot(page, true).SetFocus(form.Form).Run(); err != nil { + return "", err + } + if cancelled { + return "", ErrDecryptAborted + } + return secret, nil +} diff --git a/internal/orchestrator/workflow_ui_tui_decrypt_test.go b/internal/orchestrator/workflow_ui_tui_decrypt_test.go new file mode 100644 index 0000000..28ddc7f --- /dev/null +++ b/internal/orchestrator/workflow_ui_tui_decrypt_test.go @@ -0,0 +1,122 @@ +package orchestrator + +import ( + "context" + "errors" + "path/filepath" + "testing" + + "github.com/gdamore/tcell/v2" +) + +func stubTUIExistingPathDecisionPrompt(fn func(path, description, failure, configPath, buildSig string) (ExistingPathDecision, string, error)) func() { + orig := tuiPromptExistingPathDecision + tuiPromptExistingPathDecision = fn + return func() { tuiPromptExistingPathDecision = orig } +} + +func TestTUIWorkflowUIResolveExistingPath_Overwrite(t *testing.T) { + restore := stubTUIExistingPathDecisionPrompt(func(path, description, failure, configPath, buildSig string) (ExistingPathDecision, string, error) { + if path != "/tmp/archive.tar" { + t.Fatalf("path=%q, want /tmp/archive.tar", path) + } + if description != "archive" { + t.Fatalf("description=%q, want archive", description) + } + if configPath != "/tmp/config.env" { + t.Fatalf("configPath=%q, want /tmp/config.env", configPath) + } + if buildSig != "sig" { + t.Fatalf("buildSig=%q, want sig", buildSig) + } + return PathDecisionOverwrite, "", nil + }) + defer restore() + + ui := newTUIWorkflowUI("/tmp/config.env", "sig", nil) + decision, newPath, err := ui.ResolveExistingPath(context.Background(), "/tmp/archive.tar", "archive", "") + if err != nil { + t.Fatalf("ResolveExistingPath error: %v", err) + } + if decision != PathDecisionOverwrite { + t.Fatalf("decision=%v, want %v", decision, PathDecisionOverwrite) + } + if newPath != "" { + t.Fatalf("newPath=%q, want empty", newPath) + } +} + +func TestTUIWorkflowUIResolveExistingPath_NewPathIsCleaned(t *testing.T) { + restore := stubTUIExistingPathDecisionPrompt(func(path, description, failure, configPath, buildSig string) (ExistingPathDecision, string, error) { + return PathDecisionNewPath, "/tmp/out/../out/final.tar", nil + }) + defer restore() + + ui := newTUIWorkflowUI("/tmp/config.env", "sig", nil) + decision, newPath, err := ui.ResolveExistingPath(context.Background(), "/tmp/archive.tar", "archive", "") + if err != nil { + t.Fatalf("ResolveExistingPath error: %v", err) + } + if decision != PathDecisionNewPath { + t.Fatalf("decision=%v, want %v", decision, PathDecisionNewPath) + } + if newPath != filepath.Clean("/tmp/out/../out/final.tar") { + t.Fatalf("newPath=%q, want %q", newPath, filepath.Clean("/tmp/out/../out/final.tar")) + } +} + +func TestTUIWorkflowUIResolveExistingPath_PropagatesError(t *testing.T) { + wantErr := errors.New("boom") + restore := stubTUIExistingPathDecisionPrompt(func(path, description, failure, configPath, buildSig string) (ExistingPathDecision, string, error) { + return PathDecisionCancel, "", wantErr + }) + defer restore() + + ui := newTUIWorkflowUI("/tmp/config.env", "sig", nil) + if _, _, err := ui.ResolveExistingPath(context.Background(), "/tmp/archive.tar", "archive", ""); !errors.Is(err, wantErr) { + t.Fatalf("expected %v, got %v", wantErr, err) + } +} + +func TestTUIWorkflowUIPromptDestinationDir_ContinueReturnsCleanPath(t *testing.T) { + withSimApp(t, []tcell.Key{tcell.KeyTab, tcell.KeyEnter}) + + ui := newTUIWorkflowUI("/tmp/config.env", "sig", nil) + got, err := ui.PromptDestinationDir(context.Background(), "/tmp/out/../out") + if err != nil { + t.Fatalf("PromptDestinationDir error: %v", err) + } + if got != "/tmp/out" { + t.Fatalf("destination=%q, want %q", got, "/tmp/out") + } +} + +func TestTUIWorkflowUIPromptDestinationDir_CancelReturnsAborted(t *testing.T) { + withSimApp(t, []tcell.Key{tcell.KeyTab, tcell.KeyTab, tcell.KeyEnter}) + + ui := newTUIWorkflowUI("/tmp/config.env", "sig", nil) + _, err := ui.PromptDestinationDir(context.Background(), "/tmp/out") + if !errors.Is(err, ErrDecryptAborted) { + t.Fatalf("err=%v, want %v", err, ErrDecryptAborted) + } +} + +func TestValidateDistinctNewPathInputRejectsEquivalentNormalizedPath(t *testing.T) { + _, err := validateDistinctNewPathInput("/tmp/out/", "/tmp/out") + if err == nil { + t.Fatalf("expected validation error") + } + if err.Error() != "path must be different from existing path" { + t.Fatalf("err=%q, want %q", err.Error(), "path must be different from existing path") + } +} + +func TestValidateDistinctNewPathInputAcceptsDifferentPath(t *testing.T) { + got, err := validateDistinctNewPathInput(" /tmp/out/alt ", "/tmp/out") + if err != nil { + t.Fatalf("validateDistinctNewPathInput error: %v", err) + } + if got != "/tmp/out/alt" { + t.Fatalf("path=%q, want %q", got, "/tmp/out/alt") + } +} diff --git a/internal/orchestrator/workflow_ui_tui_shared.go b/internal/orchestrator/workflow_ui_tui_shared.go new file mode 100644 index 0000000..3488bbb --- /dev/null +++ b/internal/orchestrator/workflow_ui_tui_shared.go @@ -0,0 +1,49 @@ +package orchestrator + +import ( + "github.com/gdamore/tcell/v2" + + "github.com/tis24dev/proxsave/internal/tui/components" +) + +func enableFormNavigation(form *components.Form, dropdownOpen *bool) { + if form == nil || form.Form == nil { + return + } + form.Form.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey { + if event == nil { + return event + } + if dropdownOpen != nil && *dropdownOpen { + return event + } + + formItemIndex, buttonIndex := form.Form.GetFocusedItemIndex() + isOnButton := formItemIndex < 0 && buttonIndex >= 0 + isOnField := formItemIndex >= 0 + + if isOnButton { + switch event.Key() { + case tcell.KeyLeft, tcell.KeyUp: + return tcell.NewEventKey(tcell.KeyBacktab, 0, tcell.ModNone) + case tcell.KeyRight, tcell.KeyDown: + return tcell.NewEventKey(tcell.KeyTab, 0, tcell.ModNone) + } + } else if isOnField { + // If focused item is a ListFormItem, let it handle navigation internally. + if formItemIndex >= 0 { + if _, ok := form.Form.GetFormItem(formItemIndex).(*components.ListFormItem); ok { + return event + } + } + // For other form fields, convert arrows to tab navigation. + switch event.Key() { + case tcell.KeyUp: + return tcell.NewEventKey(tcell.KeyBacktab, 0, tcell.ModNone) + case tcell.KeyDown: + return tcell.NewEventKey(tcell.KeyTab, 0, tcell.ModNone) + } + } + return event + }) +} diff --git a/internal/tui/wizard/age.go b/internal/tui/wizard/age.go index 524fcf1..35afd18 100644 --- a/internal/tui/wizard/age.go +++ b/internal/tui/wizard/age.go @@ -70,8 +70,8 @@ func validatePrivateKey(value string) (string, error) { if key == "" { return "", fmt.Errorf("private key cannot be empty") } - if !strings.HasPrefix(key, "AGE-SECRET-KEY-1") { - return "", fmt.Errorf("private key must start with 'AGE-SECRET-KEY-1'") + if err := orchestrator.ValidateAgePrivateKeyString(key); err != nil { + return "", err } return key, nil } @@ -486,8 +486,9 @@ func RunAgeSetupWizard(ctx context.Context, recipientPath, configPath, buildSig form.AddSubmitButton("Continue") form.AddCancelButton("Cancel") - // Run the app - ignore errors from normal app termination - _ = ageWizardRunner(app, flex, form.Form) + if err := ageWizardRunner(app, flex, form.Form); err != nil { + return nil, err + } if data == nil { return nil, ErrAgeSetupCancelled diff --git a/internal/tui/wizard/age_test.go b/internal/tui/wizard/age_test.go index 1a64a1f..0e30c79 100644 --- a/internal/tui/wizard/age_test.go +++ b/internal/tui/wizard/age_test.go @@ -9,6 +9,7 @@ import ( "strings" "testing" + "filippo.io/age" "github.com/gdamore/tcell/v2" "github.com/rivo/tview" "golang.org/x/crypto/ssh" @@ -82,15 +83,21 @@ func TestValidatePassphrase(t *testing.T) { } func TestValidatePrivateKey(t *testing.T) { + identity, err := age.GenerateX25519Identity() + if err != nil { + t.Fatalf("GenerateX25519Identity: %v", err) + } + cases := []struct { name string input string want string wantErr bool }{ - {name: "valid", input: " AGE-SECRET-KEY-1abc ", want: "AGE-SECRET-KEY-1abc"}, + {name: "valid", input: " " + identity.String() + " ", want: identity.String()}, {name: "empty", input: "", wantErr: true}, {name: "wrong prefix", input: "SECRET", wantErr: true}, + {name: "invalid body", input: "AGE-SECRET-KEY-1invalid", wantErr: true}, } for _, tc := range cases { @@ -366,11 +373,16 @@ func TestRunAgeSetupWizardPassphrase(t *testing.T) { } func TestRunAgeSetupWizardPrivateKey(t *testing.T) { + identity, err := age.GenerateX25519Identity() + if err != nil { + t.Fatalf("GenerateX25519Identity: %v", err) + } + data, err := runAgeWizardTest(t, func(form *tview.Form) { drop := form.GetFormItem(0).(*tview.DropDown) drop.SetCurrentOption(2) privateField := form.GetFormItem(4).(*tview.InputField) - privateField.SetText("AGE-SECRET-KEY-1valid") + privateField.SetText(identity.String()) pressFormButton(t, form, "Continue") }) if err != nil { @@ -379,7 +391,7 @@ func TestRunAgeSetupWizardPrivateKey(t *testing.T) { if data.SetupType != "privatekey" { t.Fatalf("unexpected setup type: %s", data.SetupType) } - if data.PrivateKey != "AGE-SECRET-KEY-1valid" { + if data.PrivateKey != identity.String() { t.Fatalf("expected private key saved, got %q", data.PrivateKey) } if data.Passphrase != "" || data.PublicKey != "" { @@ -399,6 +411,20 @@ func TestRunAgeSetupWizardCancel(t *testing.T) { } } +func TestRunAgeSetupWizardRunnerError(t *testing.T) { + originalRunner := ageWizardRunner + defer func() { ageWizardRunner = originalRunner }() + + expected := errors.New("boom") + ageWizardRunner = func(app *tui.App, root, focus tview.Primitive) error { + return expected + } + + if _, err := RunAgeSetupWizard(context.Background(), "/tmp/recipient.age", "/etc/proxsave/config.env", "sig-test"); !errors.Is(err, expected) { + t.Fatalf("err=%v; want %v", err, expected) + } +} + func runAgeWizardTest(t *testing.T, configure func(form *tview.Form)) (*AgeSetupData, error) { t.Helper() originalRunner := ageWizardRunner diff --git a/internal/tui/wizard/age_ui_adapter.go b/internal/tui/wizard/age_ui_adapter.go new file mode 100644 index 0000000..cca0719 --- /dev/null +++ b/internal/tui/wizard/age_ui_adapter.go @@ -0,0 +1,62 @@ +package wizard + +import ( + "context" + "errors" + "fmt" + + "github.com/tis24dev/proxsave/internal/orchestrator" +) + +type ageSetupUIAdapter struct { + configPath string + buildSig string +} + +func NewAgeSetupUI(configPath, buildSig string) orchestrator.AgeSetupUI { + return &ageSetupUIAdapter{ + configPath: configPath, + buildSig: buildSig, + } +} + +func (a *ageSetupUIAdapter) ConfirmOverwriteExistingRecipient(ctx context.Context, recipientPath string) (bool, error) { + return ConfirmRecipientOverwrite(recipientPath, a.configPath, a.buildSig) +} + +func (a *ageSetupUIAdapter) CollectRecipientDraft(ctx context.Context, recipientPath string) (*orchestrator.AgeRecipientDraft, error) { + data, err := RunAgeSetupWizard(ctx, recipientPath, a.configPath, a.buildSig) + if err != nil { + if errors.Is(err, ErrAgeSetupCancelled) { + return nil, orchestrator.ErrAgeRecipientSetupAborted + } + return nil, err + } + if data == nil { + return nil, orchestrator.ErrAgeRecipientSetupAborted + } + + switch data.SetupType { + case "existing": + return &orchestrator.AgeRecipientDraft{ + Kind: orchestrator.AgeRecipientInputExisting, + PublicKey: data.PublicKey, + }, nil + case "passphrase": + return &orchestrator.AgeRecipientDraft{ + Kind: orchestrator.AgeRecipientInputPassphrase, + Passphrase: data.Passphrase, + }, nil + case "privatekey": + return &orchestrator.AgeRecipientDraft{ + Kind: orchestrator.AgeRecipientInputPrivateKey, + PrivateKey: data.PrivateKey, + }, nil + default: + return nil, fmt.Errorf("unknown AGE setup type: %s", data.SetupType) + } +} + +func (a *ageSetupUIAdapter) ConfirmAddAnotherRecipient(ctx context.Context, currentCount int) (bool, error) { + return ConfirmAddRecipient(a.configPath, a.buildSig, currentCount) +} diff --git a/internal/tui/wizard/age_ui_adapter_test.go b/internal/tui/wizard/age_ui_adapter_test.go new file mode 100644 index 0000000..1babaf3 --- /dev/null +++ b/internal/tui/wizard/age_ui_adapter_test.go @@ -0,0 +1,50 @@ +package wizard + +import ( + "context" + "errors" + "testing" + + "github.com/rivo/tview" + + "github.com/tis24dev/proxsave/internal/orchestrator" + "github.com/tis24dev/proxsave/internal/tui" +) + +func TestAgeSetupUIAdapterCollectRecipientDraftCancelMapsAbort(t *testing.T) { + originalRunner := ageWizardRunner + defer func() { ageWizardRunner = originalRunner }() + + ageWizardRunner = func(app *tui.App, root, focus tview.Primitive) error { + form, ok := focus.(*tview.Form) + if !ok { + t.Fatalf("expected *tview.Form focus, got %T", focus) + } + pressFormButton(t, form, "Cancel") + return nil + } + + ui := NewAgeSetupUI("/etc/proxsave/config.env", "sig-test") + draft, err := ui.CollectRecipientDraft(context.Background(), "/tmp/recipient.age") + if !errors.Is(err, orchestrator.ErrAgeRecipientSetupAborted) { + t.Fatalf("err=%v; want %v", err, orchestrator.ErrAgeRecipientSetupAborted) + } + if draft != nil { + t.Fatalf("draft=%+v; want nil", draft) + } +} + +func TestAgeSetupUIAdapterCollectRecipientDraftRunnerError(t *testing.T) { + originalRunner := ageWizardRunner + defer func() { ageWizardRunner = originalRunner }() + + expected := errors.New("boom") + ageWizardRunner = func(app *tui.App, root, focus tview.Primitive) error { + return expected + } + + ui := NewAgeSetupUI("/etc/proxsave/config.env", "sig-test") + if _, err := ui.CollectRecipientDraft(context.Background(), "/tmp/recipient.age"); !errors.Is(err, expected) { + t.Fatalf("err=%v; want %v", err, expected) + } +} diff --git a/internal/tui/wizard/install.go b/internal/tui/wizard/install.go index 66484b9..a33e9bf 100644 --- a/internal/tui/wizard/install.go +++ b/internal/tui/wizard/install.go @@ -6,30 +6,29 @@ import ( "errors" "fmt" "os" - "path/filepath" - "strconv" "strings" "github.com/gdamore/tcell/v2" "github.com/rivo/tview" "github.com/tis24dev/proxsave/internal/config" + cronutil "github.com/tis24dev/proxsave/internal/cron" "github.com/tis24dev/proxsave/internal/tui" "github.com/tis24dev/proxsave/internal/tui/components" "github.com/tis24dev/proxsave/pkg/utils" ) 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 + EncryptionEnabled bool } // InstallWizardData holds the collected installation data @@ -52,14 +51,18 @@ type InstallWizardData struct { type ExistingConfigAction int const ( - ExistingConfigOverwrite ExistingConfigAction = iota // Start from embedded template (overwrite) - ExistingConfigEdit // Keep existing file as base and edit - ExistingConfigSkip // Leave the file untouched and skip wizard + ExistingConfigOverwrite ExistingConfigAction = iota // Start from embedded template (overwrite) + ExistingConfigEdit // Keep existing file as base and edit + ExistingConfigKeepContinue // Leave file untouched and continue installation + ExistingConfigCancel // Abort installation ) var ( // ErrInstallCancelled is returned when the user aborts the install wizard. - ErrInstallCancelled = errors.New("installation aborted by user") + ErrInstallCancelled = errors.New("installation aborted by user") + runInstallWizardRunner = func(app *tui.App, root, focus tview.Primitive) error { + return app.SetRoot(root, true).SetFocus(focus).Run() + } checkExistingConfigRunner = func(app *tui.App, root, focus tview.Primitive) error { return app.SetRoot(root, true).SetFocus(focus).Run() } @@ -71,7 +74,7 @@ func RunInstallWizard(ctx context.Context, configPath string, baseDir string, bu data := &InstallWizardData{ BaseDir: baseDir, ConfigPath: configPath, - CronTime: "02:00", + CronTime: cronutil.DefaultTime, EnableEncryption: false, // Default to disabled BackupFirewallRules: &defaultFirewallRules, } @@ -327,15 +330,10 @@ func RunInstallWizard(ctx context.Context, configPath string, baseDir string, bu // Collect data data.EnableSecondaryStorage = secondaryEnabled if secondaryEnabled { - data.SecondaryPath = secondaryPathField.GetText() - data.SecondaryLogPath = secondaryLogField.GetText() - - // Validate paths - if !filepath.IsAbs(data.SecondaryPath) { - return fmt.Errorf("secondary backup path must be absolute") - } - if !filepath.IsAbs(data.SecondaryLogPath) { - return fmt.Errorf("secondary log path must be absolute") + data.SecondaryPath = strings.TrimSpace(secondaryPathField.GetText()) + data.SecondaryLogPath = strings.TrimSpace(secondaryLogField.GetText()) + if err := validateSecondaryInstallData(data); err != nil { + return err } } @@ -370,24 +368,11 @@ func RunInstallWizard(ctx context.Context, configPath string, baseDir string, bu // Get encryption setting data.EnableEncryption = values["Enable Backup Encryption (AGE)"] == "Yes" - // Cron time validation (HH:MM) - cron := strings.TrimSpace(cronField.GetText()) - if cron == "" { - cron = "02:00" - } - parts := strings.Split(cron, ":") - if len(parts) != 2 { - return fmt.Errorf("cron time must be in HH:MM format") - } - hour, err := strconv.Atoi(parts[0]) - if err != nil || hour < 0 || hour > 23 { - return fmt.Errorf("cron hour must be between 00 and 23") - } - minute, err := strconv.Atoi(parts[1]) - if err != nil || minute < 0 || minute > 59 { - return fmt.Errorf("cron minute must be between 00 and 59") + normalizedCron, err := cronutil.NormalizeTime(cronField.GetText(), cronutil.DefaultTime) + if err != nil { + return err } - data.CronTime = fmt.Sprintf("%02d:%02d", hour, minute) + data.CronTime = normalizedCron return nil }) @@ -469,8 +454,9 @@ func RunInstallWizard(ctx context.Context, configPath string, baseDir string, bu SetBorderColor(tui.ProxmoxOrange). SetBackgroundColor(tcell.ColorBlack) - // Run the app - ignore errors from normal app termination - _ = app.SetRoot(flex, true).SetFocus(form.Form).Run() + if err := runInstallWizardRunner(app, flex, form.Form); err != nil { + return nil, err + } if data == nil { return nil, ErrInstallCancelled @@ -491,6 +477,9 @@ func ApplyInstallData(baseTemplate string, data *InstallWizardData) (string, err if strings.TrimSpace(template) == "" { template = config.DefaultEnvTemplate() } + if err := validateSecondaryInstallData(data); err != nil { + 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. @@ -500,13 +489,12 @@ func ApplyInstallData(baseTemplate string, data *InstallWizardData) (string, err template = unsetEnvValue(template, "CRON_MINUTE") // Apply secondary storage - if data.EnableSecondaryStorage { - template = setEnvValue(template, "SECONDARY_ENABLED", "true") - template = setEnvValue(template, "SECONDARY_PATH", data.SecondaryPath) - template = setEnvValue(template, "SECONDARY_LOG_PATH", data.SecondaryLogPath) - } else { - template = setEnvValue(template, "SECONDARY_ENABLED", "false") - } + template = config.ApplySecondaryStorageSettings( + template, + data.EnableSecondaryStorage, + data.SecondaryPath, + data.SecondaryLogPath, + ) // Apply cloud storage if data.EnableCloudStorage { @@ -562,6 +550,19 @@ func ApplyInstallData(baseTemplate string, data *InstallWizardData) (string, err return template, nil } +func validateSecondaryInstallData(data *InstallWizardData) error { + if data == nil || !data.EnableSecondaryStorage { + return nil + } + if err := config.ValidateRequiredSecondaryPath(data.SecondaryPath); err != nil { + return err + } + if err := config.ValidateOptionalSecondaryLogPath(data.SecondaryLogPath); err != nil { + return err + } + return nil +} + // setEnvValue sets or updates an environment variable in the template func setEnvValue(template, key, value string) string { return utils.SetEnvValue(template, key, value) @@ -688,7 +689,7 @@ func CheckExistingConfig(configPath string, buildSig string) (ExistingConfigActi if _, err := os.Stat(configPath); err == nil { // File exists, ask how to proceed app := tui.NewApp() - action := ExistingConfigSkip + action := ExistingConfigCancel // Welcome text (same as main wizard) welcomeText := tview.NewTextView(). @@ -727,16 +728,19 @@ func CheckExistingConfig(configPath string, buildSig string) (ExistingConfigActi "Choose how to proceed:\n"+ "[yellow]Overwrite[white] - Start from embedded template\n"+ "[yellow]Edit existing[white] - Keep current file as base\n"+ - "[yellow]Keep & exit[white] - Leave file untouched, exit wizard", configPath)). - AddButtons([]string{"Overwrite", "Edit existing", "Keep & exit"}). + "[yellow]Keep & continue[white] - Leave file untouched, continue install\n"+ + "[yellow]Cancel[white] - Exit installation", configPath)). + AddButtons([]string{"Overwrite", "Edit existing", "Keep & continue", "Cancel"}). SetDoneFunc(func(buttonIndex int, buttonLabel string) { switch buttonLabel { case "Overwrite": action = ExistingConfigOverwrite case "Edit existing": action = ExistingConfigEdit + case "Keep & continue": + action = ExistingConfigKeepContinue default: - action = ExistingConfigSkip + action = ExistingConfigCancel } app.Stop() }) @@ -764,12 +768,13 @@ func CheckExistingConfig(configPath string, buildSig string) (ExistingConfigActi SetBorderColor(tui.ProxmoxOrange). SetBackgroundColor(tcell.ColorBlack) - // Run the modal - ignore errors from normal app termination - _ = checkExistingConfigRunner(app, flex, modal) + if err := checkExistingConfigRunner(app, flex, modal); err != nil { + return ExistingConfigCancel, err + } return action, nil } else if !os.IsNotExist(err) { - return ExistingConfigSkip, err + return ExistingConfigCancel, err } return ExistingConfigOverwrite, nil // File doesn't exist, proceed diff --git a/internal/tui/wizard/install_test.go b/internal/tui/wizard/install_test.go index 4a7c960..fc20e37 100644 --- a/internal/tui/wizard/install_test.go +++ b/internal/tui/wizard/install_test.go @@ -1,13 +1,16 @@ package wizard import ( + "errors" "os" "path/filepath" "strings" "testing" + "github.com/gdamore/tcell/v2" "github.com/rivo/tview" + cronutil "github.com/tis24dev/proxsave/internal/cron" "github.com/tis24dev/proxsave/internal/tui" ) @@ -102,6 +105,96 @@ func TestApplyInstallDataDefaultsBaseTemplate(t *testing.T) { } } +func TestApplyInstallDataAllowsEmptySecondaryLogPath(t *testing.T) { + data := &InstallWizardData{ + BaseDir: "/tmp/base", + EnableSecondaryStorage: true, + SecondaryPath: "/mnt/sec", + SecondaryLogPath: "", + } + + result, err := ApplyInstallData("", data) + if err != nil { + t.Fatalf("ApplyInstallData returned error: %v", err) + } + if !strings.Contains(result, "SECONDARY_ENABLED=true") { + t.Fatalf("expected secondary enabled in result:\n%s", result) + } + if !strings.Contains(result, "SECONDARY_PATH=/mnt/sec") { + t.Fatalf("expected secondary path in result:\n%s", result) + } + if !strings.Contains(result, "SECONDARY_LOG_PATH=") { + t.Fatalf("expected empty secondary log path in result:\n%s", result) + } +} + +func TestApplyInstallDataDisabledSecondaryClearsExistingValues(t *testing.T) { + baseTemplate := strings.Join([]string{ + "SECONDARY_ENABLED=true", + "SECONDARY_PATH=/mnt/old-secondary", + "SECONDARY_LOG_PATH=/mnt/old-secondary/logs", + "TELEGRAM_ENABLED=false", + "EMAIL_ENABLED=false", + "ENCRYPT_ARCHIVE=false", + "", + }, "\n") + data := &InstallWizardData{ + BaseDir: "/tmp/base", + EnableSecondaryStorage: false, + } + + result, err := ApplyInstallData(baseTemplate, data) + if err != nil { + t.Fatalf("ApplyInstallData returned error: %v", err) + } + + for _, needle := range []string{ + "SECONDARY_ENABLED=false", + "SECONDARY_PATH=", + "SECONDARY_LOG_PATH=", + } { + if !strings.Contains(result, needle) { + t.Fatalf("expected %q in result:\n%s", needle, result) + } + } + if strings.Contains(result, "/mnt/old-secondary") { + t.Fatalf("expected old secondary values to be cleared:\n%s", result) + } +} + +func TestApplyInstallDataRejectsInvalidSecondaryPath(t *testing.T) { + data := &InstallWizardData{ + BaseDir: "/tmp/base", + EnableSecondaryStorage: true, + SecondaryPath: "relative/path", + } + + _, err := ApplyInstallData("", data) + if err == nil { + t.Fatal("expected ApplyInstallData to fail") + } + if got, want := err.Error(), "SECONDARY_PATH must be an absolute local filesystem path"; got != want { + t.Fatalf("ApplyInstallData error = %q, want %q", got, want) + } +} + +func TestApplyInstallDataRejectsInvalidSecondaryLogPath(t *testing.T) { + data := &InstallWizardData{ + BaseDir: "/tmp/base", + EnableSecondaryStorage: true, + SecondaryPath: "/mnt/sec", + SecondaryLogPath: "remote:/logs", + } + + _, err := ApplyInstallData("", data) + if err == nil { + t.Fatal("expected ApplyInstallData to fail") + } + if got, want := err.Error(), "SECONDARY_LOG_PATH must be an absolute local filesystem path"; got != want { + t.Fatalf("ApplyInstallData error = %q, want %q", got, want) + } +} + func TestApplyInstallDataCronAndNotifications(t *testing.T) { baseTemplate := "CRON_SCHEDULE=\nCRON_HOUR=\nCRON_MINUTE=\nTELEGRAM_ENABLED=true\nEMAIL_ENABLED=false\nENCRYPT_ARCHIVE=true\n" data := &InstallWizardData{ @@ -132,6 +225,37 @@ func TestApplyInstallDataCronAndNotifications(t *testing.T) { assertContains("ENCRYPT_ARCHIVE", "false") } +func TestRunInstallWizardBlankCronIgnoresEnvOverride(t *testing.T) { + t.Setenv("CRON_SCHEDULE", "5 1 * * *") + + originalRunner := runInstallWizardRunner + t.Cleanup(func() { runInstallWizardRunner = originalRunner }) + + runInstallWizardRunner = func(app *tui.App, root, focus tview.Primitive) error { + form, ok := focus.(*tview.Form) + if !ok { + t.Fatalf("focus primitive = %T, want *tview.Form", focus) + } + button := form.GetButton(0) + if button == nil { + t.Fatal("expected install button") + } + button.InputHandler()(tcell.NewEventKey(tcell.KeyEnter, 0, tcell.ModNone), nil) + return nil + } + + data, err := RunInstallWizard(t.Context(), "/tmp/proxsave/backup.env", "/opt/proxsave", "sig", "") + if err != nil { + t.Fatalf("RunInstallWizard returned error: %v", err) + } + if data == nil { + t.Fatal("expected wizard data") + } + if data.CronTime != cronutil.DefaultTime { + t.Fatalf("CronTime = %q, want %q", data.CronTime, cronutil.DefaultTime) + } +} + func TestCheckExistingConfigActions(t *testing.T) { tmp := t.TempDir() configPath := filepath.Join(tmp, "prox.env") @@ -149,7 +273,8 @@ func TestCheckExistingConfigActions(t *testing.T) { }{ {name: "overwrite", button: "Overwrite", want: ExistingConfigOverwrite}, {name: "edit existing", button: "Edit existing", want: ExistingConfigEdit}, - {name: "keep", button: "Keep & exit", want: ExistingConfigSkip}, + {name: "keep continue", button: "Keep & continue", want: ExistingConfigKeepContinue}, + {name: "cancel", button: "Cancel", want: ExistingConfigCancel}, } for _, tc := range tests { @@ -189,7 +314,31 @@ func TestCheckExistingConfigPropagatesStatErrors(t *testing.T) { if err == nil { t.Fatalf("expected error for invalid path") } - if action != ExistingConfigSkip { - t.Fatalf("expected skip action on stat error, got %v", action) + if action != ExistingConfigCancel { + t.Fatalf("expected cancel action on stat error, got %v", action) + } +} + +func TestCheckExistingConfigPropagatesRunnerErrors(t *testing.T) { + tmp := t.TempDir() + configPath := filepath.Join(tmp, "prox.env") + if err := os.WriteFile(configPath, []byte("base"), 0o600); err != nil { + t.Fatalf("failed to write config: %v", err) + } + + originalRunner := checkExistingConfigRunner + t.Cleanup(func() { checkExistingConfigRunner = originalRunner }) + + expectedErr := errors.New("ui runner failure") + checkExistingConfigRunner = func(app *tui.App, root, focus tview.Primitive) error { + return expectedErr + } + + action, err := CheckExistingConfig(configPath, "sig") + if !errors.Is(err, expectedErr) { + t.Fatalf("expected runner error %v, got %v", expectedErr, err) + } + if action != ExistingConfigCancel { + t.Fatalf("expected cancel action on runner error, got %v", action) } } diff --git a/internal/tui/wizard/new_install.go b/internal/tui/wizard/new_install.go index e799db9..ba826a3 100644 --- a/internal/tui/wizard/new_install.go +++ b/internal/tui/wizard/new_install.go @@ -14,10 +14,26 @@ var confirmNewInstallRunner = func(app *tui.App, root, focus tview.Primitive) er return app.SetRoot(root, true).SetFocus(focus).Run() } +func formatPreservedEntries(entries []string) string { + formatted := make([]string, 0, len(entries)) + for _, entry := range entries { + trimmed := strings.TrimSpace(entry) + if trimmed == "" { + continue + } + formatted = append(formatted, trimmed+"/") + } + if len(formatted) == 0 { + return "(none)" + } + return strings.Join(formatted, " ") +} + // ConfirmNewInstall shows a TUI confirmation before wiping baseDir for --new-install. -func ConfirmNewInstall(baseDir string, buildSig string) (bool, error) { +func ConfirmNewInstall(baseDir string, buildSig string, preservedEntries []string) (bool, error) { app := tui.NewApp() proceed := false + preservedText := formatPreservedEntries(preservedEntries) // Header text (align with main install wizard) welcomeText := tview.NewTextView(). @@ -51,7 +67,7 @@ func ConfirmNewInstall(baseDir string, buildSig string) (bool, error) { // Confirmation modal modal := tview.NewModal(). - SetText(fmt.Sprintf("Base directory to reset:\n[yellow]%s[white]\n\nThis keeps [yellow]build/ env/ identity/[white]\nbut deletes everything else.\n\nContinue?", baseDir)). + SetText(fmt.Sprintf("Base directory to reset:\n[yellow]%s[white]\n\nThis keeps [yellow]%s[white]\nbut deletes everything else.\n\nContinue?", baseDir, preservedText)). AddButtons([]string{"Continue", "Cancel"}). SetDoneFunc(func(buttonIndex int, buttonLabel string) { if buttonLabel == "Continue" { @@ -83,8 +99,9 @@ func ConfirmNewInstall(baseDir string, buildSig string) (bool, error) { SetBorderColor(tui.ProxmoxOrange). SetBackgroundColor(tcell.ColorBlack) - // Run the app - ignore errors from normal app termination - _ = confirmNewInstallRunner(app, flex, modal) + if err := confirmNewInstallRunner(app, flex, modal); err != nil { + return false, err + } return proceed, nil } diff --git a/internal/tui/wizard/new_install_test.go b/internal/tui/wizard/new_install_test.go index ebe3b18..a7267fc 100644 --- a/internal/tui/wizard/new_install_test.go +++ b/internal/tui/wizard/new_install_test.go @@ -1,6 +1,7 @@ package wizard import ( + "errors" "strings" "testing" @@ -19,7 +20,7 @@ func TestConfirmNewInstallContinue(t *testing.T) { return nil } - proceed, err := ConfirmNewInstall("/opt/proxmox", "sig-123") + proceed, err := ConfirmNewInstall("/opt/proxmox", "sig-123", []string{"build", "env", "identity"}) if err != nil { t.Fatalf("ConfirmNewInstall error: %v", err) } @@ -38,7 +39,7 @@ func TestConfirmNewInstallCancel(t *testing.T) { return nil } - proceed, err := ConfirmNewInstall("/opt/proxmox", "sig-123") + proceed, err := ConfirmNewInstall("/opt/proxmox", "sig-123", []string{"build", "env", "identity"}) if err != nil { t.Fatalf("ConfirmNewInstall error: %v", err) } @@ -57,7 +58,7 @@ func TestConfirmNewInstallMessageIncludesBaseDir(t *testing.T) { return nil } - _, err := ConfirmNewInstall("/var/lib/data", "build-sig") + _, err := ConfirmNewInstall("/var/lib/data", "build-sig", []string{"build", "env", "identity"}) if err != nil { t.Fatalf("ConfirmNewInstall error: %v", err) } @@ -65,3 +66,37 @@ func TestConfirmNewInstallMessageIncludesBaseDir(t *testing.T) { t.Fatalf("expected modal text to mention base dir, got %q", captured) } } + +func TestConfirmNewInstallMessageIncludesPreservedEntries(t *testing.T) { + originalRunner := confirmNewInstallRunner + defer func() { confirmNewInstallRunner = originalRunner }() + + var captured string + confirmNewInstallRunner = func(app *tui.App, root, focus tview.Primitive) error { + captured = extractModalText(focus.(*tview.Modal)) + return nil + } + + _, err := ConfirmNewInstall("/var/lib/data", "build-sig", []string{"build", "env", "identity"}) + if err != nil { + t.Fatalf("ConfirmNewInstall error: %v", err) + } + if !strings.Contains(captured, "build/ env/ identity/") { + t.Fatalf("expected modal text to mention preserved entries, got %q", captured) + } +} + +func TestConfirmNewInstallPropagatesRunnerError(t *testing.T) { + originalRunner := confirmNewInstallRunner + defer func() { confirmNewInstallRunner = originalRunner }() + + expectedErr := errors.New("runner failed") + confirmNewInstallRunner = func(app *tui.App, root, focus tview.Primitive) error { + return expectedErr + } + + _, err := ConfirmNewInstall("/opt/proxmox", "sig-123", []string{"build", "env", "identity"}) + if !errors.Is(err, expectedErr) { + t.Fatalf("expected error %v, got %v", expectedErr, err) + } +} diff --git a/internal/tui/wizard/telegram_setup_tui.go b/internal/tui/wizard/telegram_setup_tui.go index 90e2098..ea79eb6 100644 --- a/internal/tui/wizard/telegram_setup_tui.go +++ b/internal/tui/wizard/telegram_setup_tui.go @@ -3,16 +3,14 @@ package wizard import ( "context" "fmt" - "os" "strings" "sync" "github.com/gdamore/tcell/v2" "github.com/rivo/tview" - "github.com/tis24dev/proxsave/internal/config" - "github.com/tis24dev/proxsave/internal/identity" "github.com/tis24dev/proxsave/internal/notify" + "github.com/tis24dev/proxsave/internal/orchestrator" "github.com/tis24dev/proxsave/internal/tui" ) @@ -21,29 +19,16 @@ var ( return app.SetRoot(root, true).SetFocus(focus).Run() } - telegramSetupLoadConfig = config.LoadConfig - telegramSetupReadFile = os.ReadFile - telegramSetupStat = os.Stat - telegramSetupIdentityDetect = identity.Detect - telegramSetupCheckRegistration = notify.CheckTelegramRegistration - telegramSetupQueueUpdateDraw = func(app *tui.App, f func()) { app.QueueUpdateDraw(f) } - telegramSetupGo = func(fn func()) { go fn() } + telegramSetupBuildBootstrap = orchestrator.BuildTelegramSetupBootstrap + telegramSetupCheckRegistration = notify.CheckTelegramRegistration + telegramSetupQueueUpdateDraw = func(app *tui.App, f func()) { app.QueueUpdateDraw(f) } + telegramSetupGo = func(fn func()) { go fn() } ) type TelegramSetupResult struct { - Shown bool - - ConfigLoaded bool - ConfigError string + orchestrator.TelegramSetupBootstrap - TelegramEnabled bool - TelegramMode string - ServerAPIHost string - - ServerID string - IdentityFile string - IdentityPersisted bool - IdentityDetectError string + Shown bool CheckAttempts int Verified bool @@ -55,51 +40,18 @@ type TelegramSetupResult struct { } func RunTelegramSetupWizard(ctx context.Context, baseDir, configPath, buildSig string) (TelegramSetupResult, error) { - result := TelegramSetupResult{Shown: true} - - cfg, cfgErr := telegramSetupLoadConfig(configPath) - if cfgErr != nil { - result.ConfigLoaded = false - result.ConfigError = cfgErr.Error() - // Fall back to raw env parsing so the wizard can still run even when the full - // config parser fails for unrelated keys. - if configBytes, readErr := telegramSetupReadFile(configPath); readErr == nil { - values := parseEnvTemplate(string(configBytes)) - result.TelegramEnabled = readTemplateBool(values, "TELEGRAM_ENABLED") - result.TelegramMode = strings.ToLower(strings.TrimSpace(readTemplateString(values, "BOT_TELEGRAM_TYPE"))) - } - } else { - result.ConfigLoaded = true - result.TelegramEnabled = cfg.TelegramEnabled - result.TelegramMode = strings.ToLower(strings.TrimSpace(cfg.TelegramBotType)) - result.ServerAPIHost = strings.TrimSpace(cfg.TelegramServerAPIHost) + state, err := telegramSetupBuildBootstrap(configPath, baseDir) + if err != nil { + return TelegramSetupResult{}, err } - - if !result.TelegramEnabled { + result := TelegramSetupResult{ + TelegramSetupBootstrap: state, + Shown: true, + } + if result.Eligibility != orchestrator.TelegramSetupEligibleCentralized { result.Shown = false return result, nil } - if result.TelegramMode == "" { - result.TelegramMode = "centralized" - } - if result.ServerAPIHost == "" { - // Fallback (keeps behavior aligned with internal/config defaults). - result.ServerAPIHost = "https://bot.tis24.it:1443" - } - - idInfo, idErr := telegramSetupIdentityDetect(baseDir, nil) - if idErr != nil { - result.IdentityDetectError = idErr.Error() - } - if idInfo != nil { - result.ServerID = strings.TrimSpace(idInfo.ServerID) - result.IdentityFile = strings.TrimSpace(idInfo.IdentityFile) - if result.IdentityFile != "" { - if _, err := telegramSetupStat(result.IdentityFile); err == nil { - result.IdentityPersisted = true - } - } - } app := tui.NewApp() pages := tview.NewPages() @@ -173,36 +125,16 @@ func RunTelegramSetupWizard(ctx context.Context, baseDir, configPath, buildSig s return s[:max] + "...(truncated)" } - modeLabel := result.TelegramMode - if modeLabel == "" { - modeLabel = "centralized" - } - var b strings.Builder - b.WriteString(fmt.Sprintf("[yellow]Mode:[white] %s\n", modeLabel)) - if !result.ConfigLoaded && result.ConfigError != "" { - b.WriteString(fmt.Sprintf("[red]WARNING:[white] failed to load config: %s\n\n", truncate(result.ConfigError, 200))) - } - if result.TelegramMode == "personal" { - b.WriteString("\nPersonal mode uses your own bot.\n\n") - b.WriteString("This installer does not guide the personal bot setup.\n") - b.WriteString("Edit backup.env and set:\n") - b.WriteString(" - TELEGRAM_BOT_TOKEN\n") - b.WriteString(" - TELEGRAM_CHAT_ID\n\n") - b.WriteString("Then run ProxSave once to validate notifications.\n") - } else { - b.WriteString("\n1) Open Telegram and start [yellow]@ProxmoxAN_bot[white]\n") - b.WriteString("2) Send the [yellow]Server ID[white] below (digits only)\n") - b.WriteString("3) Press [yellow]Check[white] to verify\n\n") - b.WriteString("If the check fails, you can press Check again.\n") - b.WriteString("You can also Skip verification and complete pairing later.\n") - } + b.WriteString("[yellow]Mode:[white] centralized\n") + b.WriteString("\n1) Open Telegram and start [yellow]@ProxmoxAN_bot[white]\n") + b.WriteString("2) Send the [yellow]Server ID[white] below (digits only)\n") + b.WriteString("3) Press [yellow]Check[white] to verify\n\n") + b.WriteString("If the check fails, you can press Check again.\n") + b.WriteString("You can also Skip verification and complete pairing later.\n") instructions.SetText(b.String()) - serverIDLine := "[red]Server ID unavailable.[white]" - if result.ServerID != "" { - serverIDLine = fmt.Sprintf("[yellow]%s[white]", result.ServerID) - } + serverIDLine := fmt.Sprintf("[yellow]%s[white]", result.ServerID) identityLine := "" if result.IdentityFile != "" { persisted := "not persisted" @@ -217,17 +149,7 @@ func RunTelegramSetupWizard(ctx context.Context, baseDir, configPath, buildSig s statusView.SetText(text) } - initialStatus := "[yellow]Not checked yet.[white]\n\nPress [yellow]Check[white] after sending the Server ID to the bot." - if result.TelegramMode == "personal" { - initialStatus = "[yellow]No centralized pairing check for personal mode.[white]" - } - if result.ServerID == "" && result.TelegramMode != "personal" { - initialStatus = "[red]Cannot check registration: Server ID missing.[white]" - if result.IdentityDetectError != "" { - initialStatus += "\n\n" + truncate(result.IdentityDetectError, 200) - } - } - setStatus(initialStatus) + setStatus("[yellow]Not checked yet.[white]\n\nPress [yellow]Check[white] after sending the Server ID to the bot.") var mu sync.Mutex checking := false @@ -256,10 +178,6 @@ func RunTelegramSetupWizard(ctx context.Context, baseDir, configPath, buildSig s var refreshButtons func() checkHandler := func() { - if result.TelegramMode == "personal" || strings.TrimSpace(result.ServerID) == "" { - return - } - mu.Lock() if checking || closing { mu.Unlock() @@ -318,23 +236,13 @@ func RunTelegramSetupWizard(ctx context.Context, baseDir, configPath, buildSig s refreshButtons = func() { form.ClearButtons() - - // Centralized mode pairing only works when the Server ID is available. - if result.TelegramMode != "personal" && strings.TrimSpace(result.ServerID) != "" { - form.AddButton("Check", checkHandler) - } - - switch { - case result.TelegramMode == "personal": + form.AddButton("Check", checkHandler) + if result.Verified { form.AddButton("Continue", func() { doClose(false) }) - case strings.TrimSpace(result.ServerID) == "": - form.AddButton("Continue", func() { doClose(false) }) - case result.Verified: - form.AddButton("Continue", func() { doClose(false) }) - default: - // Until verification succeeds, require an explicit skip to leave without pairing. - form.AddButton("Skip", func() { doClose(true) }) + return } + // Until verification succeeds, require an explicit skip to leave without pairing. + form.AddButton("Skip", func() { doClose(true) }) } refreshButtons() @@ -372,7 +280,7 @@ func RunTelegramSetupWizard(ctx context.Context, baseDir, configPath, buildSig s app.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey { if event.Key() == tcell.KeyEscape { - if result.TelegramMode != "personal" && strings.TrimSpace(result.ServerID) != "" && !result.Verified { + if !result.Verified { doClose(true) } else { doClose(false) diff --git a/internal/tui/wizard/telegram_setup_tui_test.go b/internal/tui/wizard/telegram_setup_tui_test.go index 19e7a8f..47ef7c7 100644 --- a/internal/tui/wizard/telegram_setup_tui_test.go +++ b/internal/tui/wizard/telegram_setup_tui_test.go @@ -3,18 +3,14 @@ package wizard import ( "context" "errors" - "os" - "path/filepath" - "strings" "testing" "github.com/gdamore/tcell/v2" "github.com/rivo/tview" - "github.com/tis24dev/proxsave/internal/config" - "github.com/tis24dev/proxsave/internal/identity" "github.com/tis24dev/proxsave/internal/logging" "github.com/tis24dev/proxsave/internal/notify" + "github.com/tis24dev/proxsave/internal/orchestrator" "github.com/tis24dev/proxsave/internal/tui" ) @@ -22,20 +18,14 @@ func stubTelegramSetupDeps(t *testing.T) { t.Helper() origRunner := telegramSetupWizardRunner - origLoadConfig := telegramSetupLoadConfig - origReadFile := telegramSetupReadFile - origStat := telegramSetupStat - origIdentityDetect := telegramSetupIdentityDetect + origBuildBootstrap := telegramSetupBuildBootstrap origCheckRegistration := telegramSetupCheckRegistration origQueueUpdateDraw := telegramSetupQueueUpdateDraw origGo := telegramSetupGo t.Cleanup(func() { telegramSetupWizardRunner = origRunner - telegramSetupLoadConfig = origLoadConfig - telegramSetupReadFile = origReadFile - telegramSetupStat = origStat - telegramSetupIdentityDetect = origIdentityDetect + telegramSetupBuildBootstrap = origBuildBootstrap telegramSetupCheckRegistration = origCheckRegistration telegramSetupQueueUpdateDraw = origQueueUpdateDraw telegramSetupGo = origGo @@ -45,15 +35,28 @@ func stubTelegramSetupDeps(t *testing.T) { telegramSetupQueueUpdateDraw = func(app *tui.App, f func()) { f() } } +func eligibleTelegramSetupBootstrap() orchestrator.TelegramSetupBootstrap { + return orchestrator.TelegramSetupBootstrap{ + Eligibility: orchestrator.TelegramSetupEligibleCentralized, + ConfigLoaded: true, + TelegramEnabled: true, + TelegramMode: "centralized", + ServerAPIHost: "https://api.example.test", + ServerID: "123456789", + IdentityFile: "/tmp/.server_identity", + IdentityPersisted: false, + } +} + func TestRunTelegramSetupWizard_DisabledSkipsUIAndRunnerNotCalled(t *testing.T) { stubTelegramSetupDeps(t) - telegramSetupLoadConfig = func(path string) (*config.Config, error) { - return &config.Config{TelegramEnabled: false}, nil - } - telegramSetupIdentityDetect = func(baseDir string, logger *logging.Logger) (*identity.Info, error) { - t.Fatalf("identity detect should not be called when telegram is disabled") - return nil, nil + telegramSetupBuildBootstrap = func(configPath, baseDir string) (orchestrator.TelegramSetupBootstrap, error) { + return orchestrator.TelegramSetupBootstrap{ + Eligibility: orchestrator.TelegramSetupSkipDisabled, + ConfigLoaded: true, + TelegramEnabled: false, + }, nil } telegramSetupWizardRunner = func(app *tui.App, root, focus tview.Primitive) error { t.Fatalf("runner should not be called when telegram is disabled") @@ -75,17 +78,17 @@ func TestRunTelegramSetupWizard_DisabledSkipsUIAndRunnerNotCalled(t *testing.T) } } -func TestRunTelegramSetupWizard_ConfigLoadAndReadFailSkipsUI(t *testing.T) { +func TestRunTelegramSetupWizard_ConfigErrorSkipsUI(t *testing.T) { stubTelegramSetupDeps(t) - telegramSetupLoadConfig = func(path string) (*config.Config, error) { - return nil, errors.New("parse failed") - } - telegramSetupReadFile = func(path string) ([]byte, error) { - return nil, errors.New("read failed") + telegramSetupBuildBootstrap = func(configPath, baseDir string) (orchestrator.TelegramSetupBootstrap, error) { + return orchestrator.TelegramSetupBootstrap{ + Eligibility: orchestrator.TelegramSetupSkipConfigError, + ConfigError: "parse failed", + }, nil } telegramSetupWizardRunner = func(app *tui.App, root, focus tview.Primitive) error { - t.Fatalf("runner should not be called when env cannot be read") + t.Fatalf("runner should not be called when config bootstrap failed") return nil } @@ -104,33 +107,20 @@ func TestRunTelegramSetupWizard_ConfigLoadAndReadFailSkipsUI(t *testing.T) { } } -func TestRunTelegramSetupWizard_FallbackPersonalMode_Continue(t *testing.T) { +func TestRunTelegramSetupWizard_PersonalModeSkipsUI(t *testing.T) { stubTelegramSetupDeps(t) - identityFile := filepath.Join(t.TempDir(), ".server_identity") - if err := os.WriteFile(identityFile, []byte("id"), 0o600); err != nil { - t.Fatalf("write identity file: %v", err) - } - - telegramSetupLoadConfig = func(path string) (*config.Config, error) { - return nil, errors.New(strings.Repeat("x", 250)) - } - telegramSetupReadFile = func(path string) ([]byte, error) { - return []byte("TELEGRAM_ENABLED=true\nBOT_TELEGRAM_TYPE=Personal\n"), nil - } - telegramSetupIdentityDetect = func(baseDir string, logger *logging.Logger) (*identity.Info, error) { - return &identity.Info{ServerID: " 123 ", IdentityFile: " " + identityFile + " "}, nil + telegramSetupBuildBootstrap = func(configPath, baseDir string) (orchestrator.TelegramSetupBootstrap, error) { + return orchestrator.TelegramSetupBootstrap{ + Eligibility: orchestrator.TelegramSetupSkipPersonalMode, + ConfigLoaded: true, + TelegramEnabled: true, + TelegramMode: "personal", + ServerAPIHost: "https://bot.tis24.it:1443", + }, nil } - telegramSetupStat = os.Stat telegramSetupWizardRunner = func(app *tui.App, root, focus tview.Primitive) error { - form := focus.(*tview.Form) - if form.GetButtonIndex("Check") != -1 { - t.Fatalf("expected no Check button in personal mode") - } - if form.GetButtonIndex("Continue") == -1 { - t.Fatalf("expected Continue button in personal mode") - } - pressFormButton(t, form, "Continue") + t.Fatalf("runner should not be called in personal mode") return nil } @@ -138,56 +128,57 @@ func TestRunTelegramSetupWizard_FallbackPersonalMode_Continue(t *testing.T) { if err != nil { t.Fatalf("RunTelegramSetupWizard error: %v", err) } - if !result.Shown { - t.Fatalf("expected wizard to be shown") - } - if result.ConfigLoaded { - t.Fatalf("expected ConfigLoaded=false for fallback mode") + if result.Shown { + t.Fatalf("expected wizard to not be shown") } - if result.ConfigError == "" { - t.Fatalf("expected ConfigError to be set") + if result.TelegramMode != "personal" { + t.Fatalf("TelegramMode=%q, want personal", result.TelegramMode) } if !result.TelegramEnabled { t.Fatalf("expected TelegramEnabled=true") } - if result.TelegramMode != "personal" { - t.Fatalf("TelegramMode=%q, want personal", result.TelegramMode) - } - if result.ServerAPIHost != "https://bot.tis24.it:1443" { - t.Fatalf("ServerAPIHost=%q, want default", result.ServerAPIHost) +} + +func TestRunTelegramSetupWizard_IdentityUnavailableSkipsUI(t *testing.T) { + stubTelegramSetupDeps(t) + + telegramSetupBuildBootstrap = func(configPath, baseDir string) (orchestrator.TelegramSetupBootstrap, error) { + return orchestrator.TelegramSetupBootstrap{ + Eligibility: orchestrator.TelegramSetupSkipIdentityUnavailable, + ConfigLoaded: true, + TelegramEnabled: true, + TelegramMode: "centralized", + ServerAPIHost: "https://api.example.test", + IdentityDetectError: "detect failed", + }, nil } - if result.ServerID != "123" { - t.Fatalf("ServerID=%q, want 123", result.ServerID) + telegramSetupWizardRunner = func(app *tui.App, root, focus tview.Primitive) error { + t.Fatalf("runner should not be called when server ID is unavailable") + return nil } - if !result.IdentityPersisted { - t.Fatalf("expected IdentityPersisted=true") + + result, err := RunTelegramSetupWizard(context.Background(), t.TempDir(), "/fake/backup.env", "sig") + if err != nil { + t.Fatalf("RunTelegramSetupWizard error: %v", err) } - if result.Verified { - t.Fatalf("expected Verified=false") + if result.Shown { + t.Fatalf("expected wizard to not be shown") } - if result.SkippedVerification { - t.Fatalf("expected SkippedVerification=false") + if result.IdentityDetectError == "" { + t.Fatalf("expected IdentityDetectError to be set") } - if result.CheckAttempts != 0 { - t.Fatalf("CheckAttempts=%d, want 0", result.CheckAttempts) + if result.ServerID != "" { + t.Fatalf("ServerID=%q, want empty", result.ServerID) } } func TestRunTelegramSetupWizard_CentralizedSuccess_RequiresCheckBeforeContinue(t *testing.T) { stubTelegramSetupDeps(t) - telegramSetupLoadConfig = func(path string) (*config.Config, error) { - return &config.Config{ - TelegramEnabled: true, - TelegramBotType: " ", - TelegramServerAPIHost: " https://api.example.test ", - }, nil - } - telegramSetupIdentityDetect = func(baseDir string, logger *logging.Logger) (*identity.Info, error) { - return &identity.Info{ServerID: " 987654321 ", IdentityFile: " /missing "}, nil - } - telegramSetupStat = func(path string) (os.FileInfo, error) { - return nil, os.ErrNotExist + telegramSetupBuildBootstrap = func(configPath, baseDir string) (orchestrator.TelegramSetupBootstrap, error) { + state := eligibleTelegramSetupBootstrap() + state.ServerID = "987654321" + return state, nil } telegramSetupCheckRegistration = func(ctx context.Context, serverAPIHost, serverID string, logger *logging.Logger) notify.TelegramRegistrationStatus { if serverAPIHost != "https://api.example.test" { @@ -259,25 +250,21 @@ func TestRunTelegramSetupWizard_CentralizedFailure_CanRetryAndSkip(t *testing.T) stubTelegramSetupDeps(t) var calls int - telegramSetupLoadConfig = func(path string) (*config.Config, error) { - return &config.Config{ - TelegramEnabled: true, - TelegramBotType: "centralized", - TelegramServerAPIHost: "https://api.example.test", - }, nil - } - telegramSetupIdentityDetect = func(baseDir string, logger *logging.Logger) (*identity.Info, error) { - return &identity.Info{ServerID: "111222333"}, nil + telegramSetupBuildBootstrap = func(configPath, baseDir string) (orchestrator.TelegramSetupBootstrap, error) { + state := eligibleTelegramSetupBootstrap() + state.ServerID = "111222333" + return state, nil } telegramSetupCheckRegistration = func(ctx context.Context, serverAPIHost, serverID string, logger *logging.Logger) notify.TelegramRegistrationStatus { calls++ - if calls == 1 { + switch calls { + case 1: return notify.TelegramRegistrationStatus{Code: 403, Error: errors.New("not registered")} - } - if calls == 2 { + case 2: return notify.TelegramRegistrationStatus{Code: 422, Message: "invalid"} + default: + return notify.TelegramRegistrationStatus{Code: 500, Message: "oops"} } - return notify.TelegramRegistrationStatus{Code: 500, Message: "oops"} } telegramSetupWizardRunner = func(app *tui.App, root, focus tview.Primitive) error { form := focus.(*tview.Form) @@ -315,106 +302,11 @@ func TestRunTelegramSetupWizard_CentralizedFailure_CanRetryAndSkip(t *testing.T) } } -func TestRunTelegramSetupWizard_CentralizedMissingServerID_ExitsOnEscWithoutSkipping(t *testing.T) { - stubTelegramSetupDeps(t) - - telegramSetupLoadConfig = func(path string) (*config.Config, error) { - return &config.Config{ - TelegramEnabled: true, - TelegramBotType: "centralized", - TelegramServerAPIHost: "https://api.example.test", - }, nil - } - telegramSetupIdentityDetect = func(baseDir string, logger *logging.Logger) (*identity.Info, error) { - return nil, errors.New("detect failed") - } - telegramSetupWizardRunner = func(app *tui.App, root, focus tview.Primitive) error { - form := focus.(*tview.Form) - if form.GetButtonIndex("Check") != -1 { - t.Fatalf("expected no Check button without Server ID") - } - if form.GetButtonIndex("Skip") != -1 { - t.Fatalf("expected no Skip button without Server ID") - } - if form.GetButtonIndex("Continue") == -1 { - t.Fatalf("expected Continue button without Server ID") - } - - capture := app.GetInputCapture() - if capture == nil { - t.Fatalf("expected input capture to be set") - } - capture(tcell.NewEventKey(tcell.KeyEscape, 0, tcell.ModNone)) - return nil - } - - result, err := RunTelegramSetupWizard(context.Background(), t.TempDir(), "/fake/backup.env", "sig") - if err != nil { - t.Fatalf("RunTelegramSetupWizard error: %v", err) - } - if result.SkippedVerification { - t.Fatalf("expected SkippedVerification=false") - } - if result.Verified { - t.Fatalf("expected Verified=false") - } - if result.CheckAttempts != 0 { - t.Fatalf("CheckAttempts=%d, want 0", result.CheckAttempts) - } - if result.ServerID != "" { - t.Fatalf("ServerID=%q, want empty", result.ServerID) - } - if result.IdentityDetectError == "" { - t.Fatalf("expected IdentityDetectError to be set") - } -} - -func TestRunTelegramSetupWizard_CentralizedMissingServerID_CanContinueButton(t *testing.T) { - stubTelegramSetupDeps(t) - - telegramSetupLoadConfig = func(path string) (*config.Config, error) { - return &config.Config{ - TelegramEnabled: true, - TelegramBotType: "centralized", - TelegramServerAPIHost: "https://api.example.test", - }, nil - } - telegramSetupIdentityDetect = func(baseDir string, logger *logging.Logger) (*identity.Info, error) { - return &identity.Info{ServerID: ""}, nil - } - telegramSetupWizardRunner = func(app *tui.App, root, focus tview.Primitive) error { - form := focus.(*tview.Form) - pressFormButton(t, form, "Continue") - return nil - } - - result, err := RunTelegramSetupWizard(context.Background(), t.TempDir(), "/fake/backup.env", "sig") - if err != nil { - t.Fatalf("RunTelegramSetupWizard error: %v", err) - } - if result.SkippedVerification { - t.Fatalf("expected SkippedVerification=false") - } - if result.Verified { - t.Fatalf("expected Verified=false") - } - if result.CheckAttempts != 0 { - t.Fatalf("CheckAttempts=%d, want 0", result.CheckAttempts) - } -} - func TestRunTelegramSetupWizard_CentralizedEscSkipsWhenNotVerified(t *testing.T) { stubTelegramSetupDeps(t) - telegramSetupLoadConfig = func(path string) (*config.Config, error) { - return &config.Config{ - TelegramEnabled: true, - TelegramBotType: "centralized", - TelegramServerAPIHost: "https://api.example.test", - }, nil - } - telegramSetupIdentityDetect = func(baseDir string, logger *logging.Logger) (*identity.Info, error) { - return &identity.Info{ServerID: "123456"}, nil + telegramSetupBuildBootstrap = func(configPath, baseDir string) (orchestrator.TelegramSetupBootstrap, error) { + return eligibleTelegramSetupBootstrap(), nil } telegramSetupWizardRunner = func(app *tui.App, root, focus tview.Primitive) error { capture := app.GetInputCapture() @@ -449,15 +341,8 @@ func TestRunTelegramSetupWizard_CentralizedEscSkipsWhenNotVerified(t *testing.T) func TestRunTelegramSetupWizard_PropagatesRunnerError(t *testing.T) { stubTelegramSetupDeps(t) - telegramSetupLoadConfig = func(path string) (*config.Config, error) { - return &config.Config{ - TelegramEnabled: true, - TelegramBotType: "centralized", - TelegramServerAPIHost: "https://api.example.test", - }, nil - } - telegramSetupIdentityDetect = func(baseDir string, logger *logging.Logger) (*identity.Info, error) { - return &identity.Info{ServerID: "123456"}, nil + telegramSetupBuildBootstrap = func(configPath, baseDir string) (orchestrator.TelegramSetupBootstrap, error) { + return eligibleTelegramSetupBootstrap(), nil } telegramSetupWizardRunner = func(app *tui.App, root, focus tview.Primitive) error { return errors.New("runner failed") @@ -480,16 +365,10 @@ func TestRunTelegramSetupWizard_CheckIgnoredWhileChecking_AndUpdateSuppressedAft telegramSetupGo = func(fn func()) { pending = fn } telegramSetupQueueUpdateDraw = func(app *tui.App, f func()) { f() } - - telegramSetupLoadConfig = func(path string) (*config.Config, error) { - return &config.Config{ - TelegramEnabled: true, - TelegramBotType: "centralized", - TelegramServerAPIHost: "https://api.example.test", - }, nil - } - telegramSetupIdentityDetect = func(baseDir string, logger *logging.Logger) (*identity.Info, error) { - return &identity.Info{ServerID: "999888777"}, nil + telegramSetupBuildBootstrap = func(configPath, baseDir string) (orchestrator.TelegramSetupBootstrap, error) { + state := eligibleTelegramSetupBootstrap() + state.ServerID = "999888777" + return state, nil } telegramSetupCheckRegistration = func(ctx context.Context, serverAPIHost, serverID string, logger *logging.Logger) notify.TelegramRegistrationStatus { checkCalls++ @@ -503,10 +382,10 @@ func TestRunTelegramSetupWizard_CheckIgnoredWhileChecking_AndUpdateSuppressedAft t.Fatalf("expected pending check goroutine") } - pressFormButton(t, form, "Check") // should be ignored while checking=true - pressFormButton(t, form, "Skip") // closes the wizard + pressFormButton(t, form, "Check") + pressFormButton(t, form, "Skip") - pending() // simulate late completion after closing + pending() return nil }