以周/月为单位汇总蜜罐命中,把来源 IP 通过公共服务查地理位置,画到地图上,
生成 HTML 报告并发邮件。同时补齐 ipinfo 无法回答「这个 IP 在哪儿」的问题。
新增 IP 地理定位层 internal/security/geoip:
- 与 internal/security/ipinfo 正交。ipinfo 走 cdncheck 离线指纹,只认云厂商/CDN/WAF,
答的是「是不是代理」;肉鸡(家宽、企业宽带、移动网络)必然被判为
「未识别(普通网络)」,所以「在哪儿」必须另建一套。
- ip-api.com 批量打底(100 个/请求、1.4s 节流)+ 腾讯位置服务精修国内
(一 IP 一请求,只精修命中数最高的前 N 个,受 ip_geo_tencent_key 控制)。
- Result{Geo, Unresolved, Requests, Failures} 三态。服务明确回答「没有这个地址」
才允许写失败缓存;请求超时/限流/5xx 一律不写 —— 否则一次抖动会把整批 IP
钉成「查不到」三天,此后每份报告的定位覆盖率都莫名变成 0。
- addrs 按调用方给的顺序构建(不是 map 遍历):腾讯精修只取前 N 个,乱序会让
每次运行精修到不同 IP,配额花在长尾上。
报告生成 internal/security/report:
- period:PreviousWeek / PreviousMonth,窗口一律 [start, end) 左闭右开,
相邻两期不重叠也不漏边界整点;period_key(week:2026-W44)做幂等。
- aggregate:按 shared.LimitKey 合并(IPv4 精确 / IPv6 折 /64);国家、城市、
路径、每日趋势;显式统计未定位命中数(覆盖率 60% 的图看起来和 100% 一样)。
- mapdraw:自绘等距圆柱投影气泡图。零海岸线零国界 —— 国界/台湾/南海诸岛绘制
必须符合国家标准,自绘极易画错,真实底图交给腾讯。经纬网 + 度数刻度 +
标签防重叠(同一都市圈两城市坐标差 0.01°,曾把「AU 55」和「AU 30」叠成
看起来像 AU 305 的假数字)。
- html:自包含报告页,内嵌 SVG + 腾讯交互地图(无 key 时给申请指引,
代码里不预置任何 key)。
- countryname:港澳台统一显示「中国香港/中国澳门/中国台湾」,常见国家用中文名。
存储与调度:
- ip_geo_cache(主键 = LimitKey,成功 TTL 90 天 / 明确无数据 TTL 3 天)
- security_report_logs(period_key 唯一索引 → 重启、错过时间窗都能正确补发,
失败重试上限 5 次后停止)
- honeypot report --period week|month [--to] [--dir] [--no-email]
- web 启动时按配置挂 30 分钟一轮的调度器(与保留期清理共用停止信号,
defer 移出 if 外,避免「清理关掉、报告开着」时 goroutine 泄漏)
配置([security]):ip_geo_provider=auto|ipapi|tencent|none、ip_geo_tencent_key、
honeypot_report_enabled / _weekly / _monthly / _email / _dir。
顺带修复:
- route/honeypot_test.go 的 captureStore 缺 AggregateSources/AggregatePaths/
TimestampsBetween,整个 route 包编译不过。
- templates/render_test.go 的 mockLoggedUser 缺 TelegramChatID,3 个渲染测试失败。
569 lines
19 KiB
Go
569 lines
19 KiB
Go
// 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 report --period week 生成安全汇总周报(含地图)并发邮件
|
||
// 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,
|
||
},
|
||
// 周期安全汇总:把命中按 IP 定位到城市,出地图报告并邮件送达。
|
||
reportCommand,
|
||
{
|
||
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))
|
||
|
||
// 按家族汇总:运维真正关心的是「哪几类后台在伪装」,而不是 100 条路径。
|
||
// 家族决定返回哪种登录页(WordPress / phpMyAdmin / Jenkins / Tomcat …)。
|
||
counts := make(map[honeypot.Family]int, len(honeypot.Families()))
|
||
for _, p := range active {
|
||
counts[honeypot.FamilyOf(p)]++
|
||
}
|
||
parts := make([]string, 0, len(counts))
|
||
for _, fam := range honeypot.Families() {
|
||
if n := counts[fam]; n > 0 {
|
||
parts = append(parts, fmt.Sprintf("%s %d", fam, n))
|
||
}
|
||
}
|
||
fmt.Println("伪装家族:" + strings.Join(parts, " / "))
|
||
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
|
||
}
|