Files
tamabox/internal/cmd/honeypot.go
T
tamakyi 63e851ddae feat(honeypot): 自动保留期清理 + 行数上限 + VACUUM 空间回收 + size 子命令
问题:honeypot_hits 是只追加审计表,写入完全由外部扫描流量驱动(不可预期、
不可节流),而原实现只在手动跑 CLI prune 时才清理,站点运行期表体积只增不减。

实测取证(本地 PostgreSQL 建同构表灌 10.5 万行真实数据):
- 典型行 244 字节(含索引),10.5 万行 24 MB
- 保留期清理对照(行数恒定 10 万,每轮删 25% 插 25%):
  有 VACUUM 22→24MB 收敛;无 VACUUM 22→52MB 持续膨胀
- VACUUM 不缩小文件,只让空间可复用;二次实测(5 万行、连续 4 轮删插)
  行数恒定 5 万、文件稳定 15MB 不再增长

改动:
- 新增 HoneypotRetention 后台清理器(每小时一轮):时间闸 honeypot_retain_days
  + 数量闸 honeypot_max_rows,清理后调 Compact 回收空间
- db: 新增 Compact()(PG 执行 VACUUM (ANALYZE),走 GORM Exec 非事务路径)、
  DeleteOldestBeyond()(跨方言通用)、ListSinceByIP()、Size()
- 去掉 honeypot_hits.provider 无用索引(10 万行占 648kB,全代码库零查询使用),
  并在启动时显式清理既有库上的旧索引(GORM 去 tag 不会自动删)
- 修 CLI hits --ip 过滤 bug:原先取回最近 N 条再在 Go 侧筛,导致指定 IP 的
  历史命中基本查不出来 → 改到 SQL 层过滤
- 新增 CLI honeypot size 子命令:报告行数、磁盘占用、时间跨度,并与两道闸对照
- 新增配置 honeypot_auto_clean / honeypot_max_rows
2026-10-21 13:16:14 +08:00

377 lines
11 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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"
"strings"
"time"
"github.com/pkg/errors"
"github.com/urfave/cli/v2"
"github.com/tamakyi/TamaBox/internal/conf"
"github.com/tamakyi/TamaBox/internal/db"
)
// 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 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,
},
},
}
// 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
}
func runHoneypotHits(ctx *cli.Context) error {
dur, err := parseSince(ctx.String("since"))
if err != nil {
return err
}
database, err := openDatabase()
if err != nil {
return err
}
store := db.NewHoneypotStore(database)
since := time.Now().Add(-dur)
filterIP := strings.TrimSpace(ctx.String("ip"))
showPwd := ctx.Bool("show-password")
// 按 IP 过滤在 SQL 层做:取回最近 N 条再在内存里筛,会导致「指定 IP 的
// 历史命中基本查不出来」(它的记录被其他 IP 的更近记录挤出了窗口)。
rows, err := store.ListSinceByIP(context.Background(), since, filterIP, ctx.Int("limit"))
if err != nil {
return errors.Wrap(err, "query honeypot hits")
}
fmt.Printf("蜜罐命中记录(近 %s,共 %d 条)\n", dur, len(rows))
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.CreatedAt.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.Provider != "" {
fmt.Printf(" 归属:%s%s\n", r.Provider, proxiedSuffix(r.Proxied))
}
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")
}
sources, err := store.TopSources(cctx, since, ctx.Int("limit"))
if err != nil {
return errors.Wrap(err, "query top sources")
}
paths, err := store.TopPaths(cctx, since, ctx.Int("limit"))
if err != nil {
return errors.Wrap(err, "query top paths")
}
creds, err := store.TopCredentials(cctx, 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("来源 IP 排行:")
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)
}
}
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")
}
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])
}
}
func proxiedSuffix(proxied bool) string {
if proxied {
return "(CDN/代理出口)"
}
return ""
}