tamakyi
62edf88029
feat(db): 版本化数据库迁移,改列名/删列不再静默丢数据
...
GORM 的 AutoMigrate 只会加表/加列/加索引,从不改列名/删列/改约束。此前这类
破坏性变更靠「HasColumn 判断 + 幂等补丁」硬撑,既没有账本记录「跑过什么」,
也把 5 个一次性回填(backfillAnsweredAt / backfillFeatureControls /
reconcileLegacyChatEnabled / normalizeChatQuota / normalizeHarassmentSetting)
留成了每次启动都全表扫一遍。
新增 schema_migrations 账本表 + runMigrations:
- 迁移按版本号升序执行,每个迁移在事务里「跑 up + 记版本」,任一失败阻断启动
- 那 5 个幂等补丁收编为 v1~v5,收编后只跑一次,之后启动直接跳过
- AutoMigrate 仍负责声明式的加表/加列/加索引,版本迁移负责它做不了的破坏性变更
与一次性回填,两者分工互不替代
测试覆盖:幂等(跑两遍不重复)、失败阻断后续、清单严格升序。实测真实库首次
启动建账本 + v1~v5 落库,二次重启账本不变。
2026-10-29 19:27:03 +08:00
tamakyi
693b1102c2
fix: 封堵 ask-status domain 枚举、私聊写路径 fail-open、压缩兜底重复提交
...
三处小修 + 两个格式清理,均来自全盘复查:
1. ask-status domain 枚举:ErrUserNotExists 分支原返回空数组、正常分支返回带元素
数组,凭空/非空可枚举 domain 是否存在(含被 L1 隐藏或设了页面密码的半公开箱)。
改为箱主不存在时也回显 ok:false,四种失败响应完全同形。
2. 私聊 SendMessage 写路径用 FeatureStateOf(fail-open 变体):读配置失败会回退默认值,
与「写路径必须 FeatureStateOfStrict(失败即拒)」的约定不符。此前靠 chat 默认关闭
的巧合侥幸安全,现统一改 Strict。
3. 图片压缩 submit 兜底可重复提交:capture 监听器里 tbBeginSubmit 从 Promise 回调提前
到 preventDefault 同步栈,物理阻止压缩窗口内双击重复提交。
顺带:box_views.go / message_image.go / insights 两个文件补末尾换行与 struct 字段对齐
(gofmt 干净)。
2026-10-29 18:52:38 +08:00
tamakyi
c9b426acbb
fix(insights): 数据面板「每日回答数」按 answered_at 分组,而非 created_at
...
BoxTrend 的回答那条 SQL 复用了 d.day(基于 created_at 的日期表达式)做 GROUP BY,
但 WHERE 过滤用的是 answered_at。于是「40 天前创建、昨天才回答」的提问被排除在
窗口外(created_at < from),昨天的回答数里不出现;「今天创建、明天回答」的又
被记到今天。趋势图里的「每日回答数」根本不是「某天被回答了多少」。
单独构造 answerDay(基于 COALESCE(answered_at, updated_at)),WHERE / GROUP BY /
SELECT 三者口径一致。PG 用 to_char(... AT TIME ZONE INTERVAL ...),MySQL 用
CONVERT_TZ,与 analyticsDialectFor 对 created_at 的处理对称。
新增 box_analytics_test.go:构造「40 天前创建、昨天回答」的提问,断言计入昨天,
并做变异验证(改回 d.day → 测试失败)。
2026-10-29 18:52:29 +08:00
tamakyi
87176516f3
fix(data): 删除提问时追问与图片一并真删,不再软删残留
...
questions.DeleteByID 对主表用 Unscoped 真删,但级联清理的两个子 store 仍用软删:
FollowUp(内嵌 dbutil.Model)与 UploadImage(内嵌 gorm.Model)都带 DeletedAt,
裸 Delete 是软删。于是提问正文(含 FromIP、邮箱)删干净了,追问文本与图片元数据
(name/md5/key)却带着 deleted_at 永久残留,孤儿行的 question_id 指向已不存在的提问
—— 正是「真删以免个人数据删不干净」想消灭的问题,却在子表漏改。
两个 DeleteByQuestionID 改为 Unscoped 真删。关联表 UploadImageQuestion 无 DeletedAt,
本来就真删,无需 Unscoped(已注释)。
新增 question_delete_hard_test.go:用 Unscoped 计数断言删除后追问/图片/关联行物理上=0,
并做变异验证(改回软删 → 测试失败)证明测试真的能抓住这个回归。
2026-10-29 18:52:19 +08:00
tamakyi
4e574ac64a
feat(insights): 箱主数据面板——访问量、提问趋势、处理漏斗
...
新增 /user/insights,让箱主第一次能看到「有多少人来过我的箱、提问处理得怎么样」。
数据来源:
- 新表 box_views:一行 = 一访客×一天×一箱,views 累加,唯一索引
(user_id, viewed_on, visitor)。刻意不内嵌 dbutil.Model —— 软删除 + 唯一索引
会让被删行永久占槽,且删过的槽位再也插不进去。
- 访客标识 = HMAC(日盐, IP) 前 32 hex:同日可去重(UV 准)、跨日不可关联(隐私)。
代价是近 7/30 天 UV = 各日 UV 之和,页面上已写明口径。
写入是异步的:buffered channel 512 + 2s 批量 flush,队列满即丢(统计尽力而为,
不该因为记一笔访问就拖慢页面)。过滤箱主本人、空 UA、爬虫 UA、预取请求。
密钥复用 server.xsrf_key;每日清理 400 天前的明细。
页面(纯 CSS 画图,不引图表库):
- 今日/近 7 天/近 30 天 KPI + 上周对比
- 30 天 PV/UV 双柱趋势、24 小时时段分布
- 提问处理漏斗 + 回答率进度条 + 自动结论
聚合层 internal/db/box_analytics.go 复用 analyticsDialectFor 的时区处理,
与全站 stats 分开(那个不含单箱维度、按自然日缓存)。
测试:box_views 4 项(含「同批同键先内存合并」——PG 的 ON CONFLICT 不允许
同条 INSERT 二次命中同一行)、insights 的 pct/窗口切分/visitorID/爬虫过滤。
2026-10-29 18:25:53 +08:00
tamakyi
d5d25c804b
fix(data): 删除改成一个事务且真删、配额与内容同事务
...
四类问题都出在「多次独立写入 + 失败只打日志」这个模式上。
1) 删除投票的级联没有事务(route/user/vote.go)
选项 / 表单字段 / 提交记录 / 投票本体是 4 次独立写入,前 3 步失败只 Warn。
中途失败会留下「选项没了但票还在」这种界面无法修复的状态 —— 重试删除时
投票已不存在,只能报 not exist,脏数据永远留着。
→ db.Votes.DeleteWithCascade:四张表在同一事务里删完。
2) 删除其实是软删除,个人数据没真删掉
模型都嵌了 dbutil.Model(含 DeletedAt),原来所有 Delete 都是软删:
提交记录里参与者填写的文本、voter_ip、voter_token,以及提问里的 FromIP,
只是「界面上看不见」,库里永久留着。用户点删除的预期不是这样。
软删还会占住 link_token 唯一索引,删过的链接永远不能复用。
→ 删除投票与删除提问统一 Unscoped 真删;提问的追问/图片关联/配额回退
原来也是三个独立写入(失败只 Warn),一并收进事务。
3) 配额计数会永久偏低
「创建内容」与「quota_count+1」是两次写入,后者失败只 Warn:
箱主看到的「已收集 7/10」偏小,达到上限后因为计数偏小实际还能继续收,
等于定量收集被静默绕过;重置计数也校准不回来(重置是把计数清零,更脱节)。
→ 抽出 incrementQuotaTx(tx, userID, feature, delta) 作为唯一实现,
提问 Questions.Create、私聊 CreateWithFirstMessage、投票提交
VoteSubmissions.Create(新增 QuotaUserID)都在自己的事务里完成 +1。
注意口径:投票的定量收集是「累计收到的票数」(提交成功时 +1),
不是「创建了几个投票」—— 所以删除投票不回退这个计数。
4) 改写追问回答每次都发通知邮件
箱主改措辞、补一句、修错别字都会重新保存,每次都发的话提问者会连着收到
好几封一模一样的「您的提问有回复」。主回答那边早有 question.Answer == ""
的判定,追问这里补齐:UpdateAnswerByID 在事务内读旧值返回 wasFirst。
顺带:
- isQuestioner 的 token 比较从 == 改为 subtle.ConstantTimeCompare,
与 Pager 中间件里的校验同口径(== 按字节短路,可用响应时间逐字节试探)。
- CSV 导出的表头(字段名)与选项内容列补 sanitizeCSVCell。原来只有数据列走它,
而选项名同样可能是 =HYPERLINK(...) 形式;判断也改成看 trim 后的首字符。
- 写路径的功能开关改 fail-closed:新增 db.FeatureStateOfStrict,读配置失败时
拒绝而不是回退默认配置 —— 提问/投票的默认值是「开启」,fail-open 等于让一次
数据库抖动绕过箱主设的关闭与定量上限。读展示仍用宽松版。
验证:全量单测(带 DB)与 go build ./... 通过;verify_round3 6/6(私聊
message_count 与实际消息数一致)、verify_p1 4/4、verify_round2 19/19、
verify_page_next 6/6、verify_chat_owner 3/3。
2026-10-29 17:18:28 +08:00
tamakyi
f2e0c43083
feat(ops): 健康检查端点,并把上传超时与服务端读超时对齐
...
1) /healthz 与 /readyz
改动前站内唯一的「探活」手段是 curl /,而首页只渲染模板、不查数据库:
DB 挂了首页照样 200(漏报),Redis 挂了首页又可能 500(误报)。
监控、CDN 源站健康检查、滚动重启的摘流判定都没有可靠依据。
→ /healthz 永远 200,刻意不碰任何依赖:进程活着 ≠ 能对外服务,
把 DB 检查塞进存活探针会导致数据库抖动时进程被反复重启,对恢复毫无帮助;
→ /readyz 查 DB(2s 超时),失败 503;响应体只回组件状态,
绝不回错误详情 —— 这是公开端点,DB 报错常带库名/主机名/账号。
实测:停掉 PostgreSQL 后 /healthz 仍 200、/readyz 503,恢复后 200。
2) ReadTimeout 30s → 120s
站内允许单图 8MB、请求体上限 12MB。30s 传完 8MB 需要 ≥273 KB/s 上行,
而手机 4G 上行常在 1–2 Mbps(125–250 KB/s)—— 一张 8MB 图必然超时,
且用户已写好的正文会一起丢(POST 失败不回填)。120s 对应约 68 KB/s,
偏远地区 3G 也能过。
放宽它不会让 slowloris 重新可行:那类攻击卡在「发不完 header」,
由 10s 的 ReadHeaderTimeout 拦住,ReadTimeout 只影响读完 header 之后传 body 的时长。
2026-10-29 16:28:44 +08:00
tamakyi
0124e392c7
fix(correctness): 三处幂等缺陷、私聊首条事务化、验证码失败不再丢正文
...
四处都属于「并发/失败路径下状态不一致」,用户侧表现为「点了没反应」「发了没收到」。
1) 删除会话重复回退配额
MarkDeletedByOwner 是 check-then-act:双击时两个请求都读到 is_deleted_by_owner=false,
都执行 UPDATE、都执行一次 IncrementQuota(-1)。计数不会变负,但会白吃掉其他会话的额度,
表现为定量收集提前关闭,箱主重置计数也校准不回来。
→ 改条件更新 + RowsAffected 决定是否回退。
2) 双击产生两条重复追问
「上一条追问必须先被回复」原来只在 handler 里判断(事务外):两个请求都能通过那次检查,
随后被 CreateIfBelowLimit 的问题行锁串行化,各自 count 都 < 3 —— 两条相同追问都建成,
吃掉 2/3 名额,箱主还收两封邮件。
→ 判定挪进事务,新增 ErrFollowUpAwaitingReply;handler 的预检查保留作为快速失败。
3) 双击置顶结果与预期相反
SetPinned(id, !question.IsPinned) 是 read-modify-write:两个请求都读到 false 都写 true,
用户点两次期望「置顶后取消」,实际仍是「置顶」。
→ 新增 SetPinnedIfCurrent(current, next),第二次因状态已变而 0 行,语义是「只切换一次」。
4) 私聊首条三步写入无事务
建会话 → 写首条消息 → 计数是三次独立写入,第二步失败会留下 message_count=0 的空会话,
箱主列表里能点进去却什么都没有;而 BeforeCreate 已占掉一个 6 字符 token(唯一索引),
孤儿会话永久占用它。
→ 新增 CreateWithFirstMessage,三步收进一个事务。
5) 验证码失败丢正文
该分支原来走 SetErrorFlash + Redirect,而其它失败分支都是 SetError + Success 回填表单。
验证码失败恰恰是最常见的失败(token 过期、网络抖动),结果用户写好的正文整段丢失。
→ 改为同请求渲染并回填;错误文案不再透传 err.Error()(可能含上游原始信息)。
6) 5 处裸类型断言改带 ok(share.go ×3 / context.go ×1 / user/vote.go ×1),
断言失败时丢弃脏缓存并走重建,而不是 panic 掉整个请求。share.go 的 ttlCache 补 Delete。
验证:内部 DB 测试 3 项(并发删除只回退一次配额 5→4、双击只产生一条追问、
双击只切换一次置顶);verify_round3.py 6/6(验证码失败 200+正文回填+不建提问、
私聊首条 message_count 与实际消息数一致)。
2026-10-29 16:28:34 +08:00
tamakyi
46932b4c53
fix(chat): 会话消息只取最早 50 条,第 51 条起对双方永久不可见
...
GetByConversationID 原来是 Order("created_at ASC") + Limit(50),而会话上限是
conf.Limits.ChatMaxMessages(默认 200)。唯一调用方 chat_handler 永远传 nil cursor,
于是取到的永远是「最早 50 条」:第 51 条起写库成功、message_count 原子递增、
页面显示 51/200,但列表里没有它 —— 等多久、换设备、重新登录都不会出现。
本地实测 55 条会话只渲染 #1–#50,双方都以为对方没回,直到 200 条被判满自动关闭。
改为:
- Order("id DESC") 取最新 limit 条,再在内存反转成时间正序(气泡从上到下=旧→新)
- limit 取 conf.Limits.ChatMaxMessages,兜底只处理零值(配置未初始化);
不写成 limit<50→50,那样会把箱主显式配的小上限抬大
- 排序键用 id 而非 created_at:同毫秒写入的 created_at 相同,排序不稳定
- 游标 query 去掉 NULL 风险:显式要求 cursor.Value 非空
验证:55 条实测渲染 55 条、范围 #1–#55;单测 3 项(全量可见 / 超限保留最新 /
游标翻页),变异验证把 ORDER 改回 ASC 时「超限保留最新」立刻变红。
2026-10-29 15:53:22 +08:00
tamakyi
7802bb2078
fix(security): 修复安全审计的 P1/P2/P3 项,含全局复查新发现的两处越权与外跳
...
P1:
- 外链图片:markdown 渲染层只放行站内与自有 CDN 图片。配置里的
image_bucket_cdn_host 常写成 host/path,统一归一化后再比对,避免把自家图一起剥掉
- 密码:页面/提问密码最小 6 位;密码比对挪到验证码之后;页面密码失败锁定 5 次/10 分钟
- 客户端 IP:CF-Connecting-IP / EO-Client-IP 只在 TCP 对端可信(回环或 trusted_proxies)
时采信,公网对端带来的权威头一律忽略
P2/P3:
- 跳转参数收敛到 shared.SafeRedirectTarget:拒反斜杠与控制字符(url.Parse 会把 %0d%0a
解成 CRLF,伪造 Location),最后再用 path.Clean 验一次前缀
- 注册不再暴露「邮箱已注册 / 已停用」:与全新注册共用同一个成功出口,只在日志留痕
- CSP:默认 enforce,白名单由配置派生,覆盖 umami(运行时注入)、Turnstile 与
按 sentry_dsn 条件放行的 Sentry CDN
- /user 与 /_ 下发 no-store;补 X-XSS-Protection、Permissions-Policy,并把安全头前移到
静态资源中间件之前(原先静态文件拿不到 nosniff/HSTS)
- 封禁事实落库 ban_records;429 统一带 Retry-After 与本地化文案
- token 比较改常量时间;探索页页码上限;JSON 响应显式 Content-Type
全局复查补的两处:
- 私聊 SendMessage 的 POST 缺 conv.OwnerID 校验,被拉黑者可换 domain 继续发消息
- 页面密码 next 与登录 to 同源的反斜杠外跳
验收:verify_p1_fixes 4/4、verify_round2 19/19、verify_page_next 6/6、
verify_chat_owner 3/3(含变异对照);单测与 go build ./... 全通过。
2026-10-27 12:37:40 +08:00
tamakyi
219adefc95
fix(stats): 「今日回答」改按 answered_at 计,不再把置顶算成回答
...
`TodayCounts` 的今日回答数用 `updated_at >= 0 点` 过滤。GORM 的 Update
一律刷新 updated_at,于是箱主今晚整理一批三个月前的老问题(置顶、
公开/私密切换,一次回答都没发生),/stats 顶部「今日回答」就凭空多出几十条。
实测(临时测试,跑完已删):一条三天前回答的提问,依次 SetPinned /
SetPrivate / SetPublic 之后,today answers 从 1 变成 2。
改成 `answered_at >= since`(首次回答时间,改写回答不重置):
口径变成「今天首次被回答的提问数」,与页面文案「今日回答 / Answered
today」和「回答耗时按首次回答算」都对齐。历史行 answered_at 为空时
`>=` 不成立、自然不计数 —— 那些行本来也不可能「今天被回答」。
性能:answered_at 没有索引,这条 COUNT 是全表过滤;但改动前的
updated_at 同样没有索引,本来就全是 Seq Scan。20 万行实测
旧 36.7ms / 新 38.0ms(差 3.5%,噪声级),执行计划都是
Seq Scan + Aggregate。页面另有 60 秒缓存,不需要为它加索引;
注释里写了真慢了再加 idx_questions_answered_at。
新增 TestTodayAnswersIgnoreNonAnswerWrites(改前红:期望 1 得到 2;
改后绿)。
2026-10-24 10:37:36 +08:00
tamakyi
8380fc9e2b
fix(db): 「最后一个回答」不再被置顶/公开切换推到现在
...
`QuestionStats` 的 `last_a` 取的是 `MAX(updated_at)`,而 GORM 的 Update
一律刷新 updated_at:置顶、公开/私密切换、标记已读都会走 Update。
后果是箱主整理一下旧问题(一次回答都没发生),公开接口
`/api/v1/site-stats` 就说「刚刚有人回答了」。
实测(真库,临时测试已删):一条 9 天前回答的提问,置顶/公开切换后
LastAnswerAt 从 `10-15 10:23` 变成 `10-24 10:23`,偏差 216 小时。
改成 `COALESCE(answered_at, updated_at)`:
- 新数据取 answered_at(首次回答时间,改写回答不重置);
- 加列之前的历史行 answered_at 为空,退回 updated_at —— 与改动前口径
一致,不会让这些行从统计里消失(有测试锁住这一半)。
同时删掉 `GetHotQuestions`:全仓零调用点(只有接口声明 + 实现,route/
里 Questions 的 15 个调用不含它),且同样按 updated_at 排序 ——
死代码 + 错误排序,留着就是给以后的人埋坑,要用时按 answered_at 重写。
影响面:`/api/v1/site-stats` 的 `last_answer_at` 字段与 `text` 里的
「最后一个回答发生在」,字段名与结构不变,只是值更准(更早)。无 schema
变更、无接口变更。性能:执行计划仍是 Seq Scan + Aggregate,5 万行实测
7.9ms vs 8.0ms,差异在噪声带内(0~1.8ms,折合每行约 30ns),
而该接口有 30 分钟进程内缓存。
新增两个回归测试:
- TestQuestionStatsLastAnswerIgnoresLaterEdits(改前红、改后绿)
- TestQuestionStatsLastAnswerFallsBackToUpdatedAt(历史行回退)
2026-10-24 10:30:43 +08:00
tamakyi
0226956feb
fix(stats): 回答耗时改用 answered_at,修测试环境实测出的三处问题
...
在测试环境(box-dev-api)上逐项验算后发现「回答耗时」这一口径是错的。
1. 回答耗时不能用 updated_at - created_at(P0)
GORM 的 Update 会自动刷新 updated_at,而已读、置顶、公开/私密切换全走 Update。
实测:一条 10 天前的提问被 SetRead 标记已读后,updated_at 被推到当下(delta=240h)。
于是「回答耗时」实际测的是「提问到最后一次被碰」:线上那 6 条「超过 7 天」
和偏低的 24 小时回复占比都是这么来的。
修法:新增 questions.answered_at(首次回答时间,可空),只在 answer 由空变非空时
写一次,改写回答不重置;聚合改用 COALESCE(answered_at, updated_at),
首次加列时用 updated_at 冻结历史一次(Migrator().HasColumn 判断是否刚加列),
否则历史值会随着后续的已读/置顶继续变大。
2. 环比句式读不通
中文「上月提问量比前一个月 -69.6%」、英文「...: -69.6% questions.」都是病句。
改为「上月提问量环比 %s%%」/「Month-over-month change for last month: %s%%」。
3. 脚注说「当日 0 点的快照」不实
实际是当天第一次访问时算、服务重启也会重算(实测算出时刻 01:11:50,
基准日却写 2026-10-24,说明它含了 0 点之后的数据)。改成准确描述。
4. 数据库故障时请求会排成 N×30s
ovRefresh 串行化 + 30 秒超时意味着数据库挂起时第 N 个请求要等 N×30 秒。
加 30 秒失败冷却:冷却期内直接降级(有旧快照用旧快照),不再碰数据库。
todayNumbers 失败也盖时间戳,不再每个请求都打两条 COUNT。
测试:新增 internal/db/answered_at_test.go(5 例:只记一次、私密路径也记、
分桶以 answered_at 为准、历史行回退、回填),并做过变异验证 —— 把口径改回
updated_at 后测试立即失败。internal/stats 补 3 例冷却测试。全量构建与测试通过
(internal/storage 的构建失败是 overlay 桩的既有问题)。
2026-10-24 01:45:34 +08:00
tamakyi
0c282914c2
fix(stats): 今日计数不再被每日缓存冻结,日期分组不依赖库会话时区
...
自查部署结果时发现两处正确性问题,都是本页上线时引入的:
1. 「今日提问 / 今日回答」原本随整页聚合一整天缓存,页面却写着「实时」,
实际当天第一次访问后数字就冻住到次日。现拆出 TodayCounts 单独取,
60 秒 TTL、跨日按日期失效;命中整页缓存时返回副本,不原地改缓存指针
(那会被并发读踩成数据竞争)。
2. 日/月/时分组用 to_char/EXTRACT 直接作用在 timestamptz 上,结果跟着数据库
会话时区走,而 PG 官方镜像默认 UTC、Go 侧按容器时区(Asia/Shanghai)切今天。
实测同一条 2026-10-24 01:30+08 的提问,UTC 会话下被算成 10-23 17 点 ——
日报表整体偏 8 小时、热力图时段错位,且与「今日」对不上。
现统一经 analyticsLocalize() 加 AT TIME ZONE INTERVAL 换算,偏移取自
time.Time.Zone()(Go 未设 TZ 时给 "Local",PG 不认该名,故不用 IANA 名)。
过滤条件仍是裸列,索引照用。
另外两处顺手修:
- PeakDay 用 >= 比较,并列时会漂到最近的同值日期,与 busiestSlot 的取值规则不一致。
- 聚合改用 context.WithoutCancel + 30s 超时:第一个访客在聚合途中关掉页面时,
排在后面等锁的请求不会各自重跑一遍全表 GROUP BY。
测试:新增 TestTodayNumbersNotFrozen(缓存命中后今日数仍更新、整页聚合当天只算一次)
与 TestTodayCacheExpiresAcrossMidnight;analytics 连真库通过。
2026-10-24 01:20:02 +08:00
tamakyi
a5441c99fd
feat(stats): 新增公开数据页 /stats,入口收敛到页脚
...
首页底部那张「站点统计」卡片下线,完整数据移到公开只读页 /stats:
今日实时数、概览六格、近 30 天折线与 12 个月柱状、三个占比环、
7×24 时段热力图、提问字数与回答耗时分布。
- 聚合在 internal/db/analytics.go(SUM(CASE WHEN),方言差异收敛在 analyticsDialectFor)
- 结果按自然日缓存,跨日重算一次后全站共用;重算失败降级返回上一份快照
- 图表零前端依赖:服务端算坐标的 SVG + CSS grid,禁用 JS 页面仍完整
- 环比取「上月 vs 上上月」:本月未过完,否则每月 1 号会得出 -95%
- 入口只在页脚「社区规则」与「找站长」之间;route.Home 不再计算统计
- 补 questions(created_at) 索引;启动后异步预热当天快照
- 测试:analytics 连真库验证软删除不计入、分桶下标、DOW 对齐
2026-10-24 00:57:08 +08:00
tamakyi
818fa74160
fix(honeypot): 取证记精确地址、封禁用 /64 键,并补建封与拦截日志
...
线上实测:IPv6 访问蜜罐后收到「你的 IP 已被封禁」,邮件里却是
`2408:…::/64` —— 既不像自己的地址,也说不清封了多大范围;同时用户自己
仍能正常访问,看日志也无从判断到底有没有封。
根因是「取证地址」与「封禁键」混用同一个变量(都取 LimitKey 的折叠值):
- 取证丢掉精确 IPv6 后 64 位,事后无法定位到具体主机、没法和其他日志对账;
- 邮件只给聚合键,运维第一反应是「不是我」,排查方向直接被带偏。
改动:
- Serve 拆 clientIP(ResolveClientIP,精确)与 banKey(LimitKey,/64):
DB / 凭据文件 / 告警邮件用前者,封禁 / 探测采样 / 告警去重用后者;
- autoban.Options 新增 OnBan 回调(保持纯标准库、不引入 logrus),
route.newBanner 注入后每次建封打 WARN:ban_key / reason / duration / expires_at;
中间件 403 分支加 Debug 日志(被封者会持续请求,Info 会被刷屏);
- 告警邮件新增「封禁范围」行,仅 ip != ban_key 时渲染(IPv4 不重复显示),
顺带把过时的「已入库」改为「写入取证文件」——凭据早就外置了;
- honeypot stats 的来源排行改按 LimitKey 合并,否则 IPv6 轮换地址会把
排行碎成几十条各 1 次的记录;合并口径与封禁完全一致。
测试(每项都做了变异验证:改坏→确认变红→恢复):
- TestHoneypotRecordsExactIPv6ButBansByPrefix:精确地址进 DB 与凭据文件、
/64 才是封禁键、同 /64 换地址仍 403;
- TestOnBanCallback:建封回调带键/时长/原因,封禁期内重复命中不重复回调;
- TestHoneypotAlertTemplateRenders:IPv6 渲染封禁范围、IPv4 不重复;
- TestMergeSourcesByKey:同 /64 合并、IPv4 原样、limit 与空输入。
2026-10-23 10:53:03 +08:00
tamakyi
b939d26ff0
fix templates v2
2026-10-22 17:49:25 +08:00
tamakyi
bd02eb7ed2
fix templates v1
2026-10-22 17:04:10 +08:00
tamakyi
c29e1d071f
fix(honeypot): 补齐封禁装配与诱捕语义,凭据外置为文件不再入库
...
一、封禁链路整条没接上(P0,已实测复现)
route.New 的 autoban.New 漏传 HoneypotEnabled/Threshold/Duration,而
RecordHoneypot 首行就是 `if !b.honeypotEnabled { return false }`
→ 蜜罐命中从不封禁、落库 banned 恒 false、IsBanned 永不为真。
同一批改动又把中间件里蜜罐路径的常规计数也跳过了,净效果是蜜罐路径
从「10 次封禁」退化为零计数零封禁 —— 是 regression 不是修复。
处理:抽 newBanner() 作为唯一装配入口并补齐参数;新增 banner_config_test
四个用例(全部经由 newBanner,不再自建 Banner)。变异验证:分别把
HoneypotEnabled 置 false、HoneypotThreshold 置 0,对应用例按预期失败。
二、诱捕语义(审计时漏掉,比上面更影响功能价值)
原实现对**所有方法**计数且阈值 1 → 扫描器 GET 一次假登录页就吃 403,
永远走不到「POST 提交账号密码」那一步,蜜罐的核心价值(拿到字典)归零。
处理:提交凭据 → RecordHoneypot(不受 enable_auto_ban 约束,立即封);
纯探测 → 常规 Record(阈值 10)。新增 TestHoneypotProbeDoesNotBanImmediately,
变异验证:把 GET 也塞进 RecordHoneypot,测试按预期失败。
三、落库边界(实测取证,非推断)
- Path 原样落库而列宽 512 → PG `SQLSTATE 22001 值太长了(512)`,整条证据丢失;
实测 600 字符失败、500 字符成功;
- 三处按字节截断 → `SQLSTATE 22021 无效的 "UTF8" 编码字节顺序`(中文密码/URL
可稳定触发)。统一改 rune 截断并按列宽逐项对齐。
四、资源与噪音
- alerted 无界 map → 带 TTL 的 deduper(上限 2 万,过期清理 + 超限硬裁),
同时承担告警 24h 冷却与纯探测落库采样(每 IP 每分钟一条)——
GET 洪泛不再变成等量 INSERT;
- credentialKeys 去掉 id/name,普通 JSON 探测不再被误判为凭据提交并触发假告警;
- canary 从 meta generator 移到 HTML 注释,generator 伪装成 WordPress,
不再同时向扫描器自曝「这是蜜罐」和「这站是 TamaBox」;
- serveLoginFailure 复用 WriteLoginPage,不再手抄响应头(已开始漂移);
- Describer 死代码 → Prefixes(),接到新命令 honeypot paths;
- honeypot_paths 解析挪到 honeypot.SplitPaths(CLI 与 route 共用),
补前导斜杠、拒绝 "/"(会让所有 404 变假登录页)。
五、凭据外置:数据库不再存用户名/密码
明文账号密码一旦入库就会跟着进备份、主从复制与任何能连库的报表工具,
事后无法彻底清除。改为:
- 新增 honeypot.CredRecord/CredStore:按天文件 creds-YYYY-MM-DD.jsonl,
目录 0700、文件 0600、坏行跳过、PurgeBefore 只删整天过期的文件;
- db.HoneypotHit 删掉 Username/Password/Fields/RawBody,只留 HadFields 元数据;
- HoneypotStore 删掉 TopCredentials/ListSinceByIP(CLI 改读文件后零调用);
- 新增 LegacyCredentialColumns/PurgeLegacyCredentialColumns:AutoMigrate 不删
既有列,老库升级后历史明文仍在 → 启动 WARN + `honeypot purge-creds` 显式 DROP;
- 清理器 HoneypotRetention 增加 CredPurgeable 参数,与 DB 记录共用保留期;
honeypot prune 也一并删过期凭据文件;
- CLI:hits 不再连库(DB 挂了也能查),stats 的凭据排行读文件;
- 配置 honeypot_cred_dir(缺省 data/honeypot-creds),.gitignore 加 /data/。
验证:go build ./... / go vet ./internal/... / go test ./...(含 PostgreSQL)全绿;
新增 credstore_test(追加/倒序/IP 过滤/坏行/Top/Purge/权限)、
TestHoneypotLegacyCredentialColumns(ADD COLUMN 模拟老库 → 检测 → DROP)、
TestHoneypotWritesCredentialsToFile;CLI 端到端手动验证 hits/--ip/--show-password。
2026-10-21 15:43:25 +08:00
tamakyi
d300167a30
fix(conf): 配置键名容错 + 未知键告警 + 蜜罐保留期缺省值修正
...
现场故障:配置写 `honeypot_retention_days <= 30`,日志却报
「蜜罐自动清理未启用:honeypot_retention_days <= 0」。
根因(实测 ini v1.67.0 行为确认):
1. 键名多一个 t(retention vs retain)→ ini 对未知键**静默忽略**,保留期成 0;
2. `<= 30` 这种写法让 ini 把键名解析成 "honeypot_retention_days <"
(带尾随比较符),更加匹配不上;
3. 警告文案里写的也是错键名,等于在误导运维往错的键上配。
修复(三层):
- 键名归一化(internal/conf/keynorm.go):修剪首尾空白、剥掉尾随 `<` `>`
`=` 等比较符残留,并按别名表纠正常见误拼。归一化结果必须命中该段
合法键名白名单才会被采用 —— 不认识的键不猜,交给未知键告警。
- 未知键告警(internal/conf/unknown.go):启动时逐段比对键名,无法识别的
键 WARN 出来并给出最接近的合法键名建议。合法键名由反射从结构体 ini tag
得出(单一事实来源,新增配置项自动纳入,不会脱节)。
- 缺省值语义修正(internal/conf/honeypot_defaults.go):
* 保留期缺省 30 天(原先零值 0 = 永不清理);显式 <= 0 也回退 30。
要关闭自动清理请用 honeypot_auto_clean = false。
* honeypot_auto_clean 缺省 true。原先靠 bool 零值,ini 的 bool 解析是
「有键才赋值」,未配置与显式关闭无法区分 → 老配置升级上来自动清理
实际是关着的。现改用 HasKey 判定。
* honeypot_max_rows 缺省 200000;显式 0 = 不限(会告警提醒只增不减)。
* honeypot_threshold 缺省 1。
实测 ini 行为(探针程序,已删):
- 键缺失或值非法时 MapTo **不覆盖**预置默认值 → 可安全预置缺省值;
- 键名大小写敏感(Honeypot_Auto_Clean 不生效);
- HasKey 能区分「缺失」与「显式 0」;
- 段不存在时 MapTo 返回 nil 且不修改目标。
顺带清理 app.sample.ini 里的两条死配置(app.title / server.xsrf_expire):
代码中从未读取(站点标题走 i18n),留着会让新检测对官方模板误报。
验证:
- 用现场原始配置端到端复现 → 归一化后保留期 30 天、自动清理开启、上限 20 万,
日志明确说明「配置键名已自动纠正」;
- 误拼键(honeypot_autoclean / honeypot_max_row)自动纠正;未知键(totally_bogus)
WARN 告警;
- 显式 honeypot_auto_clean = false 被尊重为关闭;完全未配置时缺省开启;
- 新增 TestSampleIniHasNoUnknownKeys 回归检测:官方样例配置零误报;
- go build ./... + go test -timeout 600s ./... 全绿,go vet 无输出。
2026-10-21 13:41:19 +08:00
tamakyi
63e851ddae
feat(honeypot): 自动保留期清理 + 行数上限 + VACUUM 空间回收 + size 子命令
...
问题:honeypot_hits 是只追加审计表,写入完全由外部扫描流量驱动(不可预期、
不可节流),而原实现只在手动跑 CLI prune 时才清理,站点运行期表体积只增不减。
实测取证(本地 PostgreSQL 建同构表灌 10.5 万行真实数据):
- 典型行 244 字节(含索引),10.5 万行 24 MB
- 保留期清理对照(行数恒定 10 万,每轮删 25% 插 25%):
有 VACUUM 22→24MB 收敛;无 VACUUM 22→52MB 持续膨胀
- VACUUM 不缩小文件,只让空间可复用;二次实测(5 万行、连续 4 轮删插)
行数恒定 5 万、文件稳定 15MB 不再增长
改动:
- 新增 HoneypotRetention 后台清理器(每小时一轮):时间闸 honeypot_retain_days
+ 数量闸 honeypot_max_rows,清理后调 Compact 回收空间
- db: 新增 Compact()(PG 执行 VACUUM (ANALYZE),走 GORM Exec 非事务路径)、
DeleteOldestBeyond()(跨方言通用)、ListSinceByIP()、Size()
- 去掉 honeypot_hits.provider 无用索引(10 万行占 648kB,全代码库零查询使用),
并在启动时显式清理既有库上的旧索引(GORM 去 tag 不会自动删)
- 修 CLI hits --ip 过滤 bug:原先取回最近 N 条再在 Go 侧筛,导致指定 IP 的
历史命中基本查不出来 → 改到 SQL 层过滤
- 新增 CLI honeypot size 子命令:报告行数、磁盘占用、时间跨度,并与两道闸对照
- 新增配置 honeypot_auto_clean / honeypot_max_rows
2026-10-21 13:16:14 +08:00
tamakyi
79051acae8
miguan v1
2026-10-21 12:09:28 +08:00
tamakyi
e9fba8b9ed
theme v1
2026-10-19 15:23:11 +08:00
tamakyi
4592b6ef24
fix v13
2026-10-19 01:58:00 +08:00
tamakyi
7a6417c33d
restrict v8
2026-10-18 23:32:38 +08:00
tamakyi
bd81398a06
restrict v6
2026-10-18 22:18:21 +08:00
tamakyi
6bd90779f8
restrict v1
2026-10-18 21:14:59 +08:00
tamakyi
5ee2dc1540
fix login error
2026-09-15 13:10:57 +08:00
tamakyi
adcc42cc8c
fix anwser token and database issus
2026-09-15 11:06:09 +08:00
tamakyi
8e2325eda5
aliyun sensor server
2026-09-10 18:04:19 +08:00
tamakyi
e3e59e7c69
chore: baseline snapshot before moderation-strictness feature
...
Repo .git was damaged (refs/ missing, index truncated, object store near-empty).
Repaired via git init (rebuilt refs), stale index preserved as .git/index.corrupt-20260910.bak,
then full worktree committed as a rollback baseline.
2026-09-10 10:56:27 +08:00