- 筛选栏第 5 列标签「阅读状态」四字,在手机约 53px 的窄列里必然折成两行, 改为「已读」,选项改为 全部/是/否(option value 仍是 read/unread,后端不变)。 词条 qlist.readStatus 由 qlist.readFilter + qlist.filterYes/filterNo 取代,四语言同步。 - 条目行「时间 + 状态标签 + 标记/置顶」在手机上放不下:实测 360 宽可用 306px, 完整时间单条就占 127px,2 标签 + 2 按钮需 343px。手机端时间改为「月-日 时:分」 (tb-short/tb-text 复用状态标签的 640px 断点,桌面端仍显示完整年月日时分秒), 同时把 .qlist-pill 的基础内边距 40/8 收到 34/6(桌面 @media 仍覆盖回 62/10)。 结果:2 标签 + 2 按钮在 360 宽即单行;三标签(私密+待答+未读)在 ≤375 仍会折行。
16 KiB
2026-10-23
单题分享卡二维码提到 5px/模块(310f851)
用户决定「单题一起做」,同时否掉方案 5(结果落盘/R2)——理由:功能使用率不高,不值得大动干戈。
- 单题卡卡片 210×255 放不下 5px 模块(37 模块要 185px):
QQRCardW236、QQRCardH300、QQRSize190。X 由QContentX+QContentW-QQRCardW派生 → 679,Y 居中 → 120。 QTextMaxW/qLeftClip都从QQRCardX派生,左栏自动收窄 26px,无需另改。- 抽了
drawQuestionQRCard(canvas, user, question, logo, fontCtx)(handler 只剩调用), 这样测试能直接渲染整卡:TestQuestionQRCardLayout断言正方形 / 模块≥5 / 四边留白≥20 / 居中≤1px,TEST_PREVIEW=1出outputs/sharecard-preview/question-card.png。 - 实测:二维码 185px、居中偏差 0.5px、上下留白 34px;gozxing 在 960/720/480/320 全通过。
变异验证:
QQRSize改回 150 → "QR module is 4px, want >=5" FAIL。 - 未做(用户明确不要):分享卡结果落盘/上 R2。进程内 LRU 仍是 16 条 + TTL 1h, 冷渲染成本接受现状。
部署后复验(箱子卡 + 单题卡,310f851)
- 箱子卡:QR 185×185 @ y120-304 / x702-886,卡中心 795 → 着墨中心 794.0(−1.0px); 提示语 794.0、分隔线 795.0、链接两行 795.0 / 794.5;上下留白各 18px。
- 单题卡:QR 185×185 @ y154-338 / x704-888,卡中心 797 → 着墨中心 796.0(−1.0px); 提示语 797.0;上下留白 34 / 33px。
- 端到端解码(部署图,含真实背景与内嵌头像):两张卡在 960 / 720 / 480 / 320 宽全部 OK,
内容正确(单题卡
/114)。 - 顺带确认 不是 bug:删除提问后分享卡立刻 302(
GetByID在缓存查询之前), 不存在「删了还能靠缓存取到图」的窗口。
缓存 key 排查结论(675cecb)
用户要求「有影响就修」。查下来当前没有实际影响,只有一条理论缺口:
| 可能场景 | 是否真有影响 | 原因 |
|---|---|---|
| 用户改域名 → 二维码指向旧域名 | ❌ 当前无 | Domain 只在注册时写(register.go / CreateUser),无改域名入口 |
| 改 ExternalURL / ShareBackground | ❌ 无 | 配置需重启,缓存是进程内的,重启即清 |
| 换头像/背景后仍是旧图 | ❌ 无 | R2 key = picture/日期/随机 hex,内容不可变,换图必换 URL |
| 域名将来可改 | ⚠️ 理论 | 单题卡 key 未含 user.Domain |
处理:给 questionShareCardCacheKey 补 user.Domain(箱子卡本来就有);
两处「TTL 30 分钟」注释改成 1 小时(代码一直是 time.Hour)。
新增 TestShareCardCacheKeysCoverDrawnFields——逐字段变异断言 key 必须变,
去掉 Domain 立刻 FAIL。以后改分享卡要加新字段时,先看这张测试。
正式环境「蜜罐封了但还能访问」排查(IPv6)
本地用 IPv6 全程复现了一遍 autoban 链路(CF-Connecting-IP 注入 + trust_proxy=true):
封禁前 200 → POST 凭据后 IsBanned=true → 同地址 403 → 同 /64 换地址仍 403 →
不同 /64 放行 200。判定逻辑与中间件都正确,不是代码 bug。
封禁键 = shared.LimitKey(ResolveClientIP(req)):
- IPv4 原样
- IPv6 折成
xxxx:xxxx:xxxx:xxxx::/64写入(honeypot.Serve / route.NotFound)与检查(autobanMiddleware)用的是同一个函数,一致。
⇒ 「邮件说封了但能访问」只能是键不一致或进程重启(封禁纯内存态)。
三种可能:① 部署后重启过;② trust_proxy 未配 → 键是反代地址(127.0.0.1 / ::/64);
③ 前后请求地址族或 /64 不同(双栈、IPv6 前缀轮换、多实例)。
判定方法:honeypot hits / data/honeypot-creds/creds-YYYY-MM-DD.jsonl 里的 ip 字段
就是被封的键,与本机 curl -6 ifconfig.co 比对即可。
发现的真实取证缺陷:db.HoneypotHit.IP 与 creds 文件存的是折叠后的 /64 键,
IPv6 丢掉了精确地址(IPv4 无损);邮件里也只给键,运维会误以为「这不是我的 IP」。
建议:取证记精确 IP,封禁仍用 /64;建封与 403 命中各打一条日志。
据此动手改的三处(精确 IP / 封禁键分离 + 可观测性)
honeypotHandler.Serve拆两个变量:clientIP = ResolveClientIP(精确,取证/邮件用)、banKey = LimitKey(clientIP)(/64,封禁/采样/告警去重用)。DB 与凭据文件都写 clientIP。autoban.Options.OnBan(key, duration, reason)回调(新增ReasonProbe/ReasonHoneypot), 保持 autoban 纯标准库、不再依赖 logrus。route.newBanner 注入 → 建封打 WARN (ban_key / reason / duration / expires_at)。中间件 403 分支打 Debug。- 邮件同时给「来源 IP」(精确)与「封禁范围」(ban_key,仅
ne .ip .ban_key时渲染); 顺手把模板里过时的「已入库」改成「写入取证文件」(上一轮凭据外置后没同步)。
副作用修掉:honeypot stats 的来源排行改按 LimitKey 合并(否则 IPv6 轮换地址会把排行碎成
几十条各 1 次)。SQL 取 limit*4 再合并,属近似。
测试:TestHoneypotRecordsExactIPv6ButBansByPrefix(精确 IP + /64 键 + store 断言)、
TestOnBanCallback、TestHoneypotAlertTemplateRenders、TestMergeSourcesByKey。
四处变异验证有效(改回存聚合键 / 封禁用精确地址 / 去掉 notifyBan / 不合并排行)。
变异验证的坑:把 shared.LimitKey(r.IP) 改成 r.IP 会因 shared 未使用而
编译失败(不是断言失败);变异要保证仍能编译(改成 LimitKey(r.IP) + r.IP 才测到逻辑)。
测试环境验收(部署 29ddee0 后)
伪造 CF-Connecting-IP: 2001:db8:…(RFC 文档保留段,封了不影响任何人)直连源站端口:
- 基线 200 → POST 凭据 200(假页面)→ 同 /64 换地址 403(聚合封禁生效);
- 凭据文件
ip=2001:db8:dead:beef::1234(精确,无 /64 后缀),banned:true; - 换另一个 /64(
2001:db8:cafe::1)→ 200,说明封的是 /64 不是全局; od -c复核"path":"/wp-login.php"无前导空格 → cleanPath 生效。
教训:上一次那条 " /wp-login.php" 是那次请求的 URI 本身带空格(本地实测
Match(" /wp-login.php")=true 靠 TrimSpace 命中、Match("/ wp-login.php")=false),
不是代码问题;判断这类"看不见的字符"一律用 od -c,别靠终端渲染。
测试环境服务是前台跑在 /dev/pts/1,日志不在文件里 —— 拿日志要 tmux capture-pane 或
下次启动加 >> logs/tamabox.log 2>&1。
蜜罐升级为 9 个伪装家族
新增 family.go(皮肤表)/ asset.go(静态资源占位)/ 重写 page.go:
- 按路径分发:wordpress / phpmyadmin / tomcat(401+Basic) / jenkins / grafana / gitlab / luci / joomla / generic;每类独立字段名、配色、指纹响应头、Set-Cookie;
credentialKeys扩pma_*/j_*/luci_*/user[login];token只用于判 HadFields,不进 isPasswordKey(否则 PMA 的 hidden token 会随机顶掉真密码);- 静态资源(
/wp-admin/css/…等)回占位内容,不落库不计数 —— 一次页面加载 5–10 个请求; honeypot paths加家族汇总(实测 110 条:generic 84 / wordpress 10 / tomcat 6 / gitlab 3 / pma 3 …);- 性能:RenderFamily 12µs→3.7µs、22→2 allocs、19.5KB→5.2KB(禁
"a"+b+"c"、Replacer 提包级)。
新工具 dev/preview-honeypot:渲染 9 个家族到 outputs/honeypot-preview/index.html,
配 chrome headless 截图核对外观。靠它发现两处「源码看不出、截图一眼假」:
Tomcat 的 skin 忘了 css(按钮白字无底色)、WP 登录页底部挂着 404 文案。
测试环境验收:9 家族伪装(部署 7225f68 后,只读 GET)
全部符合预期:8 个家族 title/generator 各不相同、Tomcat 401+Basic realm、 静态资源 200 且 MIME 正确(128B css / 81B js / 70B png)、插件目录 .php 回 404、 业务路由(/login /user /static /api)无 canary、界外路径仍 302 → /。
发现的漏洞(按优先级):
- P1
/xmlrpc.php回 HTML 登录页 —— 而我们自己发了X-Pingback: /xmlrpc.php, 真实 WP 回 text/xml,等于自己指给自己一个明显假的地址;同时丢掉 WP 爆破最有价值的 捕获通道(xmlrpc system.multicall 一次可试上千组,绕开登录限速)。 - P2 每次请求恒定值:PMA
token=e1a4c0b7d2f58g93(还带非 hex 的 g)、 genericcsrf_token=a7f3c1e9b2d4、X-Jenkins-Session: 8b1f2c4d。 两次请求完全相同 → 跨请求指纹即可识别。应改每请求随机。 - P2
X-Pingback用相对路径,真实 WP 给绝对 URL。 - P3
/phpinfo.php漏匹配:路径清单里是/phpinfo、/info.php精确项, 段前缀匹配不覆盖.php变体 → 302。可加「p 去掉 .php 后在集合内」的兜底。 - P3
/wp-json/wp/v2/users回 HTML,真实 WP 回 JSON(用户枚举接口)。 - P3
X-Frame-Options: DENY/Cache-Control: no-store与真实 WP 不同, 但属全局中间件,改动影响全站。
修 P1/P2:XML-RPC 端点 + 动态 token + .php 兜底
- 新增
internal/security/honeypot/xmlrpc.go:/xmlrpc.php不再回登录页。 GET→405+XML fault「XML-RPC services are disabled」;POST methodCall→200+fault 「Incorrect username or password」;POST system.listMethods→方法列表模板。 凭据提取用正则(不上 encoding/xml,蜜罐高频命中):抓<string>、剔除 methodName 与 http(s) URL、两两配对;blog_id=1这种「奇数长度+首项短数字」 会丢首项纠正错位;第一组进标准字段,其余进cred2.*,原始串全量进xmlrpc.N(配置上限 16 对 / raw 8KB)。 - 动态占位符:
LoginPageVars.Nonce(32 hex,math/rand/v2,刻意不用 crypto/rand, 这些是假 token 只需「每次不同」)。支持$hex8/16/32与$base; 页头、hidden、Set-Cookie 共用同一套 nonce。 X-Pingback改绝对地址($base/xmlrpc.php),没配external_url就不发。Cache-Control从no-store改no-cache, must-revalidate, max-age=0(更像 WP/PMA)。- Grafana/GitLab 补真实会种的会话 cookie(
grafana_sess、_gitlab_session,动态)。 Match增加.php变体兜底:/phpinfo.php之前漏匹配 → 302。- 性能:RenderFamily 3.7µs→4.18µs、2→3 allocs(多的一次是 hex 编码),可接受。
四处变异验证有效(固定 token / xmlrpc 分支失效 / Pingback 相对路径 / .php 兜底失效)。
测试环境验收:XML-RPC 端点(部署 9d3f5a7 后)
GET /xmlrpc.php→ 405 + text/xml(与真实 WP 一致,XML-RPC 只接受 POST), 用户误以为这是报错;405 的 body 是<fault>XML-RPC services are disabled…。POST system.listMethods→ 200 + 方法列表 ✓。POST wp.getUsersBlogs(伪造CF-Connecting-IP: 2001:db8:beef::7)→ 凭据落文件:username=xruser / password=xrpass、fields.xmlrpc.0/1、xmlrpc.method、banned:true✓。- 紧接的下一次请求
403 Forbidden= 已封禁(第一次 POST 就封了该 /64),属预期。 - 动态 token 两次不同(
d85a8c…/76a404…)✓。
再次出现的 "path":" /xmlrpc.php" 已判定为粘贴/终端产物:IsXMLRPCPath 不做 trim,
若 path 真带前导空格根本进不了 XML-RPC 分支,而记录里有 xmlrpc.* 字段 ⇒ 落盘值必是
/xmlrpc.php。这类"看不见的字符"结论一律靠 od -c,别靠终端回显。
403 响应体换成图片
用户给了 R2 预签名链接(X-Amz-Expires=604800,7 天后失效)——不能直接热链。
下载后(256×256 webp,8.6 KB)用 //go:embed 编进二进制:
internal/route/assets/403.webp + internal/route/banned.go: writeBanned()。
- 状态码仍是 403(语义不变),只换 body;
Content-Type: image/webp+ 显式Content-Length(8 KB 超过 Go 的 2 KB 缓冲,不设会退回 chunked)+no-store;HEAD 不写 body。 - 放在
internal/route/assets/而不是static/:static 会多出一个可公开访问的 URL。 - 换图方式:覆盖同目录 403.webp → 重新编译,无配置项。
- 体积代价:每封禁请求 8.6 KB(原 13 字节)。被封来源重试频繁时要留意带宽, 已压到 256×256;别塞几百 KB 原图。
关于「403 图放 R2 是否更好」的结论
结论:保持 embed。关键事实是「服务端代理/重定向并不省我们带宽」——
字节最终还是从本机发给被封客户端;想真省只能让客户端自己去 R2 取,
那就要放弃「403 直接是图片」,改成 403 + 一个 <img src=外链> 的 HTML。
而本项目 R2 目前只有预签名 URL(7 天过期),长期用必须开 r2.dev 或绑自定义域
(docs/theme-new-sakura.md 已记:直连 *.r2.cloudflarestorage.com 会 403)。
8.6 KB 的单张图不值得引入外部依赖;被 ban 的多是脚本,根本不会加载图片。
若动机只是「换图不想重编译」,加 [security] ban_image_path 磁盘覆盖更划算。
改为 ban_image_path(配置驱动,默认纯文字)
按用户要求撤掉 embed:internal/route/assets/(含 403.webp)已删除,
行为改为「配了合法图 → 回图;留空 / 读不到 / 不是图 / >2MB → 纯文字 403」。
[security] ban_image_path(相对路径以进程工作目录为基准);loadBanImage()在route.New()里启动时读一次 →atomic.Pointer[banResponse], 热路径零磁盘 IO;任何失败只 Warn 不改启动(封禁响应是最后一道兜底,不能变 500);- 只认内容不认扩展名(
http.DetectContentType必须是image/*):配成 app.ini/.env 会被拒绝,否则等于把配置文件吐给攻击者(变异验证过这条); - 上限 2 MB(被封来源反复重试,大图放大出网带宽);
Content-Length显式给; HEAD 不写 body;no-store。 - 实测 sniff:真 webp →
image/webp;app.sample.ini→text/plain(被拒)。
教训:同一功能改了两版(先 embed 后配置化),第二版要把上一版的资源一起清掉,
否则留一个没人引用的 assets 包。go vet ./... 不报、测试也不报(该包仍被旧测试引用时)。
/user/questions:筛选项「已读 是/否」+ 手机端标签行不折行
- 筛选栏第 5 列标签「阅读状态」四字,在 ~53px 的窄列里必折两行。
→ 词条
qlist.readStatus删除,换qlist.readFilter(已读/Read/既読/已讀)+qlist.filterYes/qlist.filterNo(是/否)。只改标签,option 的 value 仍是 read/unread(后端readParam校验依赖它)。qlist.all(全部)保留作默认不筛选。 - 手机端「时间 + 状态标签 + 标记/置顶」挤一行:实测(360/390/375 iframe + dump-dom 量 rect)
一行只有 306–336px,完整时间占 127px,2 标签 + 2 按钮要 343px → 必折。
→ 时间改
tb-short=m-d H:i /tb-text=Y-m-d H:i:s(复用 pill 的 640px 断点); →.qlist-pill基础值 min-width 40→34、padding 0 8→6(手机端省 20px), 桌面@media(min-width:640px)仍覆盖回 62px/10px。 结果:2 标签 + 2 按钮在 360 就单行;「私密+待答+未读」三标签仍会在 ≤375 折行(可接受)。 - 量手机宽度别用
chrome --window-size=360:Windows headless=old 有 ~500px 最小窗宽, 实测 innerWidth 恒为 500。用 360px 宽 iframe +--allow-file-access-from-files, 结果由 iframe 里window.top.document.title带回,--dump-dom读。