Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 15 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ bwh start/stop/restart # Power management
bwh usage --period 7d # Check usage statistics
bwh abuse suspensions # Show suspension details
bwh notifications list # Show notification preferences
bwh notifications set <id> on --dry-run # Preview notification changes
bwh snapshot create "backup-name" # Create snapshots
bwh iso images # List available ISO images
bwh iso mount ubuntu-20.04.iso # Mount ISO for rescue/install
Expand Down Expand Up @@ -107,9 +108,9 @@ backups, err := c.ListBackups(ctx)

**Migration**: `GetMigrateLocations`, `StartMigration` (use `StartMigrationWithTimeout` for custom timeouts)

**Security & Abuse**: `GetSuspensionDetails`, `GetPolicyViolations`
**Security & Abuse**: `GetSuspensionDetails`, `GetPolicyViolations`, `Unsuspend`, `ResolvePolicyViolation`

**Notifications**: `GetNotificationPreferences`
**Notifications**: `GetNotificationPreferences`, `SetNotificationPreferences`

**Network**: SSH key management, IP/reverse DNS configuration, IPv6 subnet management, private IPv4 management

Expand Down Expand Up @@ -243,8 +244,8 @@ iso Manage ISO images for VPS boot
reinstall Reinstall VPS operating system (WARNING: destroys all data)
usage Display detailed VPS usage statistics
audit Display audit log entries
abuse Display suspension details and policy violations
notifications Display KiwiVM notification preferences
abuse Display and resolve suspension details and policy violations
notifications Display and update KiwiVM notification preferences
reset-password Reset the root password
snapshot Manage VPS snapshots
backup Manage VPS backups
Expand All @@ -258,6 +259,16 @@ completion Generate shell completion script

Use `bwh <command> --help` to view detailed options and usage examples for each command.

### Abuse and Notification Writes

```bash
bwh abuse unsuspend <record_id> --dry-run
bwh abuse resolve-policy <record_id> --dry-run
bwh notifications set <preference_id> <on|off> --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.

## Build

```bash
Expand Down
19 changes: 15 additions & 4 deletions README.zh.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ bwh start/stop/restart # 电源管理
bwh usage --period 7d # 检查使用统计
bwh abuse suspensions # 查看暂停详情
bwh notifications list # 查看通知偏好
bwh notifications set <id> on --dry-run # 预览通知偏好修改
bwh snapshot create "备份名称" # 创建快照
bwh iso images # 列出可用 ISO 镜像
bwh iso mount ubuntu-20.04.iso # 挂载 ISO 用于救援/安装
Expand Down Expand Up @@ -107,9 +108,9 @@ backups, err := c.ListBackups(ctx)

**迁移**: `GetMigrateLocations`、`StartMigration`(支持 `StartMigrationWithTimeout` 自定义超时)

**安全与 abuse**: `GetSuspensionDetails`、`GetPolicyViolations`
**安全与 abuse**: `GetSuspensionDetails`、`GetPolicyViolations`、`Unsuspend`、`ResolvePolicyViolation`

**通知**: `GetNotificationPreferences`
**通知**: `GetNotificationPreferences`、`SetNotificationPreferences`

**网络**: SSH 密钥管理、IP/反向 DNS 配置、IPv6 子网管理、私有 IPv4 管理

Expand Down Expand Up @@ -243,8 +244,8 @@ iso 管理 VPS 启动用 ISO 镜像
reinstall 重装 VPS 操作系统(警告:摧毁所有数据)
usage 显示详细 VPS 使用统计
audit 显示审计日志条目
abuse 显示暂停详情与策略违规
notifications 显示 KiwiVM 通知偏好
abuse 显示并处理暂停详情与策略违规
notifications 显示并更新 KiwiVM 通知偏好
reset-password 重置 root 密码
snapshot 管理 VPS 快照
backup 管理 VPS 备份
Expand All @@ -258,6 +259,16 @@ completion 生成 shell 自动补全脚本

使用 `bwh <command> --help` 查看每个命令的详细选项和用法示例。

### Abuse 与通知写命令

```bash
bwh abuse unsuspend <record_id> --dry-run
bwh abuse resolve-policy <record_id> --dry-run
bwh notifications set <preference_id> <on|off> --dry-run
```

使用 `--dry-run` 做校验和预览,不调用写 API。确认需要跳过 y/N 提示时再加 `--yes`。

## 构建

```bash
Expand Down
191 changes: 190 additions & 1 deletion cmd/bwh/abuse.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"context"
"fmt"
"sort"
"strconv"
"time"

"github.com/strahe/bwh/pkg/client"
Expand All @@ -12,10 +13,24 @@ import (

var abuseCmd = &cli.Command{
Name: "abuse",
Usage: "inspect abuse suspensions and policy violations",
Usage: "display and resolve abuse suspensions and policy violations",
Commands: []*cli.Command{
abuseSuspensionsCmd,
abusePolicyCmd,
abuseUnsuspendCmd,
abuseResolvePolicyCmd,
},
}

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",
},
}

Expand Down Expand Up @@ -59,6 +74,61 @@ var abusePolicyCmd = &cli.Command{
},
}

var abuseUnsuspendCmd = &cli.Command{
Name: "unsuspend",
Usage: "clear a soft abuse issue and unsuspend the VPS",
ArgsUsage: "<record_id>",
Flags: abuseWriteFlags,
Action: func(ctx context.Context, cmd *cli.Command) error {
if cmd.Args().Len() != 1 {
return fmt.Errorf("record_id is required")
}
recordID, err := parseRecordID(cmd.Args().First())
if err != nil {
return err
}

bwhClient, resolvedName, err := createBWHClient(cmd)
if err != nil {
return err
}

return runAbuseUnsuspend(ctx, bwhClient, resolvedName, recordID, cmd.Bool("dry-run"), cmd.Bool("yes"), promptConfirmation)
},
}

var abuseResolvePolicyCmd = &cli.Command{
Name: "resolve-policy",
Usage: "mark a soft policy violation as resolved",
ArgsUsage: "<record_id>",
Flags: abuseWriteFlags,
Action: func(ctx context.Context, cmd *cli.Command) error {
if cmd.Args().Len() != 1 {
return fmt.Errorf("record_id is required")
}
recordID, err := parseRecordID(cmd.Args().First())
if err != nil {
return err
}

bwhClient, resolvedName, err := createBWHClient(cmd)
if err != nil {
return err
}

return runAbuseResolvePolicy(ctx, bwhClient, resolvedName, recordID, cmd.Bool("dry-run"), cmd.Bool("yes"), promptConfirmation)
},
}

type abuseAPI interface {
GetSuspensionDetails(context.Context) (*client.SuspensionDetailsResponse, error)
GetPolicyViolations(context.Context) (*client.PolicyViolationsResponse, error)
Unsuspend(context.Context, int) error
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)
Expand Down Expand Up @@ -131,3 +201,122 @@ func summarizeText(s string, maxLen int) string {
}
return string(runes[:maxLen]) + "..."
}

func parseRecordID(raw string) (int, error) {
recordID, err := strconv.Atoi(raw)
if err != nil || recordID <= 0 {
return 0, fmt.Errorf("record_id must be a positive integer")
}
return recordID, nil
}

func runAbuseUnsuspend(ctx context.Context, api abuseAPI, resolvedName string, recordID int, dryRun, skipConfirm bool, confirm confirmationFunc) error {
fmt.Printf("Checking suspension case #%d for instance: %s\n", recordID, resolvedName)
resp, err := api.GetSuspensionDetails(ctx)
if err != nil {
return fmt.Errorf("failed to get suspension details: %w", err)
}

record, ok := findSuspensionRecord(resp.Suspensions, recordID)
if !ok {
return fmt.Errorf("suspension case #%d not found", recordID)
}
printSuspensionActionSummary(record)
if record.IsSoft != 1 {
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)
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
}
}

if err := api.Unsuspend(ctx, recordID); err != nil {
return fmt.Errorf("failed to unsuspend case #%d: %w", recordID, err)
}
fmt.Printf("✅ Suspension case #%d cleared\n", recordID)
return nil
}

func runAbuseResolvePolicy(ctx context.Context, api abuseAPI, resolvedName string, recordID int, dryRun, skipConfirm bool, confirm confirmationFunc) error {
fmt.Printf("Checking policy violation case #%d for instance: %s\n", recordID, resolvedName)
resp, err := api.GetPolicyViolations(ctx)
if err != nil {
return fmt.Errorf("failed to get policy violations: %w", err)
}

record, ok := findPolicyViolationRecord(resp.PolicyViolations, recordID)
if !ok {
return fmt.Errorf("policy violation case #%d not found", recordID)
}
printPolicyActionSummary(record)
if record.IsSoft != 1 {
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)
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
}
}

if err := api.ResolvePolicyViolation(ctx, recordID); err != nil {
return fmt.Errorf("failed to resolve policy violation case #%d: %w", recordID, err)
}
fmt.Printf("✅ Policy violation case #%d resolved\n", recordID)
return nil
}

func findSuspensionRecord(records []client.SuspensionRecord, recordID int) (client.SuspensionRecord, bool) {
for _, record := range records {
if record.RecordID == recordID {
return record, true
}
}
return client.SuspensionRecord{}, false
}

func findPolicyViolationRecord(records []client.PolicyViolationRecord, recordID int) (client.PolicyViolationRecord, bool) {
for _, record := range records {
if record.RecordID == recordID {
return record, true
}
}
return client.PolicyViolationRecord{}, false
}

func printSuspensionActionSummary(record client.SuspensionRecord) {
fmt.Printf("\nTarget suspension case:\n")
fmt.Printf(" Case ID : %d\n", record.RecordID)
fmt.Printf(" Flag : %s\n", record.Flag)
fmt.Printf(" Soft Resolve: %s\n", yesNo(record.IsSoft == 1))
fmt.Printf(" Abuse Points: %d\n", record.AbusePoints)
}

func printPolicyActionSummary(record client.PolicyViolationRecord) {
fmt.Printf("\nTarget policy violation:\n")
fmt.Printf(" Case ID : %d\n", record.RecordID)
fmt.Printf(" Flag : %s\n", record.Flag)
fmt.Printf(" Soft Resolve: %s\n", yesNo(record.IsSoft == 1))
fmt.Printf(" Abuse Points: %d\n", record.AbusePoints)
if record.SuspendAt > 0 {
fmt.Printf(" Suspend At : %s\n", time.Unix(record.SuspendAt, 0).Format("2006-01-02 15:04:05"))
}
}
8 changes: 8 additions & 0 deletions cmd/bwh/helpers.go
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,14 @@ func formatBytes(bytes int64) string {
return fmt.Sprintf("%.1f %cB", float64(bytes)/float64(div), "KMGTPE"[exp])
}

// yesNo converts a boolean to a user-friendly Yes/No string.
func yesNo(b bool) string {
if b {
return "✅ Yes"
}
return "❌ No"
}

// validateBackupToken validates the format of a backup token
func validateBackupToken(token string) error {
// Backup tokens are 40-character hexadecimal strings
Expand Down
Loading
Loading