Files
tamabox/.workbuddy/memory/2026-10-21.md
T
tamakyi db4b3fa095 主题体系跟上设计令牌化:派生文字色按对比度下限、主题 muted 补到 AA
生成器(dev/theme-builder):
- paletteVars 的 text-secondary/text-muted 从「往底色混 36%/55%」改成 mixToContrast(5.5/4.5):二分找最淡且达标的位置,文字本身不达标就原样返回
- 半透明卡片先 flatten 压平到底色再算对比度;参考背景取「页面底色 + 压平后的卡片」中更不利的
- 配色面板加读数(正文/次要/淡文字/主色),低于 4.5 变橙;默认 primary #4a7fc1(3.69) → #3d6fac(4.63)
- 新增 ?dump=palette 四种配色回归;README 补配色一节

主题/base(含用户本轮 base.css 改动):
- sakura muted #a08c94(2.8:1) → #78696e(4.61)、shiroko #8794a7(2.76:1) → #656e7a(4.63):主题覆盖令牌,基座的 AA 修复到不了主题页
- 深色顶栏链接补 secondary 覆盖:亮色块已是次要文字色,深色块原来 !important 定成正文色,同一顶栏权重跳一档
- _scaffold.css 令牌清单 23 → 29(补 fs 阶梯与 page-position),muted 说明 AA 下限
- StaticVersion 20261021a → 20261021b
2026-10-21 17:42:43 +08:00

448 lines
36 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 内容变了,必须换键)。