From a647f846fcebf4a46a099e0639c72117fab8efef Mon Sep 17 00:00:00 2001 From: Lee <7932644+strahe@users.noreply.github.com> Date: Sun, 21 Jun 2026 15:48:33 +0800 Subject: [PATCH 1/6] feat: add dry-run safety for write commands --- README.md | 9 +- README.zh.md | 9 +- cmd/bwh/abuse.go | 2 - cmd/bwh/backup.go | 13 +- cmd/bwh/control.go | 280 +++++++++++++++++---------------- cmd/bwh/ipv6.go | 74 ++++++--- cmd/bwh/iso.go | 126 +++++++++------ cmd/bwh/migrate.go | 41 +++-- cmd/bwh/private_ip.go | 68 +++++--- cmd/bwh/reinstall.go | 113 +++++++------- cmd/bwh/reset-password.go | 128 ++++++++++----- cmd/bwh/snapshot.go | 226 ++++++++++++++++++--------- cmd/bwh/ssh.go | 102 ++++++++---- cmd/bwh/write_helpers.go | 149 ++++++++++++++++++ cmd/bwh/write_safety_test.go | 291 +++++++++++++++++++++++++++++++++++ 15 files changed, 1192 insertions(+), 439 deletions(-) create mode 100644 cmd/bwh/write_helpers.go create mode 100644 cmd/bwh/write_safety_test.go diff --git a/README.md b/README.md index eebe0b7..9bac05f 100644 --- a/README.md +++ b/README.md @@ -261,15 +261,18 @@ completion Generate shell completion script Use `bwh --help` to view detailed options and usage examples for each command. -### Abuse and Notification Writes +### Write API Safety ```bash +bwh reinstall --os debian-12-x86_64 --dry-run +bwh reset-password --dry-run +bwh ssh set "ssh-ed25519 AAAA..." --dry-run +bwh migrate start us-west --dry-run bwh abuse unsuspend --dry-run -bwh abuse resolve-policy --dry-run bwh notifications set --dry-run ``` -Use `--dry-run` to validate and preview without calling write APIs. Add `--yes` only when you want to skip the y/N prompt. +Most commands that call KiwiVM write APIs support `--dry-run` to validate and preview without calling the write API. Add `--yes` only when you want to skip the y/N prompt. Existing `--force` flags on dangerous commands such as `kill` and `reinstall` remain supported for compatibility. ## Build diff --git a/README.zh.md b/README.zh.md index 689ff4e..b383668 100644 --- a/README.zh.md +++ b/README.zh.md @@ -261,15 +261,18 @@ completion 生成 shell 自动补全脚本 使用 `bwh --help` 查看每个命令的详细选项和用法示例。 -### Abuse 与通知写命令 +### 写 API 安全 ```bash +bwh reinstall --os debian-12-x86_64 --dry-run +bwh reset-password --dry-run +bwh ssh set "ssh-ed25519 AAAA..." --dry-run +bwh migrate start us-west --dry-run bwh abuse unsuspend --dry-run -bwh abuse resolve-policy --dry-run bwh notifications set --dry-run ``` -使用 `--dry-run` 做校验和预览,不调用写 API。确认需要跳过 y/N 提示时再加 `--yes`。 +大多数会调用 KiwiVM 写 API 的命令都支持 `--dry-run`,用于校验和预览,但不调用写 API。确认需要跳过 y/N 提示时再加 `--yes`。`kill`、`reinstall` 等危险命令原有的 `--force` 仍保留以兼容旧脚本。 ## 构建 diff --git a/cmd/bwh/abuse.go b/cmd/bwh/abuse.go index 80ddd78..bd01658 100644 --- a/cmd/bwh/abuse.go +++ b/cmd/bwh/abuse.go @@ -127,8 +127,6 @@ type abuseAPI interface { ResolvePolicyViolation(context.Context, int) error } -type confirmationFunc func(string) (bool, error) - func displaySuspensionDetails(resp *client.SuspensionDetailsResponse) { fmt.Printf("\n🚫 SUSPENSION DETAILS\n") fmt.Printf(" Suspensions (YTD): %d\n", resp.SuspensionCount) diff --git a/cmd/bwh/backup.go b/cmd/bwh/backup.go index 7ca7719..67822f1 100644 --- a/cmd/bwh/backup.go +++ b/cmd/bwh/backup.go @@ -104,13 +104,7 @@ var backupCopyToSnapshotCmd = &cli.Command{ Aliases: []string{"cts"}, Usage: "copy a backup to a restorable snapshot", ArgsUsage: "", - Flags: []cli.Flag{ - &cli.BoolFlag{ - Name: "yes", - Aliases: []string{"y"}, - Usage: "skip confirmation prompt", - }, - }, + Flags: writeFlags(), Action: func(ctx context.Context, cmd *cli.Command) error { if cmd.Args().Len() != 1 { return fmt.Errorf("backup token is required") @@ -146,6 +140,11 @@ var backupCopyToSnapshotCmd = &cli.Command{ fmt.Printf(" MD5 Hash : %s\n", backup.MD5) fmt.Printf(" Created : %s\n", time.Unix(backup.Timestamp, 0).Format("2006-01-02 15:04:05")) + if cmd.Bool("dry-run") { + printDryRun("backup/copyToSnapshot", resolvedName, fmt.Sprintf("backupToken: %s", maskSensitive(backupToken))) + return nil + } + if !cmd.Bool("yes") { fmt.Printf("\n⚠️ Are you sure you want to copy this backup to a snapshot?\n") fmt.Printf("This will create a new restorable snapshot from the backup.\n") diff --git a/cmd/bwh/control.go b/cmd/bwh/control.go index 48dd809..15589ac 100644 --- a/cmd/bwh/control.go +++ b/cmd/bwh/control.go @@ -1,50 +1,52 @@ package main import ( - "bufio" "context" "fmt" - "os" "strings" + "github.com/strahe/bwh/pkg/client" "github.com/urfave/cli/v3" ) var startCmd = &cli.Command{ Name: "start", Usage: "start the VPS", + Flags: []cli.Flag{ + dryRunFlag(), + }, Action: func(ctx context.Context, cmd *cli.Command) error { - return executeVPSAction(ctx, cmd, "start", false) + bwhClient, resolvedName, err := createBWHClient(cmd) + if err != nil { + return err + } + return runVPSAction(ctx, bwhClient, resolvedName, "start", cmd.Bool("dry-run"), true, promptConfirmation) }, } var stopCmd = &cli.Command{ Name: "stop", Usage: "stop the VPS", - Flags: []cli.Flag{ - &cli.BoolFlag{ - Name: "yes", - Aliases: []string{"y"}, - Usage: "skip confirmation prompt", - }, - }, + Flags: writeFlags(), Action: func(ctx context.Context, cmd *cli.Command) error { - return executeVPSAction(ctx, cmd, "stop", !cmd.Bool("yes")) + bwhClient, resolvedName, err := createBWHClient(cmd) + if err != nil { + return err + } + return runVPSAction(ctx, bwhClient, resolvedName, "stop", cmd.Bool("dry-run"), skipConfirm(cmd), promptConfirmation) }, } var restartCmd = &cli.Command{ Name: "restart", Usage: "restart the VPS", - Flags: []cli.Flag{ - &cli.BoolFlag{ - Name: "yes", - Aliases: []string{"y"}, - Usage: "skip confirmation prompt", - }, - }, + Flags: writeFlags(), Action: func(ctx context.Context, cmd *cli.Command) error { - return executeVPSAction(ctx, cmd, "restart", !cmd.Bool("yes")) + bwhClient, resolvedName, err := createBWHClient(cmd) + if err != nil { + return err + } + return runVPSAction(ctx, bwhClient, resolvedName, "restart", cmd.Bool("dry-run"), skipConfirm(cmd), promptConfirmation) }, } @@ -52,13 +54,16 @@ var killCmd = &cli.Command{ Name: "kill", Usage: "forcefully stop a stuck VPS (WARNING: data loss)", Flags: []cli.Flag{ - &cli.BoolFlag{ - Name: "force", - Usage: "force kill without confirmation (dangerous)", - }, + forceFlag(), + yesFlag(), + dryRunFlag(), }, Action: func(ctx context.Context, cmd *cli.Command) error { - return executeVPSAction(ctx, cmd, "kill", !cmd.Bool("force")) + bwhClient, resolvedName, err := createBWHClient(cmd) + if err != nil { + return err + } + return runVPSAction(ctx, bwhClient, resolvedName, "kill", cmd.Bool("dry-run"), skipConfirmOrForce(cmd), confirmKill) }, } @@ -66,13 +71,7 @@ var hostnameCmd = &cli.Command{ Name: "hostname", Usage: "set hostname for the VPS", ArgsUsage: "", - Flags: []cli.Flag{ - &cli.BoolFlag{ - Name: "yes", - Aliases: []string{"y"}, - Usage: "skip confirmation prompt", - }, - }, + Flags: writeFlags(), Action: func(ctx context.Context, cmd *cli.Command) error { if cmd.Args().Len() != 1 { return fmt.Errorf("hostname command requires exactly one argument: ") @@ -88,22 +87,7 @@ var hostnameCmd = &cli.Command{ return err } - // Confirmation prompt - if !cmd.Bool("yes") { - if !confirmAction("set hostname", resolvedName, newHostname) { - fmt.Println("Operation cancelled.") - return nil - } - } - - fmt.Printf("Setting hostname to '%s' for instance: %s\n", newHostname, resolvedName) - - if err := bwhClient.SetHostname(ctx, newHostname); err != nil { - return fmt.Errorf("failed to set hostname: %w", err) - } - - fmt.Printf("✅ Hostname set to '%s' successfully\n", newHostname) - return nil + return runSetHostname(ctx, bwhClient, resolvedName, newHostname, cmd.Bool("dry-run"), skipConfirm(cmd), promptConfirmation) }, } @@ -112,13 +96,7 @@ var setPTRCmd = &cli.Command{ Aliases: []string{"setPTR"}, Usage: "set new PTR (rDNS) record for IP address", ArgsUsage: " ", - Flags: []cli.Flag{ - &cli.BoolFlag{ - Name: "yes", - Aliases: []string{"y"}, - Usage: "skip confirmation prompt", - }, - }, + Flags: writeFlags(), Action: func(ctx context.Context, cmd *cli.Command) error { if cmd.Args().Len() != 2 { return fmt.Errorf("setPTR command requires exactly two arguments: ") @@ -139,51 +117,66 @@ var setPTRCmd = &cli.Command{ return err } - // Confirmation prompt - if !cmd.Bool("yes") { - if !confirmAction("set PTR", resolvedName, ip, ptr) { - fmt.Println("Operation cancelled.") - return nil - } - } + return runSetPTR(ctx, bwhClient, resolvedName, ip, ptr, cmd.Bool("dry-run"), skipConfirm(cmd), promptConfirmation) + }, +} - fmt.Printf("Setting PTR record for IP '%s' to '%s' for instance: %s\n", ip, ptr, resolvedName) +type powerAPI interface { + GetLiveServiceInfo(context.Context) (*client.LiveServiceInfo, error) + Start(context.Context) error + Stop(context.Context) error + Restart(context.Context) error + Kill(context.Context) error +} - if err := bwhClient.SetPTR(ctx, ip, ptr); err != nil { - return fmt.Errorf("failed to set PTR record: %w", err) +func runVPSAction(ctx context.Context, api powerAPI, resolvedName, action string, dryRun, skipConfirm bool, confirm confirmationFunc) error { + status := "" + if info, err := api.GetLiveServiceInfo(ctx); err == nil { + status = strings.ToLower(strings.TrimSpace(info.VeStatus)) + if status != "" { + fmt.Printf("Current VPS status for instance %s: %s\n", resolvedName, info.VeStatus) } + } else { + fmt.Printf("Warning: failed to read current VPS status: %v\n", err) + } - fmt.Printf("✅ PTR record set for IP '%s' to '%s' successfully\n", ip, ptr) + if action == "start" && status == "running" { + fmt.Printf("✅ VPS is already running (no change needed)\n") return nil - }, -} + } + if (action == "stop" || action == "kill") && status == "stopped" { + fmt.Printf("✅ VPS is already stopped (no change needed)\n") + return nil + } + + if dryRun { + printDryRun(action, resolvedName) + return nil + } -func executeVPSAction(ctx context.Context, cmd *cli.Command, action string, needsConfirm bool) error { - bwhClient, resolvedName, err := createBWHClient(cmd) + prompt := fmt.Sprintf("%s VPS '%s'?", strings.ToUpper(action[:1])+action[1:], resolvedName) + if action == "kill" { + prompt = fmt.Sprintf("Forcefully kill VPS '%s'?", resolvedName) + } + confirmed, err := confirmWrite(prompt, skipConfirm, confirm) if err != nil { return err } - - // Confirmation prompt - if needsConfirm { - if !confirmAction(action, resolvedName) { - fmt.Println("Operation cancelled.") - return nil - } + if !confirmed { + return nil } fmt.Printf("Executing %s for instance: %s\n", action, resolvedName) - // Execute action switch action { case "start": - err = bwhClient.Start(ctx) + err = api.Start(ctx) case "stop": - err = bwhClient.Stop(ctx) + err = api.Stop(ctx) case "restart": - err = bwhClient.Restart(ctx) + err = api.Restart(ctx) case "kill": - err = bwhClient.Kill(ctx) + err = api.Kill(ctx) default: return fmt.Errorf("unknown action: %s", action) } @@ -196,63 +189,86 @@ func executeVPSAction(ctx context.Context, cmd *cli.Command, action string, need return nil } -func confirmAction(action, instanceName string, args ...string) bool { - var prompt string - switch action { - case "stop": - prompt = fmt.Sprintf("Stop VPS '%s'? [y/N]: ", instanceName) - case "restart": - prompt = fmt.Sprintf("Restart VPS '%s'? [y/N]: ", instanceName) - case "kill": - fmt.Printf("⚠️ WARNING: KILL will forcefully terminate VPS '%s'\n", instanceName) - fmt.Printf("⚠️ ANY UNSAVED DATA WILL BE LOST!\n") - prompt = "Type 'kill' to confirm: " - case "reset root password": - fmt.Printf("Reset root password for VPS '%s'?\n", instanceName) - fmt.Printf("This will generate a new random root password.\n") - prompt = "Continue? [y/N]: " - case "set hostname": - if len(args) > 0 { - fmt.Printf("Set hostname to '%s' for VPS '%s'? [y/N]: ", args[0], instanceName) - } else { - fmt.Printf("Set hostname for VPS '%s'? [y/N]: ", instanceName) - } - prompt = "" - case "set PTR": - if len(args) >= 2 { - fmt.Printf("Set PTR record for IP '%s' to '%s' for VPS '%s'? [y/N]: ", args[0], args[1], instanceName) - } else { - fmt.Printf("Set PTR record for VPS '%s'? [y/N]: ", instanceName) - } - prompt = "" - case "mount ISO": - if len(args) > 0 { - fmt.Printf("Mount ISO '%s' for VPS '%s'?\n", args[0], instanceName) - } else { - fmt.Printf("Mount ISO for VPS '%s'?\n", instanceName) - } - fmt.Printf("⚠️ VPS must be completely shut down and restarted after this operation.\n") - prompt = "Continue? [y/N]: " - case "unmount ISO": - fmt.Printf("Unmount ISO for VPS '%s'?\n", instanceName) - fmt.Printf("⚠️ VPS must be completely shut down and restarted after this operation.\n") - prompt = "Continue? [y/N]: " - } +type hostnameAPI interface { + GetServiceInfo(context.Context) (*client.ServiceInfo, error) + SetHostname(context.Context, string) error +} - if prompt != "" { - fmt.Print(prompt) +func runSetHostname(ctx context.Context, api hostnameAPI, resolvedName, newHostname string, dryRun, skipConfirm bool, confirm confirmationFunc) error { + info, err := api.GetServiceInfo(ctx) + if err != nil { + return fmt.Errorf("failed to get service info: %w", err) } - reader := bufio.NewReader(os.Stdin) - response, err := reader.ReadString('\n') + if info.Hostname == newHostname { + fmt.Printf("✅ Hostname is already '%s' (no change needed)\n", newHostname) + return nil + } + if dryRun { + printDryRun("setHostname", resolvedName, fmt.Sprintf("hostname: %s -> %s", info.Hostname, newHostname)) + return nil + } + confirmed, err := confirmWrite(fmt.Sprintf("Set hostname to '%s' for VPS '%s'?", newHostname, resolvedName), skipConfirm, confirm) if err != nil { - return false + return err + } + if !confirmed { + return nil } - response = strings.TrimSpace(strings.ToLower(response)) + fmt.Printf("Setting hostname to '%s' for instance: %s\n", newHostname, resolvedName) + if err := api.SetHostname(ctx, newHostname); err != nil { + return fmt.Errorf("failed to set hostname: %w", err) + } + fmt.Printf("✅ Hostname set to '%s' successfully\n", newHostname) + return nil +} - if action == "kill" { - return response == "kill" +type ptrAPI interface { + GetServiceInfo(context.Context) (*client.ServiceInfo, error) + SetPTR(context.Context, string, string) error +} + +func runSetPTR(ctx context.Context, api ptrAPI, resolvedName, ip, ptr string, dryRun, skipConfirm bool, confirm confirmationFunc) error { + info, err := api.GetServiceInfo(ctx) + if err != nil { + return fmt.Errorf("failed to get service info: %w", err) + } + if !info.RDNSAPIAvailable { + return fmt.Errorf("rDNS API is not available for instance %s", resolvedName) + } + if !containsString(info.IPAddresses, ip) { + return fmt.Errorf("IP address %s is not assigned to instance %s", ip, resolvedName) + } + currentPTR := "" + if info.PTR != nil { + currentPTR = info.PTR[ip] + } + if currentPTR == ptr { + fmt.Printf("✅ PTR record for IP '%s' is already '%s' (no change needed)\n", ip, ptr) + return nil } + if dryRun { + printDryRun("setPTR", resolvedName, fmt.Sprintf("ip: %s", ip), fmt.Sprintf("ptr: %s -> %s", currentPTR, ptr)) + return nil + } + confirmed, err := confirmWrite(fmt.Sprintf("Set PTR record for IP '%s' to '%s' on VPS '%s'?", ip, ptr, resolvedName), skipConfirm, confirm) + if err != nil { + return err + } + if !confirmed { + return nil + } + + fmt.Printf("Setting PTR record for IP '%s' to '%s' for instance: %s\n", ip, ptr, resolvedName) + if err := api.SetPTR(ctx, ip, ptr); err != nil { + return fmt.Errorf("failed to set PTR record: %w", err) + } + fmt.Printf("✅ PTR record set for IP '%s' to '%s' successfully\n", ip, ptr) + return nil +} - return response == "y" || response == "yes" +func confirmKill(prompt string) (bool, error) { + fmt.Printf("⚠️ WARNING: %s\n", prompt) + fmt.Printf("⚠️ ANY UNSAVED DATA WILL BE LOST!\n") + return promptExactConfirmation("Type 'kill' to confirm: ", "kill") } diff --git a/cmd/bwh/ipv6.go b/cmd/bwh/ipv6.go index c06d88c..1b70b7c 100644 --- a/cmd/bwh/ipv6.go +++ b/cmd/bwh/ipv6.go @@ -23,19 +23,29 @@ var ipv6Cmd = &cli.Command{ var ipv6AddCmd = &cli.Command{ Name: "add", Usage: "assign a new IPv6 /64 subnet", - Flags: []cli.Flag{ - &cli.BoolFlag{ - Name: "yes", - Aliases: []string{"y"}, - Usage: "skip confirmation prompt", - }, - }, + Flags: writeFlags(), Action: func(ctx context.Context, cmd *cli.Command) error { bwhClient, resolvedName, err := createBWHClient(cmd) if err != nil { return err } + serviceInfo, err := bwhClient.GetServiceInfo(ctx) + if err != nil { + return fmt.Errorf("failed to get service info: %w", err) + } + if !serviceInfo.LocationIPv6Ready { + return fmt.Errorf("IPv6 is not available at this location (%s)", serviceInfo.NodeLocation) + } + currentIPv6 := countIPv6Subnets(serviceInfo.IPAddresses) + if serviceInfo.PlanMaxIPv6s > 0 && currentIPv6 >= serviceInfo.PlanMaxIPv6s { + return fmt.Errorf("IPv6 subnet limit reached: %d/%d", currentIPv6, serviceInfo.PlanMaxIPv6s) + } + if cmd.Bool("dry-run") { + printDryRun("ipv6/add", resolvedName, fmt.Sprintf("assigned IPv6 subnets: %d/%d", currentIPv6, serviceInfo.PlanMaxIPv6s)) + return nil + } + if !cmd.Bool("yes") { fmt.Printf("Adding IPv6 /64 subnet to instance: %s\n", resolvedName) fmt.Printf("\n💡 This will assign a new IPv6 /64 subnet to your VPS.\n") @@ -74,13 +84,7 @@ var ipv6DeleteCmd = &cli.Command{ Name: "delete", Usage: "release an IPv6 /64 subnet", ArgsUsage: "", - Flags: []cli.Flag{ - &cli.BoolFlag{ - Name: "yes", - Aliases: []string{"y"}, - Usage: "skip confirmation prompt", - }, - }, + Flags: writeFlags(), Action: func(ctx context.Context, cmd *cli.Command) error { if cmd.Args().Len() != 1 { return fmt.Errorf("IPv6 subnet is required") @@ -95,6 +99,23 @@ var ipv6DeleteCmd = &cli.Command{ // Normalize subnet format (remove /64 suffix if present, we'll add it back for display) normalizedSubnet := strings.TrimSuffix(subnet, "/64") + bwhClient, resolvedName, err := createBWHClient(cmd) + if err != nil { + return err + } + + serviceInfo, err := bwhClient.GetServiceInfo(ctx) + if err != nil { + return fmt.Errorf("failed to get service info: %w", err) + } + if !hasIPv6Subnet(serviceInfo.IPAddresses, normalizedSubnet) { + return fmt.Errorf("IPv6 subnet %s/64 is not assigned to instance %s", normalizedSubnet, resolvedName) + } + if cmd.Bool("dry-run") { + printDryRun("ipv6/delete", resolvedName, fmt.Sprintf("subnet: %s/64", normalizedSubnet)) + return nil + } + if !cmd.Bool("yes") { fmt.Printf("⚠️ WARNING: This will release the IPv6 subnet and it cannot be undone.\n") fmt.Printf("The subnet will no longer be available to your VPS.\n") @@ -109,11 +130,6 @@ var ipv6DeleteCmd = &cli.Command{ } } - bwhClient, resolvedName, err := createBWHClient(cmd) - if err != nil { - return err - } - fmt.Printf("Deleting IPv6 subnet '%s' from instance: %s\n", normalizedSubnet, resolvedName) if err := bwhClient.DeleteIPv6(ctx, normalizedSubnet); err != nil { @@ -250,6 +266,26 @@ func displayIPv6InfoCompact(info *client.ServiceInfo, instanceName string) { } } +func countIPv6Subnets(ips []string) int { + count := 0 + for _, ip := range ips { + if isIPv6Address(ip) { + count++ + } + } + return count +} + +func hasIPv6Subnet(ips []string, subnet string) bool { + normalized := trimIPv6Subnet(subnet) + for _, ip := range ips { + if trimIPv6Subnet(ip) == normalized { + return true + } + } + return false +} + // isValidIPv6Subnet validates if the given string is a valid IPv6 address func isValidIPv6Subnet(subnet string) bool { // Remove /64 suffix if present diff --git a/cmd/bwh/iso.go b/cmd/bwh/iso.go index 2227216..15ee99e 100644 --- a/cmd/bwh/iso.go +++ b/cmd/bwh/iso.go @@ -4,6 +4,7 @@ import ( "context" "fmt" + "github.com/strahe/bwh/pkg/client" "github.com/urfave/cli/v3" ) @@ -62,13 +63,7 @@ var isoCmd = &cli.Command{ Name: "mount", Usage: "mount ISO image to boot from (requires VPS shutdown and restart)", ArgsUsage: "", - Flags: []cli.Flag{ - &cli.BoolFlag{ - Name: "yes", - Aliases: []string{"y"}, - Usage: "skip confirmation prompt", - }, - }, + Flags: writeFlags(), Action: func(ctx context.Context, cmd *cli.Command) error { if cmd.Args().Len() != 1 { return fmt.Errorf("iso mount command requires exactly one argument: ") @@ -84,61 +79,92 @@ var isoCmd = &cli.Command{ return err } - // Confirmation prompt - if !cmd.Bool("yes") { - if !confirmAction("mount ISO", resolvedName, iso) { - fmt.Println("Operation cancelled.") - return nil - } - } - - fmt.Printf("Mounting ISO '%s' for instance: %s\n", iso, resolvedName) - fmt.Printf("⚠️ Remember: VPS must be completely shut down and restarted after this operation\n") - - if err := bwhClient.MountISO(ctx, iso); err != nil { - return fmt.Errorf("failed to mount ISO: %w", err) - } - - fmt.Printf("✅ ISO '%s' mounted successfully\n", iso) - fmt.Printf("📝 Next steps: shutdown VPS completely and restart to boot from ISO\n") - return nil + return runMountISO(ctx, bwhClient, resolvedName, iso, cmd.Bool("dry-run"), skipConfirm(cmd), promptConfirmation) }, }, { Name: "unmount", Usage: "unmount ISO image and boot from primary storage (requires VPS shutdown and restart)", - Flags: []cli.Flag{ - &cli.BoolFlag{ - Name: "yes", - Aliases: []string{"y"}, - Usage: "skip confirmation prompt", - }, - }, + Flags: writeFlags(), Action: func(ctx context.Context, cmd *cli.Command) error { bwhClient, resolvedName, err := createBWHClient(cmd) if err != nil { return err } - // Confirmation prompt - if !cmd.Bool("yes") { - if !confirmAction("unmount ISO", resolvedName) { - fmt.Println("Operation cancelled.") - return nil - } - } - - fmt.Printf("Unmounting ISO for instance: %s\n", resolvedName) - fmt.Printf("⚠️ Remember: VPS must be completely shut down and restarted after this operation\n") - - if err := bwhClient.UnmountISO(ctx); err != nil { - return fmt.Errorf("failed to unmount ISO: %w", err) - } - - fmt.Printf("✅ ISO unmounted successfully\n") - fmt.Printf("📝 Next steps: shutdown VPS completely and restart to boot from primary storage\n") - return nil + return runUnmountISO(ctx, bwhClient, resolvedName, cmd.Bool("dry-run"), skipConfirm(cmd), promptConfirmation) }, }, }, } + +type isoAPI interface { + GetServiceInfo(context.Context) (*client.ServiceInfo, error) + MountISO(context.Context, string) error + UnmountISO(context.Context) error +} + +func runMountISO(ctx context.Context, api isoAPI, resolvedName, iso string, dryRun, skipConfirm bool, confirm confirmationFunc) error { + info, err := api.GetServiceInfo(ctx) + if err != nil { + return fmt.Errorf("failed to get service info: %w", err) + } + if !containsString(info.AvailableISOs, iso) { + return fmt.Errorf("ISO image %q is not available for instance %s", iso, resolvedName) + } + if info.ISO1 == iso { + fmt.Printf("✅ ISO '%s' is already mounted (no change needed)\n", iso) + return nil + } + if dryRun { + printDryRun("iso/mount", resolvedName, fmt.Sprintf("iso: %s", iso)) + return nil + } + confirmed, err := confirmWrite(fmt.Sprintf("Mount ISO '%s' for VPS '%s'?", iso, resolvedName), skipConfirm, confirm) + if err != nil { + return err + } + if !confirmed { + return nil + } + + fmt.Printf("Mounting ISO '%s' for instance: %s\n", iso, resolvedName) + fmt.Printf("⚠️ Remember: VPS must be completely shut down and restarted after this operation\n") + if err := api.MountISO(ctx, iso); err != nil { + return fmt.Errorf("failed to mount ISO: %w", err) + } + fmt.Printf("✅ ISO '%s' mounted successfully\n", iso) + fmt.Printf("📝 Next steps: shutdown VPS completely and restart to boot from ISO\n") + return nil +} + +func runUnmountISO(ctx context.Context, api isoAPI, resolvedName string, dryRun, skipConfirm bool, confirm confirmationFunc) error { + info, err := api.GetServiceInfo(ctx) + if err != nil { + return fmt.Errorf("failed to get service info: %w", err) + } + if info.ISO1 == "" && info.ISO2 == "" { + fmt.Printf("✅ No ISO is currently mounted (no change needed)\n") + return nil + } + if dryRun { + printDryRun("iso/unmount", resolvedName, fmt.Sprintf("mounted ISO1: %s", info.ISO1)) + return nil + } + confirmed, err := confirmWrite(fmt.Sprintf("Unmount ISO for VPS '%s'?", resolvedName), skipConfirm, confirm) + if err != nil { + return err + } + if !confirmed { + return nil + } + + fmt.Printf("Unmounting ISO for instance: %s\n", resolvedName) + fmt.Printf("⚠️ Remember: VPS must be completely shut down and restarted after this operation\n") + if err := api.UnmountISO(ctx); err != nil { + return fmt.Errorf("failed to unmount ISO: %w", err) + } + fmt.Printf("✅ ISO unmounted successfully\n") + fmt.Printf("📝 Next steps: shutdown VPS completely and restart to boot from primary storage\n") + return nil +} diff --git a/cmd/bwh/migrate.go b/cmd/bwh/migrate.go index aa327fc..400aef3 100644 --- a/cmd/bwh/migrate.go +++ b/cmd/bwh/migrate.go @@ -77,6 +77,7 @@ var migrateStartCmd = &cli.Command{ Aliases: []string{"y"}, Usage: "skip confirmation prompt", }, + dryRunFlag(), &cli.StringFlag{ Name: "timeout", Usage: "request timeout (e.g. 10m, 30m). Default: 15m", @@ -102,23 +103,43 @@ var migrateStartCmd = &cli.Command{ return err } - // Warn user and confirm unless --yes + timeoutStr := cmd.String("timeout") + d, err := time.ParseDuration(timeoutStr) + if err != nil || d <= 0 { + return fmt.Errorf("invalid timeout: %s", timeoutStr) + } + + locations, err := bwhClient.GetMigrateLocations(ctx) + if err != nil { + return fmt.Errorf("failed to get migration locations: %w", err) + } + if locations.CurrentLocation == locationID { + fmt.Printf("✅ Instance is already in migration location '%s' (no change needed)\n", locationID) + return nil + } + if !containsString(locations.Locations, locationID) { + return fmt.Errorf("migration location %q is not available for instance %s", locationID, resolvedName) + } + + if cmd.Bool("dry-run") { + desc := locations.Descriptions[locationID] + printDryRun("migrate/start", resolvedName, fmt.Sprintf("location: %s -> %s", locations.CurrentLocation, locationID), fmt.Sprintf("description: %s", desc), fmt.Sprintf("timeout: %s", d)) + return nil + } + if !cmd.Bool("yes") { fmt.Printf("⚠️ Starting migration will REPLACE all IPv4 addresses of VPS '%s'.\n", resolvedName) fmt.Printf("⚠️ Downtime is expected during migration.\n") - if !confirmAction("restart", resolvedName) { // reuse yes/no prompt semantics - fmt.Println("Operation cancelled.") + confirmed, err := promptConfirmation("Continue with migration?") + if err != nil { + return err + } + if !confirmed { + printOperationCancelled() return nil } } - // Parse timeout - timeoutStr := cmd.String("timeout") - d, err := time.ParseDuration(timeoutStr) - if err != nil || d <= 0 { - return fmt.Errorf("invalid timeout: %s", timeoutStr) - } - fmt.Printf("Starting migration to '%s' for instance: %s (timeout: %s)\n", locationID, resolvedName, d) wait := cmd.Bool("wait") diff --git a/cmd/bwh/private_ip.go b/cmd/bwh/private_ip.go index be94e51..1ad1083 100644 --- a/cmd/bwh/private_ip.go +++ b/cmd/bwh/private_ip.go @@ -106,13 +106,7 @@ var privateIPAssignCmd = &cli.Command{ Name: "assign", Usage: "assign a private IPv4 address (random if not specified)", ArgsUsage: "[ip]", - Flags: []cli.Flag{ - &cli.BoolFlag{ - Name: "yes", - Aliases: []string{"y"}, - Usage: "skip confirmation prompt", - }, - }, + Flags: writeFlags(), Action: func(ctx context.Context, cmd *cli.Command) error { var ip string if cmd.Args().Len() > 0 { @@ -127,6 +121,36 @@ var privateIPAssignCmd = &cli.Command{ return err } + serviceInfo, err := bwhClient.GetServiceInfo(ctx) + if err != nil { + return fmt.Errorf("failed to get service info: %w", err) + } + if !serviceInfo.PlanPrivateNetworkAvailable || !serviceInfo.LocationPrivateNetworkAvailable { + return fmt.Errorf("private IPv4 is not available for this plan or location") + } + if ip != "" && containsString(serviceInfo.PrivateIPAddresses, ip) { + fmt.Printf("✅ Private IPv4 address %s is already assigned (no change needed)\n", ip) + return nil + } + availableResp, err := bwhClient.GetAvailablePrivateIPs(ctx) + if err != nil { + return fmt.Errorf("failed to get available private IPs: %w", err) + } + if len(availableResp.AvailableIPs) == 0 { + return fmt.Errorf("no private IPv4 addresses are available") + } + if ip != "" && !containsString(availableResp.AvailableIPs, ip) { + return fmt.Errorf("private IPv4 address %s is not available for assignment", ip) + } + if cmd.Bool("dry-run") { + detail := fmt.Sprintf("available private IPs: %d", len(availableResp.AvailableIPs)) + if ip != "" { + detail = fmt.Sprintf("ip: %s", ip) + } + printDryRun("privateIp/assign", resolvedName, detail) + return nil + } + if !cmd.Bool("yes") { if ip == "" { fmt.Printf("This will assign a random private IPv4 address to instance: %s\n", resolvedName) @@ -163,13 +187,7 @@ var privateIPDeleteCmd = &cli.Command{ Name: "delete", Usage: "delete a private IPv4 address", ArgsUsage: "", - Flags: []cli.Flag{ - &cli.BoolFlag{ - Name: "yes", - Aliases: []string{"y"}, - Usage: "skip confirmation prompt", - }, - }, + Flags: writeFlags(), Action: func(ctx context.Context, cmd *cli.Command) error { if cmd.Args().Len() != 1 { return fmt.Errorf("private IPv4 address is required") @@ -179,6 +197,23 @@ var privateIPDeleteCmd = &cli.Command{ return fmt.Errorf("invalid IPv4 address: %s", ip) } + bwhClient, resolvedName, err := createBWHClient(cmd) + if err != nil { + return err + } + + serviceInfo, err := bwhClient.GetServiceInfo(ctx) + if err != nil { + return fmt.Errorf("failed to get service info: %w", err) + } + if !containsString(serviceInfo.PrivateIPAddresses, ip) { + return fmt.Errorf("private IPv4 address %s is not assigned to instance %s", ip, resolvedName) + } + if cmd.Bool("dry-run") { + printDryRun("privateIp/delete", resolvedName, fmt.Sprintf("ip: %s", ip)) + return nil + } + if !cmd.Bool("yes") { fmt.Printf("⚠️ This will delete private IPv4 address %s from the instance.\n", ip) confirmed, err := promptConfirmation("Proceed with deletion?") @@ -191,11 +226,6 @@ var privateIPDeleteCmd = &cli.Command{ } } - bwhClient, resolvedName, err := createBWHClient(cmd) - if err != nil { - return err - } - fmt.Printf("Deleting private IPv4 address '%s' from instance: %s\n", ip, resolvedName) if err := bwhClient.DeletePrivateIP(ctx, ip); err != nil { diff --git a/cmd/bwh/reinstall.go b/cmd/bwh/reinstall.go index d522d96..e458e1a 100644 --- a/cmd/bwh/reinstall.go +++ b/cmd/bwh/reinstall.go @@ -24,79 +24,86 @@ var reinstallCmd = &cli.Command{ Name: "list", Usage: "list available operating system templates", }, - &cli.BoolFlag{ - Name: "force", - Usage: "force reinstall without confirmation (dangerous)", - }, + forceFlag(), + yesFlag(), + dryRunFlag(), }, Action: func(ctx context.Context, cmd *cli.Command) error { osTemplate := cmd.String("os") listOnly := cmd.Bool("list") - force := cmd.Bool("force") bwhClient, resolvedName, err := createBWHClient(cmd) if err != nil { return err } - // Get available OS templates - osInfo, err := bwhClient.GetAvailableOS(ctx) - if err != nil { - return fmt.Errorf("failed to get available OS templates: %w", err) - } + return runReinstall(ctx, bwhClient, resolvedName, osTemplate, listOnly, cmd.Bool("dry-run"), skipConfirmOrForce(cmd), confirmReinstall) + }, +} - // If list flag is set, just display available templates - if listOnly { - displayAvailableOS(osInfo, resolvedName) - return nil - } +type reinstallAPI interface { + GetAvailableOS(context.Context) (*client.AvailableOSResponse, error) + ReinstallOS(context.Context, string) error +} - // If no OS specified, show available options and exit - if osTemplate == "" { - fmt.Printf("No OS template specified. Use --os flag with one of the following templates:\n\n") - displayAvailableOS(osInfo, resolvedName) - fmt.Printf("\nExample: bwh reinstall --os ubuntu-24.04-x86_64\n") - return nil - } +type reinstallConfirmationFunc func(instanceName, currentOS, targetOS string) bool - // Validate OS template - if !isValidOSTemplate(osTemplate, osInfo.Templates) { - fmt.Printf("❌ Invalid OS template: %s\n\n", osTemplate) - fmt.Printf("Available templates:\n") - for _, template := range osInfo.Templates { - fmt.Printf(" %s\n", template) - } - return fmt.Errorf("invalid OS template") - } +func runReinstall(ctx context.Context, api reinstallAPI, resolvedName, osTemplate string, listOnly, dryRun, skipConfirm bool, confirm reinstallConfirmationFunc) error { + osInfo, err := api.GetAvailableOS(ctx) + if err != nil { + return fmt.Errorf("failed to get available OS templates: %w", err) + } - // Show current and target OS - fmt.Printf("Instance: %s\n", resolvedName) - fmt.Printf("Current OS: %s\n", osInfo.Installed) - fmt.Printf("Target OS: %s\n", osTemplate) - fmt.Printf("\n") - - // Confirmation (unless force is used) - if !force { - if !confirmReinstall(resolvedName, osInfo.Installed, osTemplate) { - fmt.Println("Operation cancelled.") - return nil - } - } + if listOnly { + displayAvailableOS(osInfo, resolvedName) + return nil + } - fmt.Printf("🔄 Starting OS reinstall for instance: %s\n", resolvedName) - fmt.Printf("⏳ This may take several minutes...\n") + if osTemplate == "" { + fmt.Printf("No OS template specified. Use --os flag with one of the following templates:\n\n") + displayAvailableOS(osInfo, resolvedName) + fmt.Printf("\nExample: bwh reinstall --os ubuntu-24.04-x86_64\n") + return nil + } - // Execute reinstall - if err := bwhClient.ReinstallOS(ctx, osTemplate); err != nil { - return fmt.Errorf("failed to reinstall OS: %w", err) + if !isValidOSTemplate(osTemplate, osInfo.Templates) { + fmt.Printf("❌ Invalid OS template: %s\n\n", osTemplate) + fmt.Printf("Available templates:\n") + for _, template := range osInfo.Templates { + fmt.Printf(" %s\n", template) } + return fmt.Errorf("invalid OS template") + } - fmt.Printf("✅ OS reinstall initiated successfully\n") - fmt.Printf("📋 Your VPS is being reinstalled with %s\n", osTemplate) - fmt.Printf("⚠️ Note: The process may take 5-15 minutes to complete\n") + fmt.Printf("Instance: %s\n", resolvedName) + fmt.Printf("Current OS: %s\n", osInfo.Installed) + fmt.Printf("Target OS: %s\n", osTemplate) + fmt.Printf("\n") + if dryRun { + printDryRun("reinstallOS", resolvedName, fmt.Sprintf("os: %s", osTemplate)) return nil - }, + } + + if !skipConfirm { + if !confirm(resolvedName, osInfo.Installed, osTemplate) { + printOperationCancelled() + return nil + } + } + + fmt.Printf("🔄 Starting OS reinstall for instance: %s\n", resolvedName) + fmt.Printf("⏳ This may take several minutes...\n") + + if err := api.ReinstallOS(ctx, osTemplate); err != nil { + return fmt.Errorf("failed to reinstall OS: %w", err) + } + + fmt.Printf("✅ OS reinstall initiated successfully\n") + fmt.Printf("📋 Your VPS is being reinstalled with %s\n", osTemplate) + fmt.Printf("⚠️ Note: The process may take 5-15 minutes to complete\n") + + return nil } func displayAvailableOS(osInfo *client.AvailableOSResponse, instanceName string) { diff --git a/cmd/bwh/reset-password.go b/cmd/bwh/reset-password.go index db65d7e..e7cf75c 100644 --- a/cmd/bwh/reset-password.go +++ b/cmd/bwh/reset-password.go @@ -8,6 +8,7 @@ import ( "path/filepath" "time" + "github.com/strahe/bwh/pkg/client" "github.com/urfave/cli/v3" ) @@ -23,20 +24,14 @@ func generateRandomFileName() string { var resetPasswordCmd = &cli.Command{ Name: "reset-password", Usage: "reset the root password", - Flags: []cli.Flag{ - &cli.BoolFlag{ - Name: "yes", - Usage: "skip confirmation prompt", - Aliases: []string{"y"}, - }, + Flags: writeFlags( &cli.StringFlag{ Name: "output", Usage: "output password to specified file (creates random file if not specified)", Aliases: []string{"o"}, }, - }, + ), Action: func(ctx context.Context, cmd *cli.Command) error { - skipConfirm := cmd.Bool("yes") outputFile := cmd.String("output") bwhClient, resolvedName, err := createBWHClient(cmd) @@ -44,43 +39,104 @@ var resetPasswordCmd = &cli.Command{ return err } - if !skipConfirm { - if !confirmAction("reset root password", resolvedName) { - fmt.Println("Operation cancelled.") - return nil - } - } + return runResetPassword(ctx, bwhClient, resolvedName, outputFile, cmd.Bool("dry-run"), skipConfirm(cmd), promptConfirmation) + }, +} + +type resetPasswordAPI interface { + ResetRootPassword(context.Context) (*client.ResetRootPasswordResponse, error) +} + +func runResetPassword(ctx context.Context, api resetPasswordAPI, resolvedName, outputFile string, dryRun, skipConfirm bool, confirm confirmationFunc) error { + filePath := outputFile + if filePath == "" { + filePath = generateRandomFileName() + } + absPath, err := filepath.Abs(filePath) + if err != nil { + absPath = filePath + } - var filePath string - if outputFile == "" { - filePath = generateRandomFileName() - } else { - filePath = outputFile + fileExists, err := preflightPasswordOutput(filePath) + if err != nil { + return err + } + + if dryRun { + detail := fmt.Sprintf("output: %s", absPath) + if fileExists { + detail += " (would overwrite existing file)" } - absPath, err := filepath.Abs(filePath) + printDryRun("resetRootPassword", resolvedName, detail) + return nil + } + + if fileExists { + confirmed, err := confirmWrite(fmt.Sprintf("Output file '%s' already exists. Overwrite?", filePath), skipConfirm, confirm) if err != nil { - absPath = filePath + return err + } + if !confirmed { + return nil } + } - fmt.Printf("Resetting root password for instance: %s\n", resolvedName) + confirmed, err := confirmWrite(fmt.Sprintf("Reset root password for VPS '%s'?", resolvedName), skipConfirm, confirm) + if err != nil { + return err + } + if !confirmed { + return nil + } - result, err := bwhClient.ResetRootPassword(ctx) - if err != nil { - return fmt.Errorf("failed to reset root password: %w", err) - } + fmt.Printf("Resetting root password for instance: %s\n", resolvedName) + + result, err := api.ResetRootPassword(ctx) + if err != nil { + return fmt.Errorf("failed to reset root password: %w", err) + } - passwordContent := fmt.Sprintf("Root Password for BWH Instance: %s\n", resolvedName) - passwordContent += fmt.Sprintf("Generated at: %s\n", time.Now().Format("2006-01-02 15:04:05 MST")) - passwordContent += fmt.Sprintf("Password: %s\n", result.Password) + passwordContent := fmt.Sprintf("Root Password for BWH Instance: %s\n", resolvedName) + passwordContent += fmt.Sprintf("Generated at: %s\n", time.Now().Format("2006-01-02 15:04:05 MST")) + passwordContent += fmt.Sprintf("Password: %s\n", result.Password) + + err = os.WriteFile(filePath, []byte(passwordContent), 0o600) + if err != nil { + return fmt.Errorf("failed to write password to file: %w", err) + } + + fmt.Printf("\n✅ Root password reset successfully!\n") + fmt.Printf("🔑 Password saved to: %s\n", absPath) + + return nil +} - err = os.WriteFile(filePath, []byte(passwordContent), 0o600) +func preflightPasswordOutput(filePath string) (bool, error) { + info, err := os.Stat(filePath) + if err == nil { + if info.IsDir() { + return false, fmt.Errorf("output path is a directory: %s", filePath) + } + file, err := os.OpenFile(filePath, os.O_WRONLY, 0) if err != nil { - return fmt.Errorf("failed to write password to file: %w", err) + return false, fmt.Errorf("output file is not writable: %w", err) } + if err := file.Close(); err != nil { + return false, fmt.Errorf("failed to close output file: %w", err) + } + return true, nil + } + if !os.IsNotExist(err) { + return false, fmt.Errorf("failed to check output file: %w", err) + } - fmt.Printf("\n✅ Root password reset successfully!\n") - fmt.Printf("🔑 Password saved to: %s\n", absPath) - - return nil - }, + parent := filepath.Dir(filePath) + info, err = os.Stat(parent) + if err != nil { + return false, fmt.Errorf("failed to check output directory: %w", err) + } + if !info.IsDir() { + return false, fmt.Errorf("output parent path is not a directory: %s", parent) + } + return false, nil } diff --git a/cmd/bwh/snapshot.go b/cmd/bwh/snapshot.go index 01241be..4e79c95 100644 --- a/cmd/bwh/snapshot.go +++ b/cmd/bwh/snapshot.go @@ -39,18 +39,13 @@ var snapshotCmd = &cli.Command{ var snapshotCreateCmd = &cli.Command{ Name: "create", Usage: "create a snapshot", - Flags: []cli.Flag{ + Flags: writeFlags( &cli.StringFlag{ Name: "description", Aliases: []string{"d"}, Usage: "description for the snapshot", }, - &cli.BoolFlag{ - Name: "yes", - Aliases: []string{"y"}, - Usage: "skip confirmation prompt", - }, - }, + ), Action: func(ctx context.Context, cmd *cli.Command) error { bwhClient, resolvedName, err := createBWHClient(cmd) if err != nil { @@ -62,6 +57,11 @@ var snapshotCreateCmd = &cli.Command{ description = fmt.Sprintf("Created via bwh CLI on %s", time.Now().Format("2006-01-02 15:04:05")) } + if cmd.Bool("dry-run") { + printDryRun("snapshot/create", resolvedName, fmt.Sprintf("description: %s", description)) + return nil + } + if !cmd.Bool("yes") { fmt.Printf("Creating snapshot for instance: %s\n", resolvedName) fmt.Printf("Description: %s\n", description) @@ -134,44 +134,19 @@ var snapshotDeleteCmd = &cli.Command{ Name: "delete", Usage: "delete a snapshot", ArgsUsage: "", - Flags: []cli.Flag{ - &cli.BoolFlag{ - Name: "yes", - Aliases: []string{"y"}, - Usage: "skip confirmation prompt", - }, - }, + Flags: writeFlags(), Action: func(ctx context.Context, cmd *cli.Command) error { if cmd.Args().Len() != 1 { return fmt.Errorf("snapshot filename is required") } fileName := cmd.Args().First() - if !cmd.Bool("yes") { - confirmed, err := promptConfirmation(fmt.Sprintf("⚠️ Are you sure you want to delete snapshot '%s'? This cannot be undone.", fileName)) - if err != nil { - return err - } - if !confirmed { - fmt.Printf("Operation cancelled\n") - return nil - } - } - bwhClient, resolvedName, err := createBWHClient(cmd) if err != nil { return err } - fmt.Printf("Deleting snapshot '%s' for instance: %s\n", fileName, resolvedName) - - if err := bwhClient.DeleteSnapshot(ctx, fileName); err != nil { - return fmt.Errorf("failed to delete snapshot: %w", err) - } - - fmt.Printf("✅ Snapshot '%s' deleted successfully\n", fileName) - - return nil + return runSnapshotDelete(ctx, bwhClient, resolvedName, fileName, cmd.Bool("dry-run"), skipConfirm(cmd), promptConfirmation) }, } @@ -179,45 +154,19 @@ var snapshotRestoreCmd = &cli.Command{ Name: "restore", Usage: "restore a snapshot (WARNING: overwrites all data)", ArgsUsage: "", - Flags: []cli.Flag{ - &cli.BoolFlag{ - Name: "yes", - Aliases: []string{"y"}, - Usage: "skip confirmation prompt", - }, - }, + Flags: writeFlags(), Action: func(ctx context.Context, cmd *cli.Command) error { if cmd.Args().Len() != 1 { return fmt.Errorf("snapshot filename is required") } fileName := cmd.Args().First() - if !cmd.Bool("yes") { - fmt.Printf("⚠️ WARNING: Restoring snapshot '%s' will OVERWRITE ALL DATA on the VPS!\n", fileName) - confirmed, err := promptConfirmation("This operation cannot be undone. Are you sure?") - if err != nil { - return err - } - if !confirmed { - fmt.Printf("Operation cancelled\n") - return nil - } - } - bwhClient, resolvedName, err := createBWHClient(cmd) if err != nil { return err } - fmt.Printf("Restoring snapshot '%s' for instance: %s\n", fileName, resolvedName) - - if err := bwhClient.RestoreSnapshot(ctx, fileName); err != nil { - return fmt.Errorf("failed to restore snapshot: %w", err) - } - - fmt.Printf("✅ Snapshot '%s' restoration initiated\n", fileName) - - return nil + return runSnapshotRestore(ctx, bwhClient, resolvedName, fileName, cmd.Bool("dry-run"), skipConfirm(cmd), promptConfirmation) }, } @@ -225,13 +174,7 @@ var snapshotPinCmd = &cli.Command{ Name: "pin", Usage: "pin a snapshot (make it sticky - never purged)", ArgsUsage: "", - Flags: []cli.Flag{ - &cli.BoolFlag{ - Name: "yes", - Aliases: []string{"y"}, - Usage: "skip confirmation prompt", - }, - }, + Flags: writeFlags(), Action: func(ctx context.Context, cmd *cli.Command) error { if cmd.Args().Len() != 1 { return fmt.Errorf("snapshot filename or index is required") @@ -244,13 +187,7 @@ var snapshotUnpinCmd = &cli.Command{ Name: "unpin", Usage: "unpin a snapshot (remove sticky - can be purged)", ArgsUsage: "", - Flags: []cli.Flag{ - &cli.BoolFlag{ - Name: "yes", - Aliases: []string{"y"}, - Usage: "skip confirmation prompt", - }, - }, + Flags: writeFlags(), Action: func(ctx context.Context, cmd *cli.Command) error { if cmd.Args().Len() != 1 { return fmt.Errorf("snapshot filename or index is required") @@ -263,6 +200,7 @@ var snapshotExportCmd = &cli.Command{ Name: "export", Usage: "export a snapshot for transfer to another instance", ArgsUsage: "", + Flags: writeFlags(), Action: func(ctx context.Context, cmd *cli.Command) error { if cmd.Args().Len() != 1 { return fmt.Errorf("snapshot filename is required") @@ -274,6 +212,21 @@ var snapshotExportCmd = &cli.Command{ return err } + if err := ensureSnapshotExists(ctx, bwhClient, fileName); err != nil { + return err + } + if cmd.Bool("dry-run") { + printDryRun("snapshot/export", resolvedName, fmt.Sprintf("snapshot: %s", fileName)) + return nil + } + confirmed, err := confirmWrite(fmt.Sprintf("Export snapshot '%s' from instance '%s'?", fileName, resolvedName), skipConfirm(cmd), promptConfirmation) + if err != nil { + return err + } + if !confirmed { + return nil + } + fmt.Printf("Exporting snapshot '%s' for instance: %s\n", fileName, resolvedName) resp, err := bwhClient.ExportSnapshot(ctx, fileName) @@ -295,18 +248,37 @@ var snapshotImportCmd = &cli.Command{ Name: "import", Usage: "import a snapshot from another instance", ArgsUsage: " ", + Flags: writeFlags(), Action: func(ctx context.Context, cmd *cli.Command) error { if cmd.Args().Len() != 2 { return fmt.Errorf("source VEID and source token are required") } sourceVeid := cmd.Args().Get(0) sourceToken := cmd.Args().Get(1) + if strings.TrimSpace(sourceVeid) == "" { + return fmt.Errorf("source VEID cannot be empty") + } + if strings.TrimSpace(sourceToken) == "" { + return fmt.Errorf("source token cannot be empty") + } bwhClient, resolvedName, err := createBWHClient(cmd) if err != nil { return err } + if cmd.Bool("dry-run") { + printDryRun("snapshot/import", resolvedName, fmt.Sprintf("sourceVeid: %s", sourceVeid), fmt.Sprintf("sourceToken: %s", maskSensitive(sourceToken))) + return nil + } + confirmed, err := confirmWrite(fmt.Sprintf("Import snapshot from VEID '%s' to instance '%s'?", sourceVeid, resolvedName), skipConfirm(cmd), promptConfirmation) + if err != nil { + return err + } + if !confirmed { + return nil + } + fmt.Printf("Importing snapshot from VEID '%s' to instance: %s\n", sourceVeid, resolvedName) if err := bwhClient.ImportSnapshot(ctx, sourceVeid, sourceToken); err != nil { @@ -525,6 +497,103 @@ func isPrintableASCII(s string) bool { return true } +type snapshotWriteAPI interface { + ListSnapshots(context.Context) (*client.SnapshotListResponse, error) + DeleteSnapshot(context.Context, string) error + RestoreSnapshot(context.Context, string) error +} + +func findSnapshotByName(snapshots []client.SnapshotInfo, fileName string) (*client.SnapshotInfo, bool) { + for i := range snapshots { + if snapshots[i].FileName == fileName { + return &snapshots[i], true + } + } + return nil, false +} + +func ensureSnapshotExists(ctx context.Context, api interface { + ListSnapshots(context.Context) (*client.SnapshotListResponse, error) +}, fileName string, +) error { + resp, err := api.ListSnapshots(ctx) + if err != nil { + return fmt.Errorf("failed to list snapshots: %w", err) + } + if _, ok := findSnapshotByName(resp.Snapshots, fileName); !ok { + return fmt.Errorf("snapshot '%s' not found", fileName) + } + return nil +} + +func runSnapshotDelete(ctx context.Context, api snapshotWriteAPI, resolvedName, fileName string, dryRun, skipConfirm bool, confirm confirmationFunc) error { + resp, err := api.ListSnapshots(ctx) + if err != nil { + return fmt.Errorf("failed to list snapshots: %w", err) + } + snapshot, ok := findSnapshotByName(resp.Snapshots, fileName) + if !ok { + return fmt.Errorf("snapshot '%s' not found", fileName) + } + fmt.Printf("Target snapshot for instance '%s':\n", resolvedName) + fmt.Printf(" File Name : %s\n", snapshot.FileName) + fmt.Printf(" OS : %s\n", snapshot.OS) + fmt.Printf(" Size : %s\n", progress.FormatBytes(snapshot.Size.Value)) + + if dryRun { + printDryRun("snapshot/delete", resolvedName, fmt.Sprintf("snapshot: %s", fileName)) + return nil + } + confirmed, err := confirmWrite(fmt.Sprintf("Delete snapshot '%s'? This cannot be undone.", fileName), skipConfirm, confirm) + if err != nil { + return err + } + if !confirmed { + return nil + } + + fmt.Printf("Deleting snapshot '%s' for instance: %s\n", fileName, resolvedName) + if err := api.DeleteSnapshot(ctx, fileName); err != nil { + return fmt.Errorf("failed to delete snapshot: %w", err) + } + fmt.Printf("✅ Snapshot '%s' deleted successfully\n", fileName) + return nil +} + +func runSnapshotRestore(ctx context.Context, api snapshotWriteAPI, resolvedName, fileName string, dryRun, skipConfirm bool, confirm confirmationFunc) error { + resp, err := api.ListSnapshots(ctx) + if err != nil { + return fmt.Errorf("failed to list snapshots: %w", err) + } + snapshot, ok := findSnapshotByName(resp.Snapshots, fileName) + if !ok { + return fmt.Errorf("snapshot '%s' not found", fileName) + } + fmt.Printf("Target snapshot for instance '%s':\n", resolvedName) + fmt.Printf(" File Name : %s\n", snapshot.FileName) + fmt.Printf(" OS : %s\n", snapshot.OS) + fmt.Printf(" Size : %s\n", progress.FormatBytes(snapshot.Size.Value)) + + if dryRun { + printDryRun("snapshot/restore", resolvedName, fmt.Sprintf("snapshot: %s", fileName)) + return nil + } + confirmed, err := confirmWrite(fmt.Sprintf("Restore snapshot '%s'? This will overwrite all VPS data.", fileName), skipConfirm, confirm) + if err != nil { + return err + } + if !confirmed { + return nil + } + + fmt.Printf("Restoring snapshot '%s' for instance: %s\n", fileName, resolvedName) + if err := api.RestoreSnapshot(ctx, fileName); err != nil { + return fmt.Errorf("failed to restore snapshot: %w", err) + } + fmt.Printf("✅ Snapshot '%s' restoration initiated\n", fileName) + return nil +} + // downloadFileWithFallback attempts to download using HTTPS first, then falls back to HTTP func downloadFileWithFallback(ctx context.Context, snapshot *client.SnapshotInfo, outputPath string) error { // Try HTTPS first if available @@ -736,6 +805,11 @@ func toggleSnapshotSticky(ctx context.Context, cmd *cli.Command, identifier stri return nil } + if cmd.Bool("dry-run") { + printDryRun("snapshot/toggleSticky", resolvedName, fmt.Sprintf("snapshot: %s", fileName), fmt.Sprintf("sticky: %v", sticky)) + return nil + } + if !cmd.Bool("yes") { fmt.Printf("\n⚠️ Are you sure you want to %s this snapshot?\n", action) fmt.Printf("After this change, the snapshot %s.\n", newState) diff --git a/cmd/bwh/ssh.go b/cmd/bwh/ssh.go index d1496bf..2857c86 100644 --- a/cmd/bwh/ssh.go +++ b/cmd/bwh/ssh.go @@ -7,6 +7,7 @@ import ( "os" "strings" + "github.com/strahe/bwh/pkg/client" "github.com/urfave/cli/v3" ) @@ -73,12 +74,12 @@ var sshCmd = &cli.Command{ Name: "set", Usage: "set VM-level SSH keys (replaces all existing keys)", ArgsUsage: " [key2] [key3]...", - Flags: []cli.Flag{ + Flags: writeFlags( &cli.StringFlag{ Name: "file", Usage: "read SSH keys from file (one per line)", }, - }, + ), Action: func(ctx context.Context, cmd *cli.Command) error { bwhClient, resolvedName, err := createBWHClient(cmd) if err != nil { @@ -103,49 +104,92 @@ var sshCmd = &cli.Command{ return fmt.Errorf("no SSH keys provided") } - // Validate SSH keys format - for i, key := range sshKeys { - if !isValidSshKey(key) { - return fmt.Errorf("invalid SSH key format at position %d", i+1) - } - } - - fmt.Printf("Setting %d SSH key(s) for %s...\n", len(sshKeys), resolvedName) - - if err := bwhClient.UpdateSshKeys(ctx, sshKeys); err != nil { - return fmt.Errorf("failed to update SSH keys: %w", err) - } - - fmt.Printf("✅ SSH keys updated successfully\n") - fmt.Printf("Note: Keys will be applied during the next reinstallOS operation.\n") - - return nil + return runUpdateSshKeys(ctx, bwhClient, resolvedName, sshKeys, cmd.Bool("dry-run"), skipConfirm(cmd), promptConfirmation) }, }, { Name: "clear", Usage: "clear all VM-level SSH keys", + Flags: writeFlags(), Action: func(ctx context.Context, cmd *cli.Command) error { bwhClient, resolvedName, err := createBWHClient(cmd) if err != nil { return err } - fmt.Printf("Clearing all VM-level SSH keys for %s...\n", resolvedName) - - if err := bwhClient.UpdateSshKeys(ctx, []string{}); err != nil { - return fmt.Errorf("failed to clear SSH keys: %w", err) - } - - fmt.Printf("✅ VM-level SSH keys cleared successfully\n") - fmt.Printf("Note: Account-level keys (if any) will still be used during reinstallOS.\n") - - return nil + return runUpdateSshKeys(ctx, bwhClient, resolvedName, []string{}, cmd.Bool("dry-run"), skipConfirm(cmd), promptConfirmation) }, }, }, } +type sshKeysAPI interface { + GetSshKeys(context.Context) (*client.SshKeysResponse, error) + UpdateSshKeys(context.Context, []string) error +} + +func runUpdateSshKeys(ctx context.Context, api sshKeysAPI, resolvedName string, sshKeys []string, dryRun, skipConfirm bool, confirm confirmationFunc) error { + for i, key := range sshKeys { + if !isValidSshKey(key) { + return fmt.Errorf("invalid SSH key format at position %d", i+1) + } + } + + current, err := api.GetSshKeys(ctx) + if err != nil { + return fmt.Errorf("failed to get SSH keys: %w", err) + } + currentKeys := current.GetSshKeysVeidSlice() + if sameStringSlices(currentKeys, sshKeys) { + fmt.Printf("✅ VM-level SSH keys are already in the requested state (no change needed)\n") + return nil + } + + fmt.Printf("VM-level SSH keys for %s: %d current -> %d requested\n", resolvedName, len(currentKeys), len(sshKeys)) + for i, key := range sshKeys { + fmt.Printf(" Requested key %d: %s\n", i+1, maskSSHKey(key)) + } + + if dryRun { + operation := "replace" + if len(sshKeys) == 0 { + operation = "clear" + } + printDryRun("updateSshKeys", resolvedName, fmt.Sprintf("operation: %s", operation), fmt.Sprintf("keys: %d -> %d", len(currentKeys), len(sshKeys))) + return nil + } + + prompt := fmt.Sprintf("Replace VM-level SSH keys for '%s'?", resolvedName) + if len(sshKeys) == 0 { + prompt = fmt.Sprintf("Clear all VM-level SSH keys for '%s'?", resolvedName) + } + confirmed, err := confirmWrite(prompt, skipConfirm, confirm) + if err != nil { + return err + } + if !confirmed { + return nil + } + + if len(sshKeys) == 0 { + fmt.Printf("Clearing all VM-level SSH keys for %s...\n", resolvedName) + } else { + fmt.Printf("Setting %d SSH key(s) for %s...\n", len(sshKeys), resolvedName) + } + if err := api.UpdateSshKeys(ctx, sshKeys); err != nil { + return fmt.Errorf("failed to update SSH keys: %w", err) + } + + if len(sshKeys) == 0 { + fmt.Printf("✅ VM-level SSH keys cleared successfully\n") + fmt.Printf("Note: Account-level keys (if any) will still be used during reinstallOS.\n") + } else { + fmt.Printf("✅ SSH keys updated successfully\n") + fmt.Printf("Note: Keys will be applied during the next reinstallOS operation.\n") + } + return nil +} + func printKeys(keys []string) { if len(keys) == 0 { fmt.Printf(" (none)\n") diff --git a/cmd/bwh/write_helpers.go b/cmd/bwh/write_helpers.go new file mode 100644 index 0000000..f62d3cd --- /dev/null +++ b/cmd/bwh/write_helpers.go @@ -0,0 +1,149 @@ +package main + +import ( + "bufio" + "fmt" + "io" + "os" + "strings" + + "github.com/urfave/cli/v3" +) + +type confirmationFunc func(string) (bool, error) + +func yesFlag() cli.Flag { + return &cli.BoolFlag{ + Name: "yes", + Aliases: []string{"y"}, + Usage: "skip confirmation prompt", + } +} + +func dryRunFlag() cli.Flag { + return &cli.BoolFlag{ + Name: "dry-run", + Usage: "validate and show the write action without calling the write API", + } +} + +func writeFlags(extra ...cli.Flag) []cli.Flag { + flags := make([]cli.Flag, 0, len(extra)+2) + flags = append(flags, extra...) + flags = append(flags, yesFlag(), dryRunFlag()) + return flags +} + +func forceFlag() cli.Flag { + return &cli.BoolFlag{ + Name: "force", + Usage: "skip confirmation prompt for this dangerous operation", + } +} + +func skipConfirm(cmd *cli.Command) bool { + return cmd.Bool("yes") +} + +func skipConfirmOrForce(cmd *cli.Command) bool { + return cmd.Bool("yes") || cmd.Bool("force") +} + +func confirmWrite(prompt string, skip bool, confirm confirmationFunc) (bool, error) { + if skip { + return true, nil + } + confirmed, err := confirm(prompt) + if err != nil { + return false, err + } + if !confirmed { + printOperationCancelled() + return false, nil + } + return true, nil +} + +func promptExactConfirmation(prompt, expected string) (bool, error) { + fmt.Print(prompt) + + reader := bufio.NewReader(os.Stdin) + response, err := reader.ReadString('\n') + if err != nil { + if err == io.EOF { + fmt.Printf("\n") + return false, fmt.Errorf("operation cancelled (EOF)") + } + return false, fmt.Errorf("failed to read user input: %w", err) + } + + return strings.TrimSpace(response) == expected, nil +} + +func printOperationCancelled() { + fmt.Println("Operation cancelled") +} + +func printDryRun(endpoint, instanceName string, details ...string) { + fmt.Printf("DRY RUN: would call %s for instance %s\n", endpoint, instanceName) + for _, detail := range details { + if strings.TrimSpace(detail) != "" { + fmt.Printf(" %s\n", detail) + } + } +} + +func maskSensitive(value string) string { + value = strings.TrimSpace(value) + if value == "" { + return "" + } + if len(value) <= 8 { + return strings.Repeat("*", len(value)) + } + return value[:4] + "..." + value[len(value)-4:] +} + +func maskSSHKey(key string) string { + fields := strings.Fields(key) + if len(fields) == 0 { + return "" + } + if len(fields) == 1 { + return maskSensitive(fields[0]) + } + masked := fields[0] + " " + maskSensitive(fields[1]) + if len(fields) > 2 { + masked += " " + fields[len(fields)-1] + } + return masked +} + +func sameStringSlices(a, b []string) bool { + if len(a) != len(b) { + return false + } + for i := range a { + if strings.TrimSpace(a[i]) != strings.TrimSpace(b[i]) { + return false + } + } + return true +} + +func containsString(values []string, target string) bool { + for _, value := range values { + if value == target { + return true + } + } + return false +} + +func trimIPv6Subnet(subnet string) string { + return strings.TrimSuffix(strings.TrimSpace(subnet), "/64") +} + +func isIPv6Address(value string) bool { + return strings.Contains(value, ":") +} diff --git a/cmd/bwh/write_safety_test.go b/cmd/bwh/write_safety_test.go new file mode 100644 index 0000000..914a25c --- /dev/null +++ b/cmd/bwh/write_safety_test.go @@ -0,0 +1,291 @@ +package main + +import ( + "context" + "os" + "strings" + "testing" + + "github.com/strahe/bwh/pkg/client" +) + +type fakePowerAPI struct { + status string + calls []string +} + +func (f *fakePowerAPI) GetLiveServiceInfo(context.Context) (*client.LiveServiceInfo, error) { + return &client.LiveServiceInfo{VeStatus: f.status}, nil +} + +func (f *fakePowerAPI) Start(context.Context) error { + f.calls = append(f.calls, "start") + return nil +} + +func (f *fakePowerAPI) Stop(context.Context) error { + f.calls = append(f.calls, "stop") + return nil +} + +func (f *fakePowerAPI) Restart(context.Context) error { + f.calls = append(f.calls, "restart") + return nil +} + +func (f *fakePowerAPI) Kill(context.Context) error { + f.calls = append(f.calls, "kill") + return nil +} + +func TestRunVPSActionSafety(t *testing.T) { + t.Run("dry run does not write", func(t *testing.T) { + api := &fakePowerAPI{status: "Stopped"} + out := captureStdout(t, func() { + if err := runVPSAction(context.Background(), api, "test", "start", true, false, confirmNo); err != nil { + t.Fatalf("runVPSAction() error = %v", err) + } + }) + if len(api.calls) != 0 { + t.Fatalf("calls = %v, want none", api.calls) + } + if !strings.Contains(out, "DRY RUN") { + t.Fatalf("output missing DRY RUN:\n%s", out) + } + }) + + t.Run("clear stopped noop prevents write", func(t *testing.T) { + api := &fakePowerAPI{status: "Stopped"} + out := captureStdout(t, func() { + if err := runVPSAction(context.Background(), api, "test", "stop", false, true, confirmYes); err != nil { + t.Fatalf("runVPSAction() error = %v", err) + } + }) + if len(api.calls) != 0 { + t.Fatalf("calls = %v, want none", api.calls) + } + if !strings.Contains(out, "already stopped") { + t.Fatalf("output missing noop message:\n%s", out) + } + }) + + t.Run("skip confirm writes", func(t *testing.T) { + api := &fakePowerAPI{status: "Running"} + if err := runVPSAction(context.Background(), api, "test", "restart", false, true, confirmNo); err != nil { + t.Fatalf("runVPSAction() error = %v", err) + } + if len(api.calls) != 1 || api.calls[0] != "restart" { + t.Fatalf("calls = %v, want [restart]", api.calls) + } + }) +} + +type fakeSettingsAPI struct { + service *client.ServiceInfo + hosts []string + ptrCalls []string +} + +func (f *fakeSettingsAPI) GetServiceInfo(context.Context) (*client.ServiceInfo, error) { + return f.service, nil +} + +func (f *fakeSettingsAPI) SetHostname(_ context.Context, hostname string) error { + f.hosts = append(f.hosts, hostname) + return nil +} + +func (f *fakeSettingsAPI) SetPTR(_ context.Context, ip, ptr string) error { + f.ptrCalls = append(f.ptrCalls, ip+"="+ptr) + return nil +} + +func TestRunSettingsSafety(t *testing.T) { + t.Run("hostname noop", func(t *testing.T) { + api := &fakeSettingsAPI{service: &client.ServiceInfo{Hostname: "same.example"}} + if err := runSetHostname(context.Background(), api, "test", "same.example", false, true, confirmNo); err != nil { + t.Fatalf("runSetHostname() error = %v", err) + } + if len(api.hosts) != 0 { + t.Fatalf("hosts = %v, want none", api.hosts) + } + }) + + t.Run("ptr dry run validates target and does not write", func(t *testing.T) { + api := &fakeSettingsAPI{service: &client.ServiceInfo{ + IPAddresses: []string{"192.0.2.10"}, + RDNSAPIAvailable: true, + PTR: map[string]string{"192.0.2.10": "old.example"}, + }} + out := captureStdout(t, func() { + if err := runSetPTR(context.Background(), api, "test", "192.0.2.10", "new.example", true, false, confirmNo); err != nil { + t.Fatalf("runSetPTR() error = %v", err) + } + }) + if len(api.ptrCalls) != 0 { + t.Fatalf("ptrCalls = %v, want none", api.ptrCalls) + } + if !strings.Contains(out, "DRY RUN") { + t.Fatalf("output missing DRY RUN:\n%s", out) + } + }) +} + +type fakeSSHAPI struct { + keys *client.SshKeysResponse + updates [][]string +} + +func (f *fakeSSHAPI) GetSshKeys(context.Context) (*client.SshKeysResponse, error) { + return f.keys, nil +} + +func (f *fakeSSHAPI) UpdateSshKeys(_ context.Context, keys []string) error { + f.updates = append(f.updates, keys) + return nil +} + +func TestRunUpdateSshKeysDryRunMasksKeys(t *testing.T) { + fullKey := "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIFullSensitivePublicKeyMaterial user@example.com" + api := &fakeSSHAPI{keys: &client.SshKeysResponse{}} + out := captureStdout(t, func() { + if err := runUpdateSshKeys(context.Background(), api, "test", []string{fullKey}, true, false, confirmNo); err != nil { + t.Fatalf("runUpdateSshKeys() error = %v", err) + } + }) + if len(api.updates) != 0 { + t.Fatalf("updates = %v, want none", api.updates) + } + if strings.Contains(out, "AAAAC3NzaC1lZDI1NTE5AAAAIFullSensitivePublicKeyMaterial") { + t.Fatalf("output leaked full SSH key:\n%s", out) + } + if !strings.Contains(out, "DRY RUN") { + t.Fatalf("output missing DRY RUN:\n%s", out) + } +} + +type fakeResetPasswordAPI struct { + calls int +} + +func (f *fakeResetPasswordAPI) ResetRootPassword(context.Context) (*client.ResetRootPasswordResponse, error) { + f.calls++ + return &client.ResetRootPasswordResponse{Password: "secret"}, nil +} + +func TestRunResetPasswordDryRunDoesNotPromptOrWrite(t *testing.T) { + dir := t.TempDir() + path := dir + "/password.txt" + if err := os.WriteFile(path, []byte("existing"), 0o600); err != nil { + t.Fatalf("failed to seed output file: %v", err) + } + api := &fakeResetPasswordAPI{} + confirmCalled := false + out := captureStdout(t, func() { + err := runResetPassword(context.Background(), api, "test", path, true, false, func(string) (bool, error) { + confirmCalled = true + return false, nil + }) + if err != nil { + t.Fatalf("runResetPassword() error = %v", err) + } + }) + if confirmCalled { + t.Fatal("confirm called during dry-run") + } + if api.calls != 0 { + t.Fatalf("calls = %d, want 0", api.calls) + } + if !strings.Contains(out, "would overwrite existing file") { + t.Fatalf("output missing overwrite preview:\n%s", out) + } +} + +type fakeReinstallAPI struct { + info *client.AvailableOSResponse + reinstalled []string +} + +func (f *fakeReinstallAPI) GetAvailableOS(context.Context) (*client.AvailableOSResponse, error) { + return f.info, nil +} + +func (f *fakeReinstallAPI) ReinstallOS(_ context.Context, osTemplate string) error { + f.reinstalled = append(f.reinstalled, osTemplate) + return nil +} + +func TestRunReinstallSafety(t *testing.T) { + api := &fakeReinstallAPI{info: &client.AvailableOSResponse{ + Installed: "debian-12-x86_64", + Templates: []string{ + "debian-12-x86_64", + "ubuntu-24.04-x86_64", + }, + }} + out := captureStdout(t, func() { + if err := runReinstall(context.Background(), api, "test", "ubuntu-24.04-x86_64", false, true, false, func(string, string, string) bool { + t.Fatal("confirm called during dry-run") + return false + }); err != nil { + t.Fatalf("runReinstall() error = %v", err) + } + }) + if len(api.reinstalled) != 0 { + t.Fatalf("reinstalled = %v, want none", api.reinstalled) + } + if !strings.Contains(out, "DRY RUN") { + t.Fatalf("output missing DRY RUN:\n%s", out) + } + + if err := runReinstall(context.Background(), api, "test", "ubuntu-24.04-x86_64", false, false, true, func(string, string, string) bool { + return false + }); err != nil { + t.Fatalf("runReinstall() error = %v", err) + } + if len(api.reinstalled) != 1 || api.reinstalled[0] != "ubuntu-24.04-x86_64" { + t.Fatalf("reinstalled = %v, want [ubuntu-24.04-x86_64]", api.reinstalled) + } +} + +type fakeSnapshotAPI struct { + snapshots []client.SnapshotInfo + deleted []string + restored []string +} + +func (f *fakeSnapshotAPI) ListSnapshots(context.Context) (*client.SnapshotListResponse, error) { + return &client.SnapshotListResponse{Snapshots: f.snapshots}, nil +} + +func (f *fakeSnapshotAPI) DeleteSnapshot(_ context.Context, fileName string) error { + f.deleted = append(f.deleted, fileName) + return nil +} + +func (f *fakeSnapshotAPI) RestoreSnapshot(_ context.Context, fileName string) error { + f.restored = append(f.restored, fileName) + return nil +} + +func TestRunSnapshotDeleteAndRestoreSafety(t *testing.T) { + api := &fakeSnapshotAPI{snapshots: []client.SnapshotInfo{{FileName: "snap.tar.gz", OS: "debian"}}} + out := captureStdout(t, func() { + if err := runSnapshotDelete(context.Background(), api, "test", "snap.tar.gz", true, false, confirmNo); err != nil { + t.Fatalf("runSnapshotDelete() error = %v", err) + } + }) + if len(api.deleted) != 0 { + t.Fatalf("deleted = %v, want none", api.deleted) + } + if !strings.Contains(out, "DRY RUN") { + t.Fatalf("output missing DRY RUN:\n%s", out) + } + + if err := runSnapshotRestore(context.Background(), api, "test", "snap.tar.gz", false, false, confirmNo); err != nil { + t.Fatalf("runSnapshotRestore() error = %v", err) + } + if len(api.restored) != 0 { + t.Fatalf("restored = %v, want none", api.restored) + } +} From 958099ce0bc014cede0d7d6fa3b0d6d57ee3987c Mon Sep 17 00:00:00 2001 From: Lee <7932644+strahe@users.noreply.github.com> Date: Thu, 25 Jun 2026 18:17:57 +0800 Subject: [PATCH 2/6] feat: add dry-run safety for write commands and resolve security issues --- cmd/bwh/backup.go | 2 +- cmd/bwh/reset-password.go | 89 ++++++++++++++++++++++++++++++++---- cmd/bwh/write_helpers.go | 12 +++-- cmd/bwh/write_safety_test.go | 71 +++++++++++++++++++++++++++- 4 files changed, 159 insertions(+), 15 deletions(-) diff --git a/cmd/bwh/backup.go b/cmd/bwh/backup.go index 67822f1..8e5b0fa 100644 --- a/cmd/bwh/backup.go +++ b/cmd/bwh/backup.go @@ -134,7 +134,7 @@ var backupCopyToSnapshotCmd = &cli.Command{ // Show backup info for confirmation fmt.Printf("Target backup for instance '%s':\n", resolvedName) - fmt.Printf(" Token : %s\n", backupToken) + fmt.Printf(" Token : %s\n", maskSensitive(backupToken)) fmt.Printf(" OS : %s\n", backup.OS) fmt.Printf(" Size : %s\n", formatBytes(backup.Size)) fmt.Printf(" MD5 Hash : %s\n", backup.MD5) diff --git a/cmd/bwh/reset-password.go b/cmd/bwh/reset-password.go index e7cf75c..49a70fd 100644 --- a/cmd/bwh/reset-password.go +++ b/cmd/bwh/reset-password.go @@ -2,8 +2,8 @@ package main import ( "context" + "crypto/rand" "fmt" - "math/rand" "os" "path/filepath" "time" @@ -12,13 +12,12 @@ import ( "github.com/urfave/cli/v3" ) -func generateRandomFileName() string { - chars := "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" +func generateRandomFileName() (string, error) { result := make([]byte, 8) - for i := range result { - result[i] = chars[rand.Intn(len(chars))] + if _, err := rand.Read(result); err != nil { + return "", fmt.Errorf("failed to generate output filename: %w", err) } - return fmt.Sprintf("password_%s.txt", string(result)) + return fmt.Sprintf("password_%x.txt", result), nil } var resetPasswordCmd = &cli.Command{ @@ -50,7 +49,11 @@ type resetPasswordAPI interface { func runResetPassword(ctx context.Context, api resetPasswordAPI, resolvedName, outputFile string, dryRun, skipConfirm bool, confirm confirmationFunc) error { filePath := outputFile if filePath == "" { - filePath = generateRandomFileName() + generatedPath, err := generateRandomFileName() + if err != nil { + return err + } + filePath = generatedPath } absPath, err := filepath.Abs(filePath) if err != nil { @@ -89,6 +92,17 @@ func runResetPassword(ctx context.Context, api resetPasswordAPI, resolvedName, o return nil } + output, err := openPasswordOutputFile(filePath, fileExists) + if err != nil { + return err + } + keepOutput := false + defer func() { + if !keepOutput { + output.abort() + } + }() + fmt.Printf("Resetting root password for instance: %s\n", resolvedName) result, err := api.ResetRootPassword(ctx) @@ -100,10 +114,10 @@ func runResetPassword(ctx context.Context, api resetPasswordAPI, resolvedName, o passwordContent += fmt.Sprintf("Generated at: %s\n", time.Now().Format("2006-01-02 15:04:05 MST")) passwordContent += fmt.Sprintf("Password: %s\n", result.Password) - err = os.WriteFile(filePath, []byte(passwordContent), 0o600) - if err != nil { + if err := output.write(passwordContent); err != nil { return fmt.Errorf("failed to write password to file: %w", err) } + keepOutput = true fmt.Printf("\n✅ Root password reset successfully!\n") fmt.Printf("🔑 Password saved to: %s\n", absPath) @@ -140,3 +154,60 @@ func preflightPasswordOutput(filePath string) (bool, error) { } return false, nil } + +type passwordOutputFile struct { + file *os.File + path string + created bool + closed bool +} + +func openPasswordOutputFile(filePath string, fileExists bool) (*passwordOutputFile, error) { + flags := os.O_WRONLY + created := false + if !fileExists { + flags |= os.O_CREATE | os.O_EXCL + created = true + } + + file, err := os.OpenFile(filePath, flags, 0o600) + if err != nil { + if fileExists { + return nil, fmt.Errorf("failed to open output file before resetting password: %w", err) + } + return nil, fmt.Errorf("failed to create output file before resetting password: %w", err) + } + + return &passwordOutputFile{file: file, path: filePath, created: created}, nil +} + +func (o *passwordOutputFile) write(content string) error { + if err := o.file.Truncate(0); err != nil { + return err + } + if _, err := o.file.Seek(0, 0); err != nil { + return err + } + if _, err := o.file.WriteString(content); err != nil { + return err + } + if err := o.file.Close(); err != nil { + o.closed = true + return err + } + o.closed = true + return nil +} + +func (o *passwordOutputFile) abort() { + if o == nil { + return + } + if !o.closed { + _ = o.file.Close() + o.closed = true + } + if o.created { + _ = os.Remove(o.path) + } +} diff --git a/cmd/bwh/write_helpers.go b/cmd/bwh/write_helpers.go index f62d3cd..d49e99e 100644 --- a/cmd/bwh/write_helpers.go +++ b/cmd/bwh/write_helpers.go @@ -5,6 +5,7 @@ import ( "fmt" "io" "os" + "slices" "strings" "github.com/urfave/cli/v3" @@ -123,12 +124,15 @@ func sameStringSlices(a, b []string) bool { if len(a) != len(b) { return false } + normalizedA := make([]string, len(a)) + normalizedB := make([]string, len(b)) for i := range a { - if strings.TrimSpace(a[i]) != strings.TrimSpace(b[i]) { - return false - } + normalizedA[i] = strings.TrimSpace(a[i]) + normalizedB[i] = strings.TrimSpace(b[i]) } - return true + slices.Sort(normalizedA) + slices.Sort(normalizedB) + return slices.Equal(normalizedA, normalizedB) } func containsString(values []string, target string) bool { diff --git a/cmd/bwh/write_safety_test.go b/cmd/bwh/write_safety_test.go index 914a25c..6b88b84 100644 --- a/cmd/bwh/write_safety_test.go +++ b/cmd/bwh/write_safety_test.go @@ -2,6 +2,8 @@ package main import ( "context" + "errors" + "fmt" "os" "strings" "testing" @@ -165,11 +167,21 @@ func TestRunUpdateSshKeysDryRunMasksKeys(t *testing.T) { } type fakeResetPasswordAPI struct { - calls int + calls int + beforeCall func() error + err error } func (f *fakeResetPasswordAPI) ResetRootPassword(context.Context) (*client.ResetRootPasswordResponse, error) { f.calls++ + if f.beforeCall != nil { + if err := f.beforeCall(); err != nil { + return nil, err + } + } + if f.err != nil { + return nil, f.err + } return &client.ResetRootPasswordResponse{Password: "secret"}, nil } @@ -201,6 +213,63 @@ func TestRunResetPasswordDryRunDoesNotPromptOrWrite(t *testing.T) { } } +func TestRunResetPasswordPreparesOutputBeforeAPI(t *testing.T) { + dir := t.TempDir() + path := dir + "/password.txt" + api := &fakeResetPasswordAPI{ + beforeCall: func() error { + info, err := os.Stat(path) + if err != nil { + return err + } + if got := info.Mode().Perm(); got != 0o600 { + return fmt.Errorf("mode = %o, want 600", got) + } + return nil + }, + } + + if err := runResetPassword(context.Background(), api, "test", path, false, true, confirmNo); err != nil { + t.Fatalf("runResetPassword() error = %v", err) + } + if api.calls != 1 { + t.Fatalf("calls = %d, want 1", api.calls) + } + content, err := os.ReadFile(path) + if err != nil { + t.Fatalf("failed to read output file: %v", err) + } + if !strings.Contains(string(content), "Password: secret") { + t.Fatalf("output file missing password:\n%s", content) + } +} + +func TestRunResetPasswordRemovesNewOutputOnAPIError(t *testing.T) { + dir := t.TempDir() + path := dir + "/password.txt" + api := &fakeResetPasswordAPI{err: errors.New("boom")} + + err := runResetPassword(context.Background(), api, "test", path, false, true, confirmNo) + if err == nil { + t.Fatal("runResetPassword() error = nil, want error") + } + if api.calls != 1 { + t.Fatalf("calls = %d, want 1", api.calls) + } + if _, statErr := os.Stat(path); !os.IsNotExist(statErr) { + t.Fatalf("output file should be removed after API error, stat error = %v", statErr) + } +} + +func TestSameStringSlicesIgnoresOrderAndPreservesCount(t *testing.T) { + if !sameStringSlices([]string{" key-b ", "key-a"}, []string{"key-a", "key-b"}) { + t.Fatal("sameStringSlices() should ignore order and surrounding whitespace") + } + if sameStringSlices([]string{"key-a", "key-a"}, []string{"key-a"}) { + t.Fatal("sameStringSlices() should preserve duplicate counts") + } +} + type fakeReinstallAPI struct { info *client.AvailableOSResponse reinstalled []string From 89612e14f4217b9b74c7f60a03dd0b393f2359bf Mon Sep 17 00:00:00 2001 From: Lee <7932644+strahe@users.noreply.github.com> Date: Fri, 26 Jun 2026 23:26:31 +0800 Subject: [PATCH 3/6] fix(cli): mask backup token in dry-run output --- cmd/bwh/backup.go | 95 +++++++++++++++++++----------------- cmd/bwh/write_safety_test.go | 54 ++++++++++++++++++++ 2 files changed, 104 insertions(+), 45 deletions(-) diff --git a/cmd/bwh/backup.go b/cmd/bwh/backup.go index 8e5b0fa..b138aec 100644 --- a/cmd/bwh/backup.go +++ b/cmd/bwh/backup.go @@ -111,62 +111,67 @@ var backupCopyToSnapshotCmd = &cli.Command{ } backupToken := cmd.Args().First() - // Validate backup token format before making API calls - if err := validateBackupToken(backupToken); err != nil { - return err - } - bwhClient, resolvedName, err := createBWHClient(cmd) if err != nil { return err } - // First, verify the backup exists by listing backups - backupsResp, err := bwhClient.ListBackups(ctx) - if err != nil { - return fmt.Errorf("failed to list backups: %w", err) - } - - backup, exists := backupsResp.Backups[backupToken] - if !exists { - return fmt.Errorf("backup with token '%s' not found", backupToken) - } + return runBackupCopyToSnapshot(ctx, bwhClient, resolvedName, backupToken, cmd.Bool("dry-run"), skipConfirm(cmd), promptConfirmation) + }, +} - // Show backup info for confirmation - fmt.Printf("Target backup for instance '%s':\n", resolvedName) - fmt.Printf(" Token : %s\n", maskSensitive(backupToken)) - fmt.Printf(" OS : %s\n", backup.OS) - fmt.Printf(" Size : %s\n", formatBytes(backup.Size)) - fmt.Printf(" MD5 Hash : %s\n", backup.MD5) - fmt.Printf(" Created : %s\n", time.Unix(backup.Timestamp, 0).Format("2006-01-02 15:04:05")) +type backupCopyAPI interface { + ListBackups(context.Context) (*client.BackupListResponse, error) + CopyBackupToSnapshot(context.Context, string) error +} - if cmd.Bool("dry-run") { - printDryRun("backup/copyToSnapshot", resolvedName, fmt.Sprintf("backupToken: %s", maskSensitive(backupToken))) - return nil - } +func runBackupCopyToSnapshot(ctx context.Context, api backupCopyAPI, resolvedName, backupToken string, dryRun, skipConfirm bool, confirm confirmationFunc) error { + if err := validateBackupToken(backupToken); err != nil { + return err + } - if !cmd.Bool("yes") { - fmt.Printf("\n⚠️ Are you sure you want to copy this backup to a snapshot?\n") - fmt.Printf("This will create a new restorable snapshot from the backup.\n") - confirmed, err := promptConfirmation("Continue?") - if err != nil { - return err - } - if !confirmed { - fmt.Printf("Operation cancelled\n") - return nil - } - } + backupsResp, err := api.ListBackups(ctx) + if err != nil { + return fmt.Errorf("failed to list backups: %w", err) + } - fmt.Printf("\nCopying backup to snapshot for instance: %s\n", resolvedName) + backup, exists := backupsResp.Backups[backupToken] + if !exists { + return fmt.Errorf("backup with token '%s' not found", maskSensitive(backupToken)) + } - if err := bwhClient.CopyBackupToSnapshot(ctx, backupToken); err != nil { - return fmt.Errorf("failed to copy backup to snapshot: %w", err) - } + fmt.Printf("Target backup for instance '%s':\n", resolvedName) + fmt.Printf(" Token : %s\n", maskSensitive(backupToken)) + fmt.Printf(" OS : %s\n", backup.OS) + fmt.Printf(" Size : %s\n", formatBytes(backup.Size)) + fmt.Printf(" MD5 Hash : %s\n", backup.MD5) + fmt.Printf(" Created : %s\n", time.Unix(backup.Timestamp, 0).Format("2006-01-02 15:04:05")) - fmt.Printf("✅ Backup successfully copied to snapshot\n") - fmt.Printf("💡 Use 'bwh snapshot list' to see the new snapshot\n") + if dryRun { + printDryRun("backup/copyToSnapshot", resolvedName, fmt.Sprintf("backupToken: %s", maskSensitive(backupToken))) + return nil + } + if !skipConfirm { + fmt.Printf("\n⚠️ Are you sure you want to copy this backup to a snapshot?\n") + fmt.Printf("This will create a new restorable snapshot from the backup.\n") + } + confirmed, err := confirmWrite("Continue?", skipConfirm, confirm) + if err != nil { + return err + } + if !confirmed { return nil - }, + } + + fmt.Printf("\nCopying backup to snapshot for instance: %s\n", resolvedName) + + if err := api.CopyBackupToSnapshot(ctx, backupToken); err != nil { + return fmt.Errorf("failed to copy backup to snapshot: %w", err) + } + + fmt.Printf("✅ Backup successfully copied to snapshot\n") + fmt.Printf("💡 Use 'bwh snapshot list' to see the new snapshot\n") + + return nil } diff --git a/cmd/bwh/write_safety_test.go b/cmd/bwh/write_safety_test.go index 6b88b84..ac9d97b 100644 --- a/cmd/bwh/write_safety_test.go +++ b/cmd/bwh/write_safety_test.go @@ -166,6 +166,60 @@ func TestRunUpdateSshKeysDryRunMasksKeys(t *testing.T) { } } +type fakeBackupCopyAPI struct { + backups map[string]client.BackupInfo + copies []string +} + +func (f *fakeBackupCopyAPI) ListBackups(context.Context) (*client.BackupListResponse, error) { + return &client.BackupListResponse{Backups: f.backups}, nil +} + +func (f *fakeBackupCopyAPI) CopyBackupToSnapshot(_ context.Context, backupToken string) error { + f.copies = append(f.copies, backupToken) + return nil +} + +func TestRunBackupCopyToSnapshotMasksTokenInDryRun(t *testing.T) { + token := "0123456789abcdef0123456789abcdef01234567" + api := &fakeBackupCopyAPI{ + backups: map[string]client.BackupInfo{ + token: {OS: "debian-12", Size: 1024, MD5: "abc", Timestamp: 0}, + }, + } + + out := captureStdout(t, func() { + if err := runBackupCopyToSnapshot(context.Background(), api, "test", token, true, false, confirmNo); err != nil { + t.Fatalf("runBackupCopyToSnapshot() error = %v", err) + } + }) + if len(api.copies) != 0 { + t.Fatalf("copies = %v, want none", api.copies) + } + if strings.Contains(out, token) { + t.Fatalf("output leaked full backup token:\n%s", out) + } + if !strings.Contains(out, "0123...4567") { + t.Fatalf("output missing masked backup token:\n%s", out) + } +} + +func TestRunBackupCopyToSnapshotMasksMissingTokenError(t *testing.T) { + token := "0123456789abcdef0123456789abcdef01234567" + api := &fakeBackupCopyAPI{backups: map[string]client.BackupInfo{}} + + err := runBackupCopyToSnapshot(context.Background(), api, "test", token, true, false, confirmNo) + if err == nil { + t.Fatal("runBackupCopyToSnapshot() error = nil, want error") + } + if strings.Contains(err.Error(), token) { + t.Fatalf("error leaked full backup token: %v", err) + } + if !strings.Contains(err.Error(), "0123...4567") { + t.Fatalf("error missing masked backup token: %v", err) + } +} + type fakeResetPasswordAPI struct { calls int beforeCall func() error From c3ac3a6d3eed88a32890a018bfda4c9011bfc3ea Mon Sep 17 00:00:00 2001 From: Lee <7932644+strahe@users.noreply.github.com> Date: Sat, 27 Jun 2026 12:06:17 +0800 Subject: [PATCH 4/6] fix(cli): harden write command safety --- cmd/bwh/ipv6.go | 176 ++++++++++++++------------ cmd/bwh/private_ip.go | 196 ++++++++++++++++------------- cmd/bwh/reset-password.go | 44 ++++--- cmd/bwh/snapshot.go | 106 +++++++++------- cmd/bwh/write_safety_test.go | 236 ++++++++++++++++++++++++++++++++++- 5 files changed, 517 insertions(+), 241 deletions(-) diff --git a/cmd/bwh/ipv6.go b/cmd/bwh/ipv6.go index 1b70b7c..c35d193 100644 --- a/cmd/bwh/ipv6.go +++ b/cmd/bwh/ipv6.go @@ -30,53 +30,7 @@ var ipv6AddCmd = &cli.Command{ return err } - serviceInfo, err := bwhClient.GetServiceInfo(ctx) - if err != nil { - return fmt.Errorf("failed to get service info: %w", err) - } - if !serviceInfo.LocationIPv6Ready { - return fmt.Errorf("IPv6 is not available at this location (%s)", serviceInfo.NodeLocation) - } - currentIPv6 := countIPv6Subnets(serviceInfo.IPAddresses) - if serviceInfo.PlanMaxIPv6s > 0 && currentIPv6 >= serviceInfo.PlanMaxIPv6s { - return fmt.Errorf("IPv6 subnet limit reached: %d/%d", currentIPv6, serviceInfo.PlanMaxIPv6s) - } - if cmd.Bool("dry-run") { - printDryRun("ipv6/add", resolvedName, fmt.Sprintf("assigned IPv6 subnets: %d/%d", currentIPv6, serviceInfo.PlanMaxIPv6s)) - return nil - } - - if !cmd.Bool("yes") { - fmt.Printf("Adding IPv6 /64 subnet to instance: %s\n", resolvedName) - fmt.Printf("\n💡 This will assign a new IPv6 /64 subnet to your VPS.\n") - fmt.Printf("⚠️ A full VM restart (stop + start) will be required after assignment\n") - fmt.Printf(" to automatically activate IPv6 networking inside the VM.\n") - confirmed, err := promptConfirmation("Continue with IPv6 subnet assignment?") - if err != nil { - return err - } - if !confirmed { - fmt.Printf("Operation cancelled\n") - return nil - } - } - - fmt.Printf("Adding IPv6 subnet to instance: %s\n", resolvedName) - - resp, err := bwhClient.AddIPv6(ctx) - if err != nil { - return fmt.Errorf("failed to add IPv6 subnet: %w", err) - } - - fmt.Printf("✅ IPv6 subnet added successfully\n") - fmt.Printf("📋 ASSIGNED SUBNET\n") - fmt.Printf(" IPv6 Subnet : %s/64\n", resp.AssignedSubnet) - fmt.Printf("\n⚠️ IMPORTANT: VM restart required for automatic IPv6 activation\n") - fmt.Printf(" 1. Stop the VM: 'bwh stop' (status must show 'Stopped')\n") - fmt.Printf(" 2. Start the VM: 'bwh start'\n") - fmt.Printf(" This will automatically activate IPv6 networking inside the VM.\n") - - return nil + return runIPv6Add(ctx, bwhClient, resolvedName, cmd.Bool("dry-run"), skipConfirm(cmd), promptConfirmation) }, } @@ -104,41 +58,7 @@ var ipv6DeleteCmd = &cli.Command{ return err } - serviceInfo, err := bwhClient.GetServiceInfo(ctx) - if err != nil { - return fmt.Errorf("failed to get service info: %w", err) - } - if !hasIPv6Subnet(serviceInfo.IPAddresses, normalizedSubnet) { - return fmt.Errorf("IPv6 subnet %s/64 is not assigned to instance %s", normalizedSubnet, resolvedName) - } - if cmd.Bool("dry-run") { - printDryRun("ipv6/delete", resolvedName, fmt.Sprintf("subnet: %s/64", normalizedSubnet)) - return nil - } - - if !cmd.Bool("yes") { - fmt.Printf("⚠️ WARNING: This will release the IPv6 subnet and it cannot be undone.\n") - fmt.Printf("The subnet will no longer be available to your VPS.\n") - fmt.Printf("\nSubnet to delete: %s/64\n", normalizedSubnet) - confirmed, err := promptConfirmation("Continue with IPv6 subnet deletion?") - if err != nil { - return err - } - if !confirmed { - fmt.Printf("Operation cancelled\n") - return nil - } - } - - fmt.Printf("Deleting IPv6 subnet '%s' from instance: %s\n", normalizedSubnet, resolvedName) - - if err := bwhClient.DeleteIPv6(ctx, normalizedSubnet); err != nil { - return fmt.Errorf("failed to delete IPv6 subnet: %w", err) - } - - fmt.Printf("✅ IPv6 subnet '%s' deleted successfully\n", normalizedSubnet) - - return nil + return runIPv6Delete(ctx, bwhClient, resolvedName, normalizedSubnet, cmd.Bool("dry-run"), skipConfirm(cmd), promptConfirmation) }, } @@ -266,6 +186,98 @@ func displayIPv6InfoCompact(info *client.ServiceInfo, instanceName string) { } } +type ipv6WriteAPI interface { + GetServiceInfo(context.Context) (*client.ServiceInfo, error) + AddIPv6(context.Context) (*client.IPv6AddResponse, error) + DeleteIPv6(context.Context, string) error +} + +func runIPv6Add(ctx context.Context, api ipv6WriteAPI, resolvedName string, dryRun, skipConfirm bool, confirm confirmationFunc) error { + serviceInfo, err := api.GetServiceInfo(ctx) + if err != nil { + return fmt.Errorf("failed to get service info: %w", err) + } + if !serviceInfo.LocationIPv6Ready { + return fmt.Errorf("IPv6 is not available at this location (%s)", serviceInfo.NodeLocation) + } + currentIPv6 := countIPv6Subnets(serviceInfo.IPAddresses) + if serviceInfo.PlanMaxIPv6s > 0 && currentIPv6 >= serviceInfo.PlanMaxIPv6s { + return fmt.Errorf("IPv6 subnet limit reached: %d/%d", currentIPv6, serviceInfo.PlanMaxIPv6s) + } + if dryRun { + printDryRun("ipv6/add", resolvedName, fmt.Sprintf("assigned IPv6 subnets: %d/%d", currentIPv6, serviceInfo.PlanMaxIPv6s)) + return nil + } + + if !skipConfirm { + fmt.Printf("Adding IPv6 /64 subnet to instance: %s\n", resolvedName) + fmt.Printf("\n💡 This will assign a new IPv6 /64 subnet to your VPS.\n") + fmt.Printf("⚠️ A full VM restart (stop + start) will be required after assignment\n") + fmt.Printf(" to automatically activate IPv6 networking inside the VM.\n") + } + confirmed, err := confirmWrite("Continue with IPv6 subnet assignment?", skipConfirm, confirm) + if err != nil { + return err + } + if !confirmed { + return nil + } + + fmt.Printf("Adding IPv6 subnet to instance: %s\n", resolvedName) + + resp, err := api.AddIPv6(ctx) + if err != nil { + return fmt.Errorf("failed to add IPv6 subnet: %w", err) + } + + fmt.Printf("✅ IPv6 subnet added successfully\n") + fmt.Printf("📋 ASSIGNED SUBNET\n") + fmt.Printf(" IPv6 Subnet : %s/64\n", resp.AssignedSubnet) + fmt.Printf("\n⚠️ IMPORTANT: VM restart required for automatic IPv6 activation\n") + fmt.Printf(" 1. Stop the VM: 'bwh stop' (status must show 'Stopped')\n") + fmt.Printf(" 2. Start the VM: 'bwh start'\n") + fmt.Printf(" This will automatically activate IPv6 networking inside the VM.\n") + + return nil +} + +func runIPv6Delete(ctx context.Context, api ipv6WriteAPI, resolvedName, normalizedSubnet string, dryRun, skipConfirm bool, confirm confirmationFunc) error { + serviceInfo, err := api.GetServiceInfo(ctx) + if err != nil { + return fmt.Errorf("failed to get service info: %w", err) + } + if !hasIPv6Subnet(serviceInfo.IPAddresses, normalizedSubnet) { + return fmt.Errorf("IPv6 subnet %s/64 is not assigned to instance %s", normalizedSubnet, resolvedName) + } + if dryRun { + printDryRun("ipv6/delete", resolvedName, fmt.Sprintf("subnet: %s/64", normalizedSubnet)) + return nil + } + + if !skipConfirm { + fmt.Printf("⚠️ WARNING: This will release the IPv6 subnet and it cannot be undone.\n") + fmt.Printf("The subnet will no longer be available to your VPS.\n") + fmt.Printf("\nSubnet to delete: %s/64\n", normalizedSubnet) + } + confirmed, err := confirmWrite("Continue with IPv6 subnet deletion?", skipConfirm, confirm) + if err != nil { + return err + } + if !confirmed { + return nil + } + + fmt.Printf("Deleting IPv6 subnet '%s' from instance: %s\n", normalizedSubnet, resolvedName) + + if err := api.DeleteIPv6(ctx, normalizedSubnet); err != nil { + return fmt.Errorf("failed to delete IPv6 subnet: %w", err) + } + + fmt.Printf("✅ IPv6 subnet '%s' deleted successfully\n", normalizedSubnet) + + return nil +} + func countIPv6Subnets(ips []string) int { count := 0 for _, ip := range ips { diff --git a/cmd/bwh/private_ip.go b/cmd/bwh/private_ip.go index 1ad1083..d2116dd 100644 --- a/cmd/bwh/private_ip.go +++ b/cmd/bwh/private_ip.go @@ -8,6 +8,7 @@ import ( "strconv" "strings" + "github.com/strahe/bwh/pkg/client" "github.com/urfave/cli/v3" ) @@ -111,9 +112,6 @@ var privateIPAssignCmd = &cli.Command{ var ip string if cmd.Args().Len() > 0 { ip = cmd.Args().First() - if parsed := net.ParseIP(ip); parsed == nil || parsed.To4() == nil { - return fmt.Errorf("invalid IPv4 address: %s", ip) - } } bwhClient, resolvedName, err := createBWHClient(cmd) @@ -121,65 +119,7 @@ var privateIPAssignCmd = &cli.Command{ return err } - serviceInfo, err := bwhClient.GetServiceInfo(ctx) - if err != nil { - return fmt.Errorf("failed to get service info: %w", err) - } - if !serviceInfo.PlanPrivateNetworkAvailable || !serviceInfo.LocationPrivateNetworkAvailable { - return fmt.Errorf("private IPv4 is not available for this plan or location") - } - if ip != "" && containsString(serviceInfo.PrivateIPAddresses, ip) { - fmt.Printf("✅ Private IPv4 address %s is already assigned (no change needed)\n", ip) - return nil - } - availableResp, err := bwhClient.GetAvailablePrivateIPs(ctx) - if err != nil { - return fmt.Errorf("failed to get available private IPs: %w", err) - } - if len(availableResp.AvailableIPs) == 0 { - return fmt.Errorf("no private IPv4 addresses are available") - } - if ip != "" && !containsString(availableResp.AvailableIPs, ip) { - return fmt.Errorf("private IPv4 address %s is not available for assignment", ip) - } - if cmd.Bool("dry-run") { - detail := fmt.Sprintf("available private IPs: %d", len(availableResp.AvailableIPs)) - if ip != "" { - detail = fmt.Sprintf("ip: %s", ip) - } - printDryRun("privateIp/assign", resolvedName, detail) - return nil - } - - if !cmd.Bool("yes") { - if ip == "" { - fmt.Printf("This will assign a random private IPv4 address to instance: %s\n", resolvedName) - } else { - fmt.Printf("This will assign private IPv4 address %s to instance: %s\n", ip, resolvedName) - } - confirmed, err := promptConfirmation("Proceed?") - if err != nil { - return err - } - if !confirmed { - fmt.Printf("Operation cancelled\n") - return nil - } - } - - resp, err := bwhClient.AssignPrivateIP(ctx, ip) - if err != nil { - return fmt.Errorf("failed to assign private IP: %w", err) - } - - fmt.Printf("✅ Private IP assigned successfully\n") - if len(resp.AssignedIPs) > 0 { - fmt.Printf("\n📋 ASSIGNED PRIVATE IPv4 ADDRESSES\n") - for i, assigned := range resp.AssignedIPs { - fmt.Printf(" %d. %s\n", i+1, assigned) - } - } - return nil + return runPrivateIPAssign(ctx, bwhClient, resolvedName, ip, cmd.Bool("dry-run"), skipConfirm(cmd), promptConfirmation) }, } @@ -193,48 +133,126 @@ var privateIPDeleteCmd = &cli.Command{ return fmt.Errorf("private IPv4 address is required") } ip := cmd.Args().First() - if parsed := net.ParseIP(ip); parsed == nil || parsed.To4() == nil { - return fmt.Errorf("invalid IPv4 address: %s", ip) - } bwhClient, resolvedName, err := createBWHClient(cmd) if err != nil { return err } - serviceInfo, err := bwhClient.GetServiceInfo(ctx) - if err != nil { - return fmt.Errorf("failed to get service info: %w", err) - } - if !containsString(serviceInfo.PrivateIPAddresses, ip) { - return fmt.Errorf("private IPv4 address %s is not assigned to instance %s", ip, resolvedName) + return runPrivateIPDelete(ctx, bwhClient, resolvedName, ip, cmd.Bool("dry-run"), skipConfirm(cmd), promptConfirmation) + }, +} + +type privateIPWriteAPI interface { + GetServiceInfo(context.Context) (*client.ServiceInfo, error) + GetAvailablePrivateIPs(context.Context) (*client.PrivateIPAvailableResponse, error) + AssignPrivateIP(context.Context, string) (*client.PrivateIPAssignResponse, error) + DeletePrivateIP(context.Context, string) error +} + +func runPrivateIPAssign(ctx context.Context, api privateIPWriteAPI, resolvedName, ip string, dryRun, skipConfirm bool, confirm confirmationFunc) error { + if ip != "" { + if parsed := net.ParseIP(ip); parsed == nil || parsed.To4() == nil { + return fmt.Errorf("invalid IPv4 address: %s", ip) } - if cmd.Bool("dry-run") { - printDryRun("privateIp/delete", resolvedName, fmt.Sprintf("ip: %s", ip)) - return nil + } + + serviceInfo, err := api.GetServiceInfo(ctx) + if err != nil { + return fmt.Errorf("failed to get service info: %w", err) + } + if !serviceInfo.PlanPrivateNetworkAvailable || !serviceInfo.LocationPrivateNetworkAvailable { + return fmt.Errorf("private IPv4 is not available for this plan or location") + } + if ip != "" && containsString(serviceInfo.PrivateIPAddresses, ip) { + fmt.Printf("✅ Private IPv4 address %s is already assigned (no change needed)\n", ip) + return nil + } + availableResp, err := api.GetAvailablePrivateIPs(ctx) + if err != nil { + return fmt.Errorf("failed to get available private IPs: %w", err) + } + if len(availableResp.AvailableIPs) == 0 { + return fmt.Errorf("no private IPv4 addresses are available") + } + if ip != "" && !containsString(availableResp.AvailableIPs, ip) { + return fmt.Errorf("private IPv4 address %s is not available for assignment", ip) + } + if dryRun { + detail := fmt.Sprintf("available private IPs: %d", len(availableResp.AvailableIPs)) + if ip != "" { + detail = fmt.Sprintf("ip: %s", ip) } + printDryRun("privateIp/assign", resolvedName, detail) + return nil + } - if !cmd.Bool("yes") { - fmt.Printf("⚠️ This will delete private IPv4 address %s from the instance.\n", ip) - confirmed, err := promptConfirmation("Proceed with deletion?") - if err != nil { - return err - } - if !confirmed { - fmt.Printf("Operation cancelled\n") - return nil - } + if !skipConfirm { + if ip == "" { + fmt.Printf("This will assign a random private IPv4 address to instance: %s\n", resolvedName) + } else { + fmt.Printf("This will assign private IPv4 address %s to instance: %s\n", ip, resolvedName) } + } + confirmed, err := confirmWrite("Proceed?", skipConfirm, confirm) + if err != nil { + return err + } + if !confirmed { + return nil + } - fmt.Printf("Deleting private IPv4 address '%s' from instance: %s\n", ip, resolvedName) + resp, err := api.AssignPrivateIP(ctx, ip) + if err != nil { + return fmt.Errorf("failed to assign private IP: %w", err) + } - if err := bwhClient.DeletePrivateIP(ctx, ip); err != nil { - return fmt.Errorf("failed to delete private IP: %w", err) + fmt.Printf("✅ Private IP assigned successfully\n") + if len(resp.AssignedIPs) > 0 { + fmt.Printf("\n📋 ASSIGNED PRIVATE IPv4 ADDRESSES\n") + for i, assigned := range resp.AssignedIPs { + fmt.Printf(" %d. %s\n", i+1, assigned) } + } + return nil +} + +func runPrivateIPDelete(ctx context.Context, api privateIPWriteAPI, resolvedName, ip string, dryRun, skipConfirm bool, confirm confirmationFunc) error { + if parsed := net.ParseIP(ip); parsed == nil || parsed.To4() == nil { + return fmt.Errorf("invalid IPv4 address: %s", ip) + } - fmt.Printf("✅ Private IPv4 address '%s' deleted successfully\n", ip) + serviceInfo, err := api.GetServiceInfo(ctx) + if err != nil { + return fmt.Errorf("failed to get service info: %w", err) + } + if !containsString(serviceInfo.PrivateIPAddresses, ip) { + return fmt.Errorf("private IPv4 address %s is not assigned to instance %s", ip, resolvedName) + } + if dryRun { + printDryRun("privateIp/delete", resolvedName, fmt.Sprintf("ip: %s", ip)) return nil - }, + } + + if !skipConfirm { + fmt.Printf("⚠️ This will delete private IPv4 address %s from the instance.\n", ip) + } + confirmed, err := confirmWrite("Proceed with deletion?", skipConfirm, confirm) + if err != nil { + return err + } + if !confirmed { + return nil + } + + fmt.Printf("Deleting private IPv4 address '%s' from instance: %s\n", ip, resolvedName) + + if err := api.DeletePrivateIP(ctx, ip); err != nil { + return fmt.Errorf("failed to delete private IP: %w", err) + } + + fmt.Printf("✅ Private IPv4 address '%s' deleted successfully\n", ip) + return nil } // aggregateIPv4Ranges groups contiguous IPv4 addresses into concise ranges. diff --git a/cmd/bwh/reset-password.go b/cmd/bwh/reset-password.go index 49a70fd..f3676f1 100644 --- a/cmd/bwh/reset-password.go +++ b/cmd/bwh/reset-password.go @@ -156,38 +156,35 @@ func preflightPasswordOutput(filePath string) (bool, error) { } type passwordOutputFile struct { - file *os.File - path string - created bool - closed bool + file *os.File + targetPath string + tempPath string + closed bool } func openPasswordOutputFile(filePath string, fileExists bool) (*passwordOutputFile, error) { - flags := os.O_WRONLY - created := false - if !fileExists { - flags |= os.O_CREATE | os.O_EXCL - created = true - } - - file, err := os.OpenFile(filePath, flags, 0o600) + dir := filepath.Dir(filePath) + base := filepath.Base(filePath) + file, err := os.CreateTemp(dir, "."+base+".tmp-") if err != nil { if fileExists { - return nil, fmt.Errorf("failed to open output file before resetting password: %w", err) + return nil, fmt.Errorf("failed to create temporary output file before resetting password: %w", err) } return nil, fmt.Errorf("failed to create output file before resetting password: %w", err) } + if err := file.Chmod(0o600); err != nil { + _ = file.Close() + _ = os.Remove(file.Name()) + if fileExists { + return nil, fmt.Errorf("failed to prepare temporary output file before resetting password: %w", err) + } + return nil, fmt.Errorf("failed to prepare output file before resetting password: %w", err) + } - return &passwordOutputFile{file: file, path: filePath, created: created}, nil + return &passwordOutputFile{file: file, targetPath: filePath, tempPath: file.Name()}, nil } func (o *passwordOutputFile) write(content string) error { - if err := o.file.Truncate(0); err != nil { - return err - } - if _, err := o.file.Seek(0, 0); err != nil { - return err - } if _, err := o.file.WriteString(content); err != nil { return err } @@ -196,6 +193,9 @@ func (o *passwordOutputFile) write(content string) error { return err } o.closed = true + if err := os.Rename(o.tempPath, o.targetPath); err != nil { + return err + } return nil } @@ -207,7 +207,5 @@ func (o *passwordOutputFile) abort() { _ = o.file.Close() o.closed = true } - if o.created { - _ = os.Remove(o.path) - } + _ = os.Remove(o.tempPath) } diff --git a/cmd/bwh/snapshot.go b/cmd/bwh/snapshot.go index 4e79c95..b57dc4f 100644 --- a/cmd/bwh/snapshot.go +++ b/cmd/bwh/snapshot.go @@ -57,39 +57,7 @@ var snapshotCreateCmd = &cli.Command{ description = fmt.Sprintf("Created via bwh CLI on %s", time.Now().Format("2006-01-02 15:04:05")) } - if cmd.Bool("dry-run") { - printDryRun("snapshot/create", resolvedName, fmt.Sprintf("description: %s", description)) - return nil - } - - if !cmd.Bool("yes") { - fmt.Printf("Creating snapshot for instance: %s\n", resolvedName) - fmt.Printf("Description: %s\n", description) - fmt.Printf("\n⚠️ WARNING: This operation will create a snapshot of the current VPS state.\n") - fmt.Printf("The VPS will be AUTOMATICALLY RESTARTED and temporarily locked during snapshot creation.\n") - fmt.Printf("All running processes will be terminated and services will be interrupted.\n") - confirmed, err := promptConfirmation("Continue with snapshot creation and VPS restart?") - if err != nil { - return err - } - if !confirmed { - fmt.Printf("Operation cancelled\n") - return nil - } - } - - fmt.Printf("Creating snapshot for instance: %s\n", resolvedName) - resp, err := bwhClient.CreateSnapshot(ctx, description) - if err != nil { - return fmt.Errorf("failed to create snapshot: %w", err) - } - - fmt.Printf("✅ Snapshot creation initiated\n") - if resp.NotificationEmail != "" { - fmt.Printf("📧 Notification will be sent to: %s\n", resp.NotificationEmail) - } - - return nil + return runSnapshotCreate(ctx, bwhClient, resolvedName, description, cmd.Bool("dry-run"), skipConfirm(cmd), promptConfirmation) }, } @@ -503,6 +471,45 @@ type snapshotWriteAPI interface { RestoreSnapshot(context.Context, string) error } +type snapshotCreateAPI interface { + CreateSnapshot(context.Context, string) (*client.CreateSnapshotResponse, error) +} + +func runSnapshotCreate(ctx context.Context, api snapshotCreateAPI, resolvedName, description string, dryRun, skipConfirm bool, confirm confirmationFunc) error { + if dryRun { + printDryRun("snapshot/create", resolvedName, fmt.Sprintf("description: %s", description)) + return nil + } + + if !skipConfirm { + fmt.Printf("Creating snapshot for instance: %s\n", resolvedName) + fmt.Printf("Description: %s\n", description) + fmt.Printf("\n⚠️ WARNING: This operation will create a snapshot of the current VPS state.\n") + fmt.Printf("The VPS will be AUTOMATICALLY RESTARTED and temporarily locked during snapshot creation.\n") + fmt.Printf("All running processes will be terminated and services will be interrupted.\n") + } + confirmed, err := confirmWrite("Continue with snapshot creation and VPS restart?", skipConfirm, confirm) + if err != nil { + return err + } + if !confirmed { + return nil + } + + fmt.Printf("Creating snapshot for instance: %s\n", resolvedName) + resp, err := api.CreateSnapshot(ctx, description) + if err != nil { + return fmt.Errorf("failed to create snapshot: %w", err) + } + + fmt.Printf("✅ Snapshot creation initiated\n") + if resp.NotificationEmail != "" { + fmt.Printf("📧 Notification will be sent to: %s\n", resp.NotificationEmail) + } + + return nil +} + func findSnapshotByName(snapshots []client.SnapshotInfo, fileName string) (*client.SnapshotInfo, bool) { for i := range snapshots { if snapshots[i].FileName == fileName { @@ -736,8 +743,16 @@ func toggleSnapshotSticky(ctx context.Context, cmd *cli.Command, identifier stri return err } - // Get snapshots to resolve identifier - snapshotsResp, err := bwhClient.ListSnapshots(ctx) + return runToggleSnapshotSticky(ctx, bwhClient, resolvedName, identifier, sticky, cmd.Bool("dry-run"), skipConfirm(cmd), promptConfirmation) +} + +type snapshotStickyAPI interface { + ListSnapshots(context.Context) (*client.SnapshotListResponse, error) + ToggleSnapshotSticky(context.Context, string, bool) error +} + +func runToggleSnapshotSticky(ctx context.Context, api snapshotStickyAPI, resolvedName, identifier string, sticky, dryRun, skipConfirm bool, confirm confirmationFunc) error { + snapshotsResp, err := api.ListSnapshots(ctx) if err != nil { return fmt.Errorf("failed to list snapshots: %w", err) } @@ -805,25 +820,24 @@ func toggleSnapshotSticky(ctx context.Context, cmd *cli.Command, identifier stri return nil } - if cmd.Bool("dry-run") { + if dryRun { printDryRun("snapshot/toggleSticky", resolvedName, fmt.Sprintf("snapshot: %s", fileName), fmt.Sprintf("sticky: %v", sticky)) return nil } - if !cmd.Bool("yes") { + if !skipConfirm { fmt.Printf("\n⚠️ Are you sure you want to %s this snapshot?\n", action) fmt.Printf("After this change, the snapshot %s.\n", newState) - confirmed, err := promptConfirmation("Continue?") - if err != nil { - return err - } - if !confirmed { - fmt.Printf("Operation cancelled\n") - return nil - } + } + confirmed, err := confirmWrite("Continue?", skipConfirm, confirm) + if err != nil { + return err + } + if !confirmed { + return nil } - if err := bwhClient.ToggleSnapshotSticky(ctx, fileName, sticky); err != nil { + if err := api.ToggleSnapshotSticky(ctx, fileName, sticky); err != nil { return fmt.Errorf("failed to %s snapshot: %w", action, err) } diff --git a/cmd/bwh/write_safety_test.go b/cmd/bwh/write_safety_test.go index ac9d97b..9566d1e 100644 --- a/cmd/bwh/write_safety_test.go +++ b/cmd/bwh/write_safety_test.go @@ -5,6 +5,7 @@ import ( "errors" "fmt" "os" + "path/filepath" "strings" "testing" @@ -220,6 +221,149 @@ func TestRunBackupCopyToSnapshotMasksMissingTokenError(t *testing.T) { } } +type fakeIPv6API struct { + service *client.ServiceInfo + added int + deleted []string +} + +func (f *fakeIPv6API) GetServiceInfo(context.Context) (*client.ServiceInfo, error) { + return f.service, nil +} + +func (f *fakeIPv6API) AddIPv6(context.Context) (*client.IPv6AddResponse, error) { + f.added++ + return &client.IPv6AddResponse{AssignedSubnet: "2001:db8:abcd::"}, nil +} + +func (f *fakeIPv6API) DeleteIPv6(_ context.Context, subnet string) error { + f.deleted = append(f.deleted, subnet) + return nil +} + +func TestRunIPv6Safety(t *testing.T) { + api := &fakeIPv6API{service: &client.ServiceInfo{ + LocationIPv6Ready: true, + PlanMaxIPv6s: 2, + IPAddresses: []string{"2001:db8:abcd::/64"}, + }} + + out := captureStdout(t, func() { + if err := runIPv6Add(context.Background(), api, "test", true, false, confirmNo); err != nil { + t.Fatalf("runIPv6Add() error = %v", err) + } + }) + if api.added != 0 { + t.Fatalf("added = %d, want 0", api.added) + } + if !strings.Contains(out, "DRY RUN") { + t.Fatalf("output missing DRY RUN:\n%s", out) + } + + out = captureStdout(t, func() { + if err := runIPv6Delete(context.Background(), api, "test", "2001:db8:abcd::", true, false, confirmNo); err != nil { + t.Fatalf("runIPv6Delete() error = %v", err) + } + }) + if len(api.deleted) != 0 { + t.Fatalf("deleted = %v, want none", api.deleted) + } + if !strings.Contains(out, "DRY RUN") { + t.Fatalf("output missing DRY RUN:\n%s", out) + } + + if err := runIPv6Add(context.Background(), api, "test", false, false, confirmNo); err != nil { + t.Fatalf("runIPv6Add() error = %v", err) + } + if api.added != 0 { + t.Fatalf("added = %d, want 0 after cancel", api.added) + } + + if err := runIPv6Add(context.Background(), api, "test", false, true, confirmNo); err != nil { + t.Fatalf("runIPv6Add() error = %v", err) + } + if api.added != 1 { + t.Fatalf("added = %d, want 1", api.added) + } +} + +type fakePrivateIPAPI struct { + service *client.ServiceInfo + available *client.PrivateIPAvailableResponse + assigned []string + deleted []string +} + +func (f *fakePrivateIPAPI) GetServiceInfo(context.Context) (*client.ServiceInfo, error) { + return f.service, nil +} + +func (f *fakePrivateIPAPI) GetAvailablePrivateIPs(context.Context) (*client.PrivateIPAvailableResponse, error) { + return f.available, nil +} + +func (f *fakePrivateIPAPI) AssignPrivateIP(_ context.Context, ip string) (*client.PrivateIPAssignResponse, error) { + f.assigned = append(f.assigned, ip) + if ip == "" { + ip = "10.0.0.10" + } + return &client.PrivateIPAssignResponse{AssignedIPs: []string{ip}}, nil +} + +func (f *fakePrivateIPAPI) DeletePrivateIP(_ context.Context, ip string) error { + f.deleted = append(f.deleted, ip) + return nil +} + +func TestRunPrivateIPSafety(t *testing.T) { + api := &fakePrivateIPAPI{ + service: &client.ServiceInfo{ + PlanPrivateNetworkAvailable: true, + LocationPrivateNetworkAvailable: true, + PrivateIPAddresses: []string{"10.0.0.20"}, + }, + available: &client.PrivateIPAvailableResponse{AvailableIPs: []string{"10.0.0.10"}}, + } + + out := captureStdout(t, func() { + if err := runPrivateIPAssign(context.Background(), api, "test", "10.0.0.10", true, false, confirmNo); err != nil { + t.Fatalf("runPrivateIPAssign() error = %v", err) + } + }) + if len(api.assigned) != 0 { + t.Fatalf("assigned = %v, want none", api.assigned) + } + if !strings.Contains(out, "DRY RUN") { + t.Fatalf("output missing DRY RUN:\n%s", out) + } + + out = captureStdout(t, func() { + if err := runPrivateIPDelete(context.Background(), api, "test", "10.0.0.20", true, false, confirmNo); err != nil { + t.Fatalf("runPrivateIPDelete() error = %v", err) + } + }) + if len(api.deleted) != 0 { + t.Fatalf("deleted = %v, want none", api.deleted) + } + if !strings.Contains(out, "DRY RUN") { + t.Fatalf("output missing DRY RUN:\n%s", out) + } + + if err := runPrivateIPAssign(context.Background(), api, "test", "10.0.0.10", false, false, confirmNo); err != nil { + t.Fatalf("runPrivateIPAssign() error = %v", err) + } + if len(api.assigned) != 0 { + t.Fatalf("assigned = %v, want none after cancel", api.assigned) + } + + if err := runPrivateIPAssign(context.Background(), api, "test", "10.0.0.10", false, true, confirmNo); err != nil { + t.Fatalf("runPrivateIPAssign() error = %v", err) + } + if len(api.assigned) != 1 || api.assigned[0] != "10.0.0.10" { + t.Fatalf("assigned = %v, want [10.0.0.10]", api.assigned) + } +} + type fakeResetPasswordAPI struct { calls int beforeCall func() error @@ -272,7 +416,14 @@ func TestRunResetPasswordPreparesOutputBeforeAPI(t *testing.T) { path := dir + "/password.txt" api := &fakeResetPasswordAPI{ beforeCall: func() error { - info, err := os.Stat(path) + matches, err := filepath.Glob(filepath.Join(dir, ".password.txt.tmp-*")) + if err != nil { + return err + } + if len(matches) != 1 { + return fmt.Errorf("temporary output files = %v, want one", matches) + } + info, err := os.Stat(matches[0]) if err != nil { return err } @@ -315,6 +466,35 @@ func TestRunResetPasswordRemovesNewOutputOnAPIError(t *testing.T) { } } +func TestPasswordOutputPreservesExistingFileOnWriteError(t *testing.T) { + dir := t.TempDir() + path := dir + "/password.txt" + if err := os.WriteFile(path, []byte("old-password"), 0o600); err != nil { + t.Fatalf("failed to seed output file: %v", err) + } + + output, err := openPasswordOutputFile(path, true) + if err != nil { + t.Fatalf("openPasswordOutputFile() error = %v", err) + } + if err := output.file.Close(); err != nil { + t.Fatalf("failed to close temp output file: %v", err) + } + + if err := output.write("new-password"); err == nil { + t.Fatal("passwordOutputFile.write() error = nil, want error") + } + output.abort() + + content, err := os.ReadFile(path) + if err != nil { + t.Fatalf("failed to read output file: %v", err) + } + if string(content) != "old-password" { + t.Fatalf("output file content = %q, want old password preserved", content) + } +} + func TestSameStringSlicesIgnoresOrderAndPreservesCount(t *testing.T) { if !sameStringSlices([]string{" key-b ", "key-a"}, []string{"key-a", "key-b"}) { t.Fatal("sameStringSlices() should ignore order and surrounding whitespace") @@ -373,8 +553,15 @@ func TestRunReinstallSafety(t *testing.T) { type fakeSnapshotAPI struct { snapshots []client.SnapshotInfo + created []string deleted []string restored []string + sticky []string +} + +func (f *fakeSnapshotAPI) CreateSnapshot(_ context.Context, description string) (*client.CreateSnapshotResponse, error) { + f.created = append(f.created, description) + return &client.CreateSnapshotResponse{}, nil } func (f *fakeSnapshotAPI) ListSnapshots(context.Context) (*client.SnapshotListResponse, error) { @@ -391,6 +578,53 @@ func (f *fakeSnapshotAPI) RestoreSnapshot(_ context.Context, fileName string) er return nil } +func (f *fakeSnapshotAPI) ToggleSnapshotSticky(_ context.Context, fileName string, sticky bool) error { + f.sticky = append(f.sticky, fmt.Sprintf("%s=%v", fileName, sticky)) + return nil +} + +func TestRunSnapshotCreateAndStickySafety(t *testing.T) { + api := &fakeSnapshotAPI{snapshots: []client.SnapshotInfo{{FileName: "snap.tar.gz", OS: "debian", Sticky: false}}} + + out := captureStdout(t, func() { + if err := runSnapshotCreate(context.Background(), api, "test", "desc", true, false, confirmNo); err != nil { + t.Fatalf("runSnapshotCreate() error = %v", err) + } + }) + if len(api.created) != 0 { + t.Fatalf("created = %v, want none", api.created) + } + if !strings.Contains(out, "DRY RUN") { + t.Fatalf("output missing DRY RUN:\n%s", out) + } + + if err := runSnapshotCreate(context.Background(), api, "test", "desc", false, false, confirmNo); err != nil { + t.Fatalf("runSnapshotCreate() error = %v", err) + } + if len(api.created) != 0 { + t.Fatalf("created = %v, want none after cancel", api.created) + } + + out = captureStdout(t, func() { + if err := runToggleSnapshotSticky(context.Background(), api, "test", "snap.tar.gz", true, true, false, confirmNo); err != nil { + t.Fatalf("runToggleSnapshotSticky() error = %v", err) + } + }) + if len(api.sticky) != 0 { + t.Fatalf("sticky = %v, want none", api.sticky) + } + if !strings.Contains(out, "DRY RUN") { + t.Fatalf("output missing DRY RUN:\n%s", out) + } + + if err := runToggleSnapshotSticky(context.Background(), api, "test", "snap.tar.gz", true, false, true, confirmNo); err != nil { + t.Fatalf("runToggleSnapshotSticky() error = %v", err) + } + if len(api.sticky) != 1 || api.sticky[0] != "snap.tar.gz=true" { + t.Fatalf("sticky = %v, want [snap.tar.gz=true]", api.sticky) + } +} + func TestRunSnapshotDeleteAndRestoreSafety(t *testing.T) { api := &fakeSnapshotAPI{snapshots: []client.SnapshotInfo{{FileName: "snap.tar.gz", OS: "debian"}}} out := captureStdout(t, func() { From a65d19e5acc746c571d520b1c06250b293b37ccb Mon Sep 17 00:00:00 2001 From: Lee <7932644+strahe@users.noreply.github.com> Date: Sat, 27 Jun 2026 12:20:27 +0800 Subject: [PATCH 5/6] fix(cli): preserve reset password recovery file --- cmd/bwh/control.go | 3 ++- cmd/bwh/iso.go | 3 ++- cmd/bwh/migrate.go | 3 ++- cmd/bwh/private_ip.go | 7 +++--- cmd/bwh/reset-password.go | 23 ++++++++++++++++++-- cmd/bwh/write_helpers.go | 9 -------- cmd/bwh/write_safety_test.go | 42 ++++++++++++++++++++++++++++++++++++ 7 files changed, 73 insertions(+), 17 deletions(-) diff --git a/cmd/bwh/control.go b/cmd/bwh/control.go index 15589ac..6538b9f 100644 --- a/cmd/bwh/control.go +++ b/cmd/bwh/control.go @@ -3,6 +3,7 @@ package main import ( "context" "fmt" + "slices" "strings" "github.com/strahe/bwh/pkg/client" @@ -236,7 +237,7 @@ func runSetPTR(ctx context.Context, api ptrAPI, resolvedName, ip, ptr string, dr if !info.RDNSAPIAvailable { return fmt.Errorf("rDNS API is not available for instance %s", resolvedName) } - if !containsString(info.IPAddresses, ip) { + if !slices.Contains(info.IPAddresses, ip) { return fmt.Errorf("IP address %s is not assigned to instance %s", ip, resolvedName) } currentPTR := "" diff --git a/cmd/bwh/iso.go b/cmd/bwh/iso.go index 15ee99e..ba3af0f 100644 --- a/cmd/bwh/iso.go +++ b/cmd/bwh/iso.go @@ -3,6 +3,7 @@ package main import ( "context" "fmt" + "slices" "github.com/strahe/bwh/pkg/client" "github.com/urfave/cli/v3" @@ -109,7 +110,7 @@ func runMountISO(ctx context.Context, api isoAPI, resolvedName, iso string, dryR if err != nil { return fmt.Errorf("failed to get service info: %w", err) } - if !containsString(info.AvailableISOs, iso) { + if !slices.Contains(info.AvailableISOs, iso) { return fmt.Errorf("ISO image %q is not available for instance %s", iso, resolvedName) } if info.ISO1 == iso { diff --git a/cmd/bwh/migrate.go b/cmd/bwh/migrate.go index 400aef3..cc70808 100644 --- a/cmd/bwh/migrate.go +++ b/cmd/bwh/migrate.go @@ -3,6 +3,7 @@ package main import ( "context" "fmt" + "slices" "sort" "strings" "time" @@ -117,7 +118,7 @@ var migrateStartCmd = &cli.Command{ fmt.Printf("✅ Instance is already in migration location '%s' (no change needed)\n", locationID) return nil } - if !containsString(locations.Locations, locationID) { + if !slices.Contains(locations.Locations, locationID) { return fmt.Errorf("migration location %q is not available for instance %s", locationID, resolvedName) } diff --git a/cmd/bwh/private_ip.go b/cmd/bwh/private_ip.go index d2116dd..f552b4e 100644 --- a/cmd/bwh/private_ip.go +++ b/cmd/bwh/private_ip.go @@ -4,6 +4,7 @@ import ( "context" "fmt" "net" + "slices" "sort" "strconv" "strings" @@ -164,7 +165,7 @@ func runPrivateIPAssign(ctx context.Context, api privateIPWriteAPI, resolvedName if !serviceInfo.PlanPrivateNetworkAvailable || !serviceInfo.LocationPrivateNetworkAvailable { return fmt.Errorf("private IPv4 is not available for this plan or location") } - if ip != "" && containsString(serviceInfo.PrivateIPAddresses, ip) { + if ip != "" && slices.Contains(serviceInfo.PrivateIPAddresses, ip) { fmt.Printf("✅ Private IPv4 address %s is already assigned (no change needed)\n", ip) return nil } @@ -175,7 +176,7 @@ func runPrivateIPAssign(ctx context.Context, api privateIPWriteAPI, resolvedName if len(availableResp.AvailableIPs) == 0 { return fmt.Errorf("no private IPv4 addresses are available") } - if ip != "" && !containsString(availableResp.AvailableIPs, ip) { + if ip != "" && !slices.Contains(availableResp.AvailableIPs, ip) { return fmt.Errorf("private IPv4 address %s is not available for assignment", ip) } if dryRun { @@ -226,7 +227,7 @@ func runPrivateIPDelete(ctx context.Context, api privateIPWriteAPI, resolvedName if err != nil { return fmt.Errorf("failed to get service info: %w", err) } - if !containsString(serviceInfo.PrivateIPAddresses, ip) { + if !slices.Contains(serviceInfo.PrivateIPAddresses, ip) { return fmt.Errorf("private IPv4 address %s is not assigned to instance %s", ip, resolvedName) } if dryRun { diff --git a/cmd/bwh/reset-password.go b/cmd/bwh/reset-password.go index f3676f1..ada1b2f 100644 --- a/cmd/bwh/reset-password.go +++ b/cmd/bwh/reset-password.go @@ -109,15 +109,17 @@ func runResetPassword(ctx context.Context, api resetPasswordAPI, resolvedName, o if err != nil { return fmt.Errorf("failed to reset root password: %w", err) } + keepOutput = true passwordContent := fmt.Sprintf("Root Password for BWH Instance: %s\n", resolvedName) passwordContent += fmt.Sprintf("Generated at: %s\n", time.Now().Format("2006-01-02 15:04:05 MST")) passwordContent += fmt.Sprintf("Password: %s\n", result.Password) if err := output.write(passwordContent); err != nil { - return fmt.Errorf("failed to write password to file: %w", err) + output.preserve() + printPasswordOutputFailure(absPath, output.tempPath, err) + return fmt.Errorf("root password was reset, but failed to save password to file: %w", err) } - keepOutput = true fmt.Printf("\n✅ Root password reset successfully!\n") fmt.Printf("🔑 Password saved to: %s\n", absPath) @@ -188,6 +190,9 @@ func (o *passwordOutputFile) write(content string) error { if _, err := o.file.WriteString(content); err != nil { return err } + if err := o.file.Sync(); err != nil { + return err + } if err := o.file.Close(); err != nil { o.closed = true return err @@ -199,6 +204,14 @@ func (o *passwordOutputFile) write(content string) error { return nil } +func (o *passwordOutputFile) preserve() { + if o == nil || o.closed { + return + } + _ = o.file.Close() + o.closed = true +} + func (o *passwordOutputFile) abort() { if o == nil { return @@ -209,3 +222,9 @@ func (o *passwordOutputFile) abort() { } _ = os.Remove(o.tempPath) } + +func printPasswordOutputFailure(targetPath, tempPath string, err error) { + fmt.Printf("\n⚠️ Root password was reset, but saving it to '%s' failed: %v\n", targetPath, err) + fmt.Printf("⚠️ Temporary password file preserved at: %s\n", tempPath) + fmt.Printf("⚠️ Treat this file as sensitive and move it to a safe location immediately.\n") +} diff --git a/cmd/bwh/write_helpers.go b/cmd/bwh/write_helpers.go index d49e99e..818a5cd 100644 --- a/cmd/bwh/write_helpers.go +++ b/cmd/bwh/write_helpers.go @@ -135,15 +135,6 @@ func sameStringSlices(a, b []string) bool { return slices.Equal(normalizedA, normalizedB) } -func containsString(values []string, target string) bool { - for _, value := range values { - if value == target { - return true - } - } - return false -} - func trimIPv6Subnet(subnet string) string { return strings.TrimSuffix(strings.TrimSpace(subnet), "/64") } diff --git a/cmd/bwh/write_safety_test.go b/cmd/bwh/write_safety_test.go index 9566d1e..6cba1c6 100644 --- a/cmd/bwh/write_safety_test.go +++ b/cmd/bwh/write_safety_test.go @@ -466,6 +466,48 @@ func TestRunResetPasswordRemovesNewOutputOnAPIError(t *testing.T) { } } +func TestRunResetPasswordPreservesRecoveryFileAfterAPISuccess(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "password.txt") + api := &fakeResetPasswordAPI{ + beforeCall: func() error { + return os.Mkdir(path, 0o700) + }, + } + + var err error + out := captureStdout(t, func() { + err = runResetPassword(context.Background(), api, "test", path, false, true, confirmNo) + }) + if err == nil { + t.Fatal("runResetPassword() error = nil, want save error after API success") + } + if !strings.Contains(err.Error(), "root password was reset") { + t.Fatalf("error does not make remote side effect clear: %v", err) + } + if api.calls != 1 { + t.Fatalf("calls = %d, want 1", api.calls) + } + if !strings.Contains(out, "Root password was reset") || !strings.Contains(out, "Temporary password file preserved") { + t.Fatalf("output missing recovery warning:\n%s", out) + } + + matches, globErr := filepath.Glob(filepath.Join(dir, ".password.txt.tmp-*")) + if globErr != nil { + t.Fatalf("failed to glob temporary output files: %v", globErr) + } + if len(matches) != 1 { + t.Fatalf("temporary output files = %v, want one preserved file", matches) + } + content, readErr := os.ReadFile(matches[0]) + if readErr != nil { + t.Fatalf("failed to read preserved temporary output file: %v", readErr) + } + if !strings.Contains(string(content), "Password: secret") { + t.Fatalf("preserved temporary output missing password:\n%s", content) + } +} + func TestPasswordOutputPreservesExistingFileOnWriteError(t *testing.T) { dir := t.TempDir() path := dir + "/password.txt" From 19790883d9fd42f2c49408dcdb76a7fcef4f7bec Mon Sep 17 00:00:00 2001 From: Lee <7932644+strahe@users.noreply.github.com> Date: Sat, 27 Jun 2026 13:45:26 +0800 Subject: [PATCH 6/6] fix: harden write command safety flows --- cmd/bwh/abuse.go | 54 +++---- cmd/bwh/migrate.go | 269 ++++++++++++++++---------------- cmd/bwh/node.go | 5 +- cmd/bwh/notifications.go | 34 ++-- cmd/bwh/reinstall.go | 23 +-- cmd/bwh/reset-password.go | 42 ++++- cmd/bwh/snapshot.go | 122 +++++++++------ cmd/bwh/write_helpers.go | 4 + cmd/bwh/write_safety_test.go | 293 ++++++++++++++++++++++++++++++++++- 9 files changed, 572 insertions(+), 274 deletions(-) diff --git a/cmd/bwh/abuse.go b/cmd/bwh/abuse.go index bd01658..ab9f4f4 100644 --- a/cmd/bwh/abuse.go +++ b/cmd/bwh/abuse.go @@ -22,18 +22,6 @@ var abuseCmd = &cli.Command{ }, } -var abuseWriteFlags = []cli.Flag{ - &cli.BoolFlag{ - Name: "yes", - Aliases: []string{"y"}, - Usage: "skip confirmation prompt", - }, - &cli.BoolFlag{ - Name: "dry-run", - Usage: "validate and show the write action without calling the write API", - }, -} - var abuseSuspensionsCmd = &cli.Command{ Name: "suspensions", Usage: "show service suspension details", @@ -78,7 +66,7 @@ var abuseUnsuspendCmd = &cli.Command{ Name: "unsuspend", Usage: "clear a soft abuse issue and unsuspend the VPS", ArgsUsage: "", - Flags: abuseWriteFlags, + Flags: writeFlags(), Action: func(ctx context.Context, cmd *cli.Command) error { if cmd.Args().Len() != 1 { return fmt.Errorf("record_id is required") @@ -93,7 +81,7 @@ var abuseUnsuspendCmd = &cli.Command{ return err } - return runAbuseUnsuspend(ctx, bwhClient, resolvedName, recordID, cmd.Bool("dry-run"), cmd.Bool("yes"), promptConfirmation) + return runAbuseUnsuspend(ctx, bwhClient, resolvedName, recordID, cmd.Bool("dry-run"), skipConfirm(cmd), promptConfirmation) }, } @@ -101,7 +89,7 @@ var abuseResolvePolicyCmd = &cli.Command{ Name: "resolve-policy", Usage: "mark a soft policy violation as resolved", ArgsUsage: "", - Flags: abuseWriteFlags, + Flags: writeFlags(), Action: func(ctx context.Context, cmd *cli.Command) error { if cmd.Args().Len() != 1 { return fmt.Errorf("record_id is required") @@ -116,7 +104,7 @@ var abuseResolvePolicyCmd = &cli.Command{ return err } - return runAbuseResolvePolicy(ctx, bwhClient, resolvedName, recordID, cmd.Bool("dry-run"), cmd.Bool("yes"), promptConfirmation) + return runAbuseResolvePolicy(ctx, bwhClient, resolvedName, recordID, cmd.Bool("dry-run"), skipConfirm(cmd), promptConfirmation) }, } @@ -224,18 +212,15 @@ func runAbuseUnsuspend(ctx context.Context, api abuseAPI, resolvedName string, r return fmt.Errorf("suspension case #%d cannot be resolved through API; contact support", recordID) } if dryRun { - fmt.Printf("DRY RUN: would call unsuspend for case #%d on instance %s\n", recordID, resolvedName) + printDryRun("unsuspend", resolvedName, fmt.Sprintf("case: #%d", recordID)) return nil } - if !skipConfirm { - confirmed, err := confirm(fmt.Sprintf("Unsuspend VPS by clearing case #%d?", recordID)) - if err != nil { - return err - } - if !confirmed { - fmt.Printf("Operation cancelled\n") - return nil - } + confirmed, err := confirmWrite(fmt.Sprintf("Unsuspend VPS by clearing case #%d?", recordID), skipConfirm, confirm) + if err != nil { + return err + } + if !confirmed { + return nil } if err := api.Unsuspend(ctx, recordID); err != nil { @@ -261,18 +246,15 @@ func runAbuseResolvePolicy(ctx context.Context, api abuseAPI, resolvedName strin return fmt.Errorf("policy violation case #%d cannot be resolved through API; contact support", recordID) } if dryRun { - fmt.Printf("DRY RUN: would call resolvePolicyViolation for case #%d on instance %s\n", recordID, resolvedName) + printDryRun("resolvePolicyViolation", resolvedName, fmt.Sprintf("case: #%d", recordID)) return nil } - if !skipConfirm { - confirmed, err := confirm(fmt.Sprintf("Mark policy violation case #%d as resolved?", recordID)) - if err != nil { - return err - } - if !confirmed { - fmt.Printf("Operation cancelled\n") - return nil - } + confirmed, err := confirmWrite(fmt.Sprintf("Mark policy violation case #%d as resolved?", recordID), skipConfirm, confirm) + if err != nil { + return err + } + if !confirmed { + return nil } if err := api.ResolvePolicyViolation(ctx, recordID); err != nil { diff --git a/cmd/bwh/migrate.go b/cmd/bwh/migrate.go index cc70808..97ba85e 100644 --- a/cmd/bwh/migrate.go +++ b/cmd/bwh/migrate.go @@ -72,13 +72,7 @@ var migrateStartCmd = &cli.Command{ Name: "start", Usage: "start VPS migration to new location (IPv4 will be replaced)", ArgsUsage: "", - Flags: []cli.Flag{ - &cli.BoolFlag{ - Name: "yes", - Aliases: []string{"y"}, - Usage: "skip confirmation prompt", - }, - dryRunFlag(), + Flags: writeFlags( &cli.StringFlag{ Name: "timeout", Usage: "request timeout (e.g. 10m, 30m). Default: 15m", @@ -88,7 +82,7 @@ var migrateStartCmd = &cli.Command{ Name: "wait", Usage: "wait until VE unlocks and show live progress", }, - }, + ), Action: func(ctx context.Context, cmd *cli.Command) error { if cmd.Args().Len() != 1 { return fmt.Errorf("migrate start requires exactly one argument: ") @@ -110,151 +104,152 @@ var migrateStartCmd = &cli.Command{ return fmt.Errorf("invalid timeout: %s", timeoutStr) } - locations, err := bwhClient.GetMigrateLocations(ctx) - if err != nil { - return fmt.Errorf("failed to get migration locations: %w", err) - } - if locations.CurrentLocation == locationID { - fmt.Printf("✅ Instance is already in migration location '%s' (no change needed)\n", locationID) - return nil - } - if !slices.Contains(locations.Locations, locationID) { - return fmt.Errorf("migration location %q is not available for instance %s", locationID, resolvedName) - } + return runMigrateStart(ctx, bwhClient, resolvedName, locationID, d, cmd.Bool("wait"), cmd.Bool("dry-run"), skipConfirm(cmd), promptConfirmation) + }, +} - if cmd.Bool("dry-run") { - desc := locations.Descriptions[locationID] - printDryRun("migrate/start", resolvedName, fmt.Sprintf("location: %s -> %s", locations.CurrentLocation, locationID), fmt.Sprintf("description: %s", desc), fmt.Sprintf("timeout: %s", d)) - return nil - } +type migrationStartAPI interface { + GetMigrateLocations(context.Context) (*client.MigrateLocationsResponse, error) + StartMigrationWithTimeout(context.Context, string, time.Duration) (*client.MigrateStartResponse, error) +} - if !cmd.Bool("yes") { - fmt.Printf("⚠️ Starting migration will REPLACE all IPv4 addresses of VPS '%s'.\n", resolvedName) - fmt.Printf("⚠️ Downtime is expected during migration.\n") - confirmed, err := promptConfirmation("Continue with migration?") - if err != nil { - return err - } - if !confirmed { - printOperationCancelled() - return nil - } - } +func runMigrateStart(ctx context.Context, api migrationStartAPI, resolvedName, locationID string, timeout time.Duration, wait, dryRun, skipConfirm bool, confirm confirmationFunc) error { + locations, err := api.GetMigrateLocations(ctx) + if err != nil { + return fmt.Errorf("failed to get migration locations: %w", err) + } + if locations.CurrentLocation == locationID { + fmt.Printf("✅ Instance is already in migration location '%s' (no change needed)\n", locationID) + return nil + } + if !slices.Contains(locations.Locations, locationID) { + return fmt.Errorf("migration location %q is not available for instance %s", locationID, resolvedName) + } - fmt.Printf("Starting migration to '%s' for instance: %s (timeout: %s)\n", locationID, resolvedName, d) + if dryRun { + desc := locations.Descriptions[locationID] + printDryRun("migrate/start", resolvedName, fmt.Sprintf("location: %s -> %s", locations.CurrentLocation, locationID), fmt.Sprintf("description: %s", desc), fmt.Sprintf("timeout: %s", timeout)) + return nil + } - wait := cmd.Bool("wait") - if !wait { - // Immediate return after API acceptance - resp, err := bwhClient.StartMigrationWithTimeout(ctx, locationID, d) - if err != nil { - return fmt.Errorf("failed to start migration: %w", err) - } - fmt.Printf("\n✅ Migration task accepted\n") - if resp.NotificationEmail != "" { - fmt.Printf("Notification will be sent to: %s\n", resp.NotificationEmail) - } - if len(resp.NewIPs) > 0 { - ipv4, ipv6 := splitIPsByFamily(resp.NewIPs) - fmt.Printf("New IP addresses (after completion):\n") - if len(ipv4) > 0 { - fmt.Printf(" IPv4:\n") - for _, ip := range ipv4 { - fmt.Printf(" • %s\n", ip) - } - } - if len(ipv6) > 0 { - fmt.Printf(" IPv6:\n") - for _, ip := range ipv6 { - fmt.Printf(" • %s\n", ip) - } - } - } - return nil + if !skipConfirm { + fmt.Printf("⚠️ Starting migration will REPLACE all IPv4 addresses of VPS '%s'.\n", resolvedName) + fmt.Printf("⚠️ Downtime is expected during migration.\n") + } + confirmed, err := confirmWrite("Continue with migration?", skipConfirm, confirm) + if err != nil { + return err + } + if !confirmed { + return nil + } + + fmt.Printf("Starting migration to '%s' for instance: %s (timeout: %s)\n", locationID, resolvedName, timeout) + if !wait { + resp, err := api.StartMigrationWithTimeout(ctx, locationID, timeout) + if err != nil { + return fmt.Errorf("failed to start migration: %w", err) } + printMigrationAccepted(resp, true) + return nil + } - // Wait mode: accept then poll until unlock - migCtx, cancel := context.WithTimeout(ctx, d) - defer cancel() + return runMigrateStartWait(ctx, api, locationID, timeout) +} - resultCh := make(chan *client.MigrateStartResponse, 1) - errCh := make(chan error, 1) +func printMigrationAccepted(resp *client.MigrateStartResponse, includeIPs bool) { + fmt.Printf("\n✅ Migration task accepted\n") + if resp.NotificationEmail != "" { + fmt.Printf("Notification will be sent to: %s\n", resp.NotificationEmail) + } + if includeIPs { + printMigrationNewIPs(resp) + } +} - go func() { - resp, err := bwhClient.StartMigrationWithTimeout(migCtx, locationID, d) - if err != nil { - errCh <- err - return - } - resultCh <- resp - }() +func printMigrationNewIPs(resp *client.MigrateStartResponse) { + if resp == nil || len(resp.NewIPs) == 0 { + return + } + ipv4, ipv6 := splitIPsByFamily(resp.NewIPs) + fmt.Printf("New IP addresses (after completion):\n") + if len(ipv4) > 0 { + fmt.Printf(" IPv4:\n") + for _, ip := range ipv4 { + fmt.Printf(" • %s\n", ip) + } + } + if len(ipv6) > 0 { + fmt.Printf(" IPv6:\n") + for _, ip := range ipv6 { + fmt.Printf(" • %s\n", ip) + } + } +} - ticker := time.NewTicker(5 * time.Second) - defer ticker.Stop() +func runMigrateStartWait(ctx context.Context, api migrationStartAPI, locationID string, timeout time.Duration) error { + migCtx, cancel := context.WithTimeout(ctx, timeout) + defer cancel() - lastPercent := -1 - lastMsg := "" - lastOperation := "" - var acceptResp *client.MigrateStartResponse + resultCh := make(chan *client.MigrateStartResponse, 1) + errCh := make(chan error, 1) - for { - select { - case <-ticker.C: - if resp, perr := bwhClient.GetMigrateLocations(ctx); perr != nil { - if bwhErr, ok := client.GetBWHError(perr); ok && client.IsLockedError(perr) { - if bwhErr.AdditionalErrorInfo != "" && bwhErr.AdditionalErrorInfo != lastOperation { - fmt.Printf("%s\n", bwhErr.AdditionalErrorInfo) - lastOperation = bwhErr.AdditionalErrorInfo - } - if info := bwhErr.AdditionalLockingInfo; info != nil { - p := info.CompletedPercent - msg := info.FriendlyProgressMessage - updated := info.LastStatusUpdateSecondsAgo - if p != lastPercent || msg != lastMsg { - if updated > 0 { - fmt.Printf("Progress: %d%% complete - %s (updated %ds ago)\n", p, msg, updated) - } else { - fmt.Printf("Progress: %d%% complete - %s\n", p, msg) - } - lastPercent = p - lastMsg = msg - } - } + go func() { + resp, err := api.StartMigrationWithTimeout(migCtx, locationID, timeout) + if err != nil { + errCh <- err + return + } + resultCh <- resp + }() + + ticker := time.NewTicker(5 * time.Second) + defer ticker.Stop() + + lastPercent := -1 + lastMsg := "" + lastOperation := "" + var acceptResp *client.MigrateStartResponse + + for { + select { + case <-ticker.C: + if resp, perr := api.GetMigrateLocations(ctx); perr != nil { + if bwhErr, ok := client.GetBWHError(perr); ok && client.IsLockedError(perr) { + if bwhErr.AdditionalErrorInfo != "" && bwhErr.AdditionalErrorInfo != lastOperation { + fmt.Printf("%s\n", bwhErr.AdditionalErrorInfo) + lastOperation = bwhErr.AdditionalErrorInfo } - } else { - fmt.Printf("\n✅ VE unlocked. Current location: %s\n", resp.CurrentLocation) - if acceptResp != nil && len(acceptResp.NewIPs) > 0 { - ipv4, ipv6 := splitIPsByFamily(acceptResp.NewIPs) - fmt.Printf("New IP addresses (after completion):\n") - if len(ipv4) > 0 { - fmt.Printf(" IPv4:\n") - for _, ip := range ipv4 { - fmt.Printf(" • %s\n", ip) - } - } - if len(ipv6) > 0 { - fmt.Printf(" IPv6:\n") - for _, ip := range ipv6 { - fmt.Printf(" • %s\n", ip) + if info := bwhErr.AdditionalLockingInfo; info != nil { + p := info.CompletedPercent + msg := info.FriendlyProgressMessage + updated := info.LastStatusUpdateSecondsAgo + if p != lastPercent || msg != lastMsg { + if updated > 0 { + fmt.Printf("Progress: %d%% complete - %s (updated %ds ago)\n", p, msg, updated) + } else { + fmt.Printf("Progress: %d%% complete - %s\n", p, msg) } + lastPercent = p + lastMsg = msg } } - return nil - } - case resp := <-resultCh: - acceptResp = resp - fmt.Printf("\n✅ Migration task accepted\n") - if resp.NotificationEmail != "" { - fmt.Printf("Notification will be sent to: %s\n", resp.NotificationEmail) } - case e := <-errCh: - if client.IsLockedError(e) { - continue - } - return fmt.Errorf("migration failed: %w", e) - case <-migCtx.Done(): - return fmt.Errorf("migration timed out after %s", d) + } else { + fmt.Printf("\n✅ VE unlocked. Current location: %s\n", resp.CurrentLocation) + printMigrationNewIPs(acceptResp) + return nil } + case resp := <-resultCh: + acceptResp = resp + printMigrationAccepted(resp, false) + case e := <-errCh: + if client.IsLockedError(e) { + continue + } + return fmt.Errorf("migration failed: %w", e) + case <-migCtx.Done(): + return fmt.Errorf("migration timed out after %s", timeout) } - }, + } } diff --git a/cmd/bwh/node.go b/cmd/bwh/node.go index 06465b6..e5c7db2 100644 --- a/cmd/bwh/node.go +++ b/cmd/bwh/node.go @@ -309,8 +309,5 @@ var nodeValidateCmd = &cli.Command{ // maskAPIKey masks the API key for display purposes func maskAPIKey(apiKey string) string { - if len(apiKey) <= 8 { - return strings.Repeat("*", len(apiKey)) - } - return apiKey[:4] + strings.Repeat("*", len(apiKey)-8) + apiKey[len(apiKey)-4:] + return maskSecret(apiKey) } diff --git a/cmd/bwh/notifications.go b/cmd/bwh/notifications.go index e59c5ed..79bb7f0 100644 --- a/cmd/bwh/notifications.go +++ b/cmd/bwh/notifications.go @@ -20,18 +20,6 @@ var notificationsCmd = &cli.Command{ }, } -var notificationsWriteFlags = []cli.Flag{ - &cli.BoolFlag{ - Name: "yes", - Aliases: []string{"y"}, - Usage: "skip confirmation prompt", - }, - &cli.BoolFlag{ - Name: "dry-run", - Usage: "validate and show the write action without calling the write API", - }, -} - var notificationsListCmd = &cli.Command{ Name: "list", Usage: "list KiwiVM notification preferences", @@ -56,7 +44,7 @@ var notificationsSetCmd = &cli.Command{ Name: "set", Usage: "set a KiwiVM notification preference", ArgsUsage: " ", - Flags: notificationsWriteFlags, + Flags: writeFlags(), Action: func(ctx context.Context, cmd *cli.Command) error { if cmd.Args().Len() != 2 { return fmt.Errorf("notifications set requires exactly two arguments: ") @@ -75,7 +63,7 @@ var notificationsSetCmd = &cli.Command{ return err } - return runNotificationSet(ctx, bwhClient, resolvedName, preferenceID, enabled, cmd.Bool("dry-run"), cmd.Bool("yes"), promptConfirmation) + return runNotificationSet(ctx, bwhClient, resolvedName, preferenceID, enabled, cmd.Bool("dry-run"), skipConfirm(cmd), promptConfirmation) }, } @@ -181,18 +169,16 @@ func runNotificationSet( return nil } if dryRun { - fmt.Printf("\nDRY RUN: would update notification preference '%s' on instance %s\n", preferenceID, resolvedName) + fmt.Println() + printDryRun("kiwivm/setNotificationPreferences", resolvedName, fmt.Sprintf("preference: %s -> %s", preferenceID, enabledStatus(boolToInt(enabled)))) return nil } - if !skipConfirm { - confirmed, err := confirm(fmt.Sprintf("Update notification preference '%s'?", preferenceID)) - if err != nil { - return err - } - if !confirmed { - fmt.Printf("Operation cancelled\n") - return nil - } + confirmed, err := confirmWrite(fmt.Sprintf("Update notification preference '%s'?", preferenceID), skipConfirm, confirm) + if err != nil { + return err + } + if !confirmed { + return nil } updateResp, err := api.SetNotificationPreferences(ctx, map[string]bool{preferenceID: enabled}) diff --git a/cmd/bwh/reinstall.go b/cmd/bwh/reinstall.go index e458e1a..69133c2 100644 --- a/cmd/bwh/reinstall.go +++ b/cmd/bwh/reinstall.go @@ -1,10 +1,8 @@ package main import ( - "bufio" "context" "fmt" - "os" "sort" "strings" @@ -46,7 +44,7 @@ type reinstallAPI interface { ReinstallOS(context.Context, string) error } -type reinstallConfirmationFunc func(instanceName, currentOS, targetOS string) bool +type reinstallConfirmationFunc func(instanceName, currentOS, targetOS string) (bool, error) func runReinstall(ctx context.Context, api reinstallAPI, resolvedName, osTemplate string, listOnly, dryRun, skipConfirm bool, confirm reinstallConfirmationFunc) error { osInfo, err := api.GetAvailableOS(ctx) @@ -86,7 +84,11 @@ func runReinstall(ctx context.Context, api reinstallAPI, resolvedName, osTemplat } if !skipConfirm { - if !confirm(resolvedName, osInfo.Installed, osTemplate) { + confirmed, err := confirm(resolvedName, osInfo.Installed, osTemplate) + if err != nil { + return err + } + if !confirmed { printOperationCancelled() return nil } @@ -159,7 +161,7 @@ func isValidOSTemplate(template string, availableTemplates []string) bool { return false } -func confirmReinstall(instanceName, currentOS, targetOS string) bool { +func confirmReinstall(instanceName, currentOS, targetOS string) (bool, error) { fmt.Printf("🚨 DANGER: OS REINSTALL WILL DESTROY ALL DATA!\n") fmt.Printf("🚨 This action is IRREVERSIBLE!\n") fmt.Printf("\n") @@ -170,14 +172,5 @@ func confirmReinstall(instanceName, currentOS, targetOS string) bool { fmt.Printf("⚠️ MAKE SURE YOU HAVE BACKUPS!\n") fmt.Printf("\n") fmt.Printf("To confirm this dangerous operation, type the target OS exactly: %s\n", targetOS) - fmt.Printf("Type here: ") - - reader := bufio.NewReader(os.Stdin) - response, err := reader.ReadString('\n') - if err != nil { - return false - } - - response = strings.TrimSpace(response) - return response == targetOS + return promptExactConfirmation("Type here: ", targetOS) } diff --git a/cmd/bwh/reset-password.go b/cmd/bwh/reset-password.go index ada1b2f..e0372f3 100644 --- a/cmd/bwh/reset-password.go +++ b/cmd/bwh/reset-password.go @@ -20,13 +20,29 @@ func generateRandomFileName() (string, error) { return fmt.Sprintf("password_%x.txt", result), nil } +func defaultPasswordOutputPath() (string, error) { + homeDir, err := os.UserHomeDir() + if err != nil { + return "", fmt.Errorf("failed to get user home directory: %w", err) + } + if homeDir == "" { + return "", fmt.Errorf("failed to get user home directory") + } + + fileName, err := generateRandomFileName() + if err != nil { + return "", err + } + return filepath.Join(homeDir, ".bwh", fileName), nil +} + var resetPasswordCmd = &cli.Command{ Name: "reset-password", Usage: "reset the root password", Flags: writeFlags( &cli.StringFlag{ Name: "output", - Usage: "output password to specified file (creates random file if not specified)", + Usage: "output password to specified file (defaults to a random file under ~/.bwh)", Aliases: []string{"o"}, }, ), @@ -48,8 +64,9 @@ type resetPasswordAPI interface { func runResetPassword(ctx context.Context, api resetPasswordAPI, resolvedName, outputFile string, dryRun, skipConfirm bool, confirm confirmationFunc) error { filePath := outputFile + usingDefaultOutput := filePath == "" if filePath == "" { - generatedPath, err := generateRandomFileName() + generatedPath, err := defaultPasswordOutputPath() if err != nil { return err } @@ -60,7 +77,7 @@ func runResetPassword(ctx context.Context, api resetPasswordAPI, resolvedName, o absPath = filePath } - fileExists, err := preflightPasswordOutput(filePath) + fileExists, err := preflightPasswordOutput(filePath, usingDefaultOutput && !dryRun, usingDefaultOutput && dryRun) if err != nil { return err } @@ -127,7 +144,7 @@ func runResetPassword(ctx context.Context, api resetPasswordAPI, resolvedName, o return nil } -func preflightPasswordOutput(filePath string) (bool, error) { +func preflightPasswordOutput(filePath string, createParent, allowMissingParent bool) (bool, error) { info, err := os.Stat(filePath) if err == nil { if info.IsDir() { @@ -148,12 +165,29 @@ func preflightPasswordOutput(filePath string) (bool, error) { parent := filepath.Dir(filePath) info, err = os.Stat(parent) + if os.IsNotExist(err) && createParent { + if err := os.MkdirAll(parent, 0o700); err != nil { + return false, fmt.Errorf("failed to create output directory: %w", err) + } + if err := os.Chmod(parent, 0o700); err != nil { + return false, fmt.Errorf("failed to secure output directory permissions: %w", err) + } + info, err = os.Stat(parent) + } + if os.IsNotExist(err) && allowMissingParent { + return false, nil + } if err != nil { return false, fmt.Errorf("failed to check output directory: %w", err) } if !info.IsDir() { return false, fmt.Errorf("output parent path is not a directory: %s", parent) } + if createParent { + if err := os.Chmod(parent, 0o700); err != nil { + return false, fmt.Errorf("failed to secure output directory permissions: %w", err) + } + } return false, nil } diff --git a/cmd/bwh/snapshot.go b/cmd/bwh/snapshot.go index b57dc4f..2a4089b 100644 --- a/cmd/bwh/snapshot.go +++ b/cmd/bwh/snapshot.go @@ -180,35 +180,7 @@ var snapshotExportCmd = &cli.Command{ return err } - if err := ensureSnapshotExists(ctx, bwhClient, fileName); err != nil { - return err - } - if cmd.Bool("dry-run") { - printDryRun("snapshot/export", resolvedName, fmt.Sprintf("snapshot: %s", fileName)) - return nil - } - confirmed, err := confirmWrite(fmt.Sprintf("Export snapshot '%s' from instance '%s'?", fileName, resolvedName), skipConfirm(cmd), promptConfirmation) - if err != nil { - return err - } - if !confirmed { - return nil - } - - fmt.Printf("Exporting snapshot '%s' for instance: %s\n", fileName, resolvedName) - - resp, err := bwhClient.ExportSnapshot(ctx, fileName) - if err != nil { - return fmt.Errorf("failed to export snapshot: %w", err) - } - - fmt.Printf("✅ Snapshot export completed\n") - fmt.Printf("\n📋 EXPORT DETAILS\n") - fmt.Printf(" Source VEID : %s\n", instance.VeID) - fmt.Printf(" Source Token : %s\n", resp.Token) - fmt.Printf("\n💡 Use these values with 'bwh snapshot import ' on the target instance\n") - - return nil + return runSnapshotExport(ctx, bwhClient, resolvedName, instance.VeID, fileName, cmd.Bool("dry-run"), skipConfirm(cmd), promptConfirmation) }, } @@ -235,27 +207,7 @@ var snapshotImportCmd = &cli.Command{ return err } - if cmd.Bool("dry-run") { - printDryRun("snapshot/import", resolvedName, fmt.Sprintf("sourceVeid: %s", sourceVeid), fmt.Sprintf("sourceToken: %s", maskSensitive(sourceToken))) - return nil - } - confirmed, err := confirmWrite(fmt.Sprintf("Import snapshot from VEID '%s' to instance '%s'?", sourceVeid, resolvedName), skipConfirm(cmd), promptConfirmation) - if err != nil { - return err - } - if !confirmed { - return nil - } - - fmt.Printf("Importing snapshot from VEID '%s' to instance: %s\n", sourceVeid, resolvedName) - - if err := bwhClient.ImportSnapshot(ctx, sourceVeid, sourceToken); err != nil { - return fmt.Errorf("failed to import snapshot: %w", err) - } - - fmt.Printf("✅ Snapshot import initiated successfully\n") - - return nil + return runSnapshotImport(ctx, bwhClient, resolvedName, sourceVeid, sourceToken, cmd.Bool("dry-run"), skipConfirm(cmd), promptConfirmation) }, } @@ -475,6 +427,15 @@ type snapshotCreateAPI interface { CreateSnapshot(context.Context, string) (*client.CreateSnapshotResponse, error) } +type snapshotExportAPI interface { + ListSnapshots(context.Context) (*client.SnapshotListResponse, error) + ExportSnapshot(context.Context, string) (*client.SnapshotExportResponse, error) +} + +type snapshotImportAPI interface { + ImportSnapshot(context.Context, string, string) error +} + func runSnapshotCreate(ctx context.Context, api snapshotCreateAPI, resolvedName, description string, dryRun, skipConfirm bool, confirm confirmationFunc) error { if dryRun { printDryRun("snapshot/create", resolvedName, fmt.Sprintf("description: %s", description)) @@ -510,6 +471,67 @@ func runSnapshotCreate(ctx context.Context, api snapshotCreateAPI, resolvedName, return nil } +func runSnapshotExport(ctx context.Context, api snapshotExportAPI, resolvedName, sourceVeid, fileName string, dryRun, skipConfirm bool, confirm confirmationFunc) error { + if err := ensureSnapshotExists(ctx, api, fileName); err != nil { + return err + } + if dryRun { + printDryRun("snapshot/export", resolvedName, fmt.Sprintf("snapshot: %s", fileName)) + return nil + } + confirmed, err := confirmWrite(fmt.Sprintf("Export snapshot '%s' from instance '%s'?", fileName, resolvedName), skipConfirm, confirm) + if err != nil { + return err + } + if !confirmed { + return nil + } + + fmt.Printf("Exporting snapshot '%s' for instance: %s\n", fileName, resolvedName) + resp, err := api.ExportSnapshot(ctx, fileName) + if err != nil { + return fmt.Errorf("failed to export snapshot: %w", err) + } + + fmt.Printf("✅ Snapshot export completed\n") + fmt.Printf("\n📋 EXPORT DETAILS\n") + fmt.Printf(" Source VEID : %s\n", sourceVeid) + fmt.Printf(" Source Token : %s\n", resp.Token) + fmt.Printf("\n💡 Use these values with 'bwh snapshot import ' on the target instance\n") + return nil +} + +func runSnapshotImport(ctx context.Context, api snapshotImportAPI, resolvedName, sourceVeid, sourceToken string, dryRun, skipConfirm bool, confirm confirmationFunc) error { + sourceVeid = strings.TrimSpace(sourceVeid) + sourceToken = strings.TrimSpace(sourceToken) + if sourceVeid == "" { + return fmt.Errorf("source VEID cannot be empty") + } + if sourceToken == "" { + return fmt.Errorf("source token cannot be empty") + } + + if dryRun { + printDryRun("snapshot/import", resolvedName, fmt.Sprintf("sourceVeid: %s", sourceVeid), fmt.Sprintf("sourceToken: %s", maskSensitive(sourceToken))) + return nil + } + confirmed, err := confirmWrite(fmt.Sprintf("Import snapshot from VEID '%s' to instance '%s'?", sourceVeid, resolvedName), skipConfirm, confirm) + if err != nil { + return err + } + if !confirmed { + return nil + } + + fmt.Printf("Importing snapshot from VEID '%s' to instance: %s\n", sourceVeid, resolvedName) + if err := api.ImportSnapshot(ctx, sourceVeid, sourceToken); err != nil { + return fmt.Errorf("failed to import snapshot: %w", err) + } + + fmt.Printf("✅ Snapshot import initiated successfully\n") + return nil +} + func findSnapshotByName(snapshots []client.SnapshotInfo, fileName string) (*client.SnapshotInfo, bool) { for i := range snapshots { if snapshots[i].FileName == fileName { diff --git a/cmd/bwh/write_helpers.go b/cmd/bwh/write_helpers.go index 818a5cd..fe74218 100644 --- a/cmd/bwh/write_helpers.go +++ b/cmd/bwh/write_helpers.go @@ -95,6 +95,10 @@ func printDryRun(endpoint, instanceName string, details ...string) { } func maskSensitive(value string) string { + return maskSecret(value) +} + +func maskSecret(value string) string { value = strings.TrimSpace(value) if value == "" { return "" diff --git a/cmd/bwh/write_safety_test.go b/cmd/bwh/write_safety_test.go index 6cba1c6..06fb969 100644 --- a/cmd/bwh/write_safety_test.go +++ b/cmd/bwh/write_safety_test.go @@ -8,6 +8,7 @@ import ( "path/filepath" "strings" "testing" + "time" "github.com/strahe/bwh/pkg/client" ) @@ -221,6 +222,89 @@ func TestRunBackupCopyToSnapshotMasksMissingTokenError(t *testing.T) { } } +type fakeISOAPI struct { + service *client.ServiceInfo + mounted []string + unmounted int +} + +func (f *fakeISOAPI) GetServiceInfo(context.Context) (*client.ServiceInfo, error) { + return f.service, nil +} + +func (f *fakeISOAPI) MountISO(_ context.Context, iso string) error { + f.mounted = append(f.mounted, iso) + return nil +} + +func (f *fakeISOAPI) UnmountISO(context.Context) error { + f.unmounted++ + return nil +} + +func TestRunISOSafety(t *testing.T) { + api := &fakeISOAPI{service: &client.ServiceInfo{ + AvailableISOs: []string{"ubuntu.iso", "debian.iso"}, + ISO1: "debian.iso", + }} + + out := captureStdout(t, func() { + if err := runMountISO(context.Background(), api, "test", "ubuntu.iso", true, false, confirmNo); err != nil { + t.Fatalf("runMountISO() error = %v", err) + } + }) + if len(api.mounted) != 0 { + t.Fatalf("mounted = %v, want none", api.mounted) + } + if !strings.Contains(out, "DRY RUN") { + t.Fatalf("output missing DRY RUN:\n%s", out) + } + + if err := runMountISO(context.Background(), api, "test", "ubuntu.iso", false, false, confirmNo); err != nil { + t.Fatalf("runMountISO() error = %v", err) + } + if len(api.mounted) != 0 { + t.Fatalf("mounted = %v, want none after cancel", api.mounted) + } + + if err := runMountISO(context.Background(), api, "test", "ubuntu.iso", false, true, confirmNo); err != nil { + t.Fatalf("runMountISO() error = %v", err) + } + if len(api.mounted) != 1 || api.mounted[0] != "ubuntu.iso" { + t.Fatalf("mounted = %v, want [ubuntu.iso]", api.mounted) + } + + out = captureStdout(t, func() { + if err := runUnmountISO(context.Background(), api, "test", true, false, confirmNo); err != nil { + t.Fatalf("runUnmountISO() error = %v", err) + } + }) + if api.unmounted != 0 { + t.Fatalf("unmounted = %d, want 0", api.unmounted) + } + if !strings.Contains(out, "DRY RUN") { + t.Fatalf("output missing DRY RUN:\n%s", out) + } + + if err := runUnmountISO(context.Background(), api, "test", false, true, confirmNo); err != nil { + t.Fatalf("runUnmountISO() error = %v", err) + } + if api.unmounted != 1 { + t.Fatalf("unmounted = %d, want 1", api.unmounted) + } + + if err := runMountISO(context.Background(), api, "test", "missing.iso", true, true, confirmNo); err == nil { + t.Fatal("runMountISO() error = nil, want unavailable ISO error") + } + noopAPI := &fakeISOAPI{service: &client.ServiceInfo{AvailableISOs: []string{"debian.iso"}, ISO1: "debian.iso"}} + if err := runMountISO(context.Background(), noopAPI, "test", "debian.iso", false, true, confirmNo); err != nil { + t.Fatalf("runMountISO() noop error = %v", err) + } + if len(noopAPI.mounted) != 0 { + t.Fatalf("mounted = %v, want none for noop", noopAPI.mounted) + } +} + type fakeIPv6API struct { service *client.ServiceInfo added int @@ -411,6 +495,72 @@ func TestRunResetPasswordDryRunDoesNotPromptOrWrite(t *testing.T) { } } +func TestRunResetPasswordDefaultDryRunDoesNotCreateDirectory(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + api := &fakeResetPasswordAPI{} + + out := captureStdout(t, func() { + if err := runResetPassword(context.Background(), api, "test", "", true, false, confirmNo); err != nil { + t.Fatalf("runResetPassword() error = %v", err) + } + }) + if api.calls != 0 { + t.Fatalf("calls = %d, want 0", api.calls) + } + if _, err := os.Stat(filepath.Join(home, ".bwh")); !os.IsNotExist(err) { + t.Fatalf("default output directory should not be created during dry-run, stat error = %v", err) + } + if !strings.Contains(out, filepath.Join(home, ".bwh")) { + t.Fatalf("dry-run output missing secure default directory:\n%s", out) + } +} + +func TestRunResetPasswordDefaultOutputUsesSecureBWHDirectory(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + api := &fakeResetPasswordAPI{} + + out := captureStdout(t, func() { + if err := runResetPassword(context.Background(), api, "test", "", false, true, confirmNo); err != nil { + t.Fatalf("runResetPassword() error = %v", err) + } + }) + if api.calls != 1 { + t.Fatalf("calls = %d, want 1", api.calls) + } + + outputDir := filepath.Join(home, ".bwh") + info, err := os.Stat(outputDir) + if err != nil { + t.Fatalf("failed to stat default output directory: %v", err) + } + if !info.IsDir() { + t.Fatalf("default output path is not a directory: %s", outputDir) + } + if got := info.Mode().Perm(); got != 0o700 { + t.Fatalf("default output directory mode = %o, want 700", got) + } + + matches, err := filepath.Glob(filepath.Join(outputDir, "password_*.txt")) + if err != nil { + t.Fatalf("failed to glob default output files: %v", err) + } + if len(matches) != 1 { + t.Fatalf("default output files = %v, want one password file", matches) + } + content, err := os.ReadFile(matches[0]) + if err != nil { + t.Fatalf("failed to read default output file: %v", err) + } + if !strings.Contains(string(content), "Password: secret") { + t.Fatalf("default output file missing password:\n%s", content) + } + if !strings.Contains(out, matches[0]) { + t.Fatalf("output missing saved file path:\n%s", out) + } +} + func TestRunResetPasswordPreparesOutputBeforeAPI(t *testing.T) { dir := t.TempDir() path := dir + "/password.txt" @@ -569,9 +719,9 @@ func TestRunReinstallSafety(t *testing.T) { }, }} out := captureStdout(t, func() { - if err := runReinstall(context.Background(), api, "test", "ubuntu-24.04-x86_64", false, true, false, func(string, string, string) bool { + if err := runReinstall(context.Background(), api, "test", "ubuntu-24.04-x86_64", false, true, false, func(string, string, string) (bool, error) { t.Fatal("confirm called during dry-run") - return false + return false, nil }); err != nil { t.Fatalf("runReinstall() error = %v", err) } @@ -583,8 +733,8 @@ func TestRunReinstallSafety(t *testing.T) { t.Fatalf("output missing DRY RUN:\n%s", out) } - if err := runReinstall(context.Background(), api, "test", "ubuntu-24.04-x86_64", false, false, true, func(string, string, string) bool { - return false + if err := runReinstall(context.Background(), api, "test", "ubuntu-24.04-x86_64", false, false, true, func(string, string, string) (bool, error) { + return false, nil }); err != nil { t.Fatalf("runReinstall() error = %v", err) } @@ -599,6 +749,8 @@ type fakeSnapshotAPI struct { deleted []string restored []string sticky []string + exported []string + imported []string } func (f *fakeSnapshotAPI) CreateSnapshot(_ context.Context, description string) (*client.CreateSnapshotResponse, error) { @@ -625,6 +777,16 @@ func (f *fakeSnapshotAPI) ToggleSnapshotSticky(_ context.Context, fileName strin return nil } +func (f *fakeSnapshotAPI) ExportSnapshot(_ context.Context, fileName string) (*client.SnapshotExportResponse, error) { + f.exported = append(f.exported, fileName) + return &client.SnapshotExportResponse{Token: "export-token"}, nil +} + +func (f *fakeSnapshotAPI) ImportSnapshot(_ context.Context, sourceVeid, sourceToken string) error { + f.imported = append(f.imported, sourceVeid+"="+sourceToken) + return nil +} + func TestRunSnapshotCreateAndStickySafety(t *testing.T) { api := &fakeSnapshotAPI{snapshots: []client.SnapshotInfo{{FileName: "snap.tar.gz", OS: "debian", Sticky: false}}} @@ -688,3 +850,126 @@ func TestRunSnapshotDeleteAndRestoreSafety(t *testing.T) { t.Fatalf("restored = %v, want none", api.restored) } } + +func TestRunSnapshotExportImportSafety(t *testing.T) { + token := "0123456789abcdef0123456789abcdef01234567" + api := &fakeSnapshotAPI{snapshots: []client.SnapshotInfo{{FileName: "snap.tar.gz", OS: "debian"}}} + + out := captureStdout(t, func() { + if err := runSnapshotExport(context.Background(), api, "test", "12345", "snap.tar.gz", true, false, confirmNo); err != nil { + t.Fatalf("runSnapshotExport() error = %v", err) + } + }) + if len(api.exported) != 0 { + t.Fatalf("exported = %v, want none", api.exported) + } + if !strings.Contains(out, "DRY RUN") { + t.Fatalf("output missing DRY RUN:\n%s", out) + } + + if err := runSnapshotExport(context.Background(), api, "test", "12345", "snap.tar.gz", false, false, confirmNo); err != nil { + t.Fatalf("runSnapshotExport() error = %v", err) + } + if len(api.exported) != 0 { + t.Fatalf("exported = %v, want none after cancel", api.exported) + } + + if err := runSnapshotExport(context.Background(), api, "test", "12345", "snap.tar.gz", false, true, confirmNo); err != nil { + t.Fatalf("runSnapshotExport() error = %v", err) + } + if len(api.exported) != 1 || api.exported[0] != "snap.tar.gz" { + t.Fatalf("exported = %v, want [snap.tar.gz]", api.exported) + } + + out = captureStdout(t, func() { + if err := runSnapshotImport(context.Background(), api, "test", "12345", token, true, false, confirmNo); err != nil { + t.Fatalf("runSnapshotImport() error = %v", err) + } + }) + if len(api.imported) != 0 { + t.Fatalf("imported = %v, want none", api.imported) + } + if strings.Contains(out, token) { + t.Fatalf("dry-run output leaked full source token:\n%s", out) + } + if !strings.Contains(out, "0123...4567") { + t.Fatalf("dry-run output missing masked source token:\n%s", out) + } + + if err := runSnapshotImport(context.Background(), api, "test", "12345", token, false, false, confirmNo); err != nil { + t.Fatalf("runSnapshotImport() error = %v", err) + } + if len(api.imported) != 0 { + t.Fatalf("imported = %v, want none after cancel", api.imported) + } + + if err := runSnapshotImport(context.Background(), api, "test", "12345", token, false, true, confirmNo); err != nil { + t.Fatalf("runSnapshotImport() error = %v", err) + } + if len(api.imported) != 1 || api.imported[0] != "12345="+token { + t.Fatalf("imported = %v, want source pair", api.imported) + } +} + +type fakeMigrationAPI struct { + locations *client.MigrateLocationsResponse + started []string +} + +func (f *fakeMigrationAPI) GetMigrateLocations(context.Context) (*client.MigrateLocationsResponse, error) { + return f.locations, nil +} + +func (f *fakeMigrationAPI) StartMigrationWithTimeout(_ context.Context, locationID string, timeout time.Duration) (*client.MigrateStartResponse, error) { + f.started = append(f.started, fmt.Sprintf("%s@%s", locationID, timeout)) + return &client.MigrateStartResponse{ + NotificationEmail: "ops@example.com", + NewIPs: []string{"192.0.2.10", "2001:db8::1"}, + }, nil +} + +func TestRunMigrateStartSafety(t *testing.T) { + api := &fakeMigrationAPI{locations: &client.MigrateLocationsResponse{ + CurrentLocation: "us-east", + Locations: []string{"us-west", "eu"}, + Descriptions: map[string]string{"us-west": "US West"}, + }} + + out := captureStdout(t, func() { + if err := runMigrateStart(context.Background(), api, "test", "us-west", 15*time.Minute, false, true, false, confirmNo); err != nil { + t.Fatalf("runMigrateStart() error = %v", err) + } + }) + if len(api.started) != 0 { + t.Fatalf("started = %v, want none", api.started) + } + if !strings.Contains(out, "DRY RUN") { + t.Fatalf("output missing DRY RUN:\n%s", out) + } + + if err := runMigrateStart(context.Background(), api, "test", "us-west", 15*time.Minute, false, false, false, confirmNo); err != nil { + t.Fatalf("runMigrateStart() error = %v", err) + } + if len(api.started) != 0 { + t.Fatalf("started = %v, want none after cancel", api.started) + } + + if err := runMigrateStart(context.Background(), api, "test", "us-west", 15*time.Minute, false, false, true, confirmNo); err != nil { + t.Fatalf("runMigrateStart() error = %v", err) + } + if len(api.started) != 1 || api.started[0] != "us-west@15m0s" { + t.Fatalf("started = %v, want [us-west@15m0s]", api.started) + } + + noopAPI := &fakeMigrationAPI{locations: &client.MigrateLocationsResponse{CurrentLocation: "us-west", Locations: []string{"us-west"}}} + if err := runMigrateStart(context.Background(), noopAPI, "test", "us-west", 15*time.Minute, false, false, true, confirmNo); err != nil { + t.Fatalf("runMigrateStart() noop error = %v", err) + } + if len(noopAPI.started) != 0 { + t.Fatalf("started = %v, want none for noop", noopAPI.started) + } + + if err := runMigrateStart(context.Background(), api, "test", "missing", 15*time.Minute, false, true, true, confirmNo); err == nil { + t.Fatal("runMigrateStart() error = nil, want unavailable location error") + } +}