Files
tamabox/internal/mail/mail.go
T
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

380 lines
15 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 2022 E99p1ant. All rights reserved.
// Use of this source code is governed by a MIT-style
// license that can be found in the LICENSE file.
package mail
import (
"bytes"
"crypto/tls"
"embed"
"fmt"
"html/template"
"path/filepath"
"strings"
"time"
"github.com/pkg/errors"
"gopkg.in/gomail.v2"
"github.com/tamakyi/TamaBox/internal/conf"
"github.com/tamakyi/TamaBox/templates"
)
func SendNewQuestionMail(email, domain string, questionID uint, questionContent string, token string) error {
params := map[string]string{
"link": fmt.Sprintf("%s/_/%s/%d?t=%s", conf.App.ExternalURL, domain, questionID, token),
"question": questionContent,
}
return sendTemplateMail(email, "【狼的提问箱】您有一个新的提问", templates.FS, "mail/new-question.html", params)
}
func SendNewQuestionMailToUser(email, domain string, questionID uint, questionContent string, token string) error {
params := map[string]string{
"link": fmt.Sprintf("%s/_/%s/%d?t=%s", conf.App.ExternalURL, domain, questionID, token),
"question": questionContent,
}
return sendTemplateMail(email, "【狼的提问箱】您发送了一个新的提问", templates.FS, "mail/new-question-to-user.html", params)
}
func SendNewAnswerMail(email, domain string, questionID uint, question, answer string, token string) error {
params := map[string]string{
"link": fmt.Sprintf("%s/_/%s/%d?t=%s", conf.App.ExternalURL, domain, questionID, token),
"question": question,
"answer": answer,
}
return sendTemplateMail(email, "【狼的提问箱】您的提问有了回复", templates.FS, "mail/new-answer.html", params)
}
func SendPasswordRecoveryMail(email, code string) error {
params := map[string]string{
"link": fmt.Sprintf("%s/recover-password?code=%s", conf.App.ExternalURL, code),
"email": email,
}
return sendTemplateMail(email, "【狼的提问箱】账号密码找回", templates.FS, "mail/password-recovery.html", params)
}
// SendNewFollowUpMail 通知提问箱主人:有人对某条已回答的提问进行了追问。
// 链接带 token,与 SendNewQuestionMail 对齐——主人于未登录设备(手机邮件客户端)点开私密提问时
// 也能凭 token 查看,否则会被 Questioner 中间件重定向到首页。
func SendNewFollowUpMail(email, domain string, questionID uint, questionContent string, followUpContent string, token string) error {
params := map[string]string{
"link": fmt.Sprintf("%s/_/%s/%d?t=%s", conf.App.ExternalURL, domain, questionID, token),
"question": questionContent,
"followup": followUpContent,
}
return sendTemplateMail(email, "【狼的提问箱】您的提问有了追问", templates.FS, "mail/new-follow-up.html", params)
}
// SendNewConversationMail 通知有新私聊发起(使用 token 而非 conversationID)
func SendNewConversationMail(email, askerName, domain string, token string, firstMessage string) error {
params := map[string]string{
"link": fmt.Sprintf("%s/_/%s/chat/%s", conf.App.ExternalURL, domain, token),
"asker": askerName,
"content": firstMessage,
}
return sendTemplateMail(email, "【狼的提问箱】有人发起了私聊", templates.FS, "mail/new-conversation.html", params)
}
// SendNewMessageMail 通知私聊中有新消息(使用 token 而非 conversationID)
func SendNewMessageMail(email, senderName, domain string, token string, content string) error {
params := map[string]string{
"link": fmt.Sprintf("%s/_/%s/chat/%s", conf.App.ExternalURL, domain, token),
"sender": senderName,
"content": content,
}
return sendTemplateMail(email, "【狼的提问箱】私聊新消息", templates.FS, "mail/new-message.html", params)
}
// SendNewVoteSubmissionMail 通知箱主:表单投票收到新回复(链接指向管理后台结果页)。
func SendNewVoteSubmissionMail(email, voteTitle string, voteID uint) error {
params := map[string]string{
"link": fmt.Sprintf("%s/user/votes/%d/results", conf.App.ExternalURL, voteID),
"title": voteTitle,
}
return sendTemplateMail(email, "【狼的提问箱】您的投票收到了新回复", templates.FS, "mail/new-vote.html", params)
}
// SendSecurityAlertMail 发送安全告警:检测到疑似批量扫描/探测并触发自动封禁时通知管理员。
// provider 为来源 IP 的网络归属标签(见 internal/security/ipinfo),proxied 表示
// IP 属于 CDN/WAF/代理网络(此时封禁出口 IP 可能无法拦截真实攻击者,邮件中额外提示)。
func SendSecurityAlertMail(ip, provider string, proxied bool, samplePaths []string, durationMinutes int) error {
proxyHint := ""
if proxied {
proxyHint = "1"
}
params := map[string]string{
"ip": ip,
"provider": provider,
"proxy_hint": proxyHint,
"paths": strings.Join(samplePaths, "、"),
"duration": fmt.Sprintf("%d", durationMinutes),
"time": time.Now().Format("2006-01-02 15:04:05"),
}
return sendTemplateMail(conf.Security.SecurityAlertEmail, "【狼的提问箱】检测到疑似批量扫描,已自动封禁 IP", templates.FS, "mail/security-alert.html", params)
}
// HoneypotAlert 蜜罐捕获到破解尝试时的告警载荷。
type HoneypotAlert struct {
// IP 是精确客户端地址,正文里展示的就是它(人要对得上自己的地址)。
IP string
// BanKey 是实际写进封禁表的键:IPv4 与 IP 相同,IPv6 是折叠后的 /64 前缀。
// 单独给出,是为了让运维能分清「源地址」与「被封的范围」——
// 曾经只给键,收到「你的 IP 已被封禁」的人发现邮件里的地址不是自己的,
// 排查方向直接被带偏。
BanKey string
Provider string
Proxied bool
Path string
Username string
Password string
Banned bool
DurationMin int
}
// SendHoneypotAlertMail 发送「蜜罐捕获到破解尝试」告警。
//
// 与 SendSecurityAlertMail 共用 conf.Security.SecurityAlertEmail 收件人,不新增配置项。
// 邮件中直接给出捕获到的用户名/密码:这是本功能的核心价值(拿到攻击者正在用的字典),
// 但也意味着这封邮件是敏感物 —— 收件人应当只有运维本人,不要转发到公共群组。
func SendHoneypotAlertMail(a HoneypotAlert) error {
proxyHint := ""
if a.Proxied {
proxyHint = "1"
}
disposition := "已记录,未触发封禁"
if a.Banned {
disposition = fmt.Sprintf("已记录并自动临时封禁 %d 分钟", a.DurationMin)
}
password := a.Password
if password == "" {
password = "(空)"
}
username := a.Username
if username == "" {
username = "(空)"
}
params := map[string]string{
"ip": a.IP,
"ban_key": a.BanKey,
"provider": a.Provider,
"proxy_hint": proxyHint,
"path": a.Path,
"username": username,
"password": password,
"disposition": disposition,
"time": time.Now().Format("2006-01-02 15:04:05"),
}
return sendTemplateMail(conf.Security.SecurityAlertEmail, "【狼的提问箱】蜜罐捕获到后台破解尝试", templates.FS, "mail/honeypot-alert.html", params)
}
// SendAbuseAlertMail 发送「某箱主收到集中举报」的阈值告警。
//
// 与 SendSecurityAlertMail 共用 conf.Security.SecurityAlertEmail 收件人,不新增配置项。
// 邮件只给计数与下一步命令,不贴举报内容原文:邮件可能被转发/归档,贴原文等于把挂人内容
// 又复制了一份出去;要看内容请用 reports --domain。
func SendAbuseAlertMail(domain, name string, count24, count7d int64) error {
params := map[string]string{
"domain": domain,
"name": name,
"count24": fmt.Sprintf("%d", count24),
"count7d": fmt.Sprintf("%d", count7d),
"time": time.Now().Format("2006-01-02 15:04:05"),
}
return sendTemplateMail(conf.Security.SecurityAlertEmail, "【狼的提问箱】某箱主收到集中举报", templates.FS, "mail/abuse-alert.html", params)
}
// SendQuestionReportedMail 通知箱主「你的某条内容被多人举报」。
//
// 与给运维的告警不同:这封是给**箱主**的自查提醒,语气必须中性 ——
// 明确写出「不代表平台已判定违规」,避免变成变相的处罚通知。
func SendQuestionReportedMail(email, domain string, questionID uint, count int64, link string) error {
params := map[string]string{
"domain": domain,
"question_id": fmt.Sprintf("%d", questionID),
"count": fmt.Sprintf("%d", count),
"link": link,
"time": time.Now().Format("2006-01-02 15:04:05"),
}
return sendTemplateMail(email, "【狼的提问箱】你的内容被举报了", templates.FS, "mail/question-reported.html", params)
}
// RestrictionNotice 处置通知邮件的内容。
//
// 全部文案由调用方按「箱主自己的界面语言」预先翻译好再传进来:
// 邮件模板本身是单语言的,而箱主的语言存在 users.language 里,只能在调用侧解析。
//
// 刻意不包含 --reason 原文:那是运维的审计备注(可能写得很直白),
// 对外只用「级别 + 到期时间 + 规则链接」,与公告模板的口径一致。
type RestrictionNotice struct {
To string
Heading string
Greeting string
Body string
Note string
RulesText string
RulesURL string
Appeal string
}
// SendRestrictionNoticeMail 告知箱主「你的提问箱已被限制」。
func SendRestrictionNoticeMail(n RestrictionNotice) error {
params := map[string]string{
"heading": n.Heading,
// time 由邮件包统一注入:调用方(CLI / 未来可能的自动流程)不必各自拼时间,
// 也保证与其他通知邮件(question-reported / abuse-alert)的头部格式一致。
"time": time.Now().Format("2006-01-02 15:04:05"),
"greeting": n.Greeting,
"body": n.Body,
"note": n.Note,
"rulesText": n.RulesText,
"rulesURL": n.RulesURL,
"appeal": n.Appeal,
}
return sendTemplateMail(n.To, n.Heading, templates.FS, "mail/restriction-notice.html", params)
}
func sendTemplateMail(email, title string, templateFS embed.FS, templatePath string, params map[string]string) error {
var content bytes.Buffer
t, err := template.ParseFS(templateFS, templatePath)
if err != nil {
return errors.Wrap(err, "parse template file")
}
params["year"] = time.Now().Format("2006")
if err := t.Execute(&content, params); err != nil {
return errors.Wrap(err, "execute template")
}
return sendMail(email, title, content.String())
}
func sendMail(to, title, content string) error {
return sendMailWith(to, title, content, nil, nil)
}
// MailAttachment 邮件附件。
type MailAttachment struct {
Path string
// Name 附件显示名,留空则用文件名。
Name string
}
// MailInlineImage 正文内嵌图片。
//
// CID 是 Content-ID:正文 HTML 里写 <img src="cid:xxx">,邮件客户端就会用
// 附件里的这张图渲染。这是让「地图不依赖任何外链、不依赖 JS」的唯一可靠办法 ——
// 直接放外链图片,Gmail 默认不加载远程图片;放内联 SVG,Gmail/Outlook 会剥掉。
type MailInlineImage struct {
Path string
CID string
}
// sendMailWith 发送一封带内嵌图片与附件的 HTML 邮件。
//
// 图片与附件都从本地路径读取(调用方负责把文件写到临时目录或报告目录)。
// 任何一步失败都向上返回错误:报告这种「发了就该完整」的邮件,
// 静默丢附件比直接报错更糟。
func sendMailWith(to, title, content string, inline []MailInlineImage, atts []MailAttachment) error {
m := gomail.NewMessage()
m.SetHeader("From", fmt.Sprintf("TamaBox <%s>", conf.Mail.Account))
m.SetHeader("To", to)
m.SetHeader("Subject", title)
m.SetBody("text/html", content)
for _, img := range inline {
// 显式指定 Content-ID:gomail 的 Embed 默认会给一个随机值,
// 那样正文里就没法引用它。
m.Embed(img.Path, gomail.SetHeader(map[string][]string{
"Content-ID": {"<" + img.CID + ">"},
"Content-Disposition": {`inline; filename="` + filepath.Base(img.Path) + `"`},
}))
}
for _, a := range atts {
opts := []gomail.FileSetting{}
if a.Name != "" {
opts = append(opts, gomail.Rename(a.Name))
}
m.Attach(a.Path, opts...)
}
d := gomail.NewDialer(
conf.Mail.SMTP,
conf.Mail.Port,
conf.Mail.Account,
conf.Mail.Password,
)
// 默认校验证书;仅当配置了 skip_tls_verify=true(自签名/内网 SMTP)时跳过。
if conf.Mail.SkipTLSVerify {
d.TLSConfig = &tls.Config{InsecureSkipVerify: true}
}
return d.DialAndSend(m)
}
// SecurityReportMail 周期安全汇总邮件的载荷。
//
// HTML 片段用 template.HTML 传:报告正文里有 KPI 卡片和表格,
// 走 string 会被 html/template 转义成源码文本,收件人看到的是一堆尖括号。
type SecurityReportMail struct {
// To 收件人,由调用方决定(honeypot_report_email / security_alert_email)。
To string
Title string
Period string
Generated string
// MapCID 正文内嵌地图的 Content-ID;为空则不发地图(报告无数据时)。
MapCID string
// SummaryHTML KPI 卡片片段。
SummaryHTML template.HTML
// CountriesHTML 国家排行表片段。
CountriesHTML template.HTML
// Notes 口径说明。用 template.HTML 传:里面有多行 <br/> 结构。
Notes template.HTML
// Attachments 附件(报告 HTML)。
Attachments []MailAttachment
// InlineImages 内嵌图片(地图 PNG),其 CID 必须与 MapCID 一致。
InlineImages []MailInlineImage
}
// securityReportData 是模板的渲染载荷(独立成一个类型,方便单独测渲染)。
type securityReportData struct {
Title string
Period string
Generated string
MapCID string
SummaryHTML template.HTML
CountriesHTML template.HTML
Notes template.HTML
}
// SendSecurityReportMail 发送周期安全汇总邮件。
func SendSecurityReportMail(r SecurityReportMail) error {
content, err := RenderSecurityReportMail(r)
if err != nil {
return err
}
return sendMailWith(r.To, r.Title, content, r.InlineImages, r.Attachments)
}
// RenderSecurityReportMail 只渲染邮件正文,不发信。
//
// 单独拆出来是为了能在没有 SMTP 的环境里验证模板:模板里的字段名写错一个字母,
// 生产上表现为「报告发出去了但正文是空的」,而这封邮件一周才发一次,
// 等发现时已经缺了好几期。
func RenderSecurityReportMail(r SecurityReportMail) (string, error) {
t, err := template.ParseFS(templates.FS, "mail/security-report.html")
if err != nil {
return "", errors.Wrap(err, "parse template file")
}
var content bytes.Buffer
if err := t.Execute(&content, securityReportData{
Title: r.Title,
Period: r.Period,
Generated: r.Generated,
MapCID: r.MapCID,
SummaryHTML: r.SummaryHTML,
CountriesHTML: r.CountriesHTML,
Notes: r.Notes,
}); err != nil {
return "", errors.Wrap(err, "execute template")
}
return content.String(), nil
}