以周/月为单位汇总蜜罐命中,把来源 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 个渲染测试失败。
274 lines
15 KiB
Go
274 lines
15 KiB
Go
// 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 conf
|
||
|
||
// ⚠️ 仅应由 "-ldflags" 注入。
|
||
var (
|
||
BuildTime string
|
||
BuildCommit = "dev"
|
||
|
||
// StaticVersion 静态资源缓存版本号:base.css 与主题 css 共用,改样式只改这一处。
|
||
StaticVersion = "20261022c"
|
||
)
|
||
|
||
// 文本审核通道取值([security] text_censor_source)。定义在 conf 侧,
|
||
// 让配置校验(conf.Init)与通道装配(internal/security/censor)共用同一份字面量。
|
||
const (
|
||
CensorSourceAuto = "auto" // 七牛优先、阿里云兜底(历史行为,留空同义)
|
||
CensorSourceQiniu = "qiniu"
|
||
CensorSourceAliyun = "aliyun"
|
||
)
|
||
|
||
var (
|
||
App struct {
|
||
Production bool `ini:"production"`
|
||
ExternalURL string `ini:"external_url"`
|
||
ICP string `ini:"icp"`
|
||
DefaultLang string `ini:"default_lang"` // 站点默认语言(zh-CN/zh-TW/en/ja),留空回退简体中文
|
||
UptraceDSN string `ini:"uptrace_dsn"`
|
||
QiniuAccessKey string `ini:"qiniu_access_key"`
|
||
QiniuAccessSecret string `ini:"qiniu_access_secret"`
|
||
AliyunAccessKey string `ini:"aliyun_access_key"`
|
||
AliyunAccessKeySecret string `ini:"aliyun_access_key_secret"`
|
||
SentryDSN string `ini:"sentry_dsn"`
|
||
MaintenanceMode bool `ini:"maintenance_mode"`
|
||
ShareBackground string `ini:"share_background"`
|
||
FontPath string `ini:"font_path"`
|
||
}
|
||
|
||
// Theme 主题系统([theme] 段)。
|
||
// default :站点默认主题 slug,留空/非法回退内置 default(= 现在的外观)。
|
||
// available :启用哪些主题(逗号分隔),同时是箱主设置页的白名单;
|
||
// 列了但缺少 css 文件的会被忽略并告警(见 internal/theme)。
|
||
Theme struct {
|
||
Default string `ini:"default"`
|
||
Available string `ini:"available"`
|
||
}
|
||
|
||
// Announcement 站内公告([announcement] 段)。解析与展示判定见 announcement.go。
|
||
Announcement announcementConfig
|
||
|
||
Security struct {
|
||
EnableTextCensor bool `ini:"enable_text_censor"`
|
||
IPBlacklist string `ini:"ip_blacklist"`
|
||
IPBlacklistRedirectURL string `ini:"ip_blacklist_redirect_url"`
|
||
// TrustProxy 为 true 时信任 CDN 权威客户端头 CF-Connecting-IP / EO-Client-IP
|
||
// (以及受信代理设置的 X-Real-IP / X-Forwarded-For);否则一律用 RemoteAddr,
|
||
// 防止伪造 header 绕过速率限制与 IP 黑名单。
|
||
// ⚠️ 权威头只在 TCP 对端可信(回环,或命中 TrustedProxies)时才会被采信 ——
|
||
// 见 shared.ResolveClientIP:公网对端带来的权威头一律忽略(P1-3 修复)。
|
||
TrustProxy bool `ini:"trust_proxy"`
|
||
// TrustedProxies 逗号分隔的 IP / CIDR,仅限自己的反向代理 / LB。仅当 TCP 对端
|
||
// 落在该清单内才解析 X-Forwarded-For / X-Real-IP;留空则这两个头永不被信任。
|
||
// 回环地址(127.0.0.1 / ::1)无需登记即视为可信对端:cloudflared / 本机 nginx
|
||
// 回源的连接必然由本机进程发起,外部无法伪造。
|
||
// CDN 直连回源(非 Tunnel)的部署形态下,必须把该 CDN 的回源网段登记到这里,
|
||
// 否则它的权威头会被忽略、客户端 IP 退化为 CDN 边缘 IP。
|
||
TrustedProxies string `ini:"trusted_proxies"`
|
||
EnableAutoBan bool `ini:"enable_auto_ban"` // fail2ban 风格:检测到扫描特征后临时封禁来源 IP
|
||
// BanTrackNotFound 是否把未注册路径的 404 计入封禁统计。默认 false:仅命中后台
|
||
// 扫描特征路径(KindSensitive)才统计,避免共享出口(CDN 边缘 / CGNAT)下
|
||
// 正常用户被 404 探测流量连坐误伤。
|
||
BanTrackNotFound bool `ini:"ban_track_notfound"`
|
||
BanThresholdSensitive int `ini:"ban_threshold_sensitive"` // 窗口内敏感路径命中次数
|
||
BanThreshold404 int `ini:"ban_threshold_404"` // 窗口内 404 次数
|
||
BanWindowSeconds int `ini:"ban_window_seconds"` // 统计窗口(秒)
|
||
BanDurationMinutes int `ini:"ban_duration_minutes"` // 封禁时长(分钟)
|
||
// DefaultModerationStrictness 站点默认审核严格度(lax / standard / strict),
|
||
// 仅对未单独设置档位的账号生效(见 moderation.StrictnessFor)。留空或非法值回退
|
||
// strict(最严档),保证升级不会放宽拦截;默认拦截面可能较升级前略有扩大,
|
||
// 希望更少误伤则显式写 standard。
|
||
DefaultModerationStrictness string `ini:"default_moderation_strictness"`
|
||
// TextCensorSource 文本审核通道:qiniu / aliyun(内容安全 2.0 TextModeration,
|
||
// 个人实名账号即可开通)/ auto(默认,留空同义:七牛优先、阿里云复核)。
|
||
// 仅显式选定的通道会被调用,缺凭据时启动即报错(见 conf.Init)——
|
||
// 审核失败是 fail-closed(拒绝提交),会让发布与私信整站不可用且现象隐蔽。
|
||
TextCensorSource string `ini:"text_censor_source"`
|
||
// EnableHoneypot 开启「虚假后台登录界面」蜜罐:扫描器探测后台路径时
|
||
// 返回一个仿 WordPress 的假登录页,并记录其提交的用户名/密码等破解信息。
|
||
// 仅在「无任何路由匹配」的路径上生效,与真实业务路由零交集。
|
||
EnableHoneypot bool `ini:"enable_honeypot"`
|
||
// HoneypotPaths 追加自定义诱饵路径(逗号分隔),与内置清单并集。
|
||
HoneypotPaths string `ini:"honeypot_paths"`
|
||
// HoneypotRetainDays 蜜罐命中记录的保留天数,到期自动清理。
|
||
// ⚠️ 键名是 honeypot_retain_days(没有 tion)。写成 honeypot_retention_days
|
||
// 会被 ini 静默忽略 —— 现场踩过这个坑,表现为「自动清理未启用」。
|
||
// 缺省 30 天(见 sanitizeHoneypot);显式 <= 0 也回退 30。
|
||
// 要关闭自动清理请用 honeypot_auto_clean = false。
|
||
HoneypotRetainDays int `ini:"honeypot_retain_days"`
|
||
// HoneypotAutoClean 是否启用蜜罐记录的后台自动保留期清理。
|
||
// 缺省 true:honeypot_hits 是只追加的审计表,写入完全由外部扫描流量驱动,
|
||
// 没有自动清理的话表体积只增不减(这是「数据库被扫描撑爆」的真实路径)。
|
||
// 关闭后仍可手动执行 TamaBox honeypot prune。
|
||
// ⚠️ 缺省值靠 HasKey 判定(见 sanitizeHoneypot):ini 的 bool 解析是
|
||
// 「有键才赋值」,若只看零值就无法区分「未配置」与「显式关闭」。
|
||
HoneypotAutoClean bool `ini:"honeypot_auto_clean"`
|
||
// HoneypotMaxRows 蜜罐命中记录的行数上限。缺省 200000(约 53 MB)。
|
||
// 显式写 0 表示不设上限(逃生口,会告警提醒表只增不减)。
|
||
// 保留期是时间维度的上界,但攻击者可以用海量源 IP 在保留期内把表撑爆,
|
||
// 这是第二道闸。实测单行约 281 字节(含索引,常规 UA),20 万行约 53 MB。
|
||
HoneypotMaxRows int `ini:"honeypot_max_rows"`
|
||
// HoneypotThreshold 蜜罐「提交凭据」多少次即封禁。默认 1(一次即封):
|
||
// 往假后台里填账号密码是证据最强的行为,抓到一次就足够。
|
||
// ⚠️ 它不作用于纯路径探测(GET 看一眼假登录页)—— 那类请求走常规
|
||
// ban_threshold_sensitive,否则扫描器 GET 一次就被封,永远走不到
|
||
// POST 提交凭据那一步,蜜罐也就拿不到字典。
|
||
// 缺省回退 1(见 sanitizeHoneypot)。
|
||
HoneypotThreshold int `ini:"honeypot_threshold"`
|
||
// HoneypotBanMinutes 蜜罐封禁时长(分钟)。缺省 1440(24 小时),
|
||
// 刻意远长于 ban_duration_minutes:命中蜜罐意味着对方已在提交账号密码,
|
||
// 证据强度高于单纯路径探测(见 DefaultHoneypotBanMinutes)。
|
||
// 显式 <= 0 表示回退 ban_duration_minutes。
|
||
HoneypotBanMinutes int `ini:"honeypot_ban_minutes"`
|
||
// HoneypotCredDir 凭据取证文件的存放目录。
|
||
//
|
||
// ⚠️ 捕获到的用户名/密码**不入库**,只追加到这个目录下的按天文件
|
||
// (creds-YYYY-MM-DD.jsonl,一行一条 JSON)。原因:明文账号密码一旦入库,
|
||
// 就会跟着进备份、主从复制和任何能连库的报表工具,事后无法彻底清除。
|
||
// 目录会被强制为 0700、文件 0600;留空使用默认 data/honeypot-creds。
|
||
// 目录不可写时只降级为「不保存凭据」,不会影响站点启动。
|
||
HoneypotCredDir string `ini:"honeypot_cred_dir"`
|
||
// BanImagePath 被封禁时返回的图片路径(本地文件,相对路径以进程工作目录为基准)。
|
||
//
|
||
// 留空、文件读不到、或内容不是图片时,一律回退为纯文字 "403 Forbidden" ——
|
||
// 这条路径绝不能因为一张图把「拒绝」响应弄坏,更不能让缺失的资源变成 500。
|
||
//
|
||
// 只接受 image/* 的内容:如果配成 app.ini 之类的文件会被拒绝并告警,
|
||
// 否则等于把配置文件当响应体吐给每一个被封的攻击者。
|
||
BanImagePath string `ini:"ban_image_path"`
|
||
SecurityAlertEmail string `ini:"security_alert_email"` // 触发封禁时告警收件人(留空不发送)
|
||
|
||
// CSP 内容安全策略的生效模式:enforce / report / off,缺省 enforce。
|
||
//
|
||
// enforce 下发 Content-Security-Policy(真正拦截);report 只下发 Report-Only
|
||
// (照常加载、只记违规),用于临时观察。默认拦截而不是观察,原因是:
|
||
// 没人会盯着 console,且不给 csp_report_uri 时 Report-Only 等于什么都没做,
|
||
// 留个不生效的开关反而制造「已防护」的错觉。默认值的安全前提是白名单已经
|
||
// 用真浏览器逐页巡检过(csp_sweep.py:20 个页面零违规)。
|
||
// 上线后发现误伤,把 csp 改成 report 或 off 再重启即可恢复,不用改代码。
|
||
CSP string `ini:"csp"`
|
||
// CSPReportURI 违规上报地址(可选)。留空时浏览器只把违规打到 console 不上报;
|
||
// 用 report 模式观察时应当配上,否则「观察一周」就只是空谈。
|
||
CSPReportURI string `ini:"csp_report_uri"`
|
||
|
||
// IPGeoProvider 蜜罐/扫描来源 IP 的地理定位数据源。
|
||
//
|
||
// ⚠️ 与 ipinfo.Lookup(cdncheck 离线指纹)是两件事:那套只认云厂商/CDN/WAF
|
||
// 的 ASN,肉鸡(家宽、企业宽带、移动网络)一律显示「未识别(普通网络)」。
|
||
// 这里回答的是「这个 IP 在哪儿」,靠的是外部 IP 库。
|
||
//
|
||
// auto(缺省):ip-api.com 批量打底(全球覆盖、无需 key、一次 100 个),
|
||
// 配了 ip_geo_tencent_key 时再用腾讯位置服务精修国内 IP
|
||
// (省市 + GCJ-02 坐标)。腾讯的 IP 定位是一 IP 一请求,
|
||
// 拿它做批量主干会把 N 个 IP 变成 N 次 HTTP,故只做精修。
|
||
// ipapi:只用 ip-api.com
|
||
// tencent:只用腾讯位置服务(要求配 key,否则降级为 ipapi)
|
||
// none:不查询,报告里只给 IP 不提地理信息(攻击者 IP 一个字节都不外发)
|
||
//
|
||
// 隐私边界:auto/ipapi 会把攻击者 IP 发给 ip-api.com,tencent 会发给腾讯。
|
||
// 本报告只处理「攻击者」地址,不涉及任何用户 IP;不接受这一点请用 none。
|
||
IPGeoProvider string `ini:"ip_geo_provider"`
|
||
// IPGeoTencentKey 腾讯位置服务 key(lbs.qq.com 申请)。留空则跳过腾讯精修。
|
||
// 仅用于服务端 HTTP 请求,不下发到前端 —— 与地图 JS 用的 key 不是一回事。
|
||
IPGeoTencentKey string `ini:"ip_geo_tencent_key"`
|
||
|
||
// HoneypotReportEnabled 是否启用周期性安全状况汇总(周报 / 月报)邮件。
|
||
// 缺省 true:报告本身是运维最需要的「有没有人在打、从哪儿打」,
|
||
// 而收件人缺失时会 WARN 跳过,不会静默。
|
||
HoneypotReportEnabled bool `ini:"honeypot_report_enabled"`
|
||
// HoneypotReportWeekly / Monthly 分别控制周报与月报,缺省都开。
|
||
HoneypotReportWeekly bool `ini:"honeypot_report_weekly"`
|
||
HoneypotReportMonthly bool `ini:"honeypot_report_monthly"`
|
||
// HoneypotReportEmail 报告收件人。留空回退 security_alert_email。
|
||
// 单独开一个键是为了能把「随时可能发生的封禁告警」与「定期汇总」
|
||
// 分投到不同邮箱(例如告警进手机推送邮箱、汇总进归档邮箱)。
|
||
HoneypotReportEmail string `ini:"honeypot_report_email"`
|
||
// HoneypotReportDir 报告文件(HTML + PNG)的存放目录,缺省 data/reports。
|
||
// 报告里有攻击者 IP 明细,目录权限按 0700 创建。
|
||
HoneypotReportDir string `ini:"honeypot_report_dir"`
|
||
}
|
||
|
||
Server struct {
|
||
Port int `ini:"port"`
|
||
Listen string `ini:"listen"` // 绑定监听地址(含端口)。留空则默认 ":port"(监听所有网卡)。
|
||
Salt string `ini:"salt"`
|
||
XSRFKey string `ini:"xsrf_key"`
|
||
}
|
||
|
||
Database struct {
|
||
DSN string
|
||
|
||
Type string `ini:"type"`
|
||
User string `ini:"user"`
|
||
Password string `ini:"password"`
|
||
Host string `ini:"host"`
|
||
Port uint `ini:"port"`
|
||
Name string `ini:"name"`
|
||
Schema string `ini:"schema"`
|
||
DedupeUniqueFields bool `ini:"dedupe_unique_fields"`
|
||
}
|
||
|
||
Redis struct {
|
||
Addr string `ini:"addr"`
|
||
Password string `ini:"password"`
|
||
}
|
||
|
||
Recaptcha struct {
|
||
CaptchaType string `ini:"captcha_type"`
|
||
Domain string `ini:"domain"`
|
||
SiteKey string `ini:"site_key"`
|
||
ServerKey string `ini:"server_key"`
|
||
TurnstileStyle bool `ini:"turnstile_style"`
|
||
}
|
||
|
||
Pixel struct {
|
||
Host string `ini:"host"`
|
||
}
|
||
|
||
Upload struct {
|
||
DefaultAvatarURL string `ini:"default_avatar"`
|
||
DefaultBackground string `ini:"default_background"`
|
||
DefaultBackgroundImageURL string `ini:"default_qrbackgroundimage"`
|
||
AliyunEndpoint string `ini:"aliyun_endpoint"`
|
||
AliyunAccessID string `ini:"aliyun_access_id"`
|
||
AliyunAccessSecret string `ini:"aliyun_access_secret"`
|
||
AliyunBucket string `ini:"aliyun_bucket"`
|
||
AliyunBucketCDNHost string `ini:"aliyun_bucket_cdn_host"`
|
||
ImageEndpoint string `ini:"image_endpoint"`
|
||
ImageAccessID string `ini:"image_access_id"`
|
||
ImageAccessSecret string `ini:"image_access_secret"`
|
||
ImageBucket string `ini:"image_bucket"`
|
||
ImageBucketCDNHost string `ini:"image_bucket_cdn_host"`
|
||
}
|
||
|
||
Mail struct {
|
||
Account string `ini:"account"`
|
||
Password string `ini:"password"`
|
||
Port int `ini:"port"`
|
||
SMTP string `ini:"smtp"`
|
||
SkipTLSVerify bool `ini:"skip_tls_verify"` // 仅自签名 / 内网 SMTP 需要
|
||
}
|
||
|
||
// Notify 站级通知通道配置([notify] 段)。与 [mail] 的「邮件给具体收件人」不同,
|
||
// 这两个是「广播式」通道:
|
||
// - WebhookURL:通用 Webhook(Discord / Slack / 企业微信群机器人等),站级事件
|
||
// (新提问 / 新回答 / 新追问 / 新私聊 / 新投票 / 安全告警 / 举报)统一推送。
|
||
// - TelegramBotToken:Telegram Bot token,配合每个箱主自填的 chat_id 实现
|
||
// 「新提问」点对点通知。
|
||
// 两者都可留空:留空的通道对应发送静默跳过(不影响邮件等其他通道)。
|
||
Notify struct {
|
||
WebhookURL string `ini:"webhook_url"`
|
||
TelegramBotToken string `ini:"telegram_bot_token"`
|
||
}
|
||
|
||
Service struct {
|
||
Backends []struct {
|
||
Prefix string `ini:"prefix"`
|
||
ForwardURL string `ini:"forward_url"`
|
||
}
|
||
TLSInsecureSkipVerify bool `ini:"tls_insecure_skip_verify"` // 仅内网自签名后端需要
|
||
}
|
||
)
|