miguan v1

This commit is contained in:
tamakyi
2026-10-21 12:09:28 +08:00
parent 2d4609dc0e
commit 79051acae8
28 changed files with 2128 additions and 182 deletions
+61
View File
@@ -0,0 +1,61 @@
# 2026-10-21
## 扫描者处置方案设计(未实施,仅设计文档)
- 触发:线上频繁收到「疑似批量扫描已自动封禁」告警,命中路径以 wp 探测 + `/.ssh/**` 为主,来源多 Azure 云 IP 池。
- **实测纠正一处错误认知**:`internal/route/route.go:432` 的注释称「flamego 的 NotFound 会先写 404 状态再 Redirect」——
**错的**。flamego v1.9.4 的 `NotFound` 只执行 handler,不写状态码;`ctx.Redirect("/")` 实测输出 **302 → "/"`。
最小复现验证:`/wp-admin/includes/...` 与 `/.ssh/id_rsa` 均得 302 + Location:/。
后果:扫描器跟随重定向 → 每个探测路径都触发一次首页渲染(1C1G 纯浪费),且被判为「站点有价值」。
同时告警邮件文案「产生大量 404」与实际 302 不符。
- 方案分五层(L0 CDN 路径/UA 规则 → L1 真 404 分流 → L2 蜜罐假文件/假凭据 → L3 tarpit + 递进封禁 → L4 上游 abuse 举报):
- 核心判断:**对云厂商僵尸网络做 IP 封禁无效**,有效的是按**路径**在 CDN 拦 + 蜜罐取证 + 上游举报。
- 明确排除反向攻击/DoS/回打(法律风险 + Azure 共享出口伤及无辜 + 招致 CDN 封站)。
- 蜜罐:命中 `/.ssh/id_rsa`、`/.env`、`/wp-login.php` 等诱饵路径 → 短路返回假内容(含 canary 标记)+ 永久封禁 + 留证。
- 封禁需落库(当前内存 map,重启清零)+ 时长递进 15m→1h→6h→24h→7d→永久 + 多维(IP / IPv6 /64 / UA 家族)。
- tarpit 必须带全局并发上限 + 单连接超时,否则 1C1G 自身变 DoS 入口。
- 设计文档:`outputs/scanner-defense-design-2026-10-21.md`(含分级变更清单、文件级改动点、验收指标)。
- 未动任何生产代码,等用户决定实施范围。
## 蜜罐(虚假后台登录界面 + 破解凭据记录)已实施
用户明确需求原文:「能不能把这些页面指向一个简单的虚假后台登录界面,然后记录这些破解的用户名密码等信息」。
### 落点决策(关键)
- **蜜罐不改写成新中间件,而是改写 `internal/route/route.go` 的 `f.NotFound`**:
- NotFound 只在「路由表完全落空」时触发 → 与 `/login` `/register` `/_/{domain}` `/user/**` `/api/v1/**` 天然零交集;
- 位于全局 `f.Use` 链之后 → `requestBodyLimitMiddleware` 已给 body 加 12 MiB 上限;
- 组级中间件(含 CSRF)只挂具体路由,不作用于 NotFound → POST 表单可安全解析;
- flamego `context.run()` 在 `ResponseWriter().Written()` 为真时 return → 写完响应不调 `ctx.Next()` 即安全短路。
- **双重计数坑**:autoban 中间件对敏感路径已 `Record` 一次,蜜罐再记一次会让阈值实际减半。
解决:`autobanMiddleware(b, hp)` 传入蜜罐匹配器,蜜罐命中的路径由蜜罐自己计数。
### 新增文件
- `internal/security/honeypot/honeypot.go` — 匹配器(段前缀 + 片段子串 + 双重保险 `forbiddenPrefixs` 排除业务路由)、
凭据提取(表单 / JSON / BasicAuth / 查询串)、`CanaryToken = "tb-hp-7f3a1c9e"`。
- `internal/security/honeypot/page.go` — 仿 WordPress 假登录页(经典字段 `log`/`pwd`/`redirect_to`),内联 CSS 不依赖站点静态资源。
- `internal/db/honeypot.go` — `HoneypotHit`(**不嵌 dbutil.Model**,审计证据只追加)+ `HoneypotStore`。
- `internal/route/honeypot.go` — 处理器:GET 假登录页 / POST 假失败响应 / 落库 / 复用 autoban 封禁 / 每 IP 告警一次。
- `internal/cmd/honeypot.go` — `TamaBox honeypot hits|stats|prune`,密码默认脱敏(`--show-password` 显示明文)。
- `templates/mail/honeypot-alert.html` — 告警邮件模板(`mail/` 已在 embed 清单,无需改 embed.go)。
### 改动文件
- `internal/route/route.go`(CRLF):NotFound 分流 + 蜜罐实例装配 + `hph.sendAlert`。
- `internal/route/autoban.go`(LF):`autobanMiddleware(b, hp)`,蜜罐路径跳过自身 Record。
- `internal/db/db.go`(LF):`AllTables` 加 `&HoneypotHit{}`,`Init()` 加 `Honeypots = NewHoneypotStore(db)`。
- `internal/conf/static.go`(CRLF):`enable_honeypot` / `honeypot_paths` / `honeypot_retain_days`。
- `conf/app.sample.ini`(CRLF):对应说明段。
- `internal/mail/mail.go`(LF):`SendHoneypotAlertMail` + `HoneypotAlert`。
- `cmd/tamabox.go` + 根 `tamabox.go`:注册 `cmd.Honeypot`(两处必须同步)。
### 踩坑
- `normalize()` 里 `for len(p)>1 { TrimSuffix(p,"/") }` **无限循环**(路径不含尾斜杠时字符串不变)→ 改成 `HasSuffix` 判断。
- `url.ParseQuery` 遇非法百分号编码返回 error 但仍给出部分字段 → 蜜罐要「尽可能多拿凭据」,忽略 error 继续用部分结果。
- JSON 凭据提取:手写解析器又脏又易错 → 改用 `jsonPairRe` 正则扫描扁平字段。
- `templates/mail/` 已在 embed 清单内 → 新增邮件模板**不需要**改 `templates/embed.go`(顶层文件才需要)。
### 验证
- `go build ./...` 通过;`go test ./...` 全绿(含 DB 测试,postgres 门控)。
- 新增测试:`internal/security/honeypot/honeypot_test.go`(匹配/业务路由零交集/编码绕过/凭据提取)、
`internal/route/honeypot_test.go`(假页含 canary、业务路由不被劫持、关闭时回落 302、POST 返回失败页)、
`internal/db/honeypot_test.go`(写入/倒序/三张聚合表/保留期清理)。
+48 -175
View File
@@ -1,194 +1,67 @@
# TamaBox-v4 长期笔记
# TamaBox-v4 长期笔记(索引版)
明细已拆分到 `topics/*.md`(按需 Read 全文):
`coop` 协作 / `build` 构建测试 / `embed` 行尾·模板·embed / `review` 审核处置举报 /
`gorm` GORM / `style` 前端样式 / `theme` 主题 / `builder` 主题生成器 / `misc` 其它约定。
## 协作
- 结论分级 P0/P1/P2;给建议前先根因取证 + 实测复现,别只静态推断。
- 结论分级 P0/P1/P2;先根因取证 + 实测复现,别只静态推断。
- 改代码前先出变更清单;重大问题显式指出。用户常授权自主推进。
- 用户是运营者也是圈内人:处置必须可解释、可留痕、可申诉,不做自动级联封号。
## 构建与测试
## 构建与测试(详见 topics/build.md)
- **无 libwebp/GCC**:cgo 的 `internal/storage/s3.go` 挡编译 → overlay 换桩
`dev/s3stub/s3_stub.go`(`-overlay="C:/Users/Tamakyi Gekka/AppData/Local/Temp/tb-webpcheck/overlay.json"`)。
要检查 s3.go 本身得 tar 复制项目 + `go mod edit -replace`(overlay 换不了 GOMODCACHE)。
- **「编译卡死」= `proxy.golang.org` 不可达**。每条 go 命令前
`export GOPROXY=https://goproxy.cn,direct GOSUMDB=off GOFLAGS=-mod=mod`(`go env -w` 沙箱失败)。
1C1G 是正式生产环境,别用「沙箱慢」解释超时。
- DB 测试:`DB_TYPE=postgres DB_USER=postgres DB_PASSWORD=15207786347hXPm DB_HOST=127.0.0.1 DB_PORT=5432`
(DSN 不带库名,`NewTestDB` 自建临时库后 DROP)。带 DB 的 `go test ./...` 全绿。
- store 方法内用 `NewXStore(db.DB)`,别引用包级单例(测试下 nil panic;`FeatureStateOf` 有守卫)。
- ⚠️ **本沙箱起不了 `TamaBox web`**(session 固定 redis,6379 未开,启动 panic)→ HTTP 端到端只能部署后做。
探测端口 200 可能是别的进程,必须看日志确认 `Starting web server` 且无 panic。
- ⚠️ **本机 python urllib 不稳定**(代理下偶发读超时,curl 正常);python 别用 `/tmp`
(Windows 解析成 `C:\tmp`,与 MSYS 不通)。PIL 在 `~/.workbuddy/binaries/python/envs/default`。
- 无 libwebp/GCC:`internal/storage/s3.go` 挡编译 → overlay 换桩 `dev/s3stub/s3_stub.go`。
- 「编译卡死」= `proxy.golang.org` 不可达。每条 go 命令前
`export GOPROXY=https://goproxy.cn,direct GOSUMDB=off GOFLAGS=-mod=mod`。
- DB 测试:`DB_TYPE=postgres DB_USER=postgres DB_PASSWORD=15207786347hXPm DB_HOST=127.0.0.1 DB_PORT=5432`(DSN 不带库名)。
- store 方法内用 `NewXStore(db.DB)`,别引用包级单例。
- 本沙箱起不了 `TamaBox web`(session 固定 redis,6379 未开)→ HTTP 端到端只能部署后做。
- python urllib 不稳定(用 curl);python 别用 `/tmp`;PIL 在 `~/.workbuddy/binaries/python/envs/default`。
## 行尾 / 模板 / embed
## 行尾 / 模板 / embed(topics/embed.md)
- 行尾混合不可推断(`internal/db/*`、`mail.go`、`route/user/profile.go` 是 LF;`internal/route/route.go`、
`users.go`、`i18n/dict_*.go`、`route/question/*.go`、`base.css`、`dev/theme-builder/*` 是 CRLF)。
改前用 Python 数 `b'\r\n'`;`gofmt -l` 会把 CRLF 误报为未格式化 → 走 `dev/gofmt_keep_crlf.py`。
Edit 工具会保留原文件行尾,可放心用;新增整份文件用 Write 时要自己补 CRLF。
- 往 gofmt 对齐组中间插键会污染 diff,新键插组末;注释别用 `''`(gofmt 改成 `”`)。
- ⚠️ **`templates/embed.go` 是显式清单**:新增**顶层**模板必须加进 `//go:embed`,否则运行时
`html/template: "xxx" is undefined` → 500,编译期无提示。`static/fs.go` 同理。
- ⚠️ **`//go:embed` 的 `*` 不匹配 `_` / `.` 开头的文件**:`static/css/themes/_scaffold.css` 与
临时建的 `_gentest.css` **都不进 embed**,`TestDecoSlotCount` 扫不到它们(表现是"校验了但数字没变")。
验证生成的主题 CSS 要临时改成非下划线文件名。
- `templates/user/moderation-strictness.html` 只用 shell/python 改(Edit/Write 会注入 `data-page-node-id`)。
- 行尾混合不可推断,改前用 Python 数 `b'\r\n'`;gofmt 走 `dev/gofmt_keep_crlf.py`。
- `templates/embed.go` 是显式清单:新增**顶层**模板必须加 `//go:embed`。
- `//go:embed` 的 `*` 不匹配 `_`/`.` 开头文件。
## 审核 / 处置 / 举报
## 审核 / 处置 / 举报(topics/review.md)
- 线上通道 = 七牛(`ai.qiniuapi.com/v3/text/censor`,antispam);`[security] text_censor_source = qiniu|aliyun|auto`。
七牛只硬拦涉政/暴恐/色情,`review` 放行 → 裁决在 `censor/policy.go` + `moderation.Check`。
- **新增审核调用点必须走 `moderation.Check`**(直调 `censor.Text` 绕过档位)。不可用时 **fail-closed**。
- ⚠️ 圈内黑话(代称+群体标签+避雷叙述)通用审核拦不住,别指望词库;靠处置 + 举报 + 人工判断。
- 严格度 lax/standard/strict 存 `users.moderation_strictness`;**受处置用户强制 strict**:
`moderation.EffectiveStrictness(ctx, userID, raw)`(7 个入口已改)。**查询失败回退用户设置**
(否则 DB 抖动会让全站突然加严)。处置期内拒绝保存 + 设置页 disabled。
- ⚠️ **同请求渲染只能用 `ctx.SetError` / `SetInternalError`,不能用 `*Flash`**:flash 写 session,下次请求才读 →
提示静默丢失。`Set*Flash + Redirect` 才是正常模式。扫描法:找 flash 调用 → 向下第一个 `Success(`/`Redirect(`。
- 处置四级:L1 停止分发/L2 收窄公开(新回答强制私密、禁 set-public)/L3 冻结发布(仍可收信)/
L4 整页停用(`/restricted`)。到期靠 `expires_at`,**无 cron**;`restrict` 是**替换语义**;比较用 `RestrictionAtLeast`。
- ⚠️ L2 强制私密必须在**审核通过之后**,且与写回答**同一条 UPDATE**(`AnswerByIDPrivate`):
拆开会留下「公开+已作答」窗口,第二条失败还会变成「私密+无回答」。
- ⚠️ **`ListAPI`(`/api/v1/user/{domain}/questions`)不经过 Pager 中间件**,L4 与页面密码必须自己校验。
- ⚠️ **整箱分享卡 `ShareCard` 与单题分享卡都要拦**,且拦截必须在缓存查询之前(否则 30 分钟内仍给旧图)。
- 举报 `POST /_/{domain}/{qid}/report`:免登录 + 验证码 + 限流;同 IP 同题唯一索引 →
**幂等且提示与首次一致**(否则成了内容存在性探测器)。箱主只看「被举报 N 次」,**不给 IP**。
- 闭环:删除/设私密/`hide-public` → `MarkAcceptedByQuestion`;待处理超 30 天视为过期。
⚠️ `hide-public` 里必须**先快照目标 ID 再更新**(更新后带 `is_private=false` 的子查询恒为空集)。
- 告警:24h≥8 或 7d≥20 → 邮件到 `[security] security_alert_email`(24h 冷却);单条恰好 3 人举报 → 提醒箱主。
⚠️ 该邮箱为空时静默跳过、无日志;改配置必须重启。
- `/rules` 是处置依据页(四语言);注册页必须勾 `agree_rules`(服务端同样校验),改 e2e 脚本要同步。
- `restrict`/`unrestrict` 默认给箱主发通知(按 `users.language`,`mail/restriction-notice.html`),
**只含级别名/到期/规则链接,不含 `--reason`**;`--no-notify` 跳过。`--days > 90` 要 `--force`。
⚠️ **到期自动解除不发信**(读表比较时间)。
- 线上通道 = 七牛;裁决在 `censor/policy.go` + `moderation.Check`,新增调用点必须走 `moderation.Check`,不可用时 fail-closed。
- 严格度存 `users.moderation_strictness`;受处置用户强制 strict(`moderation.EffectiveStrictness`),查询失败回退用户设置。
- 同请求渲染只能用 `ctx.SetError`/`SetInternalError`,不能 `*Flash`。
- 处置四级 L1–L4;到期靠 `expires_at` 无 cron;`restrict` 是替换语义;比较用 `RestrictionAtLeast`。
- L2 强制私密须在审核通过后、与写回答同一条 UPDATE(`AnswerByIDPrivate`)。
- `ListAPI` 不经 Pager 中间件,L4 与页面密码须自校验;分享卡拦截须在缓存查询之前。
- 举报:免登录+验证码+限流;同 IP 同题唯一索引 → 幂等;箱主只看次数不给 IP。
- 告警 24h≥8 或 7d≥20 → 邮件;`[security] security_alert_email` 为空则静默跳过。
## GORM
## GORM(topics/gorm.md)
- 默认值必须带引号;`default:` 会把零值替换,空串有语义的字段别加。
- ⚠️ 事务里复用同一 `tx` 会串条件,每句 `tx.WithContext(ctx).<链式>`。
- ⚠️ 软删除 + 唯一索引 = 槽位永久占用:停用(可恢复)= 物理删内容 + 软删用户行,注册侧 `Unscoped` 给
`ErrDeactivatedEmail/Domain`;封禁 = 物理删用户行,邮箱释放。级联删除一律 `Unscoped()`。
- ⚠️ GORM 链式对象 Count/Find 后带状态,统计/预览/更新必须各自重建 query。
- 默认值必须带引号;事务里每句 `tx.WithContext(ctx)`;链式对象带状态,统计/更新各自重建 query。
- 软删除+唯一索引 = 槽位永久占用;级联删除一律 `Unscoped()`。
## 前端样式
## 前端样式(topics/style.md)
- `static/css/base.css`(`?v=` 取 `conf.StaticVersion`,**改完必须 +1**)。令牌在 `:root`(亮)+
`html[data-theme="dark"]`(暗)。新样式一律 `var(--tb-*)`,别写死色值,也别为深色另写分支。
- 页面级样式内联在各自模板 `<style>`;尽量用类不用 `style=`(深色下内联写死色值会露白)。
- 主题属性在 head 内联脚本里打(cookie `theme`),**不能挪回 body**,否则深色用户白闪。
- **移动端(≤640px)语言切换与主题按钮在固定底栏** `#tb-mobile-utils`(`.tb-nav-utils` 被隐藏,
body 留 `padding-bottom:56px`)。用户曾误以为"按钮不见了",先解释位置。
⚠️ `/_/{domain}` 箱主页 `IsUserPage = true`(`route/question/page.go:133`),语言切换包在
`{{ if not .IsUserPage }}` 里 → **箱主页没有语言切换入口,有意设计**(用户 2026-10-19 确认),别当 bug 修。
- ⚠️ **UIkit 3.3.3 的 `.uk-flex::before/::after{display:none}`**:给带 `uk-flex` 的元素加伪元素必须写
复合选择器(`.tb-cover.uk-flex::after`),否则静默失效。
- 分类/题型标签一律 `.tb-chip`;彩色 `uk-label` 只留给语义状态(置顶/私密/被举报/已读、投票四态)。
- 视觉自检:`TEST_PREVIEW=1 go test ./templates -run TestGenPreview` → `outputs/preview-*.html`;
截图必须 `chrome --headless=old --force-device-scale-factor=1`(新版 headless 本机挂住)。
⚠️ `--window-size` ≠ CSS 视口;要精确视口就用 iframe 指定 width/height。验证 CSS 靠**像素比对**
(PIL 在 venv)而非目测;扫描循环上界不能与物体边界重合。
- `static/css/base.css`(`?v=` 取 `conf.StaticVersion`,改完 +1)。令牌在 `:root` + `html[data-theme="dark"]`,新样式一律 `var(--tb-*)`。
- 移动端(≤640px)语言/主题按钮在固定底栏 `#tb-mobile-utils`;箱主页**有意**无语言切换。
- UIkit 3.3.3:`.uk-flex::before/::after{display:none}`;主色是编译期常量,需在 base.css 末尾映射。
- 视觉自检:`TEST_PREVIEW=1 go test ./templates -run TestGenPreview`;截图用 `chrome --headless=old`。
## 主题(箱主个性化)
## 主题(topics/theme.md、topics/builder.md)
- 三个注入点:`/_/{domain}/*` → `question.Pager` 按 `pageUser.Theme`;`/user/*` → `internal/context`
Contexter 按 `c.User.Theme`(`isThemeScope()` 只认 `/user` 与 `/user/`);`/vote/{token}` → `route/vote.Page`
按发布者 `owner.Theme`。都看箱主/发布者,不看访客。全站页(`/`、`/explore`、`/login`)不主题化。
- 投票页**语言**也跟发布者:`applyOwnerLocale(data, owner)`(与主题同一函数),必须在任何 i18n 取词之前调用。
- 一套主题 = `static/css/themes/<slug>.css`,**只覆盖令牌**,不碰布局。`static/fs.go` 已嵌整个 `themes/` 目录。
- 主题显示名:模板用 `{{ themeName .LangCode .Slug }}` → `theme.DisplayName`,i18n 没登记
`theme.name.<slug>` 时**回退成 slug**。⚠️ 别在模板里直接 `{{t .LangCode (printf "theme.name.%s" .Slug)}}`:
`i18n.T` 缺词条返回 key 本身,设置页就会显示 `theme.name.test01`(用户报过)。
- ⚠️ **`[theme] available` 是硬白名单,启动时读一次**:css 发布了但没登记,设置页看不到,现象同「部署没生效」。
排查:`curl /static/css/themes/<slug>.css` 200 = 文件在(没登记);404 = 旧二进制。
- ⚠️ 主题 css **必须成对写**:`html[data-theme-style="x"]`(0,1,1) + `html[data-theme="dark"][data-theme-style="x"]`(0,2,1)。
`TestThemeCSSComplete` 查两个选择器 + 10 个颜色令牌。
- ⚠️ **按页面作用域**:`internal/context.pageScope()` → `<html data-page-scope>`,取值
`box`/`question`/`admin`(/user/questions,/user/votes*)/`profile`/`vote`。深色作用域覆盖必须**三重选择器**,
否则被浅色作用域块(同特异性、写在后面)盖掉。
- ⚠️ **装饰层固定 10 槽**(base.css 12 层 = pattern1 + deco10 + image1),三组 `--tb-deco/-size/-position`
必须等长。`TestDecoSlotCount` 逐文件逐声明检查(只扫 embed 到的文件)。
- 位置一律两值 `calc(0%|50%|100% ± N)`(百分比相对「容器 − 图片」)。**别用 left/bottom 关键字混搭**:
三值语法非法时整条声明作废 → 图层静默回左上。
- 尺寸想随视口缩用 `clamp`,**不要写死 px**:`auto clamp(min(calc(100vh - 24px), 231px), 46.67vh, 420px)`。
内层 `min()` 是兜底 —— 屏幕小到连下限都放不下时让位给视口。宽幅素材只能按宽度缩(`vw`),
按高度缩的话宽度按比例涨,窄屏照样裁。
- ⚠️ **UIkit 3.3.3 主色是编译期常量 `#1e87f0`**,令牌注入不进去。base.css 末尾按组件映射(a/uk-link、
uk-button-primary、checkbox/radio、input:focus、uk-tab active、`.uk-progress`、`.uk-label:not(...)`)。
- 令牌 `--tb-navbar`(顶栏底色,默认 `var(--tb-surface)`):页面底色偏彩时主题必须覆盖。
- 静态资源版本号统一 `conf.StaticVersion`;**图片不走这个缓存键**(`max-age=86400`),换图要给 URL 带 `?v=`。
- 整页背景 `--tb-page-image`(cover)/ `--tb-page-pattern`(平铺,`-size` 控尺寸)/ `--tb-page-position`
(焦点,默认 `var(--tb-page-position, center center)`,旧主题不写也不坏)。底色用**纯色**别用渐变;
图案 ≥24px、透明度 ≤0.06。
- ⚠️ **多背景层不能塞变量列表**:变量自身是逗号列表会让层数与 size/position 错位。多张装饰用固定槽位。
- 装饰摆位:**贴边、成组、避开页脚**;四角各摆一个会读成贴纸墙纸;页脚文案右对齐 → 右下角不能放人物。
`--tb-deco` 是 fixed 视口层,贴边插画别放进 `cover` 背景图(必裁边)。
- 主题视觉的**结构**比摆位重要:通行做法是「整屏场景插画 + UI 悬浮其上」(蔚蓝档案 JP)。
白底留白里摆人物,无论多大都读成贴纸;卡片/顶栏配半透明白 + `backdrop-filter` 才有"浮在画面上"的关系。
- 素材放 `static/images/themes/<slug>/`(embed 已含 `images/themes`,子目录自动覆盖)。整页图 16:9 + 竖版两张。
- 人物素材:**图生图 + 抠白底**(`image1` 给立绘、`input_fidelity: high`、`flat vector illustration, no outlines`)。
⚠️ 正面立绘直接贴进空场景像贴纸:改**侧后方视角**(不直视访客),RGB **压白 12~16%** 与压白 38% 的场景对齐雾度。
- ⚠️ **抠白底容差 16**:背景 253、白袜子 237 只差 16,容差 22 以上会把袜子吃穿。手内侧/两腿之间是
**闭合**白区,四边 floodfill 进不去,得按连通域单独掏(面积 ≥24),再 1px 收边。
- ⚠️ `ruleBody` 取令牌前要剥 `/* */`:注释会和后面的令牌粘成一个键,色块预览静默失效。
- 三个注入点按箱主/发布者的 `Theme`;投票页语言也跟发布者(`applyOwnerLocale`)。
- 一套主题 = `static/css/themes/<slug>.css`,只覆盖令牌;显示名用 `{{ themeName ... }}`,别直接 `printf "theme.name.%s"`。
- `[theme] available` 是硬白名单(启动时读一次);css 必须成对写亮/暗两个选择器。
- 装饰层固定 10 槽,三组 `--tb-deco/-size/-position` 必须等长;位置用两值 `calc(0%|50%|100% ± N)`。
- 主题生成器 `dev/theme-builder/`:非破坏性图片处理、`layerSize`/`unitGeom` 必须共用同一函数、
`hideDeco` 会把 @media 10 槽全写 none(窄屏素材消失的头号来源)。
## 主题生成器 `dev/theme-builder/`
## 其它约定(topics/misc.md)
- `serve.py` 起静态服务(8777),iframe 加载 `outputs/preview-*.html`。**file:// 下父页读不到 iframe DOM**,必须走 HTTP。
8777 常由用户自己起着,改完直接刷新。
- 单文件 `index.html`(约 2900 行)。图片处理**非破坏**:`orig` 存原始字节 + `fx` 存参数,每次从 orig 重算;
参数没变则跳过重编码。画质(`S.export.quality/maxSide`)同理 —— 改完 `rebakeAll()` 从 orig 重来,不叠加压缩。
- **尺寸单位** 4 档:`fit`(按视口高,人物)/ `fitw`(按视口宽,横幅)/ `px` / `vh`。
`layerSize()` 出 CSS、`unitGeom()` 出预览框,**两处必须共用同一函数**,否则「框没超、图已裁」。
⚠️ 下拉的 value 用 `l.unit`,不能用 `unitOf(l)`(后者把 fit 归一成 vh → 选了 fit 却显示 vh)。
- 溢出检测 `layerMetrics(l, a, VW, VH)`:算 left/top/overL/R/T/B,编辑器里显示「显示 131×420,完整可见」或
「⚠ 左右各 88px 越界」,并给「按当前视口修好」按钮(切 px + 算刚好放下的尺寸)。导出前 `overflowReport()` 再扫一遍。
- 视口预设**带高度**(1512×900 / 1280×800 / 1024×768 / 820×1180 / 390×844):裁切几乎全由高度决定,
只改宽度模拟不出来。`fitStage()` 按预设高渲染 iframe。
- 窄屏:全局 `hideDeco`(急停)> 每层 `mob`(inherit/scale/hide)> 尺寸单位(fit/fitw 自己就缩)。
⚠️ **`hideDeco` 会把 @media 里 10 槽全写 none** —— 桌面完全正常、手机上素材全没了,
这是「素材在窄屏消失」的头号来源(test01 就是这么中招的)。UI 已在勾选时给橙色警告、
图层行标「窄屏隐藏」、加层/导出再提醒;`?narrow=hide` 可一键复现。
⚠️ `@media` 里**每个独立配置的 scope 都要写一条** `[data-page-scope="x"]`:页面覆盖块特异性 (0,2,1),
只写 `html[data-theme-style]`(0,1,1) 的话独立配置页在手机上完全不生效。
⚠️ 竖版图只替换**本来有背景图**的页面(`cfg.bg` 非空),否则等于给纯色页塞了张图。
- 自检钩子:`?dump=css|zip|validate|install|fx|fxpipe|fxkey|layers|fit|quality`,`?demo=1&fresh=1&rows=N&scope=&loadurl=`
(`?demo=1` 已含 fit 层 + 竖版背景,`@media` 才会生成)。用法:
`chrome --headless=old --virtual-time-budget=12000 --dump-dom "http://127.0.0.1:8777/dev/theme-builder/index.html?demo=1&fresh=1&dump=fit"`
抓 `<pre id="dump">`(正则要带属性:`<pre id="dump"[^>]*>`)。
- ⚠️ **canvas `toDataURL('image/webp', 1)` 不能用**:Skia 在 q=1 切无损,输出反常(噪声图 q=1 只 52KB,
q=0.95 是 440KB)。`canvasAsset` 夹到 0.95,滑块上限也是 0.95。
- ⚠️ 平涂/线稿降 q 可能**变大**(有损引入噪点)。压体积先砍长边,再降 q。缩小走 `imageSmoothingQuality='high'`。
- ⚠️ 面板只有 ~240px:`input[type=range]` 有固有宽度,必须 `flex:1 1 0;width:0;min-width:0`,
且数值放进 label 里(第三个小 flex 项会被顶出可视区)。
- `?dump=css` 的产物要过 Go 侧校验:临时存成 `static/css/themes/<非下划线名>.css` 再 `go test ./internal/theme`,
跑完删掉。
## 其它约定
- 校验靠 `valid:"required;maxlen:N"` 标签(`internal/form/*.go`),别在 handler 手写。
- 终止性操作(停用)落独立公开页 `/deactivated`(注册在 `reqUserSignIn` 外);改密码后重登才用 flash + `/login`。
- 只有箱主 `/user` 面板,**无管理员角色、无站方后台,用户要求不要加**:站方动作只走 CLI / 邮件 / 改配置重启。
- git `https://git.shiroko.one/tamakyi/tamabox`(`main`)。**`git push` 能直接推**(本机 Git for Windows 已认证);
「Failed to authenticate user」是临时态。推前 `git log --oneline -3`。`outputs/` 已 gitignore,别整目录 add。
- 打包交付用 tar.gz(排除 `.git`/`.workbuddy`/`outputs`),放项目同级。
## 测试环境 box-dev-api.337845818.best
- 账号 `[email protected]` / `adminadmin`,公开页 `/_/admin6`。
- 登录要过算数验证码:`GET /login` 取 `_csrf`+cookie → `GET /api/captcha/generate` → 算 `a op b` →
`POST /login` 带 `g-recaptcha-response="<id>:<答案>"`。默认语言 **ja**,四语言文案要一起加。
- **部署有延迟**;模板是编译期 embed,**只重启不重编不会变**。用户说「已部署但没变化」→ 先抓线上 HTML 比对标记。
- 表单字段:提问 `content`,回答 `answer`。`GET /user/votes` 故意 302;匿名无邮箱提问成功直接 200 渲染。
- 自检:`dev/devcheck-e2e.py`(28 项)、`dev/devcheck-restrict.sh`(基线非 200 就别往下走;`RUN=1` 才真跑)。
- 图片编码三档(头像 512/q78、背景 1920/q80、通用 1600/q80),**EXIF 方向必须先归一**(转 WebP 会丢)。
## CLI(`TamaBox <cmd>`)
- ⚠️ **两个构建入口必须同步**:根 `go build .`(`tamabox.go`)与 Dockerfile 的 `./cmd/`(`cmd/tamabox.go`),
否则 `No help topic for '<cmd>'`。
- 现有:`web` `censor` `uid` `ban` `restore` `reports` `restrict` `unrestrict` `hide-public`。
- `restrict --domain|--email --level L1..L4 --days N --reason` / `unrestrict`;
`reports --domain|--email [--days]`(证据包)、`--pending`、`--stats --days N`;`hide-public [--only-reported] [--since N]`。
- 本地冒烟:`NEKOBOX_CONFIG_PATH=<ini>`;`conf.Init` 强制 `app.external_url`、`server.salt`、`server.xsrf_key` 非空。
- 校验用 `valid:"required;maxlen:N"` 标签。
- 只有箱主 `/user` 面板,**无管理员角色、无站方后台**(用户要求不要加):站方动作只走 CLI / 邮件 / 改配置重启。
- CLI 有两个构建入口必须同步:根 `go build .` 与 Dockerfile 的 `./cmd/`。
- 现有 CLI:`web censor uid ban restore reports restrict unrestrict hide-public`。
- git `https://git.shiroko.one/tamakyi/tamabox`(main),`git push` 可直推;`outputs/` 已 gitignore。
+17
View File
@@ -0,0 +1,17 @@
# 构建与测试(长版明细)
## 构建与测试
- **无 libwebp/GCC**:cgo 的 `internal/storage/s3.go` 挡编译 → overlay 换桩
`dev/s3stub/s3_stub.go`(`-overlay="C:/Users/Tamakyi Gekka/AppData/Local/Temp/tb-webpcheck/overlay.json"`)。
要检查 s3.go 本身得 tar 复制项目 + `go mod edit -replace`(overlay 换不了 GOMODCACHE)。
- **「编译卡死」= `proxy.golang.org` 不可达**。每条 go 命令前
`export GOPROXY=https://goproxy.cn,direct GOSUMDB=off GOFLAGS=-mod=mod`(`go env -w` 沙箱失败)。
1C1G 是正式生产环境,别用「沙箱慢」解释超时。
- DB 测试:`DB_TYPE=postgres DB_USER=postgres DB_PASSWORD=15207786347hXPm DB_HOST=127.0.0.1 DB_PORT=5432`
(DSN 不带库名,`NewTestDB` 自建临时库后 DROP)。带 DB 的 `go test ./...` 全绿。
- store 方法内用 `NewXStore(db.DB)`,别引用包级单例(测试下 nil panic;`FeatureStateOf` 有守卫)。
- ⚠️ **本沙箱起不了 `TamaBox web`**(session 固定 redis,6379 未开,启动 panic)→ HTTP 端到端只能部署后做。
探测端口 200 可能是别的进程,必须看日志确认 `Starting web server` 且无 panic。
- ⚠️ **本机 python urllib 不稳定**(代理下偶发读超时,curl 正常);python 别用 `/tmp`
(Windows 解析成 `C:\tmp`,与 MSYS 不通)。PIL 在 `~/.workbuddy/binaries/python/envs/default`。
+33
View File
@@ -0,0 +1,33 @@
# 主题生成器 `dev/theme-builder/`(长版明细)
## 主题生成器 `dev/theme-builder/`
- `serve.py` 起静态服务(8777),iframe 加载 `outputs/preview-*.html`。**file:// 下父页读不到 iframe DOM**,必须走 HTTP。
8777 常由用户自己起着,改完直接刷新。
- 单文件 `index.html`(约 2900 行)。图片处理**非破坏**:`orig` 存原始字节 + `fx` 存参数,每次从 orig 重算;
参数没变则跳过重编码。画质(`S.export.quality/maxSide`)同理 —— 改完 `rebakeAll()` 从 orig 重来,不叠加压缩。
- **尺寸单位** 4 档:`fit`(按视口高,人物)/ `fitw`(按视口宽,横幅)/ `px` / `vh`。
`layerSize()` 出 CSS、`unitGeom()` 出预览框,**两处必须共用同一函数**,否则「框没超、图已裁」。
⚠️ 下拉的 value 用 `l.unit`,不能用 `unitOf(l)`(后者把 fit 归一成 vh → 选了 fit 却显示 vh)。
- 溢出检测 `layerMetrics(l, a, VW, VH)`:算 left/top/overL/R/T/B,编辑器里显示「显示 131×420,完整可见」或
「⚠ 左右各 88px 越界」,并给「按当前视口修好」按钮(切 px + 算刚好放下的尺寸)。导出前 `overflowReport()` 再扫一遍。
- 视口预设**带高度**(1512×900 / 1280×800 / 1024×768 / 820×1180 / 390×844):裁切几乎全由高度决定,
只改宽度模拟不出来。`fitStage()` 按预设高渲染 iframe。
- 窄屏:全局 `hideDeco`(急停)> 每层 `mob`(inherit/scale/hide)> 尺寸单位(fit/fitw 自己就缩)。
⚠️ **`hideDeco` 会把 @media 里 10 槽全写 none** —— 桌面完全正常、手机上素材全没了,
这是「素材在窄屏消失」的头号来源(test01 就是这么中招的)。UI 已在勾选时给橙色警告、
图层行标「窄屏隐藏」、加层/导出再提醒;`?narrow=hide` 可一键复现。
⚠️ `@media` 里**每个独立配置的 scope 都要写一条** `[data-page-scope="x"]`:页面覆盖块特异性 (0,2,1),
只写 `html[data-theme-style]`(0,1,1) 的话独立配置页在手机上完全不生效。
⚠️ 竖版图只替换**本来有背景图**的页面(`cfg.bg` 非空),否则等于给纯色页塞了张图。
- 自检钩子:`?dump=css|zip|validate|install|fx|fxpipe|fxkey|layers|fit|quality`,`?demo=1&fresh=1&rows=N&scope=&loadurl=`
(`?demo=1` 已含 fit 层 + 竖版背景,`@media` 才会生成)。用法:
`chrome --headless=old --virtual-time-budget=12000 --dump-dom "http://127.0.0.1:8777/dev/theme-builder/index.html?demo=1&fresh=1&dump=fit"`
抓 `<pre id="dump">`(正则要带属性:`<pre id="dump"[^>]*>`)。
- ⚠️ **canvas `toDataURL('image/webp', 1)` 不能用**:Skia 在 q=1 切无损,输出反常(噪声图 q=1 只 52KB,
q=0.95 是 440KB)。`canvasAsset` 夹到 0.95,滑块上限也是 0.95。
- ⚠️ 平涂/线稿降 q 可能**变大**(有损引入噪点)。压体积先砍长边,再降 q。缩小走 `imageSmoothingQuality='high'`。
- ⚠️ 面板只有 ~240px:`input[type=range]` 有固有宽度,必须 `flex:1 1 0;width:0;min-width:0`,
且数值放进 label 里(第三个小 flex 项会被顶出可视区)。
- `?dump=css` 的产物要过 Go 侧校验:临时存成 `static/css/themes/<非下划线名>.css` 再 `go test ./internal/theme`,
跑完删掉。
+7
View File
@@ -0,0 +1,7 @@
# 协作(长版明细)
## 协作
- 结论分级 P0/P1/P2;给建议前先根因取证 + 实测复现,别只静态推断。
- 改代码前先出变更清单;重大问题显式指出。用户常授权自主推进。
- 用户是运营者也是圈内人:处置必须可解释、可留痕、可申诉,不做自动级联封号。
+15
View File
@@ -0,0 +1,15 @@
# 行尾 / 模板 / embed(长版明细)
## 行尾 / 模板 / embed
- 行尾混合不可推断(`internal/db/*`、`mail.go`、`route/user/profile.go` 是 LF;`internal/route/route.go`、
`users.go`、`i18n/dict_*.go`、`route/question/*.go`、`base.css`、`dev/theme-builder/*` 是 CRLF)。
改前用 Python 数 `b'\r\n'`;`gofmt -l` 会把 CRLF 误报为未格式化 → 走 `dev/gofmt_keep_crlf.py`。
Edit 工具会保留原文件行尾,可放心用;新增整份文件用 Write 时要自己补 CRLF。
- 往 gofmt 对齐组中间插键会污染 diff,新键插组末;注释别用 `''`(gofmt 改成 `”`)。
- ⚠️ **`templates/embed.go` 是显式清单**:新增**顶层**模板必须加进 `//go:embed`,否则运行时
`html/template: "xxx" is undefined` → 500,编译期无提示。`static/fs.go` 同理。
- ⚠️ **`//go:embed` 的 `*` 不匹配 `_` / `.` 开头的文件**:`static/css/themes/_scaffold.css` 与
临时建的 `_gentest.css` **都不进 embed**,`TestDecoSlotCount` 扫不到它们(表现是"校验了但数字没变")。
验证生成的主题 CSS 要临时改成非下划线文件名。
- `templates/user/moderation-strictness.html` 只用 shell/python 改(Edit/Write 会注入 `data-page-node-id`)。
+9
View File
@@ -0,0 +1,9 @@
# GORM(长版明细)
## GORM
- 默认值必须带引号;`default:` 会把零值替换,空串有语义的字段别加。
- ⚠️ 事务里复用同一 `tx` 会串条件,每句 `tx.WithContext(ctx).<链式>`。
- ⚠️ 软删除 + 唯一索引 = 槽位永久占用:停用(可恢复)= 物理删内容 + 软删用户行,注册侧 `Unscoped` 给
`ErrDeactivatedEmail/Domain`;封禁 = 物理删用户行,邮箱释放。级联删除一律 `Unscoped()`。
- ⚠️ GORM 链式对象 Count/Find 后带状态,统计/预览/更新必须各自重建 query。
+33
View File
@@ -0,0 +1,33 @@
# 其它约定(长版明细)
## 其它约定
- 校验靠 `valid:"required;maxlen:N"` 标签(`internal/form/*.go`),别在 handler 手写。
- 终止性操作(停用)落独立公开页 `/deactivated`(注册在 `reqUserSignIn` 外);改密码后重登才用 flash + `/login`。
- 只有箱主 `/user` 面板,**无管理员角色、无站方后台,用户要求不要加**:站方动作只走 CLI / 邮件 / 改配置重启。
- git `https://git.shiroko.one/tamakyi/tamabox`(`main`)。**`git push` 能直接推**(本机 Git for Windows 已认证);
「Failed to authenticate user」是临时态。推前 `git log --oneline -3`。`outputs/` 已 gitignore,别整目录 add。
- 打包交付用 tar.gz(排除 `.git`/`.workbuddy`/`outputs`),放项目同级。
## 测试环境 box-dev-api.337845818.best
- 账号 `[email protected]` / `adminadmin`,公开页 `/_/admin6`。
- 登录要过算数验证码:`GET /login` 取 `_csrf`+cookie → `GET /api/captcha/generate` → 算 `a op b` →
`POST /login` 带 `g-recaptcha-response="<id>:<答案>"`。默认语言 **ja**,四语言文案要一起加。
- **部署有延迟**;模板是编译期 embed,**只重启不重编不会变**。用户说「已部署但没变化」→ 先抓线上 HTML 比对标记。
- 表单字段:提问 `content`,回答 `answer`。`GET /user/votes` 故意 302;匿名无邮箱提问成功直接 200 渲染。
- 自检:`dev/devcheck-e2e.py`(28 项)、`dev/devcheck-restrict.sh`(基线非 200 就别往下走;`RUN=1` 才真跑)。
- 图片编码三档(头像 512/q78、背景 1920/q80、通用 1600/q80),**EXIF 方向必须先归一**(转 WebP 会丢)。
## CLI(`TamaBox <cmd>`)
- ⚠️ **两个构建入口必须同步**:根 `go build .`(`tamabox.go`)与 Dockerfile 的 `./cmd/`(`cmd/tamabox.go`),
否则 `No help topic for '<cmd>'`。
- 现有:`web` `censor` `uid` `ban` `restore` `reports` `restrict` `unrestrict` `hide-public`。
- `restrict --domain|--email --level L1..L4 --days N --reason` / `unrestrict`;
`reports --domain|--email [--days]`(证据包)、`--pending`、`--stats --days N`;`hide-public [--only-reported] [--since N]`。
- 本地冒烟:`NEKOBOX_CONFIG_PATH=<ini>`;`conf.Init` 强制 `app.external_url`、`server.salt`、`server.xsrf_key` 非空。
- 蜜罐 CLI:`TamaBox honeypot hits|stats|prune`(`--since 24h` / `--show-password` / `--yes`),
注册须在 `cmd/tamabox.go` + 根 `tamabox.go` 两处同步。
- 蜜罐配置:`[security] enable_honeypot` / `honeypot_paths` / `honeypot_retain_days`(默认全关)。
+39
View File
@@ -0,0 +1,39 @@
# 审核 / 处置 / 举报(长版明细)
## 审核 / 处置 / 举报
- 线上通道 = 七牛(`ai.qiniuapi.com/v3/text/censor`,antispam);`[security] text_censor_source = qiniu|aliyun|auto`。
七牛只硬拦涉政/暴恐/色情,`review` 放行 → 裁决在 `censor/policy.go` + `moderation.Check`。
- **新增审核调用点必须走 `moderation.Check`**(直调 `censor.Text` 绕过档位)。不可用时 **fail-closed**。
- ⚠️ 圈内黑话(代称+群体标签+避雷叙述)通用审核拦不住,别指望词库;靠处置 + 举报 + 人工判断。
- 严格度 lax/standard/strict 存 `users.moderation_strictness`;**受处置用户强制 strict**:
`moderation.EffectiveStrictness(ctx, userID, raw)`(7 个入口已改)。**查询失败回退用户设置**
(否则 DB 抖动会让全站突然加严)。处置期内拒绝保存 + 设置页 disabled。
- ⚠️ **同请求渲染只能用 `ctx.SetError` / `SetInternalError`,不能用 `*Flash`**:flash 写 session,下次请求才读 →
提示静默丢失。`Set*Flash + Redirect` 才是正常模式。扫描法:找 flash 调用 → 向下第一个 `Success(`/`Redirect(`。
- 处置四级:L1 停止分发/L2 收窄公开(新回答强制私密、禁 set-public)/L3 冻结发布(仍可收信)/
L4 整页停用(`/restricted`)。到期靠 `expires_at`,**无 cron**;`restrict` 是**替换语义**;比较用 `RestrictionAtLeast`。
- ⚠️ L2 强制私密必须在**审核通过之后**,且与写回答**同一条 UPDATE**(`AnswerByIDPrivate`):
拆开会留下「公开+已作答」窗口,第二条失败还会变成「私密+无回答」。
- ⚠️ **`ListAPI`(`/api/v1/user/{domain}/questions`)不经过 Pager 中间件**,L4 与页面密码必须自己校验。
- ⚠️ **整箱分享卡 `ShareCard` 与单题分享卡都要拦**,且拦截必须在缓存查询之前(否则 30 分钟内仍给旧图)。
- 举报 `POST /_/{domain}/{qid}/report`:免登录 + 验证码 + 限流;同 IP 同题唯一索引 →
**幂等且提示与首次一致**(否则成了内容存在性探测器)。箱主只看「被举报 N 次」,**不给 IP**。
- 闭环:删除/设私密/`hide-public` → `MarkAcceptedByQuestion`;待处理超 30 天视为过期。
⚠️ `hide-public` 里必须**先快照目标 ID 再更新**(更新后带 `is_private=false` 的子查询恒为空集)。
- 告警:24h≥8 或 7d≥20 → 邮件到 `[security] security_alert_email`(24h 冷却);单条恰好 3 人举报 → 提醒箱主。
⚠️ 该邮箱为空时静默跳过、无日志;改配置必须重启。
- `/rules` 是处置依据页(四语言);注册页必须勾 `agree_rules`(服务端同样校验),改 e2e 脚本要同步。
- `restrict`/`unrestrict` 默认给箱主发通知(按 `users.language`,`mail/restriction-notice.html`),
**只含级别名/到期/规则链接,不含 `--reason`**;`--no-notify` 跳过。`--days > 90` 要 `--force`。
⚠️ **到期自动解除不发信**(读表比较时间)。
## 蜜罐(2026-10-21)
- 落点是**改写 `f.NotFound`**(不是新中间件):NotFound 只在路由表落空时触发 → 与业务路由零交集;
位于全局 Use 链之后(body 已限 12 MiB);组级 CSRF 不作用于它。
- flamego 短路机制:`context.run()` 在 `ResponseWriter().Written()` 为真时 return → 写完响应不调 `ctx.Next()` 即止。
- 蜜罐命中的路径由蜜罐自己 `autoban.Record`,autoban 中间件须跳过(否则一次请求计两遍,阈值减半)。
- `HoneypotHit` 不嵌 `dbutil.Model`(审计证据只追加,不软删)。
- 假页只含 canary + 假信息;错误提示不区分「用户不存在」与「密码错误」(防用户名枚举)。
- 密码 CLI 默认脱敏;告警邮件含明文凭据 → 收件人只能是运维本人。
+19
View File
@@ -0,0 +1,19 @@
# 前端样式(长版明细)
## 前端样式
- `static/css/base.css`(`?v=` 取 `conf.StaticVersion`,**改完必须 +1**)。令牌在 `:root`(亮)+
`html[data-theme="dark"]`(暗)。新样式一律 `var(--tb-*)`,别写死色值,也别为深色另写分支。
- 页面级样式内联在各自模板 `<style>`;尽量用类不用 `style=`(深色下内联写死色值会露白)。
- 主题属性在 head 内联脚本里打(cookie `theme`),**不能挪回 body**,否则深色用户白闪。
- **移动端(≤640px)语言切换与主题按钮在固定底栏** `#tb-mobile-utils`(`.tb-nav-utils` 被隐藏,
body 留 `padding-bottom:56px`)。用户曾误以为"按钮不见了",先解释位置。
⚠️ `/_/{domain}` 箱主页 `IsUserPage = true`(`route/question/page.go:133`),语言切换包在
`{{ if not .IsUserPage }}` 里 → **箱主页没有语言切换入口,有意设计**(用户 2026-10-19 确认),别当 bug 修。
- ⚠️ **UIkit 3.3.3 的 `.uk-flex::before/::after{display:none}`**:给带 `uk-flex` 的元素加伪元素必须写
复合选择器(`.tb-cover.uk-flex::after`),否则静默失效。
- 分类/题型标签一律 `.tb-chip`;彩色 `uk-label` 只留给语义状态(置顶/私密/被举报/已读、投票四态)。
- 视觉自检:`TEST_PREVIEW=1 go test ./templates -run TestGenPreview` → `outputs/preview-*.html`;
截图必须 `chrome --headless=old --force-device-scale-factor=1`(新版 headless 本机挂住)。
⚠️ `--window-size` ≠ CSS 视口;要精确视口就用 iframe 指定 width/height。验证 CSS 靠**像素比对**
(PIL 在 venv)而非目测;扫描循环上界不能与物体边界重合。
+44
View File
@@ -0,0 +1,44 @@
# 主题(箱主个性化)(长版明细)
## 主题(箱主个性化)
- 三个注入点:`/_/{domain}/*` → `question.Pager` 按 `pageUser.Theme`;`/user/*` → `internal/context`
Contexter 按 `c.User.Theme`(`isThemeScope()` 只认 `/user` 与 `/user/`);`/vote/{token}` → `route/vote.Page`
按发布者 `owner.Theme`。都看箱主/发布者,不看访客。全站页(`/`、`/explore`、`/login`)不主题化。
- 投票页**语言**也跟发布者:`applyOwnerLocale(data, owner)`(与主题同一函数),必须在任何 i18n 取词之前调用。
- 一套主题 = `static/css/themes/<slug>.css`,**只覆盖令牌**,不碰布局。`static/fs.go` 已嵌整个 `themes/` 目录。
- 主题显示名:模板用 `{{ themeName .LangCode .Slug }}` → `theme.DisplayName`,i18n 没登记
`theme.name.<slug>` 时**回退成 slug**。⚠️ 别在模板里直接 `{{t .LangCode (printf "theme.name.%s" .Slug)}}`:
`i18n.T` 缺词条返回 key 本身,设置页就会显示 `theme.name.test01`(用户报过)。
- ⚠️ **`[theme] available` 是硬白名单,启动时读一次**:css 发布了但没登记,设置页看不到,现象同「部署没生效」。
排查:`curl /static/css/themes/<slug>.css` 200 = 文件在(没登记);404 = 旧二进制。
- ⚠️ 主题 css **必须成对写**:`html[data-theme-style="x"]`(0,1,1) + `html[data-theme="dark"][data-theme-style="x"]`(0,2,1)。
`TestThemeCSSComplete` 查两个选择器 + 10 个颜色令牌。
- ⚠️ **按页面作用域**:`internal/context.pageScope()` → `<html data-page-scope>`,取值
`box`/`question`/`admin`(/user/questions,/user/votes*)/`profile`/`vote`。深色作用域覆盖必须**三重选择器**,
否则被浅色作用域块(同特异性、写在后面)盖掉。
- ⚠️ **装饰层固定 10 槽**(base.css 12 层 = pattern1 + deco10 + image1),三组 `--tb-deco/-size/-position`
必须等长。`TestDecoSlotCount` 逐文件逐声明检查(只扫 embed 到的文件)。
- 位置一律两值 `calc(0%|50%|100% ± N)`(百分比相对「容器 − 图片」)。**别用 left/bottom 关键字混搭**:
三值语法非法时整条声明作废 → 图层静默回左上。
- 尺寸想随视口缩用 `clamp`,**不要写死 px**:`auto clamp(min(calc(100vh - 24px), 231px), 46.67vh, 420px)`。
内层 `min()` 是兜底 —— 屏幕小到连下限都放不下时让位给视口。宽幅素材只能按宽度缩(`vw`),
按高度缩的话宽度按比例涨,窄屏照样裁。
- ⚠️ **UIkit 3.3.3 主色是编译期常量 `#1e87f0`**,令牌注入不进去。base.css 末尾按组件映射(a/uk-link、
uk-button-primary、checkbox/radio、input:focus、uk-tab active、`.uk-progress`、`.uk-label:not(...)`)。
- 令牌 `--tb-navbar`(顶栏底色,默认 `var(--tb-surface)`):页面底色偏彩时主题必须覆盖。
- 静态资源版本号统一 `conf.StaticVersion`;**图片不走这个缓存键**(`max-age=86400`),换图要给 URL 带 `?v=`。
- 整页背景 `--tb-page-image`(cover)/ `--tb-page-pattern`(平铺,`-size` 控尺寸)/ `--tb-page-position`
(焦点,默认 `var(--tb-page-position, center center)`,旧主题不写也不坏)。底色用**纯色**别用渐变;
图案 ≥24px、透明度 ≤0.06。
- ⚠️ **多背景层不能塞变量列表**:变量自身是逗号列表会让层数与 size/position 错位。多张装饰用固定槽位。
- 装饰摆位:**贴边、成组、避开页脚**;四角各摆一个会读成贴纸墙纸;页脚文案右对齐 → 右下角不能放人物。
`--tb-deco` 是 fixed 视口层,贴边插画别放进 `cover` 背景图(必裁边)。
- 主题视觉的**结构**比摆位重要:通行做法是「整屏场景插画 + UI 悬浮其上」(蔚蓝档案 JP)。
白底留白里摆人物,无论多大都读成贴纸;卡片/顶栏配半透明白 + `backdrop-filter` 才有"浮在画面上"的关系。
- 素材放 `static/images/themes/<slug>/`(embed 已含 `images/themes`,子目录自动覆盖)。整页图 16:9 + 竖版两张。
- 人物素材:**图生图 + 抠白底**(`image1` 给立绘、`input_fidelity: high`、`flat vector illustration, no outlines`)。
⚠️ 正面立绘直接贴进空场景像贴纸:改**侧后方视角**(不直视访客),RGB **压白 12~16%** 与压白 38% 的场景对齐雾度。
- ⚠️ **抠白底容差 16**:背景 253、白袜子 237 只差 16,容差 22 以上会把袜子吃穿。手内侧/两腿之间是
**闭合**白区,四边 floodfill 进不去,得按连通域单独掏(面积 ≥24),再 1px 收边。
- ⚠️ `ruleBody` 取令牌前要剥 `/* */`:注释会和后面的令牌粘成一个键,色块预览静默失效。
+1
View File
@@ -28,6 +28,7 @@ func main() {
cmd.Restrict,
cmd.Unrestrict,
cmd.HidePublic,
cmd.Honeypot,
}
if err := app.Run(os.Args); err != nil {
logrus.WithError(err).Fatal("Failed to start application")
+9
View File
@@ -71,6 +71,15 @@ ban_threshold_404 = 20 ; 统计窗口内返回 404 的次数阈值
ban_window_seconds = 60 ; 统计窗口时长(秒)
ban_duration_minutes = 15 ; 临时封禁时长(分钟),到期自动解除,重启即清零
security_alert_email = ; 触发封禁时发送告警邮件的收件人(复用下方 SMTP 发信配置),留空不发送
; ── 蜜罐(虚假后台登录界面)────────────────────────────────────────────
enable_honeypot = false ; 开启后,扫描器探测后台路径(/wp-admin、/.ssh、/.env 等)
; 时不再返回 302/404,而是返回一个仿 WordPress 的假登录页,
; 并把其提交的用户名/密码记录入库供取证。
; 只在「没有任何路由匹配」的路径上生效,真实业务路由零交集;
; 假页面只含 canary 标记与假凭据,绝不含任何真实信息。
honeypot_paths = ; 追加自定义诱饵路径(逗号分隔),与内置清单取并集。
honeypot_retain_days = 30 ; 蜜罐命中记录的保留天数(取证用途),到期可用 CLI 清理。
default_moderation_strictness = ; 站点默认「内容审核严格度」:lax(宽松)/ standard(标准)/ strict(严格)。
; 仅对未单独设置档位的账号生效(账号在设置页选择后以账号设置为准)。
; 留空回退 strict(最严档):保证升级不会放宽任何拦截。
+277
View File
@@ -0,0 +1,277 @@
// 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 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,
},
},
}
// 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)
rows, err := store.ListSince(context.Background(), since, ctx.Int("limit"))
if err != nil {
return errors.Wrap(err, "query honeypot hits")
}
filterIP := strings.TrimSpace(ctx.String("ip"))
showPwd := ctx.Bool("show-password")
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 {
if filterIP != "" && r.IP != filterIP {
continue
}
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)
return nil
}
// 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 ""
}
+9 -1
View File
@@ -78,7 +78,15 @@ var (
// 个人实名账号即可开通)/ auto(默认,留空同义:七牛优先、阿里云复核)。
// 仅显式选定的通道会被调用,缺凭据时启动即报错(见 conf.Init)——
// 审核失败是 fail-closed(拒绝提交),会让发布与私信整站不可用且现象隐蔽。
TextCensorSource string `ini:"text_censor_source"`
TextCensorSource string `ini:"text_censor_source"`
// EnableHoneypot 开启「虚假后台登录界面」蜜罐:扫描器探测后台路径时
// 返回一个仿 WordPress 的假登录页,并记录其提交的用户名/密码等破解信息。
// 仅在「无任何路由匹配」的路径上生效,与真实业务路由零交集。
EnableHoneypot bool `ini:"enable_honeypot"`
// HoneypotPaths 追加自定义诱饵路径(逗号分隔),与内置清单并集。
HoneypotPaths string `ini:"honeypot_paths"`
// HoneypotRetainDays 蜜罐命中记录的保留天数,到期由 CLI 清理。
HoneypotRetainDays int `ini:"honeypot_retain_days"`
SecurityAlertEmail string `ini:"security_alert_email"` // 触发封禁时告警收件人(留空不发送)
}
+3
View File
@@ -23,6 +23,8 @@ var AllTables = []interface{}{
&FeatureControl{},
// 站方处置与举报(无管理后台,处置经 CLI 写入、运行时读表生效)
&UserRestriction{}, &ContentReport{},
// 蜜罐命中记录(审计证据,只追加不软删)
&HoneypotHit{},
}
func Init(typ, dsn string) (*gorm.DB, error) {
@@ -117,6 +119,7 @@ func Init(typ, dsn string) (*gorm.DB, error) {
FeatureControls = NewFeatureControlsStore(db)
Restrictions = NewRestrictionsStore(db)
Reports = NewReportsStore(db)
Honeypots = NewHoneypotStore(db)
if err := db.Use(otelgorm.NewPlugin(
otelgorm.WithDBName(conf.Database.Name),
+188
View File
@@ -0,0 +1,188 @@
// 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 db
import (
"context"
"time"
"gorm.io/gorm"
)
// HoneypotHit 是一次蜜罐命中记录(审计证据)。
//
// 刻意不嵌 dbutil.Model:审计证据不做软删除、不做 Update/Delete,
// 只追加与查询。理由与 ContentReport 一致 —— 证据链一旦被软删除过滤,
// 事后追责就断了。
type HoneypotHit struct {
ID uint `gorm:"primarykey" json:"id"`
CreatedAt time.Time `gorm:"index" json:"created_at"`
// IP 为归一化后的客户端 IP(shared.LimitKey 之后,IPv6 折叠到 /64)。
IP string `gorm:"size:64;index" json:"ip"`
// Method 与 Path 记录原始请求方法与路径(未归一化的原始值)。
Method string `gorm:"size:16" json:"method"`
Path string `gorm:"size:512" json:"path"`
// Query 保存原始查询串(截断)。
Query string `gorm:"size:1024" json:"query"`
// Username / Password 为提取出的破解凭据。Password 落库前做可逆加密还是脱敏,
// 由调用方决定:这里只存明文,CLI 展示时默认脱敏。
Username string `gorm:"size:256" json:"username"`
Password string `gorm:"size:256" json:"password"`
// Fields 保存其余表单/JSON 字段(JSON 字符串),便于还原破解工具特征。
Fields string `gorm:"type:text" json:"fields"`
// RawBody 保存截断后的原始 body。
RawBody string `gorm:"type:text" json:"raw_body"`
// HadFields 标记本次提交是否真的带了凭据字段(区分「探测 GET」与「尝试破解 POST」)。
HadFields bool `json:"had_fields"`
// UserAgent / Referer / ContentType 用于扫描器画像。
UserAgent string `gorm:"size:512" json:"user_agent"`
Referer string `gorm:"size:512" json:"referer"`
ContentType string `gorm:"size:256" json:"content_type"`
// Provider 为 IP 归属(云厂商/CDN/WAF)标签,Proxied 表示是否经代理。
Provider string `gorm:"size:64;index" json:"provider"`
Proxied bool `json:"proxied"`
// Banned 为命中时是否触发了封禁。
Banned bool `json:"banned"`
}
// TableName 固定表名,避免 GORM 复数化差异。
func (HoneypotHit) TableName() string { return "honeypot_hits" }
// Honeypots 蜜罐命中记录全局 store,由 db.Init 装配。
var Honeypots HoneypotStore
// HoneypotStore 蜜罐命中记录的读写。
type HoneypotStore interface {
// Create 追加一条命中记录。
Create(ctx context.Context, hit *HoneypotHit) error
// ListSince 返回 since 之后的命中记录,按时间倒序,最多 limit 条。
ListSince(ctx context.Context, since time.Time, limit int) ([]*HoneypotHit, error)
// CountSince 统计 since 之后的命中总数。
CountSince(ctx context.Context, since time.Time) (int64, error)
// TopSources 返回 since 之后命中次数最多的来源 IP。
TopSources(ctx context.Context, since time.Time, limit int) ([]*HoneypotSourceStat, error)
// TopPaths 返回 since 之后被命中次数最多的路径。
TopPaths(ctx context.Context, since time.Time, limit int) ([]*HoneypotPathStat, error)
// TopCredentials 返回 since 之后最常被尝试的用户名/密码组合。
TopCredentials(ctx context.Context, since time.Time, limit int) ([]*HoneypotCredentialStat, error)
// DeleteBefore 清理 before 之前的记录(保留期到期),返回删除行数。
DeleteBefore(ctx context.Context, before time.Time) (int64, error)
}
// HoneypotSourceStat 来源 IP 聚合行。
type HoneypotSourceStat struct {
IP string
Count int64
Provider string
}
// HoneypotPathStat 路径聚合行。
type HoneypotPathStat struct {
Path string
Count int64
}
// HoneypotCredentialStat 凭据组合聚合行。
type HoneypotCredentialStat struct {
Username string
Password string
Count int64
}
type honeypots struct {
db *gorm.DB
}
// NewHoneypotStore 构造蜜罐记录 store。
func NewHoneypotStore(db *gorm.DB) HoneypotStore {
return &honeypots{db: db}
}
func (h *honeypots) Create(ctx context.Context, hit *HoneypotHit) error {
if hit == nil {
return nil
}
return h.db.WithContext(ctx).Create(hit).Error
}
func (h *honeypots) ListSince(ctx context.Context, since time.Time, limit int) ([]*HoneypotHit, error) {
if limit <= 0 || limit > 1000 {
limit = 200
}
var rows []*HoneypotHit
err := h.db.WithContext(ctx).
Where("created_at >= ?", since).
Order("created_at DESC").
Limit(limit).
Find(&rows).Error
return rows, err
}
func (h *honeypots) CountSince(ctx context.Context, since time.Time) (int64, error) {
var n int64
err := h.db.WithContext(ctx).Model(&HoneypotHit{}).
Where("created_at >= ?", since).
Count(&n).Error
return n, err
}
func (h *honeypots) TopSources(ctx context.Context, since time.Time, limit int) ([]*HoneypotSourceStat, error) {
if limit <= 0 || limit > 500 {
limit = 20
}
var rows []*HoneypotSourceStat
err := h.db.WithContext(ctx).
Table("honeypot_hits").
Select("ip AS ip, count(*) AS count, max(provider) AS provider").
Where("created_at >= ?", since).
Group("ip").
Order("count DESC").
Limit(limit).
Scan(&rows).Error
return rows, err
}
func (h *honeypots) TopPaths(ctx context.Context, since time.Time, limit int) ([]*HoneypotPathStat, error) {
if limit <= 0 || limit > 500 {
limit = 20
}
var rows []*HoneypotPathStat
err := h.db.WithContext(ctx).
Table("honeypot_hits").
Select("path AS path, count(*) AS count").
Where("created_at >= ?", since).
Group("path").
Order("count DESC").
Limit(limit).
Scan(&rows).Error
return rows, err
}
func (h *honeypots) TopCredentials(ctx context.Context, since time.Time, limit int) ([]*HoneypotCredentialStat, error) {
if limit <= 0 || limit > 500 {
limit = 20
}
var rows []*HoneypotCredentialStat
err := h.db.WithContext(ctx).
Table("honeypot_hits").
Select("username AS username, password AS password, count(*) AS count").
Where("created_at >= ?", since).
Where("username <> '' OR password <> ''").
Group("username, password").
Order("count DESC").
Limit(limit).
Scan(&rows).Error
return rows, err
}
func (h *honeypots) DeleteBefore(ctx context.Context, before time.Time) (int64, error) {
res := h.db.WithContext(ctx).Where("created_at < ?", before).Delete(&HoneypotHit{})
return res.RowsAffected, res.Error
}
+82
View File
@@ -0,0 +1,82 @@
// 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 db
import (
"context"
"testing"
"time"
"github.com/stretchr/testify/require"
)
// TestHoneypotStore 覆盖蜜罐命中记录的写入、检索与聚合。
//
// 重点验证「只追加」的审计语义:DeleteBefore 只按时间删,不做软删除;
// 以及 TopCredentials 能把攻击者正在用的字典聚合出来。
func TestHoneypotStore(t *testing.T) {
database, cleanup := newTestDB(t)
ctx := context.Background()
t.Cleanup(func() {
if err := cleanup("honeypot_hits"); err != nil {
t.Fatal(err)
}
})
store := NewHoneypotStore(database)
now := time.Now()
rows := []*HoneypotHit{
{IP: "1.2.3.4", Method: "GET", Path: "/wp-admin", Provider: "Azure", CreatedAt: now.Add(-3 * time.Hour)},
{IP: "1.2.3.4", Method: "POST", Path: "/wp-login.php", Username: "admin", Password: "admin123", HadFields: true, CreatedAt: now.Add(-2 * time.Hour)},
{IP: "1.2.3.4", Method: "POST", Path: "/wp-login.php", Username: "admin", Password: "admin123", HadFields: true, CreatedAt: now.Add(-time.Hour)},
{IP: "5.6.7.8", Method: "POST", Path: "/.ssh/id_rsa", Username: "root", Password: "toor", HadFields: true, CreatedAt: now.Add(-30 * time.Minute)},
}
for _, r := range rows {
require.NoError(t, store.Create(ctx, r))
}
since := now.Add(-4 * time.Hour)
total, err := store.CountSince(ctx, since)
require.NoError(t, err)
require.Equal(t, int64(4), total)
list, err := store.ListSince(ctx, since, 10)
require.NoError(t, err)
require.Len(t, list, 4)
// 倒序返回
require.Equal(t, "5.6.7.8", list[0].IP)
sources, err := store.TopSources(ctx, since, 10)
require.NoError(t, err)
require.Len(t, sources, 2)
require.Equal(t, "1.2.3.4", sources[0].IP)
require.Equal(t, int64(3), sources[0].Count)
require.Equal(t, "Azure", sources[0].Provider)
paths, err := store.TopPaths(ctx, since, 10)
require.NoError(t, err)
require.Len(t, paths, 3)
require.Equal(t, "/wp-login.php", paths[0].Path)
require.Equal(t, int64(2), paths[0].Count)
creds, err := store.TopCredentials(ctx, since, 10)
require.NoError(t, err)
require.Len(t, creds, 2)
require.Equal(t, "admin", creds[0].Username)
require.Equal(t, "admin123", creds[0].Password)
require.Equal(t, int64(2), creds[0].Count)
// 保留期清理
deleted, err := store.DeleteBefore(ctx, now.Add(-90*time.Minute))
require.NoError(t, err)
require.Equal(t, int64(2), deleted) // 3 小时前、2 小时前两条被清
total, err = store.CountSince(ctx, since)
require.NoError(t, err)
require.Equal(t, int64(2), total)
}
+47
View File
@@ -113,6 +113,53 @@ func SendSecurityAlertMail(ip, provider string, proxied bool, samplePaths []stri
return sendTemplateMail(conf.Security.SecurityAlertEmail, "【狼的提问箱】检测到疑似批量扫描,已自动封禁 IP", templates.FS, "mail/security-alert.html", params)
}
// HoneypotAlert 蜜罐捕获到破解尝试时的告警载荷。
type HoneypotAlert struct {
IP 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,
"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 收件人,不新增配置项。
+8 -3
View File
@@ -6,6 +6,7 @@ import (
"github.com/flamego/flamego"
"github.com/tamakyi/TamaBox/internal/security/autoban"
"github.com/tamakyi/TamaBox/internal/security/honeypot"
"github.com/tamakyi/TamaBox/internal/shared"
)
@@ -15,7 +16,7 @@ import (
// - 真正的 404 探测由 f.NotFound 处理器单独计数(见 route.go),
// 不再把业务性的"302 → /"(语言切换 / 登出 / 投票不存在 / 黑名单跳转等)误判为扫描。
// - 核心检测逻辑见 internal/security/autoban(可独立测试);告警发送函数由本层注入。
func autobanMiddleware(b *autoban.Banner) flamego.Handler {
func autobanMiddleware(b *autoban.Banner, hp *honeypot.Honeypot) flamego.Handler {
return func(ctx flamego.Context) {
// 经 shared.LimitKey 归一化后再计数与判定:IPv4 原样,IPv6 聚合到 /64。
// 否则攻击者可在单个 /64 内轮换地址,使每个键只命中一次而永不触发阈值。
@@ -32,8 +33,12 @@ func autobanMiddleware(b *autoban.Banner) flamego.Handler {
path := ctx.Request().URL.Path
kind := autoban.KindNone
if autoban.IsSensitivePath(path) {
kind = autoban.KindSensitive
// 蜜罐命中的路径由蜜罐处理器自己计数(它需要同时知道「是否触发了封禁」
// 以便落库)。若这里再记一次,同一次请求会被计两遍,阈值实际减半。
if hp == nil || !hp.Match(path) {
if autoban.IsSensitivePath(path) {
kind = autoban.KindSensitive
}
}
if kind != autoban.KindNone {
defer b.Record(ip, path, kind)
+261
View File
@@ -0,0 +1,261 @@
// 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 route
import (
"context"
"encoding/json"
"net/http"
"strings"
"sync"
"time"
"github.com/flamego/flamego"
"github.com/sirupsen/logrus"
"github.com/tamakyi/TamaBox/internal/conf"
"github.com/tamakyi/TamaBox/internal/db"
"github.com/tamakyi/TamaBox/internal/mail"
"github.com/tamakyi/TamaBox/internal/security/autoban"
"github.com/tamakyi/TamaBox/internal/security/honeypot"
"github.com/tamakyi/TamaBox/internal/security/ipinfo"
"github.com/tamakyi/TamaBox/internal/shared"
)
// honeypotHandler 蜜罐处理器:只在「无任何路由匹配」的路径上生效。
//
// 与真实业务路由(/login、/register、/_/{domain}、/user/**、/api/v1/**)天然零交集,
// 因为它挂在 f.NotFound 上 —— flamego 只有在路由表完全落空时才会走到这里。
//
// 行为:
// - GET → 返回仿 WordPress 的假登录页(200),让扫描器判定「后台存在」;
// - POST → 解析并记录提交的用户名/密码,返回「用户名或密码无效」的假响应;
// - 无论 GET/POST → 计入 autoban 敏感路径统计,达到阈值即临时封禁 + 邮件告警;
// - 绝不调用 ctx.Next(),不查业务表、不渲染业务模板。
type honeypotHandler struct {
hp *honeypot.Honeypot
ab *autoban.Banner
// store 允许为 nil(单测 / 未装配 DB 时降级为只记录日志)。
store db.HoneypotStore
// sendAlert 发送「捕获到破解尝试」的告警邮件。nil 则不发。
sendAlert func(mail.HoneypotAlert) error
// alerted 保证同一个 IP 只告警一次(与 autoban 的告警去重口径一致),
// 避免暴力破解脚本在封禁前反复提交把邮箱打爆。
alerted map[string]bool
alertedMu sync.Mutex
}
// newHoneypotHandler 构造蜜罐处理器。
func newHoneypotHandler(hp *honeypot.Honeypot, ab *autoban.Banner, store db.HoneypotStore) *honeypotHandler {
return &honeypotHandler{
hp: hp,
ab: ab,
store: store,
alerted: map[string]bool{},
}
}
// Serve 处理一次蜜罐命中。
func (h *honeypotHandler) Serve(ctx flamego.Context) {
req := ctx.Request().Request
ip := shared.LimitKey(shared.ResolveClientIP(req))
path := req.URL.Path
// 记录与封禁:走既有 autoban 链路,复用阈值封禁与告警。
banned := false
if h.ab != nil && ip != "" {
h.ab.Record(ip, path, autoban.KindSensitive)
// Record 内部在触发封禁时把 IP 写入 banned 表;此处再查一次得到确切状态。
banned = h.ab.IsBanned(ip)
}
// body 只能读一次:先提取凭据(POST 时读 body),再决定写哪种响应。
creds := h.hp.ExtractCredentials(req)
switch req.Method {
case http.MethodGet, http.MethodHead:
h.serveLoginPage(ctx, path)
case http.MethodPost, http.MethodPut, http.MethodPatch:
h.serveLoginFailure(ctx, path, honeypot.LoginFailureNotice)
default:
// 其他方法(PUT/DELETE/OPTIONS 等)返回一个无内容的假 200,模拟后端存在。
ctx.ResponseWriter().WriteHeader(http.StatusOK)
}
// 落库留证。落库失败只记日志,绝不影响已写出的响应。
h.record(ip, req, path, creds, banned)
}
// serveLoginPage 返回假登录页。
func (h *honeypotHandler) serveLoginPage(ctx flamego.Context, path string) {
redir := ""
if q := ctx.Request().URL.Query().Get("redirect_to"); q != "" {
redir = q
}
honeypot.WriteLoginPage(ctx.ResponseWriter(), honeypot.LoginPageVars{
Action: path,
Title: "Log In ‹ WordPress",
Heading: "WordPress",
Redir: redir,
})
}
// serveLoginFailure 返回「登录失败」的假响应。
//
// 响应体刻意与真实 WordPress 的错误提示一致(同一句文案、不区分用户不存在与密码错误),
// 这样爆破脚本拿不到「用户名枚举」信号,也就无法优化字典;而我们照样拿到完整凭据。
func (h *honeypotHandler) serveLoginFailure(ctx flamego.Context, path string, notice string) {
body := honeypot.RenderLoginPage(honeypot.LoginPageVars{
Action: path,
Title: "Log In \u2039 WordPress",
Heading: "WordPress",
Notice: notice,
Redir: ctx.Request().URL.Query().Get("redirect_to"),
})
w := ctx.ResponseWriter()
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Header().Set("Cache-Control", "no-store, no-cache, must-revalidate")
w.Header().Set("X-Robots-Tag", "noindex, nofollow")
w.WriteHeader(http.StatusOK)
_, _ = w.Write(body)
}
// record 追加一条命中记录。
func (h *honeypotHandler) record(ip string, req *http.Request, path string, creds honeypot.Credentials, banned bool) {
h.recordCredentials(ip, req, path, creds, banned)
}
// recordCredentials 把一次命中写入数据库。
func (h *honeypotHandler) recordCredentials(ip string, req *http.Request, path string, creds honeypot.Credentials, banned bool) {
provider, proxied := ipinfo.Lookup(ip)
fieldsJSON := ""
if len(creds.Fields) > 0 {
if b, err := json.Marshal(creds.Fields); err == nil {
fieldsJSON = string(b)
}
}
query := ""
if req.URL != nil {
query = req.URL.RawQuery
if len(query) > 1000 {
query = query[:1000]
}
}
hit := &db.HoneypotHit{
IP: ip,
Method: req.Method,
Path: path,
Query: query,
Username: creds.Username,
Password: creds.Password,
Fields: fieldsJSON,
RawBody: creds.RawBody,
HadFields: creds.HadFields,
UserAgent: truncate(req.UserAgent(), 500),
Referer: truncate(req.Referer(), 500),
ContentType: truncate(req.Header.Get("Content-Type"), 250),
Provider: provider,
Proxied: proxied,
Banned: banned,
CreatedAt: time.Now(),
}
// 只有真的提交了凭据才告警:纯路径探测(GET)没有取证价值,
// 而爆破脚本往往在封禁前反复 POST,只告警一次即可。
if creds.HadFields {
h.alert(ip, req, path, creds, provider, proxied, banned)
}
// 有凭据的提交才是高价值证据,日志里点名;纯探测只记路径。
if creds.HadFields {
logrus.WithFields(logrus.Fields{
"ip": ip,
"path": path,
"username": creds.Username,
"banned": banned,
}).Warn("honeypot: credential submission captured")
}
if h.store == nil {
return
}
// 用独立超时上下文,避免请求被取消时取证写不进去。
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
if err := h.store.Create(ctx, hit); err != nil {
logrus.WithError(err).WithField("ip", ip).WithField("path", path).
Warn("honeypot: failed to persist hit")
}
}
// alert 发送一次告警邮件(每个 IP 只发一次)。
func (h *honeypotHandler) alert(ip string, req *http.Request, path string, creds honeypot.Credentials, provider string, proxied, banned bool) {
if h.sendAlert == nil || ip == "" {
return
}
h.alertedMu.Lock()
if h.alerted[ip] {
h.alertedMu.Unlock()
return
}
h.alerted[ip] = true
h.alertedMu.Unlock()
durationMin := conf.Security.BanDurationMinutes
if durationMin <= 0 {
durationMin = 15
}
go func() {
if err := h.sendAlert(mail.HoneypotAlert{
IP: ip,
Provider: provider,
Proxied: proxied,
Path: path,
Username: creds.Username,
Password: creds.Password,
Banned: banned,
DurationMin: durationMin,
}); err != nil {
logrus.WithError(err).WithField("ip", ip).Warn("honeypot: failed to send alert mail")
}
}()
}
// truncate 截断字符串到指定长度(按 rune,避免截断多字节字符)。
func truncate(s string, n int) string {
if n <= 0 {
return ""
}
r := []rune(s)
if len(r) <= n {
return s
}
return string(r[:n])
}
// honeypotExtraPaths 解析配置中的自定义诱饵路径(逗号/分号/换行分隔)。
func honeypotExtraPaths(raw string) []string {
if strings.TrimSpace(raw) == "" {
return nil
}
fields := strings.FieldsFunc(raw, func(r rune) bool {
return r == ',' || r == ';' || r == '\n' || r == '\r' || r == ' ' || r == '\t'
})
out := make([]string, 0, len(fields))
for _, f := range fields {
f = strings.TrimSpace(f)
if f == "" {
continue
}
if !strings.HasPrefix(f, "/") {
f = "/" + f
}
out = append(out, f)
}
return out
}
+168
View File
@@ -0,0 +1,168 @@
// 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 route
import (
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/flamego/flamego"
"github.com/tamakyi/TamaBox/internal/security/autoban"
"github.com/tamakyi/TamaBox/internal/security/honeypot"
)
// newHoneypotTestServer 造一个只含蜜罐 NotFound 分流的最小 flamego 实例。
//
// 刻意不调用 route.New()(那会拉起 DB/Redis/session 依赖),而是复刻它的
// 关键结构:全局 Use 链 → 具体路由 → NotFound 蜜罐分流。
func newHoneypotTestServer(t *testing.T, enabled bool) *httptest.Server {
t.Helper()
f := flamego.New()
hp := honeypot.New(honeypot.Options{Enabled: enabled})
ab := autoban.New(autoban.Options{
Enabled: true,
ThresholdSensitive: 1, // 命中一次即封禁,便于断言
Window: 60,
Duration: 60,
})
hph := newHoneypotHandler(hp, ab, nil) // store=nil → 只走日志,不落库
f.Get("/login", func(ctx flamego.Context) {
ctx.ResponseWriter().WriteHeader(http.StatusOK)
_, _ = ctx.ResponseWriter().Write([]byte("REAL_LOGIN"))
})
f.Get("/api/v1/questions", func(ctx flamego.Context) {
ctx.ResponseWriter().WriteHeader(http.StatusOK)
_, _ = ctx.ResponseWriter().Write([]byte("REAL_API"))
})
f.NotFound(func(ctx flamego.Context) {
if hp.Match(ctx.Request().URL.Path) {
hph.Serve(ctx)
return
}
ctx.Redirect("/")
})
ts := httptest.NewServer(f)
t.Cleanup(ts.Close)
return ts
}
// TestHoneypotServesFakeLoginPage 验证:命中诱饵路径时返回假登录页(含 canary),
// 而不是 302 跳首页。
func TestHoneypotServesFakeLoginPage(t *testing.T) {
ts := newHoneypotTestServer(t, true)
resp, err := http.Get(ts.URL + "/wp-login.php")
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
if resp.StatusCode != http.StatusOK {
t.Errorf("status = %d, want 200", resp.StatusCode)
}
if ct := resp.Header.Get("Content-Type"); !strings.HasPrefix(ct, "text/html") {
t.Errorf("Content-Type = %q, want text/html", ct)
}
if !strings.Contains(string(body), honeypot.CanaryToken) {
t.Error("假登录页缺少 canary 标记,无法溯源")
}
if !strings.Contains(string(body), `name="log"`) || !strings.Contains(string(body), `name="pwd"`) {
t.Error("假登录页应使用 WordPress 经典字段名 log / pwd")
}
if strings.Contains(string(body), "REAL_LOGIN") {
t.Error("假登录页不应包含真实业务内容")
}
}
// TestHoneypotDoesNotHijackRealRoutes 是红线测试:真实业务路由绝不能被蜜罐接管。
func TestHoneypotDoesNotHijackRealRoutes(t *testing.T) {
ts := newHoneypotTestServer(t, true)
for path, want := range map[string]string{
"/login": "REAL_LOGIN",
"/api/v1/questions": "REAL_API",
} {
resp, err := http.Get(ts.URL + path)
if err != nil {
t.Fatal(err)
}
body, _ := io.ReadAll(resp.Body)
resp.Body.Close()
if !strings.Contains(string(body), want) {
t.Errorf("%s 返回了 %q,期望包含 %q(业务路由被劫持)", path, truncate(string(body), 80), want)
}
if strings.Contains(string(body), honeypot.CanaryToken) {
t.Errorf("%s 命中了蜜罐,业务路由被劫持", path)
}
}
}
// TestHoneypotDisabledFallsBackToRedirect 验证:蜜罐关闭时行为与原先一致(302 → /)。
func TestHoneypotDisabledFallsBackToRedirect(t *testing.T) {
ts := newHoneypotTestServer(t, false)
client := &http.Client{
CheckRedirect: func(req *http.Request, via []*http.Request) error {
return http.ErrUseLastResponse
},
}
resp, err := client.Get(ts.URL + "/wp-login.php")
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusFound {
t.Errorf("status = %d, want 302", resp.StatusCode)
}
if loc := resp.Header.Get("Location"); loc != "/" {
t.Errorf("Location = %q, want /", loc)
}
}
// TestHoneypotCapturesCredentials 验证:POST 提交的用户名/密码能被提取出来。
func TestHoneypotCapturesCredentials(t *testing.T) {
hp := honeypot.New(honeypot.Options{Enabled: true})
req := httptest.NewRequest(http.MethodPost, "/wp-login.php",
strings.NewReader("log=admin&pwd=hunter2&redirect_to=%2Fwp-admin%2F"))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
creds := hp.ExtractCredentials(req)
if creds.Username != "admin" {
t.Errorf("Username = %q, want admin", creds.Username)
}
if creds.Password != "hunter2" {
t.Errorf("Password = %q, want hunter2", creds.Password)
}
}
// TestHoneypotPostReturnsFailurePage 验证:POST 后返回 200 的假登录页并带错误提示。
func TestHoneypotPostReturnsFailurePage(t *testing.T) {
ts := newHoneypotTestServer(t, true)
resp, err := http.Post(ts.URL+"/wp-login.php",
"application/x-www-form-urlencoded",
strings.NewReader("log=admin&pwd=admin"))
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
if resp.StatusCode != http.StatusOK {
t.Errorf("status = %d, want 200", resp.StatusCode)
}
if !strings.Contains(string(body), honeypot.LoginFailureNotice) {
t.Error("提交凭据后应返回「用户名或密码无效」的假提示")
}
}
+38 -3
View File
@@ -26,11 +26,13 @@ import (
"github.com/tamakyi/TamaBox/internal/captcha"
"github.com/tamakyi/TamaBox/internal/conf"
"github.com/tamakyi/TamaBox/internal/context"
"github.com/tamakyi/TamaBox/internal/db"
"github.com/tamakyi/TamaBox/internal/form"
"github.com/tamakyi/TamaBox/internal/i18n"
"github.com/tamakyi/TamaBox/internal/mail"
"github.com/tamakyi/TamaBox/internal/route/chat" // 私聊路由
"github.com/tamakyi/TamaBox/internal/security/autoban"
"github.com/tamakyi/TamaBox/internal/security/honeypot"
"github.com/tamakyi/TamaBox/internal/security/ipinfo"
"github.com/tamakyi/TamaBox/internal/shared"
templatepkg "github.com/tamakyi/TamaBox/internal/template"
@@ -96,6 +98,19 @@ func New() *flamego.Flame {
},
})
// 蜜罐(虚假后台登录界面):默认关闭,需在 [security] 段显式 enable_honeypot = true。
// 命中即返回假登录页 + 记录破解凭据 + 复用上面的 autoban 封禁/告警链路。
hp := honeypot.New(honeypot.Options{
Enabled: conf.Security.EnableHoneypot,
ExtraPaths: honeypotExtraPaths(conf.Security.HoneypotPaths),
})
hph := newHoneypotHandler(hp, ab, db.Honeypots)
// 蜜罐告警:只有真的提交了凭据才发(纯路径探测没有取证价值),且每个 IP 只发一次。
// 收件人复用 security_alert_email,不新增配置项。
hph.sendAlert = func(a mail.HoneypotAlert) error {
return mail.SendHoneypotAlertMail(a)
}
templateFS, err := template.EmbedFS(templates.FS, ".", []string{".html"})
if err != nil {
logrus.WithError(err).Fatal("Failed to embed templates file system")
@@ -148,7 +163,7 @@ func New() *flamego.Flame {
f.Use(securityHeaders())
f.Use(ipBlacklistMiddleware())
f.Use(autobanMiddleware(ab))
f.Use(autobanMiddleware(ab, hp))
// 全局请求体上限:必须在任何会读取 body 的中间件(尤其 Contexter 内的 CSRF 校验)
// 之前执行,否则超大 multipart 会在 Bind 的路由级限制生效前落盘临时文件耗尽磁盘。
f.Use(requestBodyLimitMiddleware())
@@ -430,8 +445,28 @@ func New() *flamego.Flame {
context.Contexter(),
)
f.NotFound(func(ctx flamego.Context) {
// 真正的 404 探测在此记录(不把业务 302→/ 误判为扫描)。flamego 的 NotFound 会先
// 写 404 状态再 Redirect,故须在 Redirect 前记录。
// ── 蜜罐分流(优先)───────────────────────────────────────────────
// 命中诱饵路径(/wp-admin、/.ssh、/.env 等后台探测特征)时,返回一个
// 仿 WordPress 的假登录页,并把攻击者提交的凭据记录入库。
//
// 为什么挂在 NotFound 而不是全局中间件:NotFound 只在「路由表完全落空」
// 时触发,因此与 /login、/register、/_/{domain}、/user/**、/api/v1/** 等
// 真实业务路由天然零交集,不会劫持正常用户;且它位于全局 f.Use 链之后,
// requestBodyLimitMiddleware 已对 body 施加上限,解析 POST body 安全。
// 组级中间件(含 CSRF)只挂在具体路由上,不作用于 NotFound,故表单可解析。
//
// 短路:flamego 的 context.run() 在 ResponseWriter().Written() 为真时即
// return,故此处写完响应且不调用 ctx.Next() 即可安全终止整条链路。
if hp != nil && hp.Match(ctx.Request().URL.Path) {
hph.Serve(ctx)
return
}
// 真正的 404 探测在此记录(不把业务 302→/ 误判为扫描)。
// ⚠️ 更正:flamego 的 NotFound 处理器并不会自动写 404 状态码 ——
// ResponseWriter.Write() 在未显式 WriteHeader 时写的是 200,
// ctx.Redirect("/") 写的是 302 + Location: /。这里保持原有 302→/ 行为
// (对扫描器而言,302 到首页同样表示「此路径不存在」)。
// 键必须与 autobanMiddleware 的 IsBanned/Record 一致(同样经 LimitKey 聚合),
// 否则计数落在另一个键上,封禁永不生效。
// ⚠️ 计数受 ban_track_notfound 控制,默认关闭;Record 内部会忽略未开启的
+383
View File
@@ -0,0 +1,383 @@
// Package honeypot 提供「虚假后台登录界面」诱饵能力。
//
// 设计原则(务必遵守):
// 1. 只返回假内容,绝不返回、绝不猜测任何真实凭据;
// 2. 只在 flamego 的 NotFound 分流点生效 —— 即「没有任何路由匹配」的路径,
// 因此与 /login、/register、/_/{domain}、/user/**、/api/v1/** 等真实业务
// 路由天然零交集,不会劫持正常用户;
// 3. 命中即短路:不查业务表、不渲染业务模板、不调用 ctx.Next()。
package honeypot
import (
"net/http"
"net/url"
"path"
"regexp"
"sort"
"strconv"
"strings"
)
// CanaryToken 是所有假页面统一的溯源标记。它只会出现在诱饵响应里,
// 一旦在别处(真实页面、错误日志)出现即可判定为蜜罐泄漏/误挂。
const CanaryToken = "tb-hp-7f3a1c9e"
// maxCredentialFieldLen 限制单个凭据字段落库长度,避免超长 payload 撑爆表。
const maxCredentialFieldLen = 512
// honeypotPrefixes 为段前缀匹配的诱饵路径。取自 autoban.sensitivePaths 的语义,
// 但收敛为「与业务路由不可能同名」的一组,避免误伤。
var honeypotPrefixes = []string{
"/admin", "/administrator", "/wp-admin", "/wp-content", "/wp-includes",
"/wp-login", "/wp-login.php", "/wp-cron", "/wp-trackback", "/wp-comments-post",
"/wp-json", "/wp-config", "/wp-mail", "/wp-signup", "/wp-activate", "/wp-links-opml",
"/wp-blog-header", "/wp-load", "/wp-settings", "/wp-load.php", "/wp-settings.php",
"/xmlrpc", "/console", "/manager", "/manager/html", "/phpmyadmin", "/pma",
"/cpanel", "/plesk", "/webadmin", "/adminer", "/shell", "/cmd", "/eval",
"/webshell", "/cgi-bin", "/shell.php", "/admin.php", "/login.php",
"/.ssh", "/.aws", "/.env", "/.git", "/.svn", "/.hg", "/.docker",
"/.config", "/.npm", "/.kube", "/.vscode", "/.idea",
"/actuator", "/swagger", "/swagger-ui", "/openapi", "/v2/api-docs", "/v3/api-docs",
"/api-docs", "/druid", "/nacos", "/solr", "/elastic", "/apollo",
"/server-status", "/server-info", "/info.php", "/phpinfo", "/test.php",
"/backup", "/backups", "/dump", "/database", "/db", "/sql", "/mysql",
"/config", "/configuration", "/vendor", "/storage", "/tmp", "/temp",
"/upload", "/uploads", "/files", "/log", "/logs",
}
// honeypotFragments 为任意位置子串匹配的诱饵路径片段(大小写不敏感)。
var honeypotFragments = []string{
"/wp-admin", "/wp-login", "/wp-content", "/wp-includes", "/wp-json",
"/wp-config", "/xmlrpc.php", "/wordpress", "/phpmyadmin", "/cgi-bin/",
"/.git/", "/.env", "/.aws/", "/.ssh/", "/actuator/", "/swagger",
"/druid/", "/solr/", "/nacos/", "/vuln", "/shell?", "/cmd?",
"/.bak", "/.old", "/.orig", "/.save", "/.swp", "/.zip", "/.tar.gz", "/.sql",
}
// forbiddenPrefixes 是业务路由前缀:即便有人往诱饵表里塞了这些前缀,
// 匹配时也会被强制剔除,构成第二道保险。
var forbiddenPrefixes = []string{
"/login", "/register", "/logout", "/signup", "/sign-in", "/sign_up",
"/user", "/api", "/_", "/assets", "/static", "/css", "/js", "/images",
"/favicon", "/robots", "/sitemap", "/feed", "/rss", "/about", "/rules",
"/sponsor", "/change-logs", "/maintenance-mode", "/pixel",
}
// Options 是蜜罐行为配置。
type Options struct {
// Enabled 为false 时 Honeypot() 恒返回 nil(不接管任何路径)。
Enabled bool
// ExtraPaths 追加自定义诱饵路径(支持段前缀语义,写成 "/a/b" 即前缀匹配)。
ExtraPaths []string
// MaxPayloadBytes 单次可记录的用户名/密码等字段长度上限。
MaxPayloadBytes int
}
func (o Options) maxField() int {
if o.MaxPayloadBytes > 0 {
return o.MaxPayloadBytes
}
return maxCredentialFieldLen
}
// Honeypot 是蜜罐匹配器,构造后不可变,可安全并发使用。
type Honeypot struct {
enabled bool
prefix map[string]bool
maxField int
}
// New 构造蜜罐匹配器。ExtraPaths 会参与前缀匹配。
func New(o Options) *Honeypot {
h := &Honeypot{
enabled: o.Enabled,
prefix: make(map[string]bool, len(honeypotPrefixes)+len(o.ExtraPaths)),
maxField: o.maxField(),
}
for _, p := range honeypotPrefixes {
h.prefix[normalize(p)] = true
}
for _, p := range o.ExtraPaths {
if p = normalize(p); p != "" {
h.prefix[p] = true
}
}
return h
}
// Enabled 报告蜜罐是否启用。
func (h *Honeypot) Enabled() bool { return h != nil && h.enabled }
// normalize 归一化路径:小写、合并重复斜杠、去掉结尾斜杠(根除外)。
func normalize(p string) string {
p = strings.ToLower(strings.TrimSpace(p))
if p == "" {
return ""
}
if !strings.HasPrefix(p, "/") {
p = "/" + p
}
// 折叠重复斜杠
for strings.Contains(p, "//") {
p = strings.ReplaceAll(p, "//", "/")
}
for strings.HasSuffix(p, "/") && len(p) > 1 {
p = strings.TrimSuffix(p, "/")
}
return p
}
// Match 判断路径是否应被蜜罐接管。
//
// 归一化步骤:去查询串、URL 反解码一次(对抗 %2f 编码绕过)、折叠斜杠、
// 去掉尾部 /index.html 之类不影响结果的尾斜杠。
func (h *Honeypot) Match(rawPath string) bool {
if !h.Enabled() {
return false
}
p := normalizePath(rawPath)
if p == "" {
return false
}
for _, f := range forbiddenPrefixes {
if p == f || strings.HasPrefix(p, f+"/") {
// 业务路由,绝不接管
return false
}
}
if _, ok := h.prefix[p]; ok {
return true
}
// 段前缀:/wp-admin/install.php 命中 /wp-admin
segs := strings.Split(p, "/")
for i := 1; i < len(segs); i++ {
joined := strings.Join(segs[:i+1], "/")
if h.prefix[joined] {
return true
}
}
for _, f := range honeypotFragments {
if strings.Contains(p, f) {
return true
}
}
return false
}
func normalizePath(raw string) string {
p := raw
// 去查询串与 fragment(畸形请求可能没有 '?',用 Cut 兜底)
if i := strings.IndexAny(p, "?#"); i >= 0 {
p = p[:i]
}
if p == "" {
return ""
}
// 反解码一次;失败(非法百分号编码)则用原串继续。
if dec, err := url.PathUnescape(p); err == nil && dec != "" {
p = dec
}
p = strings.ReplaceAll(p, "\\", "/")
return normalize(p)
}
// Credentials 是从一次诱饵提交中提取出的破解信息。
type Credentials struct {
Username string
Password string
Fields map[string]string
RawBody string
HadFields bool
}
// credentialKeys 是常见的用户名/密码字段名(表单 POST 与 JSON body 均适用)。
var credentialKeys = map[string]bool{
"log": true, "pwd": true, "username": true, "user": true, "pass": true,
"password": true, "passwd": true, "pw": true, "email": true, "name": true,
"account": true, "login": true, "wp_user": true, "user_login": true,
"wp_password": true, "uname": true, "nickname": true, "id": true, "token": true,
}
// ExtractCredentials 从请求中提取破解凭据。
//
// 同时支持三类来源,按优先级合并:
// 1. URL 查询串 / POST 表单字段(application/x-www-form-urlencoded、multipart);
// 2. JSON body({"username":"admin","password":"123456"});
// 3. Basic Auth 头。
//
// 永不 panic、永不因解析失败阻断响应。
func (h *Honeypot) ExtractCredentials(r *http.Request) Credentials {
out := Credentials{Fields: map[string]string{}}
limit := maxCredentialFieldLen
if h != nil && h.maxField > 0 {
limit = h.maxField
}
put := func(k, v string) {
k = strings.ToLower(strings.TrimSpace(k))
v = strings.TrimSpace(v)
if k == "" {
return
}
if len(v) > limit {
v = v[:limit]
}
if _, exists := out.Fields[k]; !exists {
out.Fields[k] = v
}
switch k {
case "log", "username", "user", "login", "user_login", "email", "account", "uname", "wp_user", "nickname":
if out.Username == "" {
out.Username = v
}
case "pwd", "password", "passwd", "pw", "pass", "wp_password", "token":
if out.Password == "" {
out.Password = v
}
}
}
// 1. 查询串
if r != nil && r.URL != nil {
for k, vs := range r.URL.Query() {
if len(vs) > 0 {
put(k, vs[0])
}
}
}
if r == nil || r.Body == nil {
out.finish()
return out
}
if r.Body != nil {
defer func() { _ = r.Body.Close() }()
}
// 全量读取(bodylimit 中间件已对非 GET 请求施加上限,这里再做一层防御)
buf := make([]byte, 0, 1024)
tmp := make([]byte, 4096)
reader := r.Body
for {
n, err := reader.Read(tmp)
if n > 0 {
if len(buf)+n > limit*8 {
buf = append(buf, tmp[:n]...)
break
}
buf = append(buf, tmp[:n]...)
}
if err != nil {
break
}
}
raw := string(buf)
if len(raw) > limit {
raw = raw[:limit]
}
out.RawBody = raw
ct := strings.ToLower(r.Header.Get("Content-Type"))
switch {
case strings.Contains(ct, "json"):
parseJSONCredentials(raw, put)
default:
// ParseQuery 遇到非法百分号编码会返回 error,但仍会给出已成功解析的部分字段。
// 蜜罐要的是「尽可能多拿到凭据」,故忽略 error 继续用部分结果。
vals, _ := url.ParseQuery(raw)
if len(vals) > 0 {
for k, vs := range vals {
if len(vs) > 0 {
put(k, vs[0])
}
}
} else {
// 完全不像表单时再按 JSON 试一次。
parseJSONCredentials(raw, put)
}
}
// 3. Basic Auth
if user, pass, ok := r.BasicAuth(); ok {
put("username", user)
put("password", pass)
}
out.finish()
return out
}
// finish 依据 Fields 补齐 Username/Password,并置 HadFields。
func (c *Credentials) finish() {
for k, v := range c.Fields {
if credentialKeys[k] && v != "" {
c.HadFields = true
}
}
if c.Username == "" || c.Password == "" {
for k, v := range c.Fields {
if v == "" {
continue
}
if c.Username == "" && !isPasswordKey(k) {
if credentialKeys[k] {
c.Username = v
}
}
if c.Password == "" && isPasswordKey(k) {
c.Password = v
}
}
}
if c.Username != "" || c.Password != "" {
c.HadFields = true
}
}
func isPasswordKey(k string) bool {
switch k {
case "pwd", "password", "passwd", "pw", "pass", "wp_password", "token":
return true
}
return false
}
// jsonPairRe 匹配 JSON 扁平字段:"key": value(value 可为字符串/数字/布尔/null)。
// 只取第一层深度已足够覆盖 {"username":"admin","password":123456} 这类破解 payload;
// 嵌套对象/数组的内部内容不作为凭据,避免把无关结构误记成密码。
var jsonPairRe = regexp.MustCompile(`"([A-Za-z0-9_.\-]{1,64})"\s*:\s*("(?:[^"\\]|\\.)*"|-?\d+(?:\.\d+)?|true|false|null)`)
// parseJSONCredentials 极简 JSON 字段提取,容忍不规范 body,永不 panic。
func parseJSONCredentials(raw string, put func(k, v string)) {
raw = strings.TrimSpace(raw)
if raw == "" || (raw[0] != '{' && raw[0] != '[') {
return
}
for _, m := range jsonPairRe.FindAllStringSubmatch(raw, -1) {
k := strings.TrimSpace(m[1])
v := strings.TrimSpace(m[2])
if len(v) >= 2 && v[0] == '"' {
if s, err := strconv.Unquote(v); err == nil {
v = s
} else {
v = strings.Trim(v, `"`)
}
}
if v == "null" {
v = ""
}
put(k, v)
}
}
// Describer 返回 Honeypot 的可读描述,供 CLI / 排障使用。
func (h *Honeypot) Describer() string {
if h == nil || !h.enabled {
return "disabled"
}
keys := make([]string, 0, len(h.prefix))
for k := range h.prefix {
keys = append(keys, k)
}
sort.Strings(keys)
return path.Join(keys...)
}
+144
View File
@@ -0,0 +1,144 @@
package honeypot
import (
"net/http"
"net/http/httptest"
"strings"
"testing"
)
func TestMatch(t *testing.T) {
h := New(Options{Enabled: true})
hit := []string{
"/wp-admin", "/wp-admin/install.php", "/wp-login.php", "/wp-content/plugins/",
"/.ssh/id_rsa", "/.env", "/.git/config", "/.aws/credentials",
"/administrator", "/phpmyadmin", "/actuator/env", "/swagger-ui.html",
"/xmlrpc.php", "/vendor/phpunit/phpunit/src/Util/PHP/eval-stdin.php",
"/wp-admin/../wp-content/x.php",
}
for _, p := range hit {
if !h.Match(p) {
t.Errorf("Match(%q) = false, want true", p)
}
}
// 业务路由必须一律不接管 —— 这是蜜罐的红线。
miss := []string{
"/", "/login", "/register", "/logout", "/_/alice", "/user", "/user/settings",
"/api/v1/questions", "/static/css/base.css", "/favicon.ico", "/robots.txt",
"/about", "/rules", "/sponsor", "/change-logs.html", "/vote/abc",
"/explore", "/i18n/en",
}
for _, p := range miss {
if h.Match(p) {
t.Errorf("Match(%q) = true, want false(业务路由不得被蜜罐接管)", p)
}
}
}
func TestMatchDisabled(t *testing.T) {
h := New(Options{Enabled: false})
if h.Match("/wp-admin") {
t.Error("蜜罐未启用时不应匹配任何路径")
}
}
func TestMatchEncoded(t *testing.T) {
h := New(Options{Enabled: true})
// %2f 编码绕过
if !h.Match("/wp-admin%2f..%2fetc") {
t.Error("编码后的 wp-admin 路径应被匹配")
}
// 大小写
if !h.Match("/WP-ADMIN") {
t.Error("大小写不同的 WP-ADMIN 应被匹配")
}
// 查询串不应影响判定
if !h.Match("/wp-login.php?redirect_to=/wp-admin") {
t.Error("带查询串的 wp-login.php 应被匹配")
}
}
func TestExtraPaths(t *testing.T) {
h := New(Options{Enabled: true, ExtraPaths: []string{"/secret-backdoor"}})
if !h.Match("/secret-backdoor") {
t.Error("自定义诱饵路径应被匹配")
}
if !h.Match("/secret-backdoor/x.php") {
t.Error("自定义诱饵路径的子路径应被匹配")
}
}
func TestExtractCredentialsForm(t *testing.T) {
h := New(Options{Enabled: true})
req := httptest.NewRequest(http.MethodPost, "/wp-login.php",
strings.NewReader("log=admin&pwd=admin123&redirect_to=%2Fwp-admin%2F&wp-submit=Log+In"))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
creds := h.ExtractCredentials(req)
if creds.Username != "admin" {
t.Errorf("Username = %q, want admin", creds.Username)
}
if creds.Password != "admin123" {
t.Errorf("Password = %q, want admin123", creds.Password)
}
if !creds.HadFields {
t.Error("HadFields = false, want true")
}
}
func TestExtractCredentialsJSON(t *testing.T) {
h := New(Options{Enabled: true})
req := httptest.NewRequest(http.MethodPost, "/wp-login.php",
strings.NewReader(`{"username":"root","password":"toor","extra":1}`))
req.Header.Set("Content-Type", "application/json")
creds := h.ExtractCredentials(req)
if creds.Username != "root" {
t.Errorf("Username = %q, want root", creds.Username)
}
if creds.Password != "toor" {
t.Errorf("Password = %q, want toor", creds.Password)
}
}
func TestExtractCredentialsQuery(t *testing.T) {
h := New(Options{Enabled: true})
req := httptest.NewRequest(http.MethodGet, "/wp-login.php?log=admin&pwd=secret", nil)
creds := h.ExtractCredentials(req)
if creds.Username != "admin" || creds.Password != "secret" {
t.Errorf("got %q/%q, want admin/secret", creds.Username, creds.Password)
}
}
func TestExtractCredentialsBasicAuth(t *testing.T) {
h := New(Options{Enabled: true})
req := httptest.NewRequest(http.MethodGet, "/wp-admin", nil)
req.SetBasicAuth("admin", "hunter2")
creds := h.ExtractCredentials(req)
if creds.Username != "admin" || creds.Password != "hunter2" {
t.Errorf("got %q/%q, want admin/hunter2", creds.Username, creds.Password)
}
}
func TestExtractCredentialsEmpty(t *testing.T) {
h := New(Options{Enabled: true})
req := httptest.NewRequest(http.MethodGet, "/wp-admin", nil)
creds := h.ExtractCredentials(req)
if creds.HadFields {
t.Error("空请求不应标记为携带凭据")
}
}
func TestExtractCredentialsMalformed(t *testing.T) {
h := New(Options{Enabled: true})
// 畸形 body 不应 panic
req := httptest.NewRequest(http.MethodPost, "/wp-login.php",
strings.NewReader("%zz&log=admin&&=="))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
creds := h.ExtractCredentials(req)
if creds.Username != "admin" {
t.Errorf("Username = %q, want admin(畸形 body 仍应尽量提取)", creds.Username)
}
}
+110
View File
@@ -0,0 +1,110 @@
package honeypot
import (
"html"
"net/http"
"strings"
)
// LoginPageVars 是假登录页的渲染参数。
type LoginPageVars struct {
// Action 是表单提交地址(当前诱饵路径本身)。
Action string
// Title / Heading 是页面标题与表单标题。
Title string
Heading string
// Notice 是提交失败后的提示语。
Notice string
// Redir 为原始请求的查询串(WordPress 风格的 ?redirect_to=),原样回填。
Redir string
}
// RenderLoginPage 输出仿 WordPress 后台风格的假登录页(200,text/html)。
//
// 页面刻意包含:
// - 隐藏 canary 标记(HTML 注释 + meta),便于日后溯源;
// - 经典 WP 字段名 log / pwd / redirect_to —— 让暴力破解脚本原样提交,
// 我们就能拿到最干净的用户名与密码对;
// - 一个 nginx/Apache 风格的 404 页脚,暗示「这个后台真的存在」,
// 但正文是纯静态 HTML,不泄露任何版本、路径或真实凭据。
func RenderLoginPage(vars LoginPageVars) []byte {
action := html.EscapeString(vars.Action)
title := vars.Title
if title == "" {
title = "Log In ‹ WordPress"
}
heading := vars.Heading
if heading == "" {
heading = "WordPress"
}
notice := ""
if vars.Notice != "" {
notice = `<div id="login_error">` + html.EscapeString(vars.Notice) + `</div>`
}
redir := html.EscapeString(vars.Redir)
var b strings.Builder
b.Grow(4096)
b.WriteString(`<!DOCTYPE html>` + "\n")
b.WriteString(`<!-- canary: ` + CanaryToken + ` -->` + "\n")
b.WriteString(`<html lang="en-US">` + "\n")
b.WriteString(`<head>` + "\n")
b.WriteString(`<meta charset="UTF-8" />` + "\n")
b.WriteString(`<meta name="viewport" content="width=device-width, initial-scale=1" />` + "\n")
b.WriteString(`<meta name="generator" content="TamaBox honeypot ` + CanaryToken + `" />` + "\n")
b.WriteString(`<title>` + html.EscapeString(title) + `</title>` + "\n")
// 静态内联样式,避免蜜罐依赖站点静态资源(也避免污染真实缓存)
b.WriteString(`<style>` + honeypotCSS + `</style>` + "\n")
b.WriteString(`</head>` + "\n")
b.WriteString(`<body class="login">` + "\n")
b.WriteString(`<div id="login">` + "\n")
b.WriteString(`<h1><a href="` + action + `" title="` + html.EscapeString(heading) + `">` + html.EscapeString(heading) + `</a></h1>` + "\n")
b.WriteString(notice + "\n")
b.WriteString(`<form name="loginform" id="loginform" action="` + action + `" method="post" enctype="application/x-www-form-urlencoded">` + "\n")
b.WriteString(`<p><label for="user_login">Username<br /><input type="text" name="log" id="user_login" class="input" value="" size="20" autocapitalize="off" autocomplete="username" /></label></p>` + "\n")
b.WriteString(`<p><label for="user_pass">Password<br /><input type="password" name="pwd" id="user_pass" class="input" value="" size="20" autocomplete="current-password" /></label></p>` + "\n")
b.WriteString(`<p class="forgetmenot"><input name="rememberme" type="checkbox" id="rememberme" value="forever" /> Remember Me</p>` + "\n")
b.WriteString(`<p class="submit"><input type="submit" name="wp-submit" id="wp-submit" class="button button-primary button-large" value="Log In" /></p>` + "\n")
b.WriteString(`<input type="hidden" name="redirect_to" value="` + redir + `" />` + "\n")
b.WriteString(`<input type="hidden" name="testcookie" value="1" />` + "\n")
b.WriteString(`</form>` + "\n")
b.WriteString(`<p id="nav"><a href="` + action + `?action=lostpassword">Lost your password?</a> | <a href="` + action + `?action=register">Register</a></p>` + "\n")
b.WriteString(`</div>` + "\n")
b.WriteString(`<div id="footer"><p>` + html.EscapeString(NotFoundFooter) + `</p></div>` + "\n")
b.WriteString(`</body>` + "\n")
b.WriteString(`</html>` + "\n")
return []byte(b.String())
}
const honeypotCSS = `html{background:#f0f0f1}body{margin:0;padding:0;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;font-size:14px;color:#3c434a}
#login{border:1px solid #dcdcde;background:#fff;width:320px;margin:5em auto;padding:26px 24px 46px;box-shadow:0 1px 3px rgba(0,0,0,.04)}
#login h1{text-align:center;margin:0 0 1em;font-size:24px;font-weight:400}
#login h1 a{color:#444;text-decoration:none}
#login form{margin-top:16px}
#login label{display:block;font-size:14px}
#login input[type=text],#login input[type=password]{width:100%;padding:5px 8px;border:1px solid #ddd;border-radius:3px;box-sizing:border-box;margin-top:4px}
#login .button{width:100%;padding:7px 12px;background:#2271b1;border:1px solid #2271b1;border-radius:3px;color:#fff;cursor:pointer;font-size:13px}
#login .submit{margin:16px 0 0}
#login_error{border-left:4px solid #d63638;background:#fcf0f1;padding:10px 12px;margin:12px 0}
#nav,.forgetmenot{font-size:13px;color:#50575e}
#footer{text-align:center;color:#646970;font-size:12px;margin:24px auto;max-width:320px}
`
// NotFoundFooter 是 404 假页脚,用于给扫描器「后台存在但资源不存在」的错觉。
const NotFoundFooter = "The requested resource was not found on this server."
// LoginFailureNotice 是提交凭据后的统一提示(不区分「用户不存在」与「密码错误」,
// 避免因响应差异让爆破脚本调整策略;同时这也是虚假后台「存在」的证据)。
const LoginFailureNotice = "ERROR: Invalid username or password."
// WriteLoginPage 将假登录页写入响应。
func WriteLoginPage(w http.ResponseWriter, vars LoginPageVars) {
body := RenderLoginPage(vars)
h := w.Header()
h.Set("Content-Type", "text/html; charset=utf-8")
h.Set("Cache-Control", "no-store, no-cache, must-revalidate")
// 诱饵页面绝不进入任何共享/边缘缓存,也不被中间层改写。
h.Set("X-Robots-Tag", "noindex, nofollow")
w.WriteHeader(http.StatusOK)
_, _ = w.Write(body)
}
+1
View File
@@ -28,6 +28,7 @@ func main() {
cmd.Restrict,
cmd.Unrestrict,
cmd.HidePublic,
cmd.Honeypot,
}
if err := app.Run(os.Args); err != nil {
logrus.WithError(err).Fatal("Failed to start application")
+74
View File
@@ -0,0 +1,74 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta name="format-detection" content="email=no"/>
<meta name="format-detection" content="date=no"/>
</head>
<body style="margin: 0; padding: 0;" bgcolor="#FFFFFF">
<table width="100%" height="100%" style="min-width: 348px;" border="0" cellspacing="0" cellpadding="0" lang="zh-CN">
<tbody>
<tr height="32" style="height: 32px;">
<td></td>
</tr>
<tr align="center">
<td>
<table border="0" cellspacing="0" cellpadding="0"
style="padding-bottom: 20px;max-width: 516px;min-width: 220px;">
<tbody>
<tr>
<td width="8" style="width: 8px;"></td>
<td>
<div style="border-style: solid; border-width: thin; border-color:#dadce0; border-radius: 8px; padding: 40px 20px;"
align="center">
<div style="font-family: 'Google Sans',Roboto,RobotoDraft,Helvetica,Arial,sans-serif;border-bottom: thin solid #dadce0; color: rgba(0,0,0,0.87); line-height: 32px; padding-bottom: 24px;text-align: center; word-break: break-word;">
<div style="font-size: 24px;">
蜜罐捕获到破解尝试
</div>
<div style="font-size: 13px; color: rgba(0,0,0,0.54); margin-top: 6px;">
{{.time}}
</div>
</div>
<div style="font-family: Roboto-Regular,Helvetica,Arial,sans-serif; font-size: 14px; color: rgba(0,0,0,0.87); line-height: 22px; padding-top: 20px; text-align: left;">
<div style="padding: 12px 14px; border-radius: 6px; background-color: #fff4e5; border: 1px solid #ffe0b2; margin-bottom: 14px;">
<div><b>来源 IP:</b>{{.ip}}</div>
<div style="margin-top: 4px;"><b>网络归属:</b>{{.provider}}</div>
<div style="margin-top: 4px;"><b>诱饵路径:</b>{{.path}}</div>
<div style="margin-top: 4px;"><b>处置:</b>{{.disposition}}</div>
{{if .proxy_hint}}
<div style="margin-top: 8px; font-size: 13px; color: #b71c1c;">⚠️ 该 IP 属于 CDN / 代理网络,攻击者可随时更换出口 IP。</div>
{{end}}
</div>
<div style="font-size: 13px; color: rgba(0,0,0,0.54);">捕获到的破解凭据:</div>
<div style="font-family: monospace; font-size: 12px; color: #333; background-color: #f7f7f7; padding: 10px 12px; border-radius: 6px; margin-top: 6px; word-break: break-all;">
用户名:{{.username}}<br/>
密码:{{.password}}
</div>
<div style="font-size: 12px; color: rgba(0,0,0,0.54); margin-top: 12px;">
攻击者在假登录页上提交了凭据(假页面只返回静态内容,从不校验、也从不返回任何真实信息)。
完整记录(含 User-Agent、Referer、原始 body)已入库,可用
<code>TamaBox honeypot hits</code> 命令查看。
</div>
</div>
</div>
<div style="text-align: left;">
<div style="font-family: Roboto-Regular,Helvetica,Arial,sans-serif;color: rgba(0,0,0,0.54);font-size: 11px; line-height: 18px; padding-top: 12px; text-align: center;">
<div>
此邮件由系统自动发送,请勿直接回复。<br/>
{{ .year }} 狼的提问箱
</div>
</div>
</div>
</td>
<td width="8" style="width: 8px;"></td>
</tr>
</tbody>
</table>
</td>
</tr>
<tr height="32" style="height: 32px;">
<td></td>
</tr>
</tbody>
</table>
</body>
</html>