// Copyright 2026 tamakyi. All rights reserved. // Use of this source code is governed by a MIT-style // license that can be found in the LICENSE file. package cmd import ( "context" "fmt" "sort" "strings" "time" "github.com/pkg/errors" "github.com/urfave/cli/v2" "github.com/tamakyi/TamaBox/internal/conf" "github.com/tamakyi/TamaBox/internal/db" "github.com/tamakyi/TamaBox/internal/security/honeypot" "github.com/tamakyi/TamaBox/internal/shared" ) // Honeypot 提供蜜罐取证命令(只读 + 一条显式清理)。 // // TamaBox honeypot hits --since 24h 列出捕获到的破解凭据 // TamaBox honeypot stats --days 7 统计来源/路径/凭据排行 // TamaBox honeypot size 报告表行数与磁盘占用 // TamaBox honeypot prune --days 30 清理过期记录(保留期由配置决定) // // 密码默认脱敏:CLI 输出会被贴到工单、聊天窗口甚至截图里, // 明文只在确有取证需要时用 --show-password 显式打开。 var Honeypot = &cli.Command{ Name: "honeypot", Usage: "Inspect honeypot hits (fake admin login page credential capture)", Subcommands: []*cli.Command{ { Name: "hits", Usage: "List captured credential submissions", Flags: []cli.Flag{ &cli.StringFlag{Name: "since", Value: "24h", Usage: "Time window, e.g. 24h / 7d / 30m"}, &cli.IntFlag{Name: "limit", Value: 50, Usage: "Max rows to print"}, &cli.StringFlag{Name: "ip", Usage: "Only show hits from this source IP"}, &cli.BoolFlag{Name: "show-password", Usage: "Print passwords in plaintext (default: masked)"}, }, Action: runHoneypotHits, }, { Name: "stats", Usage: "Show honeypot statistics (top sources / paths / credentials)", Flags: []cli.Flag{ &cli.IntFlag{Name: "days", Value: 7, Usage: "Statistics window in days"}, &cli.IntFlag{Name: "limit", Value: 20, Usage: "Max rows per section"}, &cli.BoolFlag{Name: "show-password", Usage: "Print passwords in plaintext (default: masked)"}, }, Action: runHoneypotStats, }, { Name: "prune", Usage: "Delete honeypot records (DB metadata + credential files) older than the retention period", Flags: []cli.Flag{ &cli.IntFlag{Name: "days", Value: 0, Usage: "Days to keep (0 = use honeypot_retain_days from config)"}, &cli.BoolFlag{Name: "yes", Usage: "Skip confirmation"}, }, Action: runHoneypotPrune, }, { Name: "size", Usage: "Report honeypot table row count and disk usage", Action: runHoneypotSize, }, { Name: "paths", Usage: "List active decoy paths (built-in + honeypot_paths)", Action: runHoneypotPaths, }, { Name: "purge-creds", Usage: "Drop legacy credential columns (username/password/fields/raw_body) from honeypot_hits — irreversible", Flags: []cli.Flag{ &cli.BoolFlag{Name: "yes", Usage: "Skip confirmation"}, }, Action: runHoneypotPurgeCreds, }, }, } // parseSince 解析 --since 参数,支持 30m / 24h / 7d / 2w。 func parseSince(s string) (time.Duration, error) { s = strings.TrimSpace(strings.ToLower(s)) if s == "" { return 24 * time.Hour, nil } unit := s[len(s)-1] numPart := s[:len(s)-1] var mult time.Duration switch unit { case 'm': mult = time.Minute case 'h': mult = time.Hour case 'd': mult = 24 * time.Hour case 'w': mult = 7 * 24 * time.Hour default: return 0, errors.Errorf("invalid --since value %q (expect e.g. 30m / 24h / 7d)", s) } var n int if _, err := fmt.Sscanf(numPart, "%d", &n); err != nil || n <= 0 { return 0, errors.Errorf("invalid --since value %q", s) } return time.Duration(n) * mult, nil } // credStoreFromConfig 用已加载的配置打开凭据取证目录。 // // 凭据(用户名/密码)只在文件里,不查数据库 —— 所以任何需要展示凭据的 // 命令都走这里,而不是 db.HoneypotStore。 func credStoreFromConfig() (honeypot.CredStore, error) { return honeypot.NewFileCredStore(conf.Security.HoneypotCredDir) } func runHoneypotHits(ctx *cli.Context) error { dur, err := parseSince(ctx.String("since")) if err != nil { return err } // 凭据只在取证文件里,本命令不再连数据库 —— DB 挂了也能查。 // (元数据在库里不含凭据内容,见 db.HoneypotHit.HadFields 的注释。) if err := conf.Init(); err != nil { return errors.Wrap(err, "load configuration") } store, err := credStoreFromConfig() if err != nil { return errors.Wrap(err, "open honeypot credential store") } since := time.Now().Add(-dur) filterIP := strings.TrimSpace(ctx.String("ip")) showPwd := ctx.Bool("show-password") // IP 过滤在读取阶段做(列表本来就是流式读,先取最近 N 条再筛会让 // 指定 IP 的历史记录被其他 IP 挤出去)。 rows, err := store.ListSince(since, filterIP, ctx.Int("limit")) if err != nil { return errors.Wrap(err, "read honeypot credentials") } fmt.Printf("蜜罐凭据记录(近 %s,共 %d 条,来源 %s)\n", dur, len(rows), store.Dir()) fmt.Println(strings.Repeat("-", 72)) if len(rows) == 0 { fmt.Println(" 无") return nil } for _, r := range rows { pwd := r.Password if pwd == "" { pwd = "(空)" } else if !showPwd { pwd = maskSecret(pwd) } user := r.Username if user == "" { user = "(空)" } fmt.Printf("%s %-8s %-6s %s\n", r.Time.Format("2006-01-02 15:04:05"), r.IP, r.Method, truncateForCLI(r.Path, 48)) fmt.Printf(" 用户名:%s 密码:%s\n", truncateForCLI(user, 64), truncateForCLI(pwd, 64)) if r.UserAgent != "" { fmt.Printf(" UA:%s\n", truncateForCLI(r.UserAgent, 96)) } if r.Banned { fmt.Printf(" 处置:已触发封禁\n") } fmt.Println() } if !showPwd { fmt.Println("提示:密码默认脱敏。需要明文时加 --show-password。") } return nil } func runHoneypotStats(ctx *cli.Context) error { days := ctx.Int("days") if days <= 0 { days = 7 } database, err := openDatabase() if err != nil { return err } store := db.NewHoneypotStore(database) cctx := context.Background() since := time.Now().AddDate(0, 0, -days) total, err := store.CountSince(cctx, since) if err != nil { return errors.Wrap(err, "count honeypot hits") } // 元数据表存的是精确地址(取证需要),但 IPv6 攻击者常在一个 /64 内轮换地址: // 直接按精确 IP 排行会碎成几十条各 1 次的记录,看不出「其实是同一个人」。 // 多取一些再按封禁同口径(/64)合并,见 mergeSourcesByKey。 limit := ctx.Int("limit") if limit <= 0 { limit = 20 } sources, err := store.TopSources(cctx, since, limit*4) if err != nil { return errors.Wrap(err, "query top sources") } sources = mergeSourcesByKey(sources, limit) paths, err := store.TopPaths(cctx, since, ctx.Int("limit")) if err != nil { return errors.Wrap(err, "query top paths") } credStore, err := credStoreFromConfig() if err != nil { return errors.Wrap(err, "open honeypot credential store") } creds, err := credStore.TopCredentials(since, ctx.Int("limit")) if err != nil { return errors.Wrap(err, "query top credentials") } fmt.Printf("蜜罐统计:近 %d 天,共 %d 次命中\n", days, total) fmt.Println(strings.Repeat("-", 72)) fmt.Println("来源排行(IPv6 按 /64 聚合,与封禁口径一致):") if len(sources) == 0 { fmt.Println(" 无") } for i, s := range sources { fmt.Printf(" %2d. %-40s %5d 次 %s\n", i+1, s.IP, s.Count, s.Provider) } fmt.Println() fmt.Println("被探测路径排行:") if len(paths) == 0 { fmt.Println(" 无") } for i, p := range paths { fmt.Printf(" %2d. %-50s %5d 次\n", i+1, truncateForCLI(p.Path, 50), p.Count) } fmt.Println() fmt.Println("最常被尝试的凭据组合(来自取证文件,非数据库):") if len(creds) == 0 { fmt.Println(" 无") } showPwd := ctx.Bool("show-password") for i, c := range creds { pwd := c.Password if pwd == "" { pwd = "(空)" } else if !showPwd { pwd = maskSecret(pwd) } user := c.Username if user == "" { user = "(空)" } fmt.Printf(" %2d. %-30s %-30s %5d 次\n", i+1, truncateForCLI(user, 30), truncateForCLI(pwd, 30), c.Count) } if !showPwd { fmt.Println() fmt.Println("提示:密码默认脱敏。需要明文时加 --show-password。") } return nil } func runHoneypotPrune(ctx *cli.Context) error { days := ctx.Int("days") if days <= 0 { days = conf.Security.HoneypotRetainDays } if days <= 0 { days = 30 } database, err := openDatabase() if err != nil { return err } before := time.Now().AddDate(0, 0, -days) if !ctx.Bool("yes") { fmt.Printf("将删除 %s 之前的蜜罐记录(共保留 %d 天)。\n", before.Format("2006-01-02 15:04:05"), days) fmt.Print("确认继续?输入 yes 继续:") var answer string if _, err := fmt.Scanln(&answer); err != nil || strings.TrimSpace(answer) != "yes" { fmt.Println("已取消。") return nil } } store := db.NewHoneypotStore(database) n, err := store.DeleteBefore(context.Background(), before) if err != nil { return errors.Wrap(err, "prune honeypot records") } fmt.Printf("已删除 %d 条过期蜜罐记录。\n", n) // 回收物理空间:PostgreSQL 的 DELETE 只标记死元组,表文件不会变小。 // 不回收的话「清理」只是逻辑变小,磁盘占用继续单调增长。 if n > 0 { if err := store.Compact(context.Background()); err != nil { fmt.Printf("提示:空间回收失败(%v),数据本身已正确删除;PostgreSQL 下可手动执行 VACUUM honeypot_hits。\n", err) } } // 凭据文件同样会过期,一并清理:明文账号密码不该比元数据留得更久。 // 这也让「prune」在运维心里只有一个语义 —— 按保留期清干净,不用记两套。 if cs, err := credStoreFromConfig(); err != nil { fmt.Printf("提示:无法打开凭据目录,凭据文件未清理(%v)。\n", err) } else if m, err := cs.PurgeBefore(before); err != nil { fmt.Printf("提示:凭据文件清理失败(%v)。\n", err) } else if m > 0 { fmt.Printf("已删除 %d 个过期凭据文件(%s)。\n", m, cs.Dir()) } return nil } // runHoneypotSize 报告蜜罐表的行数与磁盘占用。 // // 这个命令存在的意义:把「会不会把数据库撑臃肿」变成运维随时能自己查的事, // 而不是只能等开发一次性汇报。 func runHoneypotSize(ctx *cli.Context) error { database, err := openDatabase() if err != nil { return err } store := db.NewHoneypotStore(database) info, err := store.Size(context.Background()) if err != nil { return errors.Wrap(err, "query honeypot table size") } fmt.Println("蜜罐表体积") fmt.Println(strings.Repeat("-", 72)) fmt.Printf(" 表名:honeypot_hits\n") fmt.Printf(" 存活记录:%d 条\n", info.Rows) if info.Bytes > 0 { fmt.Printf(" 磁盘占用:%s(含索引)\n", humanBytes(info.Bytes)) if info.Rows > 0 { fmt.Printf(" 单条均摊:%d 字节\n", info.Bytes/info.Rows) } } else { fmt.Printf(" 磁盘占用:(当前数据库不支持元数据查询)\n") } if info.Oldest != nil && info.Newest != nil { fmt.Printf(" 时间跨度:%s → %s\n", info.Oldest.Format("2006-01-02 15:04"), info.Newest.Format("2006-01-02 15:04")) } fmt.Println() // 与配置中的两道闸对照,让运维一眼看出「当前是否在界内」。 maxRows := conf.Security.HoneypotMaxRows retainDays := conf.Security.HoneypotRetainDays if maxRows > 0 { fmt.Printf("行数上限:%d 条(当前 %d 条,%s)\n", maxRows, info.Rows, usageRatio(info.Rows, int64(maxRows))) } else { fmt.Printf("行数上限:未设置(当前 %d 条)\n", info.Rows) } if retainDays > 0 { fmt.Printf("保留期:%d 天(自动清理 %s)\n", retainDays, onOff(conf.Security.HoneypotAutoClean)) } else { fmt.Printf("保留期:未设置 —— 记录只增不减,需要手动执行 TamaBox honeypot prune\n") } // 凭据与元数据是两套存储,体积必须分开看: // 数据库里只有元数据,明文账号密码在文件里。 fmt.Println() if cs, err := credStoreFromConfig(); err == nil { if n, size, err := cs.Size(); err == nil { fmt.Printf("凭据取证文件:%d 个,%s\n", n, humanBytes(size)) fmt.Printf(" 目录:%s\n", cs.Dir()) fmt.Println(" 说明:用户名/密码只存在这里,不入库;按天一个文件,随保留期一并清理。") } } return nil } // runHoneypotPaths 列出当前生效的诱饵路径。 // // 存在的意义:honeypot_paths 是自由文本配置,写错了(拼错、写成正则、漏斜杠) // 从任何输出里都看不出来 —— 这个命令让「我配的到底生效没」变成一条可查的事实。 func runHoneypotPaths(ctx *cli.Context) error { // 本命令不查库(DB 挂掉时也能查配置),因此不能走 openDatabase 那条 // 顺带 conf.Init 的路径 —— 必须显式加载,否则读到的全是零值, // 会显示「蜜罐关闭」并把 honeypot_paths 吞掉。 if err := conf.Init(); err != nil { return errors.Wrap(err, "load configuration") } extra := honeypot.SplitPaths(conf.Security.HoneypotPaths) builtin := honeypot.New(honeypot.Options{Enabled: true}).Prefixes() active := honeypot.New(honeypot.Options{Enabled: true, ExtraPaths: extra}).Prefixes() fmt.Printf("蜜罐开关:%s\n", onOff(conf.Security.EnableHoneypot)) if !conf.Security.EnableHoneypot { fmt.Println("提示:enable_honeypot = false,以下路径当前不会接管任何请求。") } fmt.Println(strings.Repeat("-", 72)) fmt.Printf("生效诱饵路径:%d 条(内置 %d + 自定义 %d,重名已合并)\n", len(active), len(builtin), len(extra)) for _, p := range active { fmt.Println(" " + p) } if len(extra) > 0 { fmt.Println() fmt.Println("自定义路径(honeypot_paths):") for _, p := range extra { fmt.Println(" " + p) } } return nil } // runHoneypotPurgeCreds 删除旧版本留在 honeypot_hits 上的明文凭据列。 // // 为什么需要:结构体早已去掉这些字段,但 GORM 的 AutoMigrate **不删既有列**, // 老库升级上来后历史明文仍在,还会跟着进备份与主从复制。 // DROP COLUMN 不可逆,所以做成显式命令 + 二次确认,不放启动流程里自动执行 // (启动时只会 WARN 提示)。 func runHoneypotPurgeCreds(ctx *cli.Context) error { database, err := openDatabase() if err != nil { return err } store := db.NewHoneypotStore(database) cols, err := store.LegacyCredentialColumns(context.Background()) if err != nil { return errors.Wrap(err, "check legacy credential columns") } if len(cols) == 0 { fmt.Println("honeypot_hits 上没有遗留的凭据列,无需清理。") return nil } fmt.Println("honeypot_hits 上仍存在以下旧版明文凭据列(整列删除,不可逆):") for _, c := range cols { fmt.Println(" " + c) } fmt.Println("注意:本命令只处理数据库;磁盘上的历史备份需要你自行处理。") if !ctx.Bool("yes") { fmt.Print("确认继续?输入 yes 继续:") var answer string if _, err := fmt.Scanln(&answer); err != nil || strings.TrimSpace(answer) != "yes" { fmt.Println("已取消。") return nil } } dropped, err := store.PurgeLegacyCredentialColumns(context.Background()) if err != nil { return errors.Wrapf(err, "凭据列删除中途失败,已删除 %v", dropped) } fmt.Printf("已删除 %d 列:%s\n", len(dropped), strings.Join(dropped, ", ")) return nil } // usageRatio 给出当前用量相对上限的百分比描述。 func usageRatio(cur int64, max int64) string { if max <= 0 { return "" } pct := float64(cur) / float64(max) * 100 switch { case pct >= 100: return "已达上限" case pct >= 80: return "接近上限" default: return fmt.Sprintf("%.1f%%", pct) } } func onOff(b bool) string { if b { return "已开启" } return "已关闭" } // humanBytes 把字节数格式化成人类可读形式。 func humanBytes(n int64) string { const unit = 1024 if n < unit { return fmt.Sprintf("%d B", n) } div, exp := int64(unit), 0 for v := n / unit; v >= unit; v /= unit { div *= unit exp++ } return fmt.Sprintf("%.2f %cB", float64(n)/float64(div), "KMGTPE"[exp]) } // maskSecret 对密码做保长脱敏:保留首尾各 1 位,中间固定长度星号。 // 目的是让运维能分辨「是不是同一个密码」,但不把字典直接摊在终端上。 func maskSecret(s string) string { r := []rune(s) switch { case len(r) <= 2: return strings.Repeat("*", len(r)) case len(r) <= 6: return string(r[0]) + strings.Repeat("*", len(r)-2) + string(r[len(r)-1]) default: return string(r[0]) + strings.Repeat("*", 6) + string(r[len(r)-1]) } } // mergeSourcesByKey 按「封禁 / 限流」的同一口径合并来源排行。 // // 元数据表存的是精确地址(取证需要,见 db.HoneypotHit.IP),但 IPv6 攻击者 // 通常在一个 /64 内轮换地址:直接按精确 IP 排行会碎成几十条各 1 次的记录, // 看不出「其实是同一台机器」。这里用 shared.LimitKey 折成 /64 —— // 与 autoban 判定、限流的键完全一致,看到的排行就是「谁在被封」。 // // 代价:SQL 只取了前 limit*4 条精确地址,极端长尾可能漏算,排行本身是近似展示。 func mergeSourcesByKey(rows []*db.HoneypotSourceStat, limit int) []*db.HoneypotSourceStat { if len(rows) == 0 { return rows } type agg struct { count int64 provider string } merged := make(map[string]*agg, len(rows)) for _, r := range rows { key := shared.LimitKey(r.IP) if a, ok := merged[key]; ok { a.count += r.Count if a.provider == "" { a.provider = r.Provider } continue } merged[key] = &agg{count: r.Count, provider: r.Provider} } out := make([]*db.HoneypotSourceStat, 0, len(merged)) for key, a := range merged { out = append(out, &db.HoneypotSourceStat{IP: key, Count: a.count, Provider: a.provider}) } sort.Slice(out, func(i, j int) bool { if out[i].Count != out[j].Count { return out[i].Count > out[j].Count } return out[i].IP < out[j].IP // 同次数时稳定排序,避免每次输出顺序抖动 }) if limit > 0 && len(out) > limit { out = out[:limit] } return out }