Files
tamakyi d690355cdc feat(honeypot): 蜜罐安全状况周期汇总(周报/月报 + 地图 + 邮件)
以周/月为单位汇总蜜罐命中,把来源 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 个渲染测试失败。
2026-10-30 16:40:44 +08:00

569 lines
19 KiB
Go
Raw Permalink 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"
"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
}