Files
tamabox/.workbuddy/memory/2026-10-21.md
T
tamakyi 6c21184733 主题生成器支持素材前缀(主题素材可放 R2/OSS)
主题素材只有一处引用:主题 css 的 url()(分享卡是 Go 用 imaging 画的,不读素材),
所以只要让 CSS 里的地址指向对象存储即可。

- 导出面板新增「素材前缀」:空 = /static/images/themes/<slug>/(图片随二进制发布,与以前一致);
  填 https://cdn.example.com/themes = CSS 写绝对 URL,zip 里的图片改为「上传用」
- 地址统一由 urlOf() 决定。修了一个重复实现:layerCSS 里复制过一份路径拼接,
  只改 urlOf 会出现「背景走 CDN、装饰还指本地」
- 前缀随意切换不影响预览(预览恒用内联 dataURL);存进工程 json;?assetbase= 可自检
- INSTALL 文案按是否托管分叉,写清图片传到哪、换域名要重新导出
- 新增 docs/theme-assets-cdn.md:三种做法(生成器前缀 / 应用内 301 / 边缘改写)、
  缓存与 ?v=、CORS 不需要、深色无素材、迁移用的 sed 命令
- README 更新;UI 修正:240px 面板里 4 字标签会被 flex 压窄,需 flex:0 0 auto + 84px
2026-10-21 19:32:55 +08:00

46 KiB
Raw Blame History

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(写入/倒序/三张聚合表/保留期清理)。

测试站黑盒验证 + P0 缺陷修复(蜜罐封禁被 enable_auto_ban 连坐)

测试站:https://box-dev-api.337845818.best(Cloudflare Tunnel + CF 边缘,cf-cache-status: DYNAMIC、无 Age → 不缓存,请求确实回源)。 生产站 https://box.tama.guru 强制 301 → www,本机不可达,无法做对照。

黑盒验证结果(全部通过)

  • 诱饵路径全部 200 + text/html:/wp-login.php 2529B、/wp-admin/ 2517B、/.ssh/id_rsa 2525B、/.env 2497B、/administrator 2533B。
  • 响应体含 canary tb-hp-7f3a1c9e(2 处)、<title>Log In ‹ WordPress</title>、name="log"/name="pwd"/name="redirect_to"。
  • 响应头:Cache-Control: no-store, no-cache, must-revalidate、x-robots-tag: noindex, nofollow、x-frame-options: DENY。
  • POST 凭据提交 → 200 + id="login_error">ERROR: Invalid username or password.。
  • 路径变体全命中:/WP-ADMIN(大小写)、/wp-admin%2f..%2fetc(编码绕过)、//wp-admin//x.php(双斜杠)、/.git/config、/.aws/credentials、/phpmyadmin/、/actuator/env、/xmlrpc.php、/vendor/phpunit/x.php、/console、/cgi-bin/test.cgi、/wp-content/plugins/x.php、/.env.bak、/wp-json/wp/v2/users。
  • 凭据来源全覆盖:表单 POST、JSON body、BasicAuth、查询串、POST 到 /.ssh/id_rsa。
  • 业务路由零交集(红线):/ 34192B、/login 23420B、/register 25194B、/_/admin6 39358B、/user 302、/api/v1/questions 302、/robots.txt 145B;非诱饵随机路径 /nonexistent-random-xyz 仍 302→/。

P0 缺陷:蜜罐命中 70+ 次但站点零封禁

  • 根因:autoban.Record() 第一行 if !b.enabled { return },b.enabled 来自 enable_auto_ban。 运维为避免共享出口误伤而关掉 enable_auto_ban → 蜜罐被另一个开关连坐,只落库不封禁。
  • 对照组证明非蜜罐专属:/index.php(autoban 敏感、蜜罐不匹配)连打 40 次 + 30 并发同样零封禁。
  • 排除项:CF 缓存(DYNAMIC/无 Age)、flamego NotFound 链(源码确认全局 Use 链生效)、蜜罐匹配器、阈值配置。

修复(已实施,测试全绿):

  • autoban.Options 新增 HoneypotEnabled / HoneypotThreshold(默认 1)/ HoneypotDuration(默认复用常规)。
  • 新增 Banner.RecordHoneypot(ip, path) bool:独立计数、独立阈值,不受 enabled 约束;键与 Record 一致(同样 shared.LimitKey 归一化),否则 IsBanned 判不出来。
  • IsBanned() 移除 !b.enabled 早退 —— enabled 只控制「是否主动创建封禁」,不控制「是否尊重已有封禁」。
  • internal/route/honeypot.go:banned = h.ab.RecordHoneypot(ip, path)。
  • internal/route/route.go(CRLF)装配三个蜜罐字段;conf/static.go + conf/app.sample.ini(CRLF)新增 honeypot_threshold / honeypot_ban_minutes。
  • 告警:仍走既有 sendAlert(「已封禁」)+ 蜜罐自己的告警邮件(「捕获了什么凭据」),互补。

后缀匹配补缺

  • /backup.zip、/db.sql、/id_rsa.pem 这类泄露扫描文件名主体随机,只有后缀稳定。
  • 原 honeypotFragments 用 "/.zip" 子串匹配 → .zip 前是 k 不是 /,全漏网。
  • 新增 honeypotSuffixes(.zip .tar .tar.gz .tgz .gz .rar .7z .bz2 .sql .sql.gz .dump .bak .old .orig .save .swp .env .env.bak .env.local .pem .key .p12 .pfx .jks .kdbx .ovpn),按 path.Base() 结尾匹配。

踩坑

  • ⚠️ 测试里 Duration: 60 = 60 纳秒,封禁瞬间过期 → 表现为「时序巧合的 200/200/403」,测试假失败。一律写 60 * time.Minute。
  • ⚠️ 路由测试只注册路由 + NotFound 会被绕过:执行 IsBanned → 403 的是 autobanMiddleware,测试服务器必须 f.Use(autobanMiddleware(ab, hp))。
  • ⚠️ internal/route/route.go、internal/conf/static.go、conf/app.sample.ini 实际是全 CRLF;grep | cat -A 不显示 ^M,极易误判为 LF。Python 脚本按 \n 锚点替换会静默失败 → 用 \r\n 锚点 + 写后统计 b'\r\n' 复核。
  • 假页纯静态 HTML(内联 CSS,不落盘)、告警走 SMTP → 取证数据只在 honeypot_hits 一张表,服务器无文件副本;autoban 封禁表是内存态(重启清零)。
  • 测试阶段产生的噪声凭据(testadmin/S3cr3tP@ss 等)可用 TamaBox honeypot prune 清理。

遗留(需用户侧)

  • 本轮修复需重新部署才在测试站生效;部署后 curl 一次 /wp-login.php 应 200、再一次 403。
  • 需确认测试环境 conf/app.ini 的 [security] enable_auto_ban(零封禁直接原因)+ enable_honeypot = true。
  • 无法 SSH(100.79.185.45:19999 全部账号 Permission denied),落库验证需用户执行 TamaBox honeypot hits --since 24h --show-password。

蜜罐表体积实测 + 自动清理(commit 63e851d,已 push)

用户问「这些记录会不会导致数据库很臃肿」。不靠推断,本地 PostgreSQL 建同构表灌仿真数据实测。

实测数字

  • 典型行 244 字节(heap 201 + 索引 43),10.5 万行 = 24 MB;常规 UA 下实测 281 B/行。
  • 字段均值:path 13B、user_agent 111B(单行最大头)、fields 3B、raw_body 3B。
  • raw_body 被 maxCredentialFieldLen = 512 截断,故字段数量随之受限(200 字段场景 rawBody 仍 512B)。
  • 最坏情况(手工构造 50 字段 × 500B)约 2375 B/行。
  • UA 截短收益:截到 100 省 1129 kB / 截到 64 省 4824 kB(10 万行)→ 仅 5–20%, 远小于自动清理的确定性上界且损失取证信息 → 决定不做。

PostgreSQL 死元组语义(本轮最关键认知)

  • DELETE 只标记死元组,表文件不会变小;空间需 VACUUM 后才可被后续 INSERT 复用。
  • 对照(10 万行,每轮删 25% 插 25%,行数恒定):有 VACUUM 22→24 MB 收敛;无 VACUUM 22→52 MB 膨胀。
  • ⚠️ VACUUM 不缩小文件,只让空间可复用。二次实测(5 万行、连续 4 轮删 12000 插 12000): 行数恒定 5 万、文件稳定 15 MB 不再增长 —— 这才是要的效果(止住增长,不是让文件变小)。 想让文件立刻变小只有 VACUUM FULL(重建整表 + 锁表),蜜罐表万级行没必要。
  • ⚠️ VACUUM 不能在事务块内执行(SQLSTATE 25001),也不能在 PL/pgSQL 函数体内执行(VACUUM 无法从函数执行)。 GORM db.Exec() 走 callbacks.Raw() 处理器、不包事务 → WithContext(ctx).Exec("VACUUM ...") 可用(已实测)。
  • PG18 已移除 pg_stat_user_tables.n_tup_vacuum,改用 last_vacuum 验证。

索引实测(EXPLAIN 驱动决策)

  • idx_honeypot_hits_provider:10 万行 648 kB,全代码库零查询使用(TopSources 用 max(provider) 随 group by 顺带取)→ 已删。
  • idx_honeypot_hits_created_at:EXPLAIN 显示 Index Scan Backward,被 ListSince 实际使用 → 保留。
  • idx_honeypot_hits_ip:4.7 MB(最大),但 GROUP BY ip 走 Seq Scan + HashAggregate;保留(ListSinceByIP 需它)。
  • ⚠️ GORM 去掉 struct tag 的 index 不会删除既有库上的旧索引 → 需显式 HasIndex + DropIndex(已加 dropUnusedHoneypotIndexes,启动时跑,失败仅告警)。

落地改动

  • internal/db/honeypot_retention.go(新):HoneypotRetention 后台清理器,每小时一轮,两道闸 (时间 honeypot_retain_days + 数量 honeypot_max_rows)→ 清理后调 Compact()。 保留期 <=0 视为不自动清理(只告警);runOnce 开头必须再挡一次 retainDays<=0, 否则 before=now 会把历史全删光(「保留 0 天」被误解释成「只留此刻」)。
  • internal/db/honeypot.go(新方法):Compact()(PG VACUUM (ANALYZE))、DeleteOldestBeyond() (跨方言通用,子查询按 id 倒序取 keepRows 边界)、ListSinceByIP()、Size()。
  • internal/cmd/web.go:接入清理器,stopRetention 用 defer close。
  • internal/cmd/honeypot.go:新增 honeypot size 子命令(行数/磁盘占用/时间跨度 + 与两道闸对照)。
  • 修 CLI hits --ip 真 bug:原先取回最近 N 条再在 Go 侧筛 → 指定 IP 的历史命中基本查不出来 → 改 SQL 层过滤。
  • 新配置:honeypot_auto_clean(默认 true)、honeypot_max_rows(默认 200000 ≈ 53 MB)。

踩坑

  • ⚠️ 测试预期别自己推算错:DeleteBefore 是严格小于(created_at < before),边界那条不删。
  • ⚠️ Python 补丁脚本里写 Go struct tag 的反引号 → bash 命令替换报错,必须写成 .py 文件执行(不能 python -c)。
  • ⚠️ psql 传中文 SQL 报编码错 → 用英文输出串或 PGCLIENTENCODING=UTF8。
  • ⚠️ 库名带连字符要用双引号:CREATE DATABASE "tamabox-cli-test"。
  • ⚠️ Edit 工具改 CRLF 文件会保持 CRLF,但改写时我打的字会转码损坏(出现过「实测指事/程定/不图」)→ 改完必须逐行读回复核。
  • 仓库内统一存 LF,工作区 CRLF 由 core.autocrlf=true 自动转换 —— 提交时 warning 属正常,不是行尾被改。

配置键名静默失效的根治(commit d300167,已 push)

现场故障

用户配置 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 <"(带尾随比较符), 实测输出:key="honeypot_retention_days <" value="30" —— 更匹配不上。
  3. 我之前那条警告文案里写的也是错键名 honeypot_retention_days, 等于在误导运维往错的键上配 —— 教训:警告文案里的键名必须与代码读的键名逐字一致。

实测到的 ini 行为(重要,写 ini 相关代码前必读)

  • MapTo 在键缺失或值非法时不覆盖目标字段 → 可以安全预置缺省值。
  • 键名大小写敏感(Honeypot_Auto_Clean 不生效)。
  • HasKey 能区分「缺失」与「显式 0」。
  • 段不存在时 MapTo 返回 nil 且不修改目标。
  • bool 的解析是「有键才赋值」→ 未配置与显式 false 无法用零值区分,必须靠 HasKey。

修复三层

  • internal/conf/keynorm.go(新):键名归一化(修剪空白、剥尾随 < > =、别名表纠错)。 ⚠️ 归一化结果必须命中该段合法键名白名单才采用 —— 不认识的键不猜,交给未知键告警。
  • internal/conf/unknown.go(新):启动逐段比对键名,未知键 WARN + 给出最接近的合法键名。 合法键名由反射从结构体 ini tag 得出(单一事实来源,新增配置项自动纳入)。
  • internal/conf/honeypot_defaults.go(新):蜜罐缺省值语义修正 (保留期缺省 30、auto_clean 缺省 true 用 HasKey 判、max_rows 缺省 200000、threshold 缺省 1)。
  • 归一化必须在 MapTo 之前做 —— 等 MapTo 跑完,错拼的键早被丢掉了。

顺带清理

app.sample.ini 里两条死配置:app.title(代码从未读,站点标题走 i18n site.title)、 server.xsrf_expire(无对应字段)。留着会让新检测对官方模板误报。

踩坑

  • ⚠️ Python 脚本里写 Go struct tag 的反引号 → bash 命令替换报错。 改用 Edit 工具逐处改(Edit 对 CRLF 文件保持 CRLF,但我打的字可能转码损坏 → 改完逐行读回复核)。
  • ⚠️ gofmt -l 对 CRLF 文件一律报警(gofmt 要求 LF)—— 那是既有状态,不是我的改动。
  • ⚠️ 我自己的测试预期写错过两次(别名生效后的期望值、static_version 不是 ini 键)。 测试失败要先怀疑自己的预期,别急着改产品代码。

蜜罐实现审计(第三方 agent 写完,我复审)

P0:蜜罐封禁整条链路未接线(已实测复现)

internal/route/route.go:85 的 autoban.New(...) 没传 HoneypotEnabled / HoneypotThreshold / HoneypotDuration,而 autoban.RecordHoneypot 首行就是 if !b.honeypotEnabled { return false } → 蜜罐命中永不封禁、落库 banned 恒 false、IsBanned 永不为真。 同一提交又把 autobanMiddleware 里蜜罐路径的常规计数也跳过了 (route/autoban.go:38 if hp == nil || !hp.Match(path),理由「蜜罐处理器自己计数」) → 净效果:蜜罐路径从「10 次封禁」退化为零计数零封禁,是 regression。 honeypot_threshold / honeypot_ban_minutes 两个配置项全代码库零读取(死配置,样例 ini 里却写了)。 测试为什么绿:route/honeypot_test.go 自建 banner 时显式传了 HoneypotEnabled, 注释还写着「与 route.go 一致」—— 实际不一致,测试绕过了生产装配。

P1

  • 蜜罐 GET 探测(无凭据)也逐条 INSERT,把「零成本 404」变成「每请求一次 DB 写」, 且 NotFound 链路上没有 rate limit → 扫描器可直接放大成 DB 写压力;20 万行闸每小时才跑一轮。
  • honeypotHandler.alerted(map[string]bool)无 TTL、无清理,随源 IP 数量无界增长; autoban 的同名 map 也是(既有问题,蜜罐放大)。

P2

  • Path 原样落库但列是 size:512 → 超长路径直接 PG 报错丢证据;Query 用 query[:1000] 字节截断,honeypot.go:243/296 的 v[:limit]、raw[:limit] 同样是字节截断 → 多字节字符被切半产生非法 UTF-8,PG 拒插。同文件里已有按 rune 的 truncate() 却没用于这些点。
  • credentialKeys 含 id / name / token → 任意 JSON 探测带 {"id":1} 就会被当成「破解凭据」 触发告警邮件 + 记为 HadFields,污染取证与告警。
  • page.go:54 <meta name="generator" content="TamaBox honeypot tb-hp-7f3a1c9e"> 把「这是蜜罐」 和「这是 TamaBox」同时明示给扫描器(nuclei 类模板可据此跳过)。
  • serveLoginFailure 手抄了一遍 WriteLoginPage 的响应头逻辑(没复用),已开始漂移 (Title 一处写 ‹、一处写 \u2039)。
  • Honeypot.Describer() 零调用死代码,且 path.Join(keys...) 语义错误(会把前缀拼成一条路径)。

结论

总体设计(挂 NotFound、零业务交集、只回假内容、脱敏 CLI、独立取证表)方向正确; 问题集中在「装配层没接上」与「写入侧的边界处理」。P0 必须先修,否则蜜罐只是个记日志的旁观者。

蜜罐审计问题的修复实施(已改完,未提交)

1. P0 装配(route.go)

抽 newBanner() 作为唯一装配入口,补 HoneypotEnabled / HoneypotThreshold / HoneypotDuration。 新增 internal/route/banner_config_test.go 四个用例,全部经由 newBanner() 断言。 变异验证:分别把 HoneypotEnabled 改成 false、Threshold 改成 0,对应用例确实失败。

2. 诱捕语义修正(审计时漏掉、比 P0 更影响功能价值)

原实现 RecordHoneypot 对所有方法计数且阈值 1 → 扫描器 GET 一次假登录页就吃 403, 永远走不到「POST 提交凭据」那一步,蜜罐核心价值(拿字典)直接归零。 改为:creds.HadFields → RecordHoneypot(立即封);纯探测 → 常规 Record(阈值 10)。 新增 TestHoneypotProbeDoesNotBanImmediately,同上做了变异验证。 连带把 honeypot_threshold 的语义在 static.go / defaults / autoban / sample.ini 统一为 「提交凭据次数」。

3. 落库边界(实测取证)

  • Path 原样落库、列宽 512 → PG SQLSTATE 22001 值太长了(512),整条证据丢失; 实测 600 字符失败、500 字符成功。
  • 三处按字节截断 → SQLSTATE 22021 无效的 "UTF8" 编码字节顺序(中文密码/URL 可稳定触发)。 全部改为 rune 截断(clipRunes / route 的 truncate),并按列宽逐个对齐。

4. 资源与噪音

  • honeypotHandler.alerted(无界 map)→ 换成带 TTL 的 deduper(容量上限 2 万, 过期清理 + 超限硬裁),同时承担「告警 24h 冷却」与「纯探测落库采样(每 IP 每分钟一条)」。
  • credentialKeys 去掉 id / name(普通 JSON 探测不再被当成凭据提交触发假告警)。
  • canary 从 <meta name="generator"> 移到 HTML 注释(<!-- build: ... -->), generator 伪装成 WordPress 6.4.3;不再向扫描器自曝「TamaBox 蜜罐」。
  • serveLoginFailure 不再手抄响应头,统一走 WriteLoginPage;LoginPageTitle 抽常量。
  • Describer() 死代码 → 改为 Prefixes() 并接到新 CLI TamaBox honeypot paths。
  • honeypot_paths 解析挪到 honeypot.SplitPaths(CLI 与 route 共用),补前导斜杠、拒绝 /。

5. 新发现的配置陷阱(影响面比蜜罐大)

conf.Init 用 ini.LoadOptions{IgnoreInlineComment: true},该选项语义是 不剥离行尾注释(实测:port = 1440 ; 注释 → 值 "1440 ; 注释",int 解析失败, MapTo 却返回 nil error)。后果:

  • app.sample.ini 照抄无法启动(text_censor_source 值带注释被 validate 拒绝);
  • 所有带行尾注释的 int/bool 静默回退零值 —— enable_auto_ban、honeypot_max_rows、 honeypot_auto_clean 全部中招(后两者靠 sanitize 兜底才没出事,max_rows 则真的失效)。 已把 sample 的蜜罐段改成「注释独占一行 + 裸值」,并在段首写明原因。 其余段落仍是老写法,未动(需用户决定是否全面清理)。

待办

  • 用户确认是否提交这批改动。
  • app.sample.ini 其余段落的行尾注释问题未修,建议后续统一。
  • 蜜罐 GET 探测目前受 enable_auto_ban 约束(关了就不计数),这是有意的: 纯探测无害,不该在运维关掉自动封禁后仍凭空封人。

后续:sample 注释清理 + 凭据外置(同日晚)

1. app.sample.ini 行尾注释全量清理

17 处 key = value ; 注释 改成「注释独占一行 + 裸值」,保留 CRLF。 新增 TestSampleIniValuesHaveNoInlineComment:遍历所有段的键,断言 Value() 不含 ;。 变异验证:把 ban_window_seconds = 60 改成带行尾注释 → 测试按预期失败。 清理后 conf.Init 读 sample 的报错从「值含注释」变成「server.xsrf_key 未填」, 说明解析路径已通(必填项留空是模板的预期行为)。

2. 凭据改为外部文件,数据库不再存用户名/密码

  • honeypot.CredRecord + CredStore(internal/security/honeypot/credstore.go): 按天分文件 creds-YYYY-MM-DD.jsonl,一行一条 JSON;目录 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(DB 挂了也能查),stats 的凭据排行读文件,新增 purge-creds。
  • 配置 [security] honeypot_cred_dir,缺省 data/honeypot-creds;.gitignore 加 /data/。

测试

credstore_test.go(追加/倒序/IP 过滤/坏行/Top/Purge/权限)、 TestHoneypotLegacyCredentialColumns(ADD COLUMN 模拟老库 → 检测 → DROP)、 TestHoneypotWritesCredentialsToFile(POST 后文件有记录)。 CLI 端到端手动验证:honeypot hits / --ip / --show-password 输出正确。 全量 go test ./...(含 PostgreSQL)通过。

遗留

  • 历史备份/主从复制里可能仍有旧明文凭据,代码无法处理,需运维自行清理。
  • 邮件告警仍会带明文用户名/密码(用户未要求改)。

已提交(2026-10-21,未推送)

  • 566b13e fix(conf): 样例配置去掉行尾注释(2 文件)
  • c29e1d0 fix(honeypot): 补齐封禁装配与诱捕语义,凭据外置为文件不再入库(21 文件)
  • main ahead of origin/main 2;用户要部署测试环境验证后再决定推送。

测试环境实测反馈(同日晚,已修并推送)

现象:purge-creds 删 4 列成功、GET 200、POST 后 GET 403、文件已生成。 但凭据文件里 "path":" /wp-login.php"(前导空格)、"ip":"127.0.0.1"。

  • path 脏字符:反代链路(CF → nginx → 源站)传出的 req.URL.Path 带前导空白。 Match 内部 normalize 会 TrimSpace 所以仍命中,但落库与假表单 action 用原始值 → 取证数据脏、爆破脚本可能提交到错误路径。本地 httptest 复现不出来,是环境相关。 修复:cleanPath() 在 Serve 入口 trim + 补前导斜杠(不改大小写)。
  • IP = 127.0.0.1:说明 trust_proxy 未开。后果不只是取证失真 —— 一次蜜罐封禁会封掉 127.0.0.1,等于封掉整站所有访客(封禁是内存态,重启即恢复)。 修复:命中回环地址且 !trust_proxy 时打一次性 WARN(24h 冷却),不改封禁行为。 sample.ini 的 trust_proxy 段补了蜜罐相关说明。
  • 846bb8a 已推送(5e8deca..846bb8a)。

收尾:path 脏字符最终定位(同日晚)

用户重启进程后,od -c 显示新记录的 "path":"/wp-login.php" —— 干净。 说明之前那条带空格的是旧进程写的(16:07 那次 ./tamabox web 并没有真正替换掉在跑的实例)。 期间补了两处加固并推送:

  • 74312fb:cleanPath 改用 TrimFunc 覆盖 U+200B/200C/200D/FEFF (TrimSpace 只认 White_Space,零宽字符会漏,且会因首字符不是 / 多补斜杠); 清洗前后不一致时 WARN 打出 %q 原始值,不可见字符靠日志才定位得到。
  • 教训:strings 不认 UTF-8 中文(已记入 topics/build.md);替换二进制后必须重启进程。

文档(3d45374,已推送)

  • 新增 docs/honeypot.md:生效范围、配置、两条封禁路径(GET 走常规阈值 / 提交凭据才即封)、凭据文件格式与不入库的理由、trust_proxy 部署必读、 旧列清理、排障表、设计取舍。
  • docs/cli.md:§0 补「替换二进制必须重启」「strings 用 grep -ac」; 命令总览加 honeypot;新增 §7 honeypot 命令(原 7/8/9 顺延为 8/9/10); 排错表加 5 条。

2026-10-21

新旧前端对比(正式 vs 测试环境),只测量未改码

方法:curl 抓两环境 4 个页面 → 本地注入脚本取计算样式 / 资源体积 / 对比度 / 点按区; 无头 Chrome 截图(--headless=old)后用 Pillow 算像素指标。 ⚠️ 本模型读不了图片,所以结论全部来自数值与结构,不靠"看截图"。

踩坑:file:// 打开抓下来的页面时,href="/static/css/base.css" 会解析成 file:///static/... → CSS 不加载, 于是 dev 页量出来全是 UIkit 旧样式(圆角 0、旧阴影)。改法:抓下来后把 /static/ 重写成 dev 域名绝对地址, 再重新采集。以后本地复现线上页面必须先重写站内绝对路径,否则测的是"无 CSS 的裸 HTML"。 另:body.scrollWidth 会被隐藏面板撑大(dev explore 1499),判断横向溢出要看 documentElement.scrollWidth(实测 1249 = 无溢出)。

实测差异(1280 宽)

指标 正式(旧) 测试(新)
首页 HTML 42.9KB 34.2KB
探索页 HTML 77.4KB 53.4KB
<style> 块 9–10 2
行内 style 属性 8–16 3–5
卡片圆角 / 阴影 0px / 单层硬阴影 12px / 双层柔阴影
圆角取值种类 2px×73、50%、8、6、4、9 6、8、12、14、5、50%
正文色 #333/#666/#999 混用 令牌 #1f2329/#5c6370/#8a919c
字体栈 UIkit 默认 system-ui + 苹方/雅黑
图标 emoji(🌙☀️) 内联 SVG
首页截图像素:色彩数 / 边缘密度 2206 / 10.3% 1174 / 5.0%

共同短板(新旧都没解决)

  • --tb-text-muted #8a919c 在 #fff 上只有 3.18:1(AA 需 4.5);旧版 #999 更低(2.85)。 建议改 #6b7280(4.83:1);暗色 muted #8b93a1 在 #242830 上 4.77 已达标。
  • 顶栏导航链接(UIkit rgba(0,0,0,.5))2.68–2.85:1,同样是低对比。
  • 移动端点按区:两版都有大量 19px 高的纯文字链接(不满足 44px 建议值)。

新版自身的毛病(我写的,待修)

  • templates/home.html 字号用了 0.75/0.78/0.86/0.9/0.95/0.98 rem → 7 个肉眼难辨的档位; static/css/base.css 里还有 0.78/0.8/0.82/0.84/0.88/0.95。排版层级比旧版更碎,应收敛到 4 档。
  • 残留圆角 5px(header 验证码框、sponsor)、9px(profile-tabs,list.html/explore.html 各一处)。

按上轮结论改代码(字号收敛 / 对比度 / 圆角收敛)

先确认过:本地 static/css/base.css 与 dev 部署版 diff 0 行,本地就是新版基线。

  1. 新增字号阶梯令牌(base.css :root):--tb-fs-xs/md/lg/xl/hero = 12/14/16/24/28px。 lg 取 1rem 而非 17px —— UIkit 的 .uk-card-title/h4 就是 16px,顺着它才能让自定义标题 与组件标题同档,否则会留下 16 与 17 差 1px 的两档并存(第一版就是这么写的,实测发现后改回)。
  2. 替换范围:home.html 全部 7 档 → 5 档;explore / question-list / question-list(list.html) 三页 的 0.7/0.72/0.82/0.84/0.85/0.9/0.98/1/1.15/1.2 全换令牌。实测 explore 由 9 档 → 5 档。
  3. --tb-text-muted #8a919c → #6b7280(#fff 上 4.83:1)。关键坑: UIkit 自带 .uk-text-muted{color:#999!important},我原先的普通声明从未生效,全站仍是 #999(2.85:1)。 必须 !important 才能压过。改完 box 页 fails 15→11、explore 14→9。
  4. 顶栏导航:UIkit 默认 rgba(0,0,0,.5) = 2.68:1 → 改 var(--tb-text-secondary) = 6.05:1, hover/active 走主色;暗色分支原有 !important 规则自然接管(实测 10.75:1)。
  5. 圆角全量收敛:9px/5px/4px/2px/7px/3px 以及等值的 6/8/10/12/14px 全部换成 var(--tb-radius[-sm|-xs])。全站(排除邮件模板)现在只剩 3 处 999px(胶囊,语义正确)。
  6. conf.StaticVersion 20261020g → 20261021a(base.css 改了必须 +1)。

验证:go vet / go test(templates+i18n+conf)/ go build 全绿;预览页内联本地 base.css 后用无头 Chrome 量计算样式,首页 12/14/16/24/28、explore 12/14/16/24/32,nav 6.05:1。

遗留(未改,属设计取舍):主色链接 #1e87f0 在纯白上 3.37–3.42:1(<4.5)。改深会影响品牌观感, 需用户决定;Waline 第三方赞数绿色 2.87 不在本站控制内。

注意:工作区有 4 个非本次改动的删除(static/css/themes/test01.css、static/images/shiroko-cutout.png、 static/images/themes/test01/*.webp),是之前就存在的未提交删除,提交时勿一并 add。

17:4x 用户前端设计令牌化 → 主题/生成器的连带影响

用户改了 12 个模板 + base.css(未提交):--tb-text-muted #8a919c→#6b7280(AA 4.83:1)、 新增 --tb-fs-xs/md/lg/xl/hero 阶梯、圆角改走 --tb-radius(-sm/-xs)、顶栏链接改 --tb-text-secondary、 .uk-text-muted 加 !important(压 UIkit 的 #999!important)。

关键结论:主题会把这套修复盖回原样。 主题 css 覆盖同名令牌 → 页面用主题的值,不是 base.css 的。 实测(worse of bg / 压平后的 surface):

  • default.css 不定义文字色 → 跟随 base ✓;sakura muted #a08c94 2.80:1、shiroko #8794a7 2.76:1(都看不见)。
  • 已调到 AA 下限:sakura #78696e(4.61)、shiroko #656e7a(4.63),同色相只压深;深色块两个主题本来就 ≥4.7,不动。
  • 主色仍不达标(未改,等用户定):base/shiroko #4a7fc1 3.69、base #1e87f0 3.26、sakura #e5678f 2.80。 候选:#3d6fac(4.63)、#0f6ac6(4.83)、#b53d61(4.91)。

生成器的必修项:paletteVars 原来 text-muted = mix(text,bg,0.55) 硬算 → 浅底掉到 2.9:1, 等于每导一次主题就把 base.css 的修复覆盖掉。改成 mixToContrast(text, [bg, flatten(surface,bg)], 4.5): 二分找「最淡但达标」的比例;文字本身不达标时原样返回(宁可不够淡,不能看不见)。

  • 半透明卡片(rgba(255,255,255,0.86))必须压平到底色再算,否则按纯白算会偏低。
  • 次要 5.5 / 弱化 4.5 两档,保证层级不倒挂。
  • 配色面板加读数 正文 12.5:1 · 次要 5.5:1 · 淡文字 4.6:1 · 主色 4.6:1,<4.5 变橙(手填主色只提醒不拦)。
  • 默认 primary 由 #4a7fc1(3.69) 压到 #3d6fac(4.63) —— 默认值不该一上手就踩线。
  • 新增 ?dump=palette(四种配色:默认亮/默认深/低对比手填/白卡彩底),ok:true。

顺手补的一致性:深色块顶栏原来 !important 定成 --tb-text(正文色),亮色块新的 secondary 规则 让同一顶栏在深浅两模式权重跳一档 → 加 html[data-theme="dark"] .uk-navbar-nav > li > a { secondary !important }。 实测计算值:无主题 nav #5c6370 / muted #6b7280;shiroko #51607a / #656e7a;sakura #6b545c / #78696e。

⚠️ 用户删了 test01(css + 2 张图)与 shiroko-cutout.png,未提交,我没动。 若测试站 [theme] available 还登记着 test01,启动日志会打「主题文件不存在,已从可用列表忽略」,设置页不再出现,箱主已选 test01 的会静默回退站点默认。 shiroko-cutout.png 只在 docs/theme-shiroko.md 里作参考图引用,不参与 embed。

令牌化的残留(只提醒,没改):templates/user/profile.html 还有 16 处 font-size: 0.XXrem; base.css 自身 11 处(0.78/0.82/0.88/0.95/1.1 —— 正是被点名要消掉的那档);圆角只剩 50%/999px(语义正确)。 --tb-fs-* 在 base.css 里只用了 3 处(主题格、chip),阶梯主要落在模板层。 StaticVersion 20261021a → 20261021b(主题 css 内容变了,必须换键)。

18:2x 提交 test01 删除

2d13c31 删掉 static/css/themes/test01.css + static/images/themes/test01/ 两张 webp(用户 18:19 要求)。 go test ./internal/theme 仍全绿(测试里的 "test01" 只作 DisplayName 回退样例,不依赖文件)。 ⚠️ static/images/shiroko-cutout.png 的删除仍未提交(属另一件事,docs/theme-shiroko.md 还引用它)。

18:2x 删除 shiroko-cutout.png + 清文档引用

用户确认一并删。docs/theme-shiroko.md 三处引用改成「参考图不入库」(不再指向具体文件路径); 顺手修了同页一处过时说法:「--tb-deco 保持 6 个值」→ 10 个(槽位早就扩到 10,漏改会让人以为写 6 个就够)。 删除后 embed 无影响(static/fs.go 只嵌 images/themes,这个文件本来就不参与打包)。

18:2x 提交模板令牌化(用户改动,一次性提交)

001306d:11 个模板的硬编码字号/圆角换成 --tb-fs-* / --tb-radius*,工作区已干净。 TEST_PREVIEW=1 go test ./templates ./internal/i18n 全绿(顺带刷新了生成器用的快照)。 提交信息里记了 4 处故意改变观感的替换,免得以后被当成 regression 查: explore .explore-name 1.2rem→1rem、.hot-vote-title 0.98→0.875、首页 .tb-stat-num 手机 1.25→1rem、 .hot-vote-action 0.82→0.875(变大)。头像 50% / 胶囊 999px 有意保留。 下一轮若要彻底统一:templates/user/profile.html 16 处 + base.css 自身 11 处;mail/* 不要动(邮件客户端不保证 var())。

18:3x 收尾:字号全令牌化 + 主色补到 AA(新增 --tb-on-primary)

字号:templates/user/profile.html 20 处、base.css 9 处全部换成 --tb-fs-*。 base.css 里那 4 档导航字号(0.95/0.88/0.82/0.78)合并成「桌面 md → ≤1000 xs」,logo 三档统一 lg。 保留未改(有理由):.tb-pin-flag / .md-body code 的 0.9em(相对父级,换成 rem 会破坏继承)、 验证码的 1.4rem/1.2rem(等宽字形尺寸,不是正文阶梯)、chat 关闭按钮 40px、password.html 的 3em、mail/*(邮件客户端不保证 var())。 行内 12px / 0.75rem 这几处恰好等值,顺手换了;chat 里还剩一个 13px(13→12 会缩,没动)。

主色(同色相压亮度,目标 4.7 而非 4.5,留余量):base/screw #1e87f0(3.26~3.42) → #0e6fd0(4.70), shiroko #4a7fc1(3.69) → #3b6dac(4.74),sakura #e5678f(2.80) → #ba3e66(4.70,饱和度压到 .5 保持柔和粉)。 --tb-primary-weak 与 shiroko 的两条 box-shadow 一并换 RGB;TestSwatch 的断言改成 #ba3e66。

⚠️ 主色有两个互相矛盾的角色:当链接色要「深」,当按钮底色配白字却要「更深的底」—— 但深色模式的主色是亮强调(#5c8dff/#ff8ab0/#6fa3e0),白字只有 2.2~3.1:1。 所以新增 --tb-on-primary(亮 #ffffff / 暗 #0b1016),.uk-button-primary 与 .uk-label:not(danger|warning|success|secondary) 都吃它 → 深色模式按钮是「浅底深字」。实测 6.10/8.65/7.27。

其它泄漏点:header 验证码 focus 的 #1e87f0/rgba(30,135,240) → 令牌;profile .feat-day.is-on 写死的 rgba(30,135,240,.14) → var(--tb-primary-weak);explore 随机投票的 JS outline、vote-results 的行内色 → 令牌。

深色 muted 之前算漏了:只对 --tb-bg 算(4.7~4.8 以为过了),对压平后的卡片才是真实背景 (sakura 4.31 / shiroko 4.17)。已提亮到 #a18790 / #7f8ea1(4.62),base 深色 4.77 本来就过。

生成器:paletteVars 增加 --tb-on-primary(白字/深字里挑更优);?dump=palette 增加 onPrimaryCr / onPrimaryPickedBest / warnings —— 主色卡在中间调时不断言失败(那是主色要调, 不是挑错),列进 warnings;配色面板给橙色提示「压深配白字 / 提亮配深字」。 _scaffold.css 令牌数 29 → 30。StaticVersion → 20261021c。 最终审计 8 种组合(base 亮暗 + 三主题亮暗)全部 ≥4.5。⚠️ 写 CSS 用 python 改时注意:git checkout 会把 base.css 还原成 CRLF,插入行要用同一行尾,否则变 MIXED。

18:5x 检查测试环境部署 → 抓到设置页色块 ZgotmplZ

部署核对(都对):base.css?v=20261021c、三份 CSS(base/sakura/shiroko)与本地逐字节一致; 设置页主题列表 = 跟随站点默认 / 基础外观 / 樱花 / 砂狼白子,test01 已消失、名称无 theme.name.* key; 登录按钮是新蓝(#0e6fd0)白字,箱主页排版正常。 ⚠️ admin6 的主题被重置成「跟随站点默认」(它原来选的是已删除的 test01)→ 箱主页 <html> 没有 data-theme-style,现在是基础外观。要验证主题得重新选 sakura/shiroko。

P1 缺陷(既有,非本次改动引入):设置页四个主题色块全是 style="background: ZgotmplZ"。 html/template 会对 style 属性做 CSS 过滤,未标记安全的 linear-gradient(...) 一律替换成 ZgotmplZ。 swatchOf 一直在生成渐变串,所以这个色块从来没显示过。 修法:FuncMap 加 safeCSS → template.CSS,模板 {{ safeCSS .Swatch }}(值只来自 swatchOf,不接受用户输入)。 加了 TestThemeSwatchRenders(渲染 user/profile,断言不含 ZgotmplZ 且含渐变串),并做变异验证: 把 safeCSS 去掉 → 测试如期 FAIL。 ⚠️ 教训:任何 style="..." 里塞模板值都要过一遍这个过滤器;模板名在测试里不带 .html(user/profile)。

19:0x 复检部署:色块修复已上线 + 主题链路验证

1f29d3e 部署后复检,全部通过:

  • 设置页 HTML 里 ZgotmplZ 计数 0,四个 tb-theme-swatch 都是真渐变 (跟随默认灰斜块 / #f7f8fa→#0e6fd0 / #fceef3→#ba3e66 / #eef3f9→#3b6dac),截图确认有色块。
  • 主题链路:把 admin6 的 theme 设为 shiroko(POST /user/profile/theme,该表单只有 _csrf+theme 两个字段,单独提交安全),随后 /_/admin6 → data-page-scope="box"、/_/admin6/95 → question、 /user/questions → admin、/user/profile → profile,四处都带 data-theme-style="shiroko" 与 themes/shiroko.css?v=20261021c。桌面渲染正常(人物站地面、车与人同向、光环完整、卡片磨砂)。
  • ⚠️ admin6 现在选着 shiroko(为验证链路设的),要还原就把主题改回「跟随站点默认」。
  • ⚠️ --window-size=390,844 得到的不是 390 CSS 视口:同一参数截本地快照也会「右侧被切」, 真实排版没问题 —— 移动端必须用宽度锁定的 iframe(生成器那套)验,别据 window-size 结论判 bug。 shiroko 的窄屏块(≤900px)只有一条 html[data-theme-style](0,1,1),但它没有任何 [data-page-scope] 块,所以不会被压住;深色块的 --tb-page-image 覆盖窄屏属预期(深色不用实景照)。

19:2x shiroko 单车/步枪「白色没抠」→ 白上白,只能全白转透明

根因不是抠图参数:素材是「白底 + 白车架」。采样确认车架涂装与背景白是同一个值 (都在 #f9f9f9,差 0~3 级),颜色上完全分不开;四角 floodfill 只吃掉外圈, 轮圈内、车架三角、车筐、枪与车架之间的白整块留着 → 看着像没抠。 量化:修复前不透明像素里近白(>240) 占 50%,全透明只有 43%。

试过三种程序化方案(都写了脚本):

  1. 只按「近白厚度」区分白洞/白涂装(腐蚀测厚度)→ 失败:轮辐间的扇形白楔宽 913px, 与车架管 510px 重叠,阈值怎么调都会留下白色楔子(更难看)。
  2. 全白转透明(min(r,g,b)≥238 软过渡 + 饱和度>14 保留彩色)→ 采用。成果: 不透明率 19.4%、近白 0.0%、29KB;轮辐/车架/车筐全成描边,粉色轮圈 + 蓝枪托 + 阴影仍在。
  3. 手绘 SVG 替换:未做(不如现成素材真实)。

代价要记住:车身填色一起没了,成品是线稿风,在 1512 整页里偏淡(远看像铅笔稿)。 若用户要「实心车」,唯一正解是重出一张绿幕/透明背景 + 车身用浅灰蓝等中间调的素材 (白车在浅场景里本来就发飘),已写进 docs/theme-shiroko-assets-spec.md 的「白上白」小节, 验收清单也加了「不透明像素近白占比应≈0」这条。

改动:props/bike.webp(397×259 画布不变,位置公式不用动)、css 里图片 ?v=20261020d→20261021a、 StaticVersion 20261021c→20261021d、两份 docs 同步。go test ./internal/theme 全绿。 ⚠️ 画布尺寸不能裁:裁完 auto 259px 的显示宽度变了,left calc(50% - 612px) 的锚点会跟着漂。

19:4x 两件事:/user/questions「卡片透明」根因 + 主题素材上 R2

1) /user/questions 卡片透明 —— 不是主题,是模板没有卡片 templates/user/question-list.html 整个文件只有 1 处 .uk-card(投票 tab 的「我的投票」标题条); 提问列表是 <div class="uk-margin-medium-bottom"> + <hr> 裸铺在页面上。 默认主题底色 #fff 看不出来,换带整页插画的主题后场景整片透上来 = 「卡片透明」。 修:.qlist-panel 加卡片底(--tb-surface + --tb-radius + --tb-shadow + padding, ≤640px 收紧),内部那张 .uk-card-default 降成 --tb-surface-2 去阴影(防卡片套卡片)。 验证:本地快照注入 shiroko 后,行内容落在卡上,场景只从卡外透出;默认外观只是多一层白卡。 ⚠️ 页面级样式仍写在模板 <style> 里(不搬 base.css),符合既有约定。

2) 主题素材放 R2 —— 三种做法,生成器已支持 A 素材只有一处引用:主题 css 的 url()(分享卡是 Go 用 imaging 画的,不读素材)。

  • A 生成器「素材前缀」:导出面板新增输入(S.assetBase,存进工程 json,?assetbase= 可测), urlOf() 一处决定地址 → 绝对 URL。踩到过坑:layerCSS 里复制了一份路径拼接, 只改 urlOf 会导致「背景走 CDN、装饰还指本地」,已统一到 urlOf。
  • B 应用内 301:[theme] asset_base + 中间件(必须注册在 flamego.Static 之前)。
  • C 边缘/反代改写(CF Origin Rule / nginx)。 细节与取舍写进新文档 docs/theme-assets-cdn.md(含缓存、CORS、深色无素材、迁移 sed 命令)。 ⚠️ UI 坑:面板 240px 里 4 个中文字的 label 会被 flex 压缩、末字被输入框切掉 → label 要 flex:0 0 auto; white-space:nowrap,宽度给 84px 才够(68px 仍会切)。