Files
tamabox/.workbuddy/memory/2026-10-21.md
T
tamakyi 8daa5b0bec 生成器默认监听 0.0.0.0,局域网/手机可直接打开
- serve.py:--host 默认改 0.0.0.0;启动时列出所有网卡地址(0.0.0.0 不能直接点开);
  --host 127.0.0.1 仍是「只给本机」的收紧方式,README 说明公共网络用它。
- 改单线程为 ThreadingTCPServer:上传几 MB 的图会占住连接,单线程时 iframe 预览
  和其它请求都得排队。
- 写接口加同源校验(Origin 与 Host 比对,无 Origin 放行):挡住同网段里「另一个网页
  借访客浏览器发写请求」,curl / 脚本不受影响。
- index.html:提示文案不再写死 127.0.0.1,改用 location.host —— 用局域网地址打开时
  不该反过来提示「请用 127.0.0.1 打开」。
- mock_s3.py 加 --host(默认仍回环),便于在别的设备上联调。
2026-10-21 21:46:34 +08:00

618 lines
50 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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. 只按「近白厚度」区分白洞/白涂装(腐蚀测厚度)→ **失败**:轮辐间的扇形白楔宽 9~13px,
与车架管 5~10px 重叠,阈值怎么调都会留下白色楔子(更难看)。
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 仍会切)。
**3) 素材托管:本地 / 任意 S3 兼容,生成器内测通即可上传**
用户要求「放本地还是放 S3 兼容(不只 R2)由部署者自己定,并且能配好即测、测通即传」。
- `serve.py` 手写 SigV4(零依赖,PUT/GET/DELETE/HEAD),新增 `/api/s3/ping|test|upload`。
测试连接四步:HEAD 桶 → PUT 探针 → GET 读回比对 → 匿名 GET(能否被浏览器直读)→ 删探针。
4xx 把 `<Code>` 翻成人话(`S3_HINTS`),签名失败直接指向密钥/Region/时钟三件事。
- `index.html` 新增「素材托管」面板,上传集合复用导出用的 `usedAssetIds()`;
成功后自动回填「素材前缀」。密钥只进 sessionStorage,不进工程 json(有断言)。
- **两个真 bug**:①`usedAssetIds()` 返回 Set,`!ids.length` 恒真 → 永远「还没有素材」;
②字段填完 700ms 内刷新会丢(scheduleSave 防抖)→ 失焦/切模式/上传成功时立即 `saveDraft()`。
- **验证手段**:`check_sigv4.py` 用 aws-sdk-go-v2 的 signer 逐字节对拍(6/6)——
裸 SignHTTP 会二次转义 URI、而 S3 client 关了它,所以用例只用 ASCII,
中文 key 的编码规则跟随 S3 client(单次 RFC3986)。
`selftest_upload.py` 假 S3 跑 16 项;`mock_s3.py` 可手动点面板。
- ⚠️ **8777 端口陷阱**:Windows `SO_REUSEADDR` 让新进程 bind 成功却抢不到 accept,
症状是「代码改了但接口还是 404」。查 `Get-NetTCPConnection -LocalPort 8777`,或换端口验证。
**4) 远端素材检测与管理(用户「还没测,想先看到 S3 上有什么」)**
- serve.py:`s3_request` 支持 query,新增 `list_objects`(ListObjectsV2 + 分页)与
`delete_objects`;端点 `/api/s3/list`、`/api/s3/delete`。
- index.html:远端素材区,按对象键名跟本地比 → 一致 / 旧图(同名大小不同)/ 多余 / 缺失;
「补齐差异」只传缺的与旧图,「删除多余」批量删,单行 ↻ 覆盖、✕ 删除。
- ⚠️ **键的两种形态**:list 返回**完整键**(含 `<路径前缀>/`),upload/delete 接口收
**相对键**。不剥这一层,服务端会再拼一次前缀 → 删一个不存在的键,S3 还回 204,
表现为「显示删除成功但东西还在」。加 `relKey()` 统一转换。
- ⚠️ `check_sigv4.py` 补了带 query 的用例(ListObjectsV2 三参数、乱序传入的分页 token),
8/8 一致 —— canonical query 的排序与百分号编码也必须对拍,漏测会静默失效。
**5) 生成器默认监听 0.0.0.0(局域网/手机可用)**
- `serve.py --host` 默认由 127.0.0.1 改成 0.0.0.0;启动时用 `local_addresses()` 列出所有
网卡 IP(getaddrinfo + UDP connect 双路兜底),0.0.0.0 本身不打印成可点地址。
- 顺带修两个真问题:①`TCPServer` 是单线程 —— 传一张几 MB 的图会卡住 iframe 预览与其它请求,
换 `ThreadingTCPServer`;②写接口加 `_same_origin()`(Origin 与 Host 比对,无 Origin 放行),
挡住「同网段另一个网页借访客浏览器发写请求」。curl/脚本不受影响。
- `index.html` 提示文案里写死的 `127.0.0.1:8777` 改成 `HERE`(`location.host`)——
用局域网 IP 打开时不能反过来告诉用户「请用 127.0.0.1 打开」。
- ⚠️ **浏览器按 origin 隔离存储**:`127.0.0.1` 与 `192.168.x.x` 是两个站点,
localStorage 草稿与 sessionStorage 里的 S3 密钥不共享,换地址要重填一次(README 已写)。
- `mock_s3.py` 加 `--host`(默认仍 127.0.0.1):生成器在别的设备上打开时,假 S3 也要 0.0.0.0。
- 验证:局域网 IP 访问 200、`--host 127.0.0.1` 时局域网访问连不上、跨站 Origin 被拒(403)、
局域网 origin 下检测/补齐全走通。⚠️ 本机 curl 访问 192.168.x.x 必须加 `--noproxy '*'`,
否则被系统代理接走报 502。