Files
Felis/AUDIT-2026-09-22.md
T

613 lines
123 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.
# Felis 生产就绪审计 — 2026-09-22(真机 E2E + 混沌)
分支:全部修复**逐 commit 直接推 `main`**(不再用功能分支)。remote = `[email protected]:FelisMC/Felis.git`(2026-09-23 起;旧 `MliroLirrorsIngenuity/Felis` 仅靠 GitHub 301 兜住)。环境:CentOS Stream 9 / aarch64 / k3s v1.36.4,
IPv6-only 接入(`ssh -6 -i ~/.ssh/id_ed25519 root@fdb2:2c26:f4e4:0:21c:42ff:fede:69ec`),
面板经 `socat TCP6:443 → 127.0.0.1:30443` 中继(手动启动,重启 VM 后需重开)。
## 已修复并验证(分支内)
| # | 缺陷 | 证据 | 修复 |
|---|------|------|------|
| 1 | **失败恢复后重试被静默吞掉**:restore Job 固定名 + `ErrAlreadyExists` 一律当"幂等成功";失败 Job 占名 10 分钟(TTL),期间重试返回 202 `restoring` 但什么都不跑 | 真机:坏 ref 制造失败 → 立刻合法重试 → Job 原地不动、无新 Pod | `k8sjobs.go`:撞名时检查已完成(成功/失败)→ 删除+**等 finalizer 释放**(有界 10s)+ 重建;进行中仍幂等吸收。单测 3 个。**真机复验:重试 4s 完成恢复** |
| 2 | **备份 Job 全部 FailedMount**:Job 在 minecraft 命名空间挂 `felis-config`,而 bootstrap 只在 felis 命名空间创建该 Secret | 事件:`MountVolume.SetUp failed: secret "felis-config" not found` | `felis setup` 用既有 `ensureSecretReplica` 把 felis-config(`felis.toml`) 复制到 minecraft;VM 上手工复制后备份成功(167MB 归档) |
| 3 | RBAC 缺 `jobs:get/delete`(修复 #1 需要) | Role 检查 | `APIMinecraftRole` jobs → create/get/delete,测试同步更新 |
| 4 | gofmt 9 文件漂移 + CI 无 gofmt 门禁;staticcheck 9 处 | 基线扫描 | 全部修复;CI 加 gofmt job |
| 5 | 依赖漏洞:pgx v5.7.1(GO-2026-5004,可达 pgrepo.go)、x/net、x/text | govulncheck | 升 pgx v5.9.2 / x/net v0.55.0 / x/text v0.39.0 |
| 16 | **`ConsumeLoginEmailOTP` PG 实现与接口契约漂移**:契约/`fakeRepo` 说「同 VerifyEmailOTP 生命周期(扣尝试/锁定/ErrOTPInvalid/ErrOTPLocked)」,PG 却是单条 UPDATE+`ErrNotFound` → 错码/重放/过期在 login 门、op-login finish、migration confirm 三个入口全部 500;且尝试次数永不累计、`otpMaxAttempts` 锁定失效 | 真机:login 门重放正确码 → **HTTP 500**;修复前错码不扣次。修复后复测:5 次错码 400 且 attempts=5(正确码因锁定也 400、码未消费)、新码可用、重放 400 | `pgrepo.go` 改置为 VerifyEmailOTP 同构事务(FOR UPDATE、先锁后比、mismatch 扣次、match 消费),无 users 写副作用。提交 `52549f7` |
| 17 | **`ListPendingOpLogins` PG 少列**:接口注释承诺「joined to its staff username」,handler 输出 `username`/`created_at`,fake 正确填充;PG SQL 未 JOIN 也未取 `created_at` → 真机 pending 列表 username 为空、created_at 为 `0001-01-01` | 真机 internal `/op-login/pending` 响应 | `pgrepo.go` SQL 改为 JOIN users + 取 created_at。提交见分支 |
| 18 | **绑定码并发兑换 500**:`RedeemPlayerBindCode` 无 `FOR UPDATE`(同文件 `VerifyLinkCode` 有),且裸 INSERT。6 路并发同码兑换 → 3×HTTP 500(`users_username_key` 唯一冲突)+ 1×400 + 2×200;跨码并发同 UUID 同样会撞 | 真机四组并发测试 + API 日志 `unmapped error ... duplicate key` | 同码:码行 `FOR UPDATE`(输家干净地 400 invalid_code);跨码:`INSERT users ... ON CONFLICT (username) DO NOTHING`+重读、`account_links ON CONFLICT (mc_uuid) DO NOTHING`(两路汇聚同一 user)。复测:同码×6=1×200+5×400、双码×2=2×200 同 ID、DB 干净、日志零 unmapped |
| 19 | **reaper CronJob 渲染位置错误,永远无法调度**:`felis manifests` 把 CronJob 渲染到控制 ns,却引用 minecraft ns 的备份 PVC(Pod 不能跨 ns 挂 PVC:真机 `FailedScheduling: persistentvolumeclaim "felis-backups" not found`);修正 ns 后又发现 ServerAccount 也不能跨 ns 使用(`serviceaccount "felis-reaper" not found`)。而 reaper 的 Role/RoleBinding 本就在 minecraft ns | 真机三层取证(PVC/SA/调度)| CronJob 与 SA、RoleBinding subject 全部移到 `MinecraftNamespace`(提交 `e4f2cff`+`c839454`)。**修复后完整演练通过**(见下) |
| 6 | **默认安装无备份能力 + 回收链路不可达/不可用**:(a) 没有任何环节渲染归档 PVC,`FELIS_BACKUP_PVC` 永远为空 → backup/restore 恒 503;(b) bootstrap 从不下传 reaper 三旗标 → 官方安装路径根本无法启用回收;(c) 即便启用,stock k3s 的 `<root>/<pvc>` 布局不存在,且 k3s storage root 是 `0700 root:root`、reaper pod 以 uid 1000 运行 → 真机 `lstat /worlds/…: permission denied`(fail-closed 跳过,但纯空转);(d) README 口径(“定时备份”)不实 | 真机全链路:默认渲染 PVC+env → 建服→marker→备份 202→jobs 端点 running→succeeded→改 marker→restore→读回原值 ✅;再建 resolvecheck 世界(20d idle,marker)→ CronJob 手动 Job:`evaluated=2 reaped=1`,归档含 marker、PVC+宿主目录回收、`world_backups` 得 `inactive_15d` 行、servers 行/CR 保留 ✅ | `platform/workloads.go` 渲染归档 PVC(minecraft ns、RWO 10Gi、默认 SC),`felis manifests --backup-pvc` 默认 `felis-backups`(`=` 空为显式关闭),bootstrap 统一下传 env/旗标并写 [archive] local_path;`resolveWorldDir` 新增精确 local-path 目录解析(读 PVC `spec.volumeName`,非 glob,杜绝陈旧 PV 目录顶替);ReaperRole 增 `pvc:get`;bootstrap 设 `FELIS_WORLDS_HOST_PATH` 时给 uid 1000 授 traverse(setfacl/o+x);README/故障手册改写。提交 `fd33fd0`+`2b87a5a` |
| 8 | **磁盘打满灾难链**:DiskPressure → kubelet 驱逐控制面(无 PriorityClass 保护)→ 镜像被 GC(无外网、registry 空)→ 全部 ImagePullBackOff;释放后数分钟才恢复调度。恢复依赖人工 `docker save | k3s ctr images import -` | 三轮真机 drill(原缺陷复现 + 两轮修复验证):①自定义 1e6 类:游戏 pod 先走、控制面随后仍被驱逐(kubelet 日志逐条列出 ranked/evicted),随后镜像被 GC → ErrImagePull;②内建 `system-cluster-critical`(2e9):填盘至 1.7G free,kubelet 对 felis-api/operator/registry 全部报 **“cannot evict a critical pod”**,三者在整个 DiskPressure 期间保持 Running;游戏 pod(0)被驱逐;③释放磁盘后:`DiskPressure` 经 ~5 分钟(`eviction-pressure-transition-period`)转 False——即“恢复慢”的主因;被 GC 的游戏镜像按 runbook 重导入后 25s 恢复 ✅ | 控制面四类 pod 挂内建 `system-cluster-critical`(用户自定义类值上限 1e9,达不到 2e9 临界阈值;preemption 保留=管理面可调度,已记录权衡);troubleshooting §13b 固化事件链、5 分钟条件过渡与镜像恢复路径。提交见下 |
| 9 | **升级策略 Recreate**:单副本 + Recreate:任何控制面升级=停机;坏升级(错 tag)中断约 95s 且需人工 `rollout undo`(无自动回滚) | 复核部署模板(Recreate + 单副本 + 无 leader election 的注释理由成立) | 不改策略(Recreate 是对无 leader election 的正确取舍),改为固化 runbook:troubleshooting §15 = 升级即重跑安装器;停机窗口=rollout 时长;坏镜像在安装器 180s 等待内以 `kubectl describe` 诊断呈现;回滚 `kubectl rollout undo`(镜像被 GC 时先按 §13b 重导入)。提交见下 |
| 10 | **备份语义**:归档包含整个 /data(jar、libraries、cache),167MB;是否符合 world backup 定位待评估 | 真机 tar 清单(cache/、config/、eula.txt、server.properties、jar)+ 恢复语义(overlay+prune) | 评估结论:**整卷备份是正确语义**(回滚=整服状态回滚,含配置/插件),保留行为;文档化:troubleshooting §10「backup contains」+ OpenAPI/README 口径改为“整服数据卷”。提交见下 |
| 20 | **验证邮箱唯一性只存在于注释里**:errors.go/repo.go 都声称迁移 0010 建了 `users_verified_email_unique` 部分唯一索引、`VerifyEmailOTP` 会返回 `ErrEmailTaken`;实际上**索引从未创建**、`ErrEmailTaken` 全仓库从未被返回 → 两个账号可同时验证一个邮箱,而登录门正是按 verified email 解析账号 → 同一邮箱的登录码归谁由数据库任意决定 | pgint 首跑即红:第二账号验证同邮箱返回 `nil`;`grep users_verified_email_unique internal/store/migrations/*.sql` 零命中。真机复验(auditfix17 邮箱 409 演练):同码二次 verify 仍 409(码未被消费) | 新增迁移 `0020_verified_email_unique.sql`(`lower(email) WHERE email_verified`);`VerifyEmailOTP` 在消费码**前**查「他人已验证」→ `ErrEmailTaken`(不消费码、不扣次数),并把并发唯一冲突映射为同一答案;handler 新增 409 `email_taken`。提交 `b6ef27c`;PG 契约测试基建 `2a55a0d`(`-tags pgint`,见 CONTRIBUTING) |
| 21 | **admin 改邮箱不清 verified**:`UpdateUser` 写入新地址但保留 `email_verified=true` → 面板改错一个字符就能让登录码寄到别人邮箱(该 flag 正是邮箱登录解析/寄码的依据);fake 同错 | pgint 契约测试 | 改地址时同写 `email_verified = email_verified AND email IS NOT DISTINCT FROM 新值`(同值 no-op 保留证明;换值即清除);fake 同步。提交 `d1ec40f`。真机验证:PATCH→`f`、PATCH 回原值仍 `f`、玩家重验证→`t` |
| 22 | **`role='owner'` 是死信**:迁移 0011 加了 owner 角色并把全部用户管理路由压在 `IsOwner()` 上,但**没有任何代码写过 `owner`**——breakGlass(`UpsertOwner`)、setup MC 绑定(`CompleteOwnerSetup`)、重置路径一律写 `admin` → 全新安装的整个 owner 层(用户列表/创建/编辑/禁用/删除/配额/会话)不可达;且新角色没进各处 staff 判定(op-login 只认 admin、玩家邮箱门只拒 admin、reclaim 保护与 AdminExists 只认 admin);面板一旦有 owner 行还可被降级/删除/禁用 | 真机:owner 会话加载 `/api/v1/users` 403;promote 后 200。op-login:修复前 owner start 中立不发码,修复后铸请求+finish 得 `role=owner` 会话 | `UpsertOwner`/`CompleteOwnerSetup` 改写 `role='owner'`(冲突臂重断言,即 0011 文档的 promote 路径);op-login 双端改 `staffRole`;玩家邮箱门改 `role != 'user'`;`IsProtectedAdminLink`/`AdminExists` 计入 owner;面板新增守卫:owner 行不可降级/删除/禁用(用户名/邮箱编辑仍可)。提交 `e0d2378` |
| 23 | **配额门非原子 + storage 缓存被清零**:(a) audit #4:`QuotaCheck` 与 `ClaimServer` 两条语句,同一用户并发认领两台无主服可双双通过 `max_servers`(deferred-seams 曾把这条挂为“只能在真 PG 上关闭”);(b) 更隐蔽:PATCH 资源时 `UpdateServerResources(..., 0)` 把本不能改的 storage 缓存写 0,而缓存列是配额聚合的**唯一**输入 → 此后该服的 storage 维度在配额里凭空消失 | (a) 新增 pgint 并发测试:修复前两台全赢;修复后恰 1 赢 + 1 `ErrQuotaExceeded`,DB 只 1 行 owned;(b) hermetic 测试 `TestPatchServerPreservesStorageCache`(修复前 `resourceUpdates` 里 storage=0) | (a) 门槛进 `ClaimServer`:同一事务内 `pg_advisory_xact_lock(hashtext(user_id))` + 四维重查(与 `QuotaCheck` 共用 `quotaAllows` 防漂移)+ 行 `FOR UPDATE`,两个 claim handler 把 `ErrQuotaExceeded` 映射为与串行一致的 403;(b) resize 前读取现值并透传 storage。提交 `bb68fef`;deferred-seams 对应条目核销 |
| 24 | **reaper 警告信从不真正投递**:`felis reaper` 从不装配任何 Warner(`r.Warner` 恒 nil),而 `maybeWarn` 对 nil warner / 投递失败一律照样 `MarkWarned` + `warned++` → 每位有主的服都在**无人收到提醒**的情况下 15 天后被静默回收,跑批日志还谎报"已警告 N 台"。红线⑤的"best-effort 不阻塞回收"被误读成了"失败也要记成已通知" | hermetic 单测 3 例(坏 notifier / nil warner → 不盖章、成功才盖章)。真机 drill(auditfix20):13d idle 实收 1 封(收件人=owner 的验证邮箱、subject 含 3d);重跑不重发;14.5d 补发 1d(elif 档位);SMTP 端口打坏 → 日志 `warn delivery failed; will retry next run` 且**不盖章**,恢复后补发;邮箱未验证 → `has no verified email` 不盖章;owner NULL → 静默跳过;边界 11d23h 不发 / 12d1m 发;全程 `world_backups` 保持 4 条不动(纯警告零备份副作用) | `mail.SendNotice` + `mailWarner`:查 owner 的 verified email → `SendNotice`;nil/失败不盖章、下次重试;reaper pod 模板加 optional `FELIS_SMTP_PASSWORD` env;`felis setup` 复制 felis-smtp 镜像并在「configure email」刷新 minecraft ns 的 felis-smtp+felis-config 镜像;docs/troubleshooting §10 更新。提交 `8e7c7bb` |
| 25 | **idle auto-stop 从未触发(三层复合缺陷)**:条件齐备的空载服永远不停。① CRD status schema 未声明 `emptySince`,apiserver **pruning** 掉计时戳(`unknown field "status.emptySince"`),计时器每次读回都是 nil;② 即使戳幸存,Running 空载稳态**没有任何 watch 事件**(玩家进出不碰 CRD、RCON 只在 Reconcile 内探),盖章一次后 reconcile 链停摆,无人叫醒;③ auto-stop 用整对象 `Update` 写 spec 且 OperatorRole 从未有 `minecraftservers:patch/update` → 403 `cannot update resource`。三层任一都让功能永久失效,而单测(fake client 不剪 schema、手动驱动、无 RBAC)全部覆盖不到 | 真机逐层实锤:schema 修复后 `empty=2026-09-22T20:02:13Z` 首次成功持久化;静置 88s+ 无动作、operator 日志 90s 零行(②实锤);修复前日志 5 条 403(③实锤);全修后场景 1:超时戳触发即 Stopped;场景 2:起服 → 20:11:43 盖章 → **全程无干预** → 20:12:17 自动 Stopped;翻转 Running→Stopped→Running 收敛 Running;`idle=null` 后不再自停 | ① schema 补 `emptySince`(`c04a3f0`);② `reconcileRunning` 尾部返回 RequeueAfter——空载=到点精确唤醒、有人=30s 探针周期,+3 个单测(`1c89a5e`);③ auto-stop 改 merge-patch(防 status clobber,与 reaper 的 Stop 同型)+ OperatorRole 补 `patch` + rbac 测试锚点(`f650bf8`)。真机 auditfix22 全通;troubleshooting §11 增自检三连 |
| 26 | **RCON Secret 被删 → 服务器永久锁死**:删掉 per-server RCON 密码 Secret 后,(a) operator 没有任何 watch 能看到删除(Running 稳态零事件),secret 一直不重建;(b) 一旦被任意事件带到 reconcile:新密码铸造成功,但运行中的 pod 仍持有旧密码、sts 模板无变化 → pod 永不重启 → 探针用新密码连旧密码 pod,**永久 `RconNotReachable`** 直至 300s `ReadinessTimeout`;恢复只有人工删 pod。`ensureRconSecret` 的注释却声称"heals on the next pass" | 真机:删 secret 后静置 60s 无重建、无日志;annotate 触发后 96s+ 持续 RconNotReachable(新密码 `91dd62…` vs pod 旧密码);删 pod 后 17s 恢复 Running——根因=密码漂移实锤 | ① `SetupWithManager` 增 `Owns(&corev1.Secret{})`(controller-owned,删除事件映射回 CR)——真机日志见 `source="kind source: *v1.Secret"`;② pod template 新增 `RconSecretAnnotation` = 当前密码 SHA-256 指纹(64-bit):secret 重建指纹变 → sts 自动 rollout 用上新密码,未重建则恒定不抖动;测试 2 例(跨 reconcile 稳定 / 重建必变)。提交 `56f3abd`。真机复测:删 secret 后**同秒**察觉+重建+换戳,31s 全自动恢复 Running |
| 27 | **陈旧启动锚点 → 已恢复的服务器被误判 StartupTimeout + Provisioned 永久 False**:`status.startRequestedAt` 只在 Stop 时清,成功(markRunningReady)不清——一旦服务器曾经历一次长 Starting,其 300s 预算就悬在健康运行中;此后任意一次 pod 波动(rollout/崩溃)都会立即套用旧戳判 `Failed(StartupTimeout)`。且 `markFailed` 写入的 `ConditionProvisioned=False` 无人复位,恢复后仍永久挂着失败标记 | 真机:20:19 已恢复 Running 的 test-one,20:21 因一次 stamp 注入引发的 pod rollout 被标记 `Failed StartupTimeout`(锚点残留自 20:16);恢复后 status.conditions 里 `Provisioned=False … StartupTimeout` 持续存在 | `markRunningReady`:清 `StartRequestedAt`(每次启动/恢复尝试各有独立预算)+ 复位 `ConditionProvisioned=True`;测试 2 例(Ready 清锚点、Failed→Running 后 Provisioned 恢复)。提交 `82b5a60`。真机复测:清戳生效(`startRequestedAt: None`)、pod blip 删→33s 恢复全程无 Failed、锚点重新盖章后成功清除 |
| 28 | **cfsetup 把 "policy already exists" 当成功吞掉 → fail-closed 保证可被旧策略顶替**:Cloudflare Access 的 `CreateAccessPolicy` 在收到 already-exists 时直接返回 nil;若该 app 上已有一条更宽松的旧策略(改 identity 后重跑、或此前手工配置),守卫 op.console 的仍是旧策略,而 Setup 报告成功——`validateFailClosed` 只校验过"我们构建的策略",从未校验证留在线上的那条 | 代码审查(集成侧无真实 CF 账号,无法真机):httptest 3 例复刻——已存在同名策略、缺席、POST 竞态。修复前第 1 例吞错返回 nil 且不发 PUT | 改为按名 upsert:lookup → PUT 覆盖守卫体 → 缺席才 POST(POST 撞 already-exists → 重查后 PUT,绝不吞)。`apiPost/apiPut` 共用一个 `apiWrite`。提交 `30857df` |
| 29 | **"configure email" 的工作负载镜像复制从不生效**:`replicateSMTPToWorkloadNamespace` 把硬编码 `namespace: felis` 的 felis-smtp manifest 用 `-n minecraft apply` 发出——kubectl 拒绝 namespace 冲突(`the namespace from the provided object ... does not match`)→ 第一段直接 return err,**第二段 felis-config 的镜像复制根本不会执行**。于是任何"装好后再改 SMTP"的部署,reaper 的 pre-reap 警告永远拿不到新配置(这正是 8e7c7bb 添加该刷新要解决的事),且失败仅 warning 不中止 | 真机 kubectl 行为实验:`-n minecraft apply` 带 `namespace: felis` 的 manifest → `error: … does not match …`;修复后(manifest namespace=minecraft)→ `accepted-namespace=minecraft`(server dry-run);felis-config 渲染+apply 序列同为 accepted | `smtpSecretManifest(password, namespace)` 显式参数(控制面调用传 "felis",镜像传工作负载 ns);回归测试 `TestSMTPSecretManifestCarriesTargetNamespace`。提交 `ed722d5` |
## 待决策台账(未修)
前两日台账的 #7、#11–#15 已全部修复(本批核销,证据见下),不再挂在"未修"里:
| # | 主题 | 状态 |
|---|------|------|
| 7 | 异步失败不可感知 | ✅ 修复:`GET /api/v1/servers/{name}/jobs`(`ff7c57c`,含 RBAC `jobs:list` 与 OpenAPI);真机 drill 用它看到 running→succeeded |
| 11 | PG 断连报 401 | ✅ 修复:会话存储故障改为 503(`2a8f897`) |
| 12 | ready 门滞后 | ✅ 修复:operator 启动期内每 2s 重探 RCON(`a2df2f2`) |
| 13 | setup URL 截断 | ✅ 修复:TUI 折行(`abce381`) |
| 14 | controller-runtime 日志噪音 | ✅ 修复:SetLogger 接 slog(`a415246`) |
| 15 | reaper 启用未演练 | ✅ 已演练(见"第二日"节;CronJob 仍 `suspend=true` 防误删) |
## 可达性分级(#1–#60)——这些缺陷真实使用中到底谁能踩到
回应质疑"是不是全在测边界条件 / 只有内部 hook 才能触发":
- **hook 的边界**:演练中使用的内部手段(读服务端日志取 OTP 码、service token 打内部面代 approve、本地 SMTP sink、tmux 驱动 TUI)只是**测试仪表**——代替"真实收邮件 / 键盘敲击 / 操作台点击";触发路径本身是公开路径,真实用户做同一动作走同一段代码。真正**本环境不可复现**的只有 #28(无真实 CF 账号,仅 httptest 复刻),另有 #4/#5/#14 属工具/卫生级(无运行时触发面)。
- **分级口径**:① 日常=普通使用或默认安装下即会踩到;② 运维=升级/重启/故障恢复/磁盘满/删资源/闲置回收等真实运维动作会踩到;③ 窗口=需要并发或特定状态时序,真实但概率低;④ 工具=仅工具判定/审查;决策=非缺陷(评估/演练项)。
| # | 真实触发路径(一句话) | 分级 |
|---|------------------------|------|
| 1 | 恢复失败后 10 分钟内重试(必然的恢复动作)→ 202 但 Job 不动 | ② |
| 2 | 默认安装下第一次点「备份」→ Job FailedMount(felis-config 缺在 minecraft ns) | ① |
| 3 | #1 修复所需 RBAC(单独不产生用户可见行为) | ② |
| 4 | gofmt/staticcheck 漂移 + CI 门禁缺失 | ④ |
| 5 | 依赖漏洞(govulncheck 判定可达):需特定输入 | ④ |
| 6 | 默认安装即踩:备份/恢复恒 503、回收链无旗标不可启用 | ① |
| 7 | 任何异步操作(备份/构建)想查结果:无端点(功能缺口) | ① |
| 8 | 磁盘打满事故链(控制面被逐 → 镜像 GC → 全体 ImagePullBackOff) | ② |
| 9 | 非缺陷:升级策略评估 → runbook | 决策 |
| 10 | 非缺陷:备份语义评估 → 文档化 | 决策 |
| 11 | PG 掉线期间任何会话校验 → 401 误导 | ② |
| 12 | 每次起服:就绪感知滞后 | ① |
| 13 | 首次安装向导:长 URL 截断 | ① |
| 14 | operator 日志噪音(卫生项) | ④ |
| 15 | 流程项:reaper 启用补演练 | 决策 |
| 16 | 邮箱登录主路径:错码/重放 → 500、5 次锁定失效 | ① |
| 17 | 面板 op-login pending 列表字段缺失 | ① |
| 18 | 绑定码并发兑换(双击/双端即可)→ 部分 500 | ③ |
| 19 | 按文档启用回收 → CronJob 永远无法调度 | ② |
| 20 | 两账号验证同一邮箱 → 登录码归属不定(安全) | ③ |
| 21 | 面板改任一用户邮箱 → verified 未清、码寄旧地址 | ① |
| 22 | 全新安装 owner 层不可达(用户管理全路由) | ① |
| 23 | (a) 并发认领两服可双双过配额;(b) PATCH 任意服资源 → storage 缓存清零 | ①(a=③) |
| 24 | 有主服闲置 12–15d:警告信从不投递、静默回收 | ② |
| 25 | 启用空载停机后等超时:三层复合缺陷永不生效 | ② |
| 26 | 运维删/轮换 RCON Secret → 永久锁死至人工删 pod | ② |
| 27 | 曾长启动的服 + 任意 pod 波动 → 误判 StartupTimeout | ② |
| 28 | 重跑 cfsetup 且线上有旧策略 → fail-closed 被顶替;⚠无真实 CF 账号,仅 httptest | ④ |
| 29 | 装后改 SMTP(configure email)→ 复制链从未生效 | ② |
| 30 | 对不存在/刚被删的用户 id 操作 → 500 而非 404 | ③ |
| 31 | 真实 owner 打开 /admin/builds 被 mock 残留误判 | ① |
| 32 | 新浏览器首访 /admin/builds 播种两个假 404 | ①(轻) |
| 33 | 禁用/已删账号重走邮箱登录门 → 可复活(安全) | ① |
| 34 | 同族:死账号游戏内身份面仍存活 | ① |
| 35 | 任何真实服务器保存过一次后:所有备份/fileread 必败 | ① |
| 36 | 任何构建失败的提交者永远看不到结果 | ① |
| 37 | 管理端 fleet:login/lobby 行全是死操作 | ①(轻) |
| 38 | felis manifests 输出残留过期指导 | ②(文档) |
| 39 | 断玻璃 reset owner 用非在位名 → 铸第二 owner 永不可清 | ② |
| 40 | 断玻璃 add operator 撞名 → 裸 SQLSTATE | ② |
| 41 | 断玻璃 Sync 选系统服 → 裸内部错误 | ② |
| 42 | 对无世界盘服备份/恢复 → 202 后静默卡 30 分钟 | ② |
| 43 | #42 配套:409 一刀切文案 | ②(轻) |
| 44 | 重跑 `felis setup` 完成 s/c reconfigure → 状态框退回“Setup complete”(信息一致性) | ①(轻) |
| 45 | 评审看不到将被执行的 recipe(执行的是上传 blob 里的 Dockerfile)→ 盲批 | ① |
| 46 | 大层推送打死 registry(256Mi 模板被 OOM kill;实测 475MB 层)——用户构建/安装器入仓即触发 | ① |
| 47 | Mac 打包树构建:`._*.sql` 旁文件混入 embed → `felis migrate` 全挂 | ② |
| 48 | 安装器重跑:每镜像 start/stop docker 触发 systemd 限流 → 批量入仓中途断 | ② |
| 49 | 安装器重跑:`[registry]`/`[archive]` 运维配置静默回退(构建/S3/reaper 行为回默认) | ② |
| 50 | 配过 SMTP 的安装按文档重跑安装器 → 生成的注释块被 `[smtp]` carry 吞并并每次 +1(纯膨胀) | ② |
| 51 | 改配置/轮换 DB 凭据后:工作负载 ns 的 `felis-config` 副本永不刷新 → backup/reaper 静默读旧配置 | ② |
| 52 | 向导内 `s`/`c` 重配置后:工作负载 ns 的 `felis-config` 镜像停在上一次运行快照,直到下次 `felis setup`/安装器才收敛 | ② |
| 53 | 默认安装或 `felis update` 的 felis-api 检查:坐标仍指向已迁移的旧仓库,现仅靠 GitHub 301 兜住——redirect 一退休,安装器默认 URL 与更新检查全挂 | ② |
| 54 | 完成态安装上照 `felis update` 指引升级:`sudo felis setup` 只开配置控制台,任何组件都不会动(三处 run 指引 + trailer 全假) | ②(文档) |
| 55 | 配置的源回 200 但档案字段不可用(马虎自建/三方 Yggdrasil):坏源在前 → 经梯子的登录被静默吞掉(零日志、后续源不被询问) | ② |
| 56 | 给"还没进过服"的玩家加白名单/封禁/踢出(面板说成功、vanilla 实际拒绝)——任何新玩家第一次被管理就撞 | ① |
| 57 | 控制台发任何命令后无反馈(回复被丢弃、pod 日志也不回显) | ① |
| 58 | 对从未启动过的服务器点"文件"页(90s 卡死 → 误导性 504) | ① |
| 59 | 装了 LuckPerms 的服打开 LP 页:读不到被报成"未分配任何权限"(假事实),且历史伪造 `[RCON]` 输出 | ① |
| 60 | 新建服务器输入非法名/子域名(前端预检比后端弱)→ 对话框直出 Go 英文错误;账户验证撞已占邮箱同理 | ① |
统计:① 25 | ② 25 | ③ 3+#23(a) | ④ 4 | 决策 3 = 60。第三批构建链另有 3 处未编号修复(Job requests 超小节点上限 → 永远 Pending、Kaniko chown、Trivy DB egress 被锁)——均属 ①/②。
## 已验证事实(正向清单)
- 安装→hook 发码→Owner 绑定→passkey(虚拟认证器)→面板管理员全链路 ✅
- 建服(POST /servers)→ 唤醒(operator 拉 StatefulSet pod)→ RCON `list` → SSE 控制台 → 停止 ✅
- 文件编辑:列目录/读/写(wire 为 base64)/256KiB 413/路径穿越 5 变体全拦截/运行中 409 ✅
- **备份→篡改→恢复数据演练**:v1→备份→v2→恢复→读回 v1 ✅(G2 数据可恢复)
- **毒档案 fail-closed**:穿越/绝对路径/符号链接条目 → `archive entry escapes target` 退出码 1,零写入 ✅
- 混沌:PG 掉线(healthz 仍 200、恢复后连接池自愈)、API pod 击杀(~2s 中断)、整机重启(32s 回归、会话/CRD/停止态全保留)✅
- `felis update` 报告(k3s/velocity 有更新、私有仓库 404 优雅处理)✅
- 账户全套真机 E2E:绑定码新玩家注册(幂等/并发见 #18)、邮箱验证(onboarding 门)、邮箱 OTP 登录(错码扣次/5 次锁定后正确码也 400、重放 400、staff 账号 403 拒绝)、Passkey 注册+discoverable 登录+邮箱优先登录(虚拟认证器;Chrome 要求 `Page.bringToFront` 才能过 focus 检查)、登出吊销会话(旧 cookie 401)
- op-login 全状态机(start→status→approve→finish;早 finish 不烧码、错码扣次且请求保留、重放/重复批准/非管理员批准/未知 handle 全部按契约返回)✅
- 并发:OTP 风暴 8 路 = 1×202 + 7×429 且仅铸 1 码;绑定码并发(同码/双码)见 #18 ✅
- **reaper 全链路真机演练**(修复 #19 后):绑定挂载假世界 → `felis reaper` 一 pass:归档 tar 落盘(内容含标记文件)、`world_backups` 行 `inactive_15d`+90d 过期、**PVC 删除且宿主目录回收**、servers 行保留且 activity 时钟重置(红线②)、CRD 保留;第二 pass:伪造过期归档被驱逐(`expired=1`,文件删、行转 `deleted`),合法归档未动;`evaluated=2 reaped=1` 只动到期的世界 ✅
- **S3 存储向导全链**(第三十二批):错误凭据预检零副作用、正例落地(Secret + 两 toml + API 滚 + 状态行)、上传 → MinIO 桶 → 内部面取件、UI 回滚归位、控制面/工作负载镜像对齐 ✅
- **NetworkPolicy 栅栏真机**(第三十五批):pod 源"身份×端口"矩阵全对(允许行与拒绝行同源对照)、转发型外部源白名单闭环(拒→放→拒,ipset 与 spec 双幂等)、host/ClusterIP 路径全通、Mac→NodePort 面板 200;两条归因注记见该批 ✅
### 本轮新增真机证据(第二日)
- **默认安装的备份闭环**:apply 新 bundle → 归档 PVC 自动创建(WaitForFirstConsumer,被首个 backup Job 拉绑定)→ `POST /backup` 202 → `GET /jobs` running→succeeded → 漂移 marker → `restore-backup` → 读回备份前内容 ✅
- **回收闭环(真实 k3s 布局 + 权限)**:resolvecheck(20d idle)→ CronJob 派 Job:`evaluated=2 reaped=1`;归档含 marker、PVC+宿主目录回收、`world_backups` 记 `inactive_15d`;期间先经历 `lstat /worlds/…: permission denied`(k3s storage root 0700)→ 修 ACL/root 权限后通过 ✅
- **磁盘压力三连 drill**:①1e6 自定义类:游戏 pod 先走、控制面随后仍被逐(kubelet ranked/evicted 日志)→ 镜像 GC → ErrImagePull;②改内建 `system-cluster-critical` 后:`cannot evict a critical pod` × felis-api/operator/registry,全部保持 Running;③恢复阶段实测 `DiskPressure` 条件 ~5min(eviction-pressure-transition-period)转 False,被 GC 的游戏镜像按 runbook 重导入后 25s 恢复 ✅
- **混沌**:SIGKILL k3s 主进程 → kubectl 与 felis-api ready 均在 **10s** 内恢复(api pod 本身未被重启)✅;内存压力(2.6G tmpfs 满写):无 OOM kill、无人被逐(swap 5.9G 吸收 + 内核回收),控制面不受影响 ✅
- **构建链路定位(当时未修)**:Kaniko/Trivy 默认镜像是外部的 → 已加 `[registry] kaniko_image/trivy_image/build_cpu_limit/build_mem_limit` 覆写;上下文跨 ns 不可挂载(PVC 不能跨 ns)是设计级缺口,s3 lane 也缺凭据注入 ✅(证据:`internal/submit/blobstore.go:40`、`cmd/felis/api.go` contextBase 分支、jobspec 无 volumes/env)→ **第三批已修复,见下**
### 本轮新增真机证据(第三批:探针 + 构建链路全通)
- **控制面探针(`0c8e29b`)**:felis-api / felis-operator / registry 三个 Deployment 此前**完全没有探针**。修复后真机验证:api、operator `/readyz`+`/healthz`(internal 8081),registry `/v2/`(含命名端口解析);三个 Pod `Ready=true`、`restartCount=0`、无 `Unhealthy` 事件;operator 新增 `--health-probe-bind-address`(8081,与 metrics 8080 分离,零检查也 404 → 已注册 ping)
- **构建链路全通(`f79e5eb` + `02fd2de`)**:
- 传输:context_ref 改为 internal-face URL;`felis fetch-context` initContainer 走 service token(对象来自 `felis-build` 里按 `bootstrap`/`felis setup` 同款 Secret 复制机制落地的 `felis-service-token`;netpol 只放行控制 ns:8081)+ zip-slip 安全解包到限容 emptyDir;Kaniko `--context=/context` 只读挂载
- **真机 E2E**:玩家 OTP 登录 → 建 submission → 上传 gzip context(marker `felis-e2e-build-marker`)→ Owner approve → Job:fetch ✅ → Kaniko 构建并 push `registry.felis.svc:5000/user-uploads/<sub>:latest` ✅ → Trivy 扫描(内建镜像 DB)✅ → Job `Complete`;**从 registry 拉回镜像校验 `/hello.txt` 内容 = 上传的 marker** ✅
- 演练中真机抓到并修复 3 个单测看不到的缺陷:① Job requests=limits(2C/4Gi)在 4C/5.5G starter 上**永远 Pending**(`FailedScheduling/Insufficient memory`)→ requests 改为地板值(250m/512Mi,不超上限);② Kaniko 重拷 Dockerfile 时 chown/chmod 到源文件 owner(distroless uid 65532)在 drop-ALL 能力下失败(`copying dockerfile: chown … operation not permitted`)→ fetch 容器以 root 解包(= Kaniko 自身 uid);③ Trivy DB 默认 `mirror.gcr.io` 正被 egress 锁拒绝(fail-closed)→ 新配置 `[registry] trivy_db_repository` + 文档 §8e 固化镜像配方(`docker pull/tag/push aquasec/trivy-db:2` 进内建 registry;`--insecure` 已覆盖明文 HTTP)
### 本轮新增真机证据(第四批:PG 契约测试 + 验证邮箱唯一性 + owner 角色)
- **PG 契约测试首跑抓到 #20**:`internal/pgint` 对着真实 PG(`felis_pgint`,drop schema + 重放迁移)跑会话/OTP/op-login/绑定码/submission/build 全契约;`TestOnboardingEmailOTPRejectsTakenEmail` 当场变红 → 挖出「索引从未创建 + `ErrEmailTaken` 从未返回」。
- **迁移 0020 真机应用**:`felis migrate up` → `applied 1 migration(s): [20]`;`schema_migrations` max=20;`pg_indexes` 出现 `users_verified_email_unique`。
- **邮箱唯一性真机 409 演练(auditfix17)**:owner 已验地址被玩家 onboarding 流程二次验证 → 第一次 409 `email_taken`;**同码重放仍是 409**(证明码未被消费,符合契约;若被消费会是 400)。
- **admin 改邮箱清 verified 真机演练(auditfix18)**:PATCH player.test → `[email protected]` 后 `email_verified=f`;PATCH 回原地址仍 `f`;玩家走 onboarding 重验证 → `t`。
- **owner 角色真机闭环(auditfix18)**:owner 行按 0011 文档升级路径置 `role='owner'` → `GET /api/v1/users` 200(修复前 403);owner op-login start 铸请求+发码、finish 得 `role=owner` 会话;PATCH owner role / DELETE owner / DISABLE owner 全部 403;玩家邮箱登录回归 200。
- **配额原子门(第五批,`bb68fef`,auditfix19 已上线)**:pgint 并发实证(真实 PG 上两路并发认领:恰 1 赢 + 1 `ErrQuotaExceeded`;修复前两路全赢);`docs/deferred-seams.md` 的 audit #4 条目核销。
### 本轮新增真机证据(第六批:reaper 警告链路端到端演练,auditfix20)
- **演练装置**:独立 hostNetwork Pod(`felis:auditfix20`,SA/卷结构复刻 live CronJob)+ 宿主本地零依赖 SMTP sink(捕获完整 RFC5322 原文)+ 专用 `felis-config-drill` Secret(SMTP 指向 `127.0.0.1:1025`);场景服 `warntest`(独立 CRD,desiredState=Stopped,不占资源),期间 operator 暂停防 reconcile。
- **投递**:13d idle → 恰 1 封,`To: [email protected]`(owner 的**验证**邮箱)、subject 含 `3d`、正文含 server/remaining;`warned_3d_at` 盖章、`warned_1d_at` 留空;`world_backups` 保持 4 条(纯警告零备份)。
- **去重与档位**:重跑 0 封(已盖章不重发);14.5d idle → 补发第 2 封 = `1d` 档(elif 顺序正确,不是重复 3d),`warned_1d_at` 盖章。
- **失败语义(三连)**:SMTP 端口打坏 → 日志 `warn delivery failed; will retry next run`(err=`connection refused`)、**不盖章**,恢复端口后同一 run 补发成功;邮箱 `email_verified=false` → `resolve owner email: … has no verified email`、不盖章,恢复后补发;owner NULL → 静默跳过(无日志无警告)。
- **边界**:idle=11d23h(< 12d 阈值)不发不盖章;idle=12d1m 发。`http_code` 冒烟:升级 auditfix20 后 panel 200 / op-login 200。
- **环境还原**:warntest CRD+行删除、drill Secret/Pod 删除、operator 恢复;`servers` 表回到 resolvecheck+test-one,CRD 回到 lobby/login/resolvecheck/test-one。
### 本轮新增真机证据(第七批:idle auto-stop 三层修复,auditfix21/22)
- **发现路径**:operator 深挖时先怀疑"计时器无驱动",真机实验立刻抓到 pruning(操作日志 `unknown field "status.emptySince"`)+ 触发后 88s 无动作 + RBAC 403,三层各自独立、各自足以致死。
- **场景 1(超时点唤醒 + 写权限)**:EmptySince 已超时 11 分钟 → annotate 触发一次 → 秒级内 desiredState=Stopped、sts 0/replicas、EmptySince 清、phase=Stopped。
- **场景 2(完整自驱,决定性)**:desired=Running → pod 起 → 20:11:43 盖章 → 静置无干预 → **20:12:17 自动 Stopped**(30s 到点后 ~4s 完成 stop+scaledown+markStopped)。
- **翻转混沌**:3 秒内 Running→Stopped→Running,最终收敛 Running ready(期间 409 竞争为控制器正常噪音,controller-runtime 重试自愈)。
- **收尾**:`spec.idle` 删除后再无自停;test-one 回 Stopped、空 sts、无 EmptySince;VM 镜像 auditfix22 = `f650bf8`。
### 本轮新增真机证据(第八批:operator 自愈深挖,auditfix23/24)
- **RCON Secret 删除自愈(`56f3abd`)**:修复前——删 secret 静置 60s 零察觉、触发后 96s+ 永久 `RconNotReachable`、仅人工删 pod 可恢复;修复后——删 secret **同秒**(20:22:16)察觉(Secret watch 日志)→ 新密码 + stamp 换值(`3b24164f…`→`c465b627…`)→ rollout → **20:22:47 全自动恢复 Running**(31s)。
- **陈旧锚点(`82b5a60`)**:修复前——已恢复服务器因锚点残留被 `Failed(StartupTimeout)`、`Provisioned=False` 永挂;修复后——Ready 即清锚(`startRequestedAt: None`)、`Provisioned: True`;pod blip 演练:删除→20:24:48 重锚→20:25:19 恢复→锚清空,**全程无 Failed**。
- **StatefulSet 误删自愈**:删除 sts → **秒级**重建(PVC `world-test-one-0` 保持 Bound,Retain 策略)、29s 后 Running、stamp 保持 `c465b627…`(未触发多余 rollout)。
- **收尾**:期间 7 条 operator ERROR 全为 CR/sts 409 竞争噪音(自愈);test-one 回 Stopped。
### 本轮新增真机证据(第九批:未演练端点地毯覆盖 — 迁移 / access / window / credentials)
- **account/migrate 四步状态机全链路(首个端到端演练,0 缺陷)**:造 user2 目标账户(绑定码 `FDTGKYAQ`)→ player.test claim test-one → 内部 start(未联动 UUID=404 not_linked;源=201 initiated)→ `passkey_required 409`(有 passkey 强制强因子)→ **passkey credentials list + DELETE 204**(顺带覆盖管理端点;删后 OTP 门自动解除)→ OTP start 202 / 错码 400 / 真码 confirmed → issue-code(self=400 / missing=400 / 真目标=201)→ redeem 负例×2(源持码=400;target 错码=403 setup_required——**onboarding 门正确拦截未验证账户**;验证邮箱后重试)→ **redeem 成功**(小写+空格码兼容,`servers_moved:1`)。
- **retire 断言全对**:servers.owner→user2;migration=redeemed(含时间戳);源 disabled+软删;源活跃 session=0;源旧 cookie 401;double-redeem 400;源再加迁移 409 account_retired。**环境复原**:源复活 + 新 OTP 会话、test-one 归还无主。
- **access 玩家管理全组(0 缺陷)**:players/whitelist/banlist 三个读 projector 解析正确(`There are 0 of a max of 20 players online: `→[]);kick/permission/group/luckperms-info 调用全通(demo 服无 LuckPerms → 原样透传 `Unknown or incomplete command`,API 不掩饰);输入负例 4 连 400(bad player/bad action/bad node/bad world);`whitelist add/remove` 在空档案服上得到 vanilla `That player does not exist`(fail-closed egress 无法解析 Mojang profile——**vanilla 约束非 API 缺陷**,命令已如实送达);手写 whitelist.json + `command whitelist reload` 验证成功路径解析(`players:["E2E_Tester"]`);**audit 7 条 access.\* 全记录**。
- **updates/window**:unset→null;PUT 正例+读回;半设 400;倒置 400;clear→null 全对。
- **/fleet**:4 服全量(含运行中 lobby/login 的 endpoint 地址)一次读全。
- **bootstrap-assets crd**:嵌入式资产含 `emptySince`(auditfix24 二进制核对)。
### 本轮新增真机证据(第十批:configure email 复制链修复,auditfix25)
- **发现路径**:复核 `8e7c7bb` 新增的 replicate 逻辑时怀疑 manifest/-n namespace 冲突 → kubectl 真机实验裁决(hardcoded `namespace: felis` 版直接 `error: … does not match …`,证实第一段必错、第二段被跳过)。
- **修复后行为验证**:两条命令序列(felis-smtp manifest 携带目标 ns + `felis-config` create--dry-run|apply)均被 server 接受且落点 `minecraft`。
- **live 收敛项(随下次 `felis install/setup` 重跑)**:live CronJob 仍是旧模板(无 `FELIS_SMTP_PASSWORD` env);`felis-smtp`/镜像两 ns 均未创建(SMTP 未配置,属正常);与 operator Role 手动补 patch 同批处理。
- **部署**:`felis:auditfix25` = `ed722d5`(api/operator/reaper 三处 set;api/operator rollout 完成,panel 200)。
### 本轮新增真机证据(第十一批:submission 全路由负例矩阵 + 内部面取件 + owner 会话重铸)
- **session 重铸路径(hook)**:owner 旧 cookie 过期(401)→ 临时插 `account_links`(owner↔假 UUID)→ op-login 三件套:start 202 → 日志取码 → 内部面 `op-login/{id}/approve`(hook,经 service token)→ status `approved:true` → finish 200 `role=owner` → op 域新 cookie 生效(`/fleet` 200);**演习后已删除假链接行**。顺带复证:approve 失败时 status=false、finish 400 `op_login_invalid`(码不烧,复用同码二次 finish 成功)。
- **submission 车道负例矩阵(23/23 PASS,0 缺陷)**:create 未知字段/空名/非法字符/超长 → 全 400;非 gzip 上传 400、未知 id 404;跨用户上传 → **404 不可见**(非 403);玩家打 admin 三路由(queue/approve/reject)全 403;reject 空理由/1001 字/夹带字段 → 400,成功 200(`reject_reason`/`reviewed_by`=验主邮箱/`status=rejected` 全对);重复 reject 409、reject 后 approve 409;**无 blob approve → 400 且行保持 pending_review(未 stranded)**;玩家列表严格只见自己。
- **内部面取件**:`GET /api/v1/internal/submissions/{id}/context`(service token)→ 200 + gzip magic `1f8b` + 解包内容 = 上传 marker;无 token 401;未知 id 404。
- **演习清理**:E2E-Lane-\* 8 行已删、假链接已删;队列只留历史 "E2E *"(approved)行。
- 备注:port-forward 再次因 API pod 重建悬死(空响应)→ 按速查重启即恢复;本轮无需新镜像(纯验证)。
### 本轮新增真机证据(第十二批:users 管理面全量矩阵 → 抓到并修复 #30)
- **/users 管理面矩阵(修复前,39 PASS / 2 NOTE)**:create/get/patch/list 权限与负例全对(未知字段/非法用户名/owner 角色/未知 id → 400/404;owner 保护 403 复证);sessions 单条吊销→旧 cookie 立即 401→重登恢复、revoke-all 同效(重登两次含 61s 冷却等待,全绿);passkeys 无凭证 200 no-op;links 幂等/跨用户 409/非法 auth_source 400/解链 404 全对;disable/enable/delete/double-delete 全对。
- **缺陷 #30(两处 FK 500 + 一处假 200)**:`PUT /users/{unknown}/quotas` 与 `POST /users/{unknown}/links` 触碰 user_id 外键 → **500 internal**(契约要求 404);`GET /users/{unknown}/quotas` 回**零值视图(=unlimited)**,像 id 存在。修复:`PGRepo.requireLiveUser`(live 行 + `deleted_at IS NULL`)守卫三个方法,`handleGetQuotas`/`handleLinkAccount` 补 ErrNotFound→404 映射;单测 `TestAdminSubresourcesRequireLiveUser`(fake 同步 liveUserExists 契约)+ pgint 真库契约(ghost→ErrNotFound、live 对照、软删后→ErrNotFound)。commit `1918da2`。
- **auditfix26 真机复验**:ghost 三连 → **404 not_found**;活用户对照 → PUT/GET quotas 200(读回 2)、POST link 200、delete 200;panel 200,api/operator 滚动完成(新 pod 24s Ready)。
- 契约备注(非缺陷):软删用户 `GET /users/{id}` 仍 200(带 `disabled=true` + `deleted_at`,列表已过滤);`DELETE .../sessions/deadbeef` 幂等 200;revoke-all 对未知用户 200 no-op。
### 本轮新增真机证据(第十三批:CLI / 镜像面 / 直接构建 / 面板 CDP 全路由 + #31/#32)
- **CLI 面 19/19(VM,`felis-auditfix26`)**:`version`/`-h`/无参=exit2/未知命令=exit2;`manifests` 缺 `--velocity-cidr`、缺 `--felis-image`、坏 CIDR、坏 NodePort 全 fail-loud exit2,正例渲染 25 文档且 `--worlds-host-path` 分支出 CronJob+三条前置警示——整包 `kubectl apply --dry-run=server` **全部 `configured`**;`apply` 缺 `-f`/文件不存在/坏 JSON 各自 exit2/1/1;`migrate up` 二次幂等(`database already up to date`);`bootstrap-assets crd`(含 `emptySince`)与 `game-stack`(tar)正常;`update` 正常。
- **镜像面负例 + builds 负例**:`/images` 列表 200、空体 400、合法 201、重复幂等 201、无 `?ref` 400、删除 **204**(契约;我的 200 预期作废)、再删 404、坏 ref 400;`/images/build/unknown` GET/cancel 均 404;空体提交 400;玩家打 `/images*` 全 403。
- **`/images/build` 正路径(直接构建)**:借遗留 submission blob 作 context,`FROM scratch` 内联 Dockerfile → **202 → 12s succeeded**;`/logs` SSE 实收 Kaniko 流;真机 registry `tags/list` 出现 `e2e/direct-probe:latest`(镜像确实被推入)。留档:该 drill 镜像保留在 registry。
- **`auth/options`**:未知邮箱 → `{"methods":[]}`;玩家 → `["email_otp"]`(无 passkey 时);坏邮箱 400。
- **`PATCH /servers/{name}`**:空体/storage/空 policy/未知字段/坏名 → 400;未知服 404;displayName 设置+还原 200。**契约备注**:`displayName:""` 因 `omitempty`+merge-null 语义 = **清除该字段**(本次 drill 先误读为设空串,比对 patch 前 fleet 快照确认 test-one 原无 displayName,无数据损失)。
- **面板 CDP 全路由巡检(owner 会话,14 路由)**:全部有标题/有内容/无崩溃;唯二发现 = #31/#32(见下);`luckperms` 页对 stopped 服 409 → **优雅降级**为「服务器已休眠 + 启动」态(browser 级 409 日志属正常资源日志,非缺陷)。玩家会话 4 路由零错误且**导航分级正确**(不见管理/平台组);未登录 `/login` 渲染正常(`/me` 401 为预期探测)、`/account` 未登录重定向 `/login`。
- **缺陷 #31(面板 mock 残留·owner 判定)**:`ImageBuildPage` 用 `email==="[email protected]" || startsWith("owner@")` 猜 owner——真实 owner(`[email protected]`)**不被识别**(只能看 approved 提交),而任何 `owner@x` 邮箱都冒充 owner。改为消费 TierProvider 服务端 `isOwner`。commit `6907961`。
- **缺陷 #32(面板 mock 残留·假构建种子)**:首次访问 `/admin/builds` 向 localStorage 播种 `["bld-1","bld-2"]` → 每个新浏览器打两个必然 404 的请求(页面注释自认“in production will 404”)。移除播种。同 commit。
- **auditfix27 复验**:清掉该 localStorage 键后重访 → **只发 `/me`**、零错误、空态正确;面板 111 单测 + typecheck 全绿。
- **passkey 全流程复验(CDP 虚拟认证器,面板 UI 实操)**:注册(对话框输入→仪式→列表出现 `E2E-Key`)→ API logout → 邮箱优先 passkey 登录(`/me` 回 `[email protected] user`、落回 `/`)→ `DELETE credentials/{id}` 204 → 列表清空(环境复原)。
- **环境**:api/operator/reaper 镜像 = `felis:auditfix28`(= `6907961`);面板 200。**教训**:`pkill -f "port-forward …"` 会匹配到**执行该命令的 ssh 自身 cmdline**(命令里同时含明文 `port-forward svc/…`)→ 自杀式断连;改为 `ss -ltnp` 取 PID kill,另起一条 ssh 启动转发。
### 本轮新增真机证据(第十四批:#33 死账号复活漏洞 —— 全登录门闭环 + 资产切断)
- **发现路径**:users 管理面演习收尾核对时发现软删用户仍留 `account_links`/`quotas` 孤儿行 → 顺藤摸瓜确认 `UserByEmail` 与 `SessionUser` 均不过滤 `disabled`/`deleted_at`。
- **红证据(auditfix27,真机)**:禁用用户的邮箱门重登录 `verify=200`、`/me=200`(**禁用锁死可绕过**);**已删号**用户 `start=202`(真实发码)、`verify=200`、`/me=200`(**删号可复活**)。
- **修复(`5853589`,7 文件 / +306 行)**:① `UserByEmail` 只解析 live 账号(覆盖 email/passkey 邮箱优先/op-login/auth options 全部前置门);② `SessionUser` 同样过滤(皮带:任何门铸出的死号会话都立即失效);③ `RedeemPlayerBindCode` 两个解析臂拒绝死账号(新哨兵 `ErrPlayerAccountRetired` → 403 `account_retired`,**不消费码**,可逆重试);④ `DeleteUser` 事务内 `DELETE webauthn_credentials` + `account_links`(凭证与 `UNIQUE(mc_uuid)` 占用不再外泄);⑤ discoverable passkey resolve 补 liveness 检查。单测 `TestDeadAccountsCannotLogInOrKeepSessions` + pgint `TestDeadAccountsAreLockedOutInPG`(含"删后新铸会话不验证"与资产清点)。
- **绿证据(auditfix28,真机)**:红期为死号铸出的会话 → **401**(皮带生效);死号 `start=202` 中立且 **90s 日志窗口 0 条码**、`verify=400 invalid_code`;禁用中 `start` 中立(日志码数 1→1 不变)→ `verify=400`;**恢复启用后 `verify=200` + `/me=200`(可逆)**;bind 门死账号 → **403 `account_retired`** 且码保留 `count=1`(retryable);删号后 `account_links`/`webauthn_credentials` 行数 = 0。
- **演习清理**:红演练遗留(2 个测试账号的 quotas 行、1 条旧链接、2 条死号会话)已清除;`/fleet` 与 panel 冒烟保持 200。
### 本轮新增真机证据(第十五批:#34 死账号同族收尾 —— in-game 身份解析与链接接管,auditfix29)
- **发现路径**:#33 修完 Web 登录门后做同族复查——**in-game 面是 UUID 驱动、不走 Web 会话,因此 #33 的会话皮带盖不住它**。两处:① `UserByMCUUID`(claim / menu / wake 授权 / op-login vouch / QR link-status 五处共享解析)直接读 `account_links`、不过滤 users 的 liveness;② `VerifyLinkCode` 对「holder 已死」的接管语义未定义——一刀切 409 会把迁移退役这类真实场景锁死。
- **修复(`bb9798e`,5 文件 / +182−7)**:① `UserByMCUUID` JOIN users 过滤 `disabled`+`deleted_at`——死账号的链接在游戏面**读作未链接**(claim 412、wake 落回 policy 门、vouch 403、status `linked:false`),绝不作为遗留身份存活;② `VerifyLinkCode`:**软删** holder 的链接可由新账号凭新 mint 码**接管**(账号已亡,码证明调用者仍持有该 UUID),**禁用** holder 仍 409(接管= 绕过锁死,不允许)、任何失败都不消费码。fake 单测 `TestLinkVerifyTakesOverDeletedLinkOnly`;pgint `TestVerifyLinkCodeTakesOverDeletedLink` + `TestDeadAccountsAreLockedOutInPG` 增「禁用链接无资格 / 复启用恢复」断言。
- **绿证据(auditfix29 真机,三面六点)**:
1. `link/status`:活 `{"linked":true}` → 禁用 `{"linked":false}` → 恢复 `{"linked":true}`;
2. `claim`:活链 404(身份已解析、ghost 服不存在)→ 禁用 **412 not_linked** → 恢复 404;未知 UUID 对照 412;
3. **接管正例**:临时软删 holder(`de49df52`)→ 新 mint 码 `X3G3RZZM` 由 player.test verify → **200 linked:true**;链接行迁移到 player.test(`auth_source=mojang`)、码被消费(`account_link_codes` 0 行);演练后 holder 与链接全部还原;
4. **接管负例**:临时禁用 holder(user2)→ 新码 `X5W94S42` verify → **409 already_linked**;同码重试仍 409(**不消费**);恢复启用后**同码**由 holder verify → **200**(码保留、可逆);
5. **wake 授权**:活 owner **202**(真实启动)→ 禁用 **403 forbidden** → 恢复 **202**;test-one 已停回 Stopped 且所有权释放;
6. **op-login vouch**:禁用 owner UUID → **403 not_admin**(与未链接/非 staff 同一个拒绝面);活 owner UUID → 404 op_login_not_found(vouch 已解析、只是请求不存在)。
- **环境刷新(部署态)**:热升级遗留 `FELIS_IMAGE=felis:auditfix16`(job 侧执行器 backup/restore/fileedit/forwarding-init 引用的镜像)→ 已 `set env` api/operator 双 Deployment 至 `felis:auditfix29` 并滚动完成;api/operator/reaper 三镜像一致 = auditfix29;port-forward 重启后内部面绿;panel 200、op 面 `/me` 200。
- **运维备注**:`account_link_codes` 会积累过期行("失败不消费"的另一面),巡检顺手 `DELETE FROM account_link_codes WHERE expires_at < now()`。
### 本轮新增真机证据(第十六批:内部面残面地毯补测 —— ready / join-event / status / reclaim / blacklist / backup 负例)
- **背景**:对内部面做覆盖盘点后发现五个端点此前无真机证据:`ready`、`join-event`、`status`、`player/reclaim`、`player/blacklist`,以及 `internal/servers/{name}/backup` 的负例臂。本批全部补测(auditfix29),**0 新缺陷**。
- **`ready`**:test-one → **204**(advisory 语义);坏名 → 400。
- **`join-event`**(临时 UUID `3333…`):首次 **204**、重复 **204**(幂等);DB 实锤:`test-one.last_active_at` 15:02→05:53(重置)、`server_allowlist` 恰 1 行;缺 uuid → 400;未知服 → 404。演习后 allowlist 行删除、`last_active_at` 复原。
- **`status`**:test-one → 200 全字段(phase/desiredState/endpointMode=fallback/…);未知服 → 404。
- **`player/reclaim`**:新建(临时 UUID `4444…`)→ 200 `hold_expires_at`=+30d;**幂等重试返回同一时间戳**(首窗保留,与存储行 21:53:51.726017Z 完全一致);缺字段 → 400;**protected-admin 负例**:临时插 owner↔thirdparty 链接 → **409 protected_admin**(不 bar、不 stash)。清理后 `username_blacklist`/`player_data_holds`/`server_allowlist`/临时链接 = 0/0/0/0。
- **`player/blacklist`**:bar 后命中 `{"blacklisted":true}`;陌生 UUID → `false`。
- **`internal backup` 负例**:未知服 → 404;坏名 → 400;保留名(login)→ 400 `bad_name`(在入队前拒绝,RWO 闸门单测已覆盖)。
- **探针**:`/healthz` 200、`/readyz` `{"status":"ready"}`。
### 本轮新增真机证据(第十七批:hasJoined 多源会话校验器 —— 假 Yggdrasil 全链路 + 三方身份重写/改名)
- **背景**:`hasJoined`(velocity 指向的 vanilla sessionserver 协议面,`handleHasJoined`)此前零真机覆盖。本批用「VM 主机假 Yggdrasil + 临时将 `[[auth_source]]` 换向」的方式把正/负路径全部打通。
- **装置**:主机 python 假源(`:18099`,按 username 分流 `ftok`/`ftnotch`/`ftbadname`/`ftdown`/其余 204);`felis-config` 的 littleskin 源临时改指 `http://10.42.0.1:18099/fake`(tag=`faketest`/prefix=`FT`),api 滚动后逐项打靶。**演练后配置已还原 littleskin、装置已清理**。
- **结果(全绿,0 缺陷)**:
1. **三方身份重写**:`ftok` → 200 `{"id":"74409c3bbae93acabd2176e517b4f2a0",…}`,与本地按 `uuid.NewMD5(felisAuthNS, "faketest:native-123")` 的预算值**逐位一致**;
2. **保费名冲突改名**:`ftnotch` → `47c5527a18b03fe5a49ec50c11154dcd` + `name="FT_Notch"`(Mojang 实查 Notch=200 premium);`ftok` 的 `FtPlayer` 恰也是真实 Mojang 名(`640c1672…`)→ `FT_FtPlayer`,改名按设计触发(非保费名保持原名);
3. **敌意插件名拒绝**:假源返回 `§4admin` → **204**(不落 proxy 玩家列表);
4. **源故障不静默**:假源 500 → **503**(velocity 报 auth servers down),对照未知玩家 → 204;
5. **形状负例**:缺参 / 超长参 → 204;声明 body → **400 + `Connection: close`**(防 drain 挂连接)。
- **顺带覆盖**:`isPremiumName` 的 Mojang 实查(`api.mojang.com`,超时/错误 fail-closed=改名)——404→非保费、200→保费判别实测成立。
### 本轮新增真机证据(第十八批:缺陷 #35 —— world 执行器 uid 1000 读不了游戏服写的世界 + 面板备份模块补全)
- **发现路径**:给备份页新增「立即备份 + 最近操作」后做第一次真机 drill——面板链路全对(按钮/成功消息/运行态→终态、零坏请求),**但 backup Job 真的失败了**:`Job has reached the specified backoff limit`,pod 日志 `felis backup: archive: backup: tar walk: open /world/world/level.dat: permission denied`。
- **根因(缺陷 #35,跨模块)**:世界卷的属主是**游戏镜像自己的 UID**(我们发布的 Paper 镜像都是 root),而 Paper 保存 `level.dat` 用的是 **0600**(Files.createTempFile 默认权限)→ 固定 uid 1000 的 **backup / restore / fileedit / reaper** 四类执行器:读不了(归档 `permission denied`)、也覆盖不了(restore 写不进 600-root 的 level.dat)。此前 drill 侥幸全绿,是因为当时的世界文件由 uid-1000 工具(restore/夹具)写的;**服务器真实启动保存过一次之后**,所有备份从此必死。测试全部是 shape 断言(无集群),这条只能真机抓。
- **修复(`2010961`)**:四类执行器统一改**以 root 运行**(`runAsNonRoot:false`,省略 fsGroup 防误 chgrp),容器保持除 `DAC_OVERRIDE` 外 drop-ALL——与 operator `init-forwarding` 容器的既定先例同源("只有 root 能可靠读写这些文件");DAC_OVERRIDE 兜住"游戏镜像是非 root UID"的任意镜像场景。四份 shape 测试同步改断言。troubleshooting §10「Permissions」段落重写(uid-1000 + setfacl 时代结束)。
- **绿证据(auditfix31,真机四联 drill)**:
1. **backup**(面板 CDP 实操,红→绿同场景):点击「立即备份」→ `进行中` → **`成功`**(同页面 reload 后仍在);Job pod `runAsUser:0` + DAC_OVERRIDE;新归档 `test-one-1790115428472416736.tar.gz` 实测含 `world/level.dat`(471B)与 server.properties,共 499 条。
2. **restore**:`POST restore-backup`(bk-9df0…)→ 202 → Job **Succeeded**(root 写路径过关),日志 `restored from … into /world`。
3. **fileedit**:`GET /servers/test-one/file?path=world/level.dat` → **200**(base64-gzip 内容 628B)——修复前该请求必然 permission denied。
4. **reaper**(从 live CronJob 派生一次性 Job + 复刻钻取世界:1Gi PV/PVC + `world/level.dat` 512B **0600 root** + marker + 20d idle 行):pod **root+DAC_OVERRIDE**;`world reaped server=reapdrill2 …`、`evaluated=3 reaped=1`;归档 711B 含 marker 与 level.dat;PVC 删除;`world_backups` 得 `inactive_15d` 行;servers 行/CRD 保留(红线②)。**钻取现场全部清理**(job/CRD/行/PV/宿主目录/临时归档)。
- **面板补全(`97a64c8`)**:备份页新增「立即备份」(仅 Stopped 可用,409 原文呈现)与「最近操作」卡(GET `/servers/{name}/jobs`,running 每 5s 自刷新,failed 显示 Job 失败文本——正是它把上面这次失败暴露出来的)。面板 113 单测 + typecheck 全绿。
- **部署注意**:backup/restore/fileedit 的 Job spec 是 api **运行时渲染**,随镜像即生效;**reaper CronJob 的 pod 模板是安装期静态渲染**——本轮已按新形状热补丁 live 对象(root + DAC_OVERRIDE),`felis install/setup` 重渲染时收敛。
### 本轮新增真机证据(第十九批:面板补齐 owner 解绑通行密钥入口)
- **背景**:`DELETE /users/{id}/passkeys`(owner-tier 凭据补救:密钥丢失/被盗时切断登录脚架,且不锁死账号——邮箱码/游戏内审批仍可用)后端早已实现并审计,但面板无入口,owner 只能靠 API。属"功能缺口"而非缺陷。
- **修复(`11ac4f5`)**:用户详情页危险操作区新增「解绑通行密钥」行 + 确认对话框(`api.unbindUserPasskeys` 客户端方法 + 双语 i18n + wire-shape 测试)。
- **绿证据(auditfix32,CDP 真机全链)**:player.test 注册虚拟认证器密钥 `E2E-Unbind` → `/auth/options` 从 `["email_otp"]` 变为 `["passkey","email_otp"]` → owner 在 `/admin/users/<id>` 点「解绑通行密钥」→ 对话框 → 确认 → **凭据列表清空、options 回落 `["email_otp"]`**(passkey 门确实关闭);审计 `user.unbind_passkeys` 落账(同批还可见 `account.passkey.registered`/`backup.create`/`backup.restore`/`reap_world` 各审计行)。面板 114 单测全绿。
### 本轮新增真机证据(第二十批:缺陷 #36 —— 提交者看不到构建结果)
- **缺陷 #36(提交/构建模块·结果不可见)**:`/me/submissions` 只回审核状态;构建的成功/失败(含失败原因)只有 admin-tier `/images/build/{id}` 看得到 → **提交者永远不知道自己的包构建死了**。修复 `72c4aa3`:两个列表路由(玩家 `/me/submissions` + 管理 `/submissions`)对 `build_id` 非空的行附 `build_status`/`build_error`,来源是只读 `Builder.Get`(**绝不调 Sync**——状态推进归 15s reconcile 循环,列表渲染不碰集群);构建行已消失(ErrNotFound)→ 字段省略;其他存储错误照常 500,绝不静默吞。OpenAPI 的 Submission schema 同步。
- **绿证据(auditfix33,真机双例)**:① 失败例(上下文 Dockerfile `COPY does-not-exist`)→ 提交 → owner approve → `/me/submissions` 返回 `build_status:"failed"` + `build_error:"build job failed or scan found a CRITICAL CVE"`;② 成功例(`FROM scratch`+LABEL)→ `build_status:"succeeded"`。面板 CDP(玩家会话):展开行显示「构建状态」徽章(构建失败/构建成功)+ 失败原因 + build_id,全程零 4xx。**演习残留已清**(2 行 submission+build、2 个 context blob、1 条 whitelist 条目;`sub-bf7dc1…` 那条是更早 E2E 遗留,未动)。
### 本轮新增真机证据(第二十一批:面板文件编辑器补齐 —— 缺口而非缺陷)
- **背景**:`GET /files`、`GET/PUT /file` 后端早已全绿(可读写 `level.dat`),但面板无入口——「一行 server.properties 写错导致起不来」的修复路径只有 API。属功能缺口。
- **修复(`0a36b3f`)**:新增 `/servers/:name/files` 页:面包屑目录浏览、编辑器对话框([]byte ↔ base64 编解码)、二进制文件打开即只读(NUL/非 UTF-8 拒绝 round-trip)、>256KiB 禁用保存;**停服门前置**(世界卷 RWO,未停服时整页显示「服务器正在运行」+ 停止动作,而不是让每个调用 409);控制台右侧新增门口卡。i18n `files` 命名空间(en/zh)+ 3 条 wire-shape 测试。
- **绿证据(auditfix34,CDP owner 全链)**:根目录 → `world/` 导航;`felis-e2e-marker.txt`(原 `v1\n`)打开 → 追加 → 保存「已保存 …」→ **API 读回一致** → 重开一致 → 还原原始字节 → 落盘复核一致(零残留);`level.dat` 打开为只读 + 二进制提示;控制台门口卡存在;`audit_logs` 两条 `file.write`(edit/restore 各一);全程零 API 4xx/5xx。面板 117 单测 + typecheck 全绿。
### 本轮新增真机证据(第二十二批:缺陷 #37 —— 系统服务在面板里全是死操作)
- **缺陷 #37(面板·fleet 死操作)**:`login`/`lobby` 是平台自建系统服务、名字在保留名单里,于是**每个 per-server 路由都用 `ValidateServerName` 拒绝**(400 `bad_name`)——但管理端 fleet 表格给这两行渲染 认领/停止/唤醒/控制台,全是死操作(控制台链接点进去也是一页 `invalid server name: reserved`)。修复 `2f90851`:`naming.IsSystemServer` 作为唯一事实源;fleet 行附 `system:true`;面板把这两行渲染为「系统服务」纯标签(owner 列 + 操作列),不再给任何动作。玩家侧不受影响(`/me/servers` 本就不含系统服务)。
- **绿证据(auditfix35,真机)**:`GET /fleet`(owner)→ `lobby system:true`、`login system:true`、`test-one/resolvecheck` 无 flag;面板 CDP:两行「系统服务」、**0 按钮 0 链接**;`test-one` 行照常 认领/控制台、无系统标记;零 4xx。Go 侧新增 `TestIsSystemServer` + `TestFleetAdminRead` 的 system 子测试。
### 本轮新增真机证据(第二十三批:缺陷 #38 + 多节点回收缺口 —— reaper 提示过期 / 钉节点)
- **缺陷 #38(CLI·提示过期)**:`felis manifests` 渲染 reaper 时的 stderr 提示还在教“uid 1000 需要 `setfacl -m u:1000:x` 才能遍历存储根”——#35 之后 reaper 已改为 **root + DAC_OVERRIDE**,这条指导已失效且会误导运维(照做无害但白做,真问题被掩盖)。同批落 **多节点回收缺口**(结论第 5 条):新增 `--reaper-node`,reaper CronJob 的 pod 渲染 `nodeSelector kubernetes.io/hostname=<node>`;多节点集群必须钉在存世界的节点,否则可能调度到 hostPath 为空的节点。`--reaper-node` 无 `--worlds-host-path` 时 fail-loud exit 2。修复 `daf7602`(双测:`TestReaperCronJob_NodePin`、`TestManifestsReaperNodePin`)。
- **绿证据(auditfix36,真机 render + dry-run + 收敛 diff)**:
1. **固定渲染**:`felis manifests --felis-image felis:auditfix36 --velocity-cidr 10.211.55.6/32 --panel-node-port 30443 --worlds-host-path /var/lib/rancher/k3s/storage --archive-local-path /var/lib/felis/archives --reaper-node localhost.localdomain` → exit 0;bundle 内 `kubernetes.io/hostname: localhost.localdomain`;stderr **0 处** uid 1000 / setfacl,改为「已钉到节点 …」;
2. **负例**:`--reaper-node` 无 `--worlds-host-path` → exit 2 + 原文;不传 node 的渲染 stderr 仍完整保留「NO nodeSelector … 多节点必须传 --reaper-node」警示,bundle 内 0 个 selector;
3. **`kubectl apply --dry-run=server -f -`**:整包 **全部 configured**(含新 nodeSelector 的 CronJob);
4. **再安装收敛性 diff**(`kubectl diff`,本批新增的收敛证据):与 live 对比只剩 **一个对象**(reaper CronJob)两处实质增量——`+env FELIS_SMTP_PASSWORD`(热升级期未补的模板字段)与本次显式传入的 `+nodeSelector`;其余 24 份文档(api/operator Deployment、RBAC、NetworkPolicy、PVC、registry)**零差异**——即“热补丁过的 live 对象”与“当前代码重渲染”已收敛(reaper 安全上下文 root+DAC_OVERRIDE 两侧一致,无 diff)。
### 本轮新增真机证据(第二十四批:S3 上传通道真机演练 —— 此前只有单测的暗路径)
- **背景**:`internal/submit/s3store.go`(S3ContextStore)此前只有单测,本装是 local 路径(`user_uploads_context = "/var/lib/felis/uploads"`)从未激活。本批用「VM 宿主 MinIO(quay.io 镜像,:9000)+ 临时把 felis-config 切到 `s3://felis-user-uploads` + `[registry.s3] endpoint=http://10.211.55.6:9000` + 创建 `felis-uploads-s3` Secret」把整条通道打通,**全程 0 缺陷**、演练后还原并逐项复核。
- **绿证据(auditfix36,真机全链)**:
1. 切 S3 后 api 滚动启动 **无** “S3 user-uploads store not configured” 告警(凭据解析成功);pod 内 busybox 探针实测可达 `http://10.211.55.6:9000/minio/health/live`(rc=0);
2. 玩家提交 + 上传 → **200**,对象实测落桶:`sub-12c953e12b0cd144/context.tar.gz`(197B,mc ls 实见);
3. owner approve → 构建 Job `build-bld-1790118222183394193` **status.succeeded=1**(fetch-context 从内部面流式取件 = api 自 S3 读回成功);`/me/submissions` 的 `build_status` 收敛为 `succeeded`;
4. **回滚**:felis-config 还原(sha256 与演练前备份**逐字节一致**)、删除 `felis-uploads-s3`、api 滚动;再演练一次本地路径:新提交上传 **200** 且 blob 实测落在 uploads PVC(context.tar.gz 197B);启动日志仅剩 smtp/jwks 两条既有提示;
5. **清理**:2 行 submission + 1 行 build 删除、回滚演练 blob 删除、S3 构建产物从 whitelist 摘除(204)、MinIO 容器 + 两个镜像移除;`/fleet` 200。
- **遗留观察(非缺陷)**:registry 里保留本次推送的 `user-uploads/sub-12c953e12b0cd144:latest` 层数据(与早前 direct-probe 同类,filesystem registry 无删除接口);`felis setup` 的「S3 存储」向导屏本身未演练(列入 TUI 逐屏待办)。
### 本轮新增真机证据(第二十五批:breakGlass 控制台逐屏全量 + 备份/恢复门禁 —— 缺陷 #39–#43)
- **背景**:breakGlass 此前只验过 Owner 首装与 Add Operator happy path。本批把菜单四操作(Owner reset / Add Operator / Halt / Sync)+ 首装(bootstrap)分支全部逐屏真机走完,并打穿 Sync/恢复背后的 API 门禁;共抓 5 个缺陷、全部修复复验。驱动方式:VM tmux(`remain-on-exit on` 才能读回 alt-screen 撕掉后的 durable summary)。
- **先落的正向证据(无缺陷)**:Halt——`test-one` Running → 选中 → 卡「is stopping」→ CRD `desiredState=Stopped`、pod 收敛消失、审计 `break_glass.halt`;面板 `wake`/`stop` 两个恢复杠杆均 202(复验后还原)。Sync 正路径——test-one(Stopped)→ 卡 backup started → Job 6s Complete → 归档落 `felis-backups` PVC(167MB)→ `world_backups` 行 `present` → `/api/v1/backups` 可见。Sync 负路径——对 Running 选 → 友好 409 卡(不误烧冷却)。
- **缺陷 #39(owner 席位可被静默复制,且不可清理)**:恢复模式下用非在位席位名做「reset」→ `UpsertOwner` insert 臂**铸出第二个 owner 行**、原席位继续存活;面板对任何 owner 行都删/降/禁 403 → 永久无法收敛回单席。修复 `55d515d`:`provisionOwner` 先查在位席位(新 `PGRepo.OwnerUsername`),非席位名 → `ownerSeatTakenError`(`Is api.ErrConflict` → TUI 路由回表单并**指名**应输入的用户名);bootstrap(无席位)与同席位名复位原样。真机双验:新名被拒(表单原位显示 `an Owner already exists as "08595879-…" — enter that username to reset the Owner`);改席位名复位成功(owner 恒 1 行、id/邮箱不变);面板删除保护同步实证(对新 owner 行 DELETE → 403)。
- **缺陷 #40(operator 撞名 = 裸 SQLSTATE,「换名重试」分支在真机从未生效)**:`InsertOperator` 冲突返回原始驱动错误(23505),TUI 却按 `api.ErrConflict` 判定「可恢复、换名重试」——fake 与 PG 漂移。真机复现:输入已存在用户名 → 控制台 exit 1 + 裸错误。修复 `55d515d`:`isUniqueViolation → ErrConflict` + pgint 契约断言。复验:撞名**回到表单**提示换名 → `drill-op-2` 成功(审计 `break_glass.operator_create` 落账,演练行已清)。
- **缺陷 #41(Sync picker 死选项)**:picker 列出 login/lobby,而备份 API 对它们**永远失败**(保留名 + 无 servers 行);真机选 lobby → 裸内部错误 + exit 1。修复 `ac3a557`:`backupPickable` 过滤系统服(halt picker 保留它们,断玻璃完整权力)。
- **缺陷 #42(缺失世界盘 → 202 后静默卡死 30 分钟)**:对「从未启动/已被回收」的服备份或恢复:202 → Job → Pod `persistentvolumeclaim "world-<name>-0" not found` **Pending 至 deadline**,全程零失败记录。真机用已回收的 `resolvecheck` 复现(留证后删除)。修复 `508a1c0`:`Cluster.WorldVolumeExists`(直接 Get 与 Job 挂载**同名**的 PVC)+ 两 handler 409 `no_world_volume`("start it once to create it, then retry")。**修复首跑翻出配套 RBAC 洞**:felis-api SA 无 `persistentvolumeclaims:get`(403 被吞成 500「internal error」)→ `APIMinecraftRole` 补 get-only 规则 + rbac 测试锚点。复验(auditfix38):resolvecheck 两面 409 + 友好文案;test-one 照常 202 → Job 10s Complete → 新行落库。
- **缺陷 #43(409 一刀切文案)**:TUI 把所有 409 当停服门 → 世界盘拒绝会展示错误原因。修复 `ac3a557`:按 body 的 `error.code` 分流(无 code 的旧体仍按停服门)。复验:无盘 pick 显示 API 原文;停服门文案不变。
- **同步完成**:① bootstrap 分支 scratch 库演练(`migrate up` 20 迁移 → 无菜单/无认证直接铸 owner;`local_auth_enabled=true`;审计 `break_glass.bootstrap`;exit 0;库/hba 规则/临时配置即测即清);② 台面收敛:reaper CronJob `suspend=true/auditfix36/无 pin` → `suspend=false / felis:auditfix38 / nodeSelector=localhost.localdomain`;③ 镜像升级 `auditfix37→38`(api/operator + `FELIS_IMAGE`)。
- **流程修正(教训)**:pgint 一度误用**本机 Docker Desktop**(启动 daemon + 临时 PG 容器)——已完全清理(容器/镜像删除、daemon 退出),并改为**经 ssh 隧道用 VM 的 postgres** 运行(`ssh -L 15433:127.0.0.1:5432` → `postgres://felis:***@localhost:15433/felis_pgint?sslmode=disable`)。勿再在本机跑容器。
### 本轮新增真机证据(第二十六批:`felis setup` 重跑向导逐屏 + 构建链 pin 回验 —— 缺陷 #44)
- **范围**:本机已装机,故覆盖"重跑状态屏 + c/s/e 三条 reconfigure 流";首装屏(postgres/owner/connect/edge/storage/smtp/mc-bind/migration/preflight/summary)此前各批已有定点真机证据(安装闭环 / 第二~三批 / 第九批 / 第二十五批),本轮不重复。
- **逐屏走查(auditfix38→39,VM tmux)**:
1. 重跑 → 直落状态屏「✓ Felis is already set up.」(owner/connect 不触碰;host bootstrap 已就绪跳过)✓
2. `c` → 三选一 chooser(Local / Cloudflare+Access / Reverse proxy + 警示语)渲染 ✓,esc 无损返回。
3. `s` → chooser 预选当前后端;S3 分支表单(Endpoint/Bucket/Region/AK/SK + 提示)渲染 ✓。**观察:reconfigure 非只读**——选「Local disk」即 apply(写 /etc 两文件 + 重渲染 felis-config + 滚 API);从 S3 表单 esc 退回会把选择重置为 Local 预选。
4. `e` → SMTP 表单渲染 ✓;esc 直接回状态屏、零副作用(felis-smtp 未创建)✓。
- **缺陷 #44(CLI·重跑框脱落)**:重跑后完成 `s`/`c` reconfigure,落回首装 summary「✓ Setup complete.」——丢了 alreadySetUp 框(smtp 路径有专门分支,storage/connect 漏)。修复 `abb5910`(`showSummary` 透传 `m.result.alreadySetUp`)+ 回归测试 `TestRootReconfigureStorageKeepsStatusFraming`。真机复验(auditfix39):storage reconfigure 完成 → 「✓ Felis is already set up.」+ storage recap ✓。
- **演练事故(自曝;环境 drift,非产品缺陷)**:首次 storage 走查意外触发 Local apply——它从 `/etc/felis/felis.pod.toml` 重渲染 Secret,而构建链 pin 值当初**只热补在 live Secret、不在 /etc 文件** → 重渲染清空 pin(放任则下次构建死在 `:latest` 拉取 + trivy DB egress)。当场修复:pin 值写回 `/etc/felis/felis.host.toml` + `/etc/felis/felis.pod.toml` → 重渲染 Secret → 滚 API;再做第二次 storage apply,重渲染后 pin 仍在(drift 修复耐久)。
- **构建链回验(direct build,真机)**:Job 规格实证 `kaniko=gcr.io/kaniko-project/executor:v1.24.0`、`trivy=aquasec/trivy:0.74.0`、`--db-repository registry.felis.svc:5000/mirror/trivy-db:2`(registry 仍有 `mirror/trivy-db`);`POST /images/build`(context=遗留 `sub-bf7dc18…` blob,内部面取件)→ 202 → **succeeded**;registry `e2e/pins-check` 落位;whitelist 条目已摘除(204)、Job 已清。
- **文档修正(`ae6e925`)**:§8e 原「编辑 felis.toml 后重启 felis-api」不完整(API 挂的是 Secret)→ 改为「写进 /etc 两文件 → 重渲染 Secret → roll」,并写明三种无效/易损做法(只 restart / 只改 host 文件 / 只热补 live Secret——后者会在下次 reconfigure 被冲掉)。
- 收尾:api 1/1、panel 200、tmux 全清。
### 本轮新增真机证据(第二十七批:告警模块落地 —— 内部面 /metrics + 规则集 + 真实构建失败实弹演练)
- **范围**:把"指标 → 规则 → 告警"链路从零补到可交付:API 内部面 `/metrics`(`94f71ee`)、`deploy/alerts/` 规则与 promtool 单测(`43df08b`)、真机实弹演练(本批)。
- **/metrics(`94f71ee`)**:`felis_image_build_failures_total` 此前只在进程内存里、无任何 scrape 出口。修复:internal face(8081)新增 `GET /metrics`(服务面 Public 路由,语义同 /healthz);单测断言外部面 404。真机:port-forward `svc/felis-api-internal 18081:8081` → 200 且含 `felis_image_build_failures_total`;operator `:8080` 提供 `felis_servers_total` / `felis_start_duration_seconds_*`。
- **规则集(`43df08b`)**:`deploy/alerts/felis-alerts.yaml`(plain Prometheus)5 条——构建失败 increase>0 / 起服 p90>300s / 磁盘可用<15% / DiskPressure / 内存可用<10%;`felis-prometheusrule.yaml` 为 prometheus-operator twin(脚本比对两文件 groups 一致);`felis-alerts_test.yml` 为 promtool 单测。VM 上 promtool 3.14.0 实跑:`check rules` + `test rules` 双 SUCCESS。
- **实弹演练(真实构建失败 → pending → firing)**:
1. 打包含 `COPY does-not-exist` 的 Dockerfile 上传到 uploads PVC `sub-alertdrill` → `POST /images/build`(`e2e/alert-drill2:latest`);
2. Kaniko `failed to get fileinfo for /context/does-not-exist` → Job Failed、build 行 `failed`;
3. 真实 Prometheus(宿主 `:19090`)scrape `127.0.0.1:18081`(api) 与 `:18080`(operator) 双 target up;`felis_image_build_failures_total{job="felis-api"}=1`;
4. `FelisImageBuildFailures` pending(activeAt 08:28:14Z)→ **08:33:14Z 准时 firing**(`for: 5m` 精确到期),labels/annotations 完整。
- **清尾(残留全清)**:whitelist `e2e/alert-drill` 摘除(204);`sub-alertdrill` 目录、`/tmp/drillctx`、两个演练 Job、tmux `prom`/`fwd`/`alertpoll`、`/root/prom-drill`(promtool+prometheus 二进制)全删;DB `%alert-drill%` 行删除(whitelist/build 复核 count=0);宿主无残留监听/进程。演练期间 live api 进程内计数器=1(重启归零,属演练事实)。
- **可达性追加**:#44 定级 ①(轻)——已装机环境重跑 `felis setup` 完成 storage/connect reconfigure 即触发。
### 本轮新增真机证据(第二十八批:缺陷 #45 —— 审核门"盲批":评审看不到将被执行的 recipe)
- **缺陷 #45(构建 lane·审核语义)**:被执行的 Dockerfile 永远来自**上传上下文压缩包根目录的 `Dockerfile`**(`build/jobspec.go` 钉死 `--dockerfile=Dockerfile`),API 的 `dockerfile` 字段**仅审计存档**(`submit.auditDockerfile`、`build.Request` 注释均已声明)——但审核者没有任何路径能看到它:`GET /api/v1/submissions` 不含 blob 内容、面板只显示 `context_ref` 文本、内部面取件路由是 service-token(评审用不了)→ "人工审核是门禁"事实上是**盲批**。同批口径缺口:`POST /api/v1/images/build` 的 `dockerfile` 字段在 OpenAPI 里无任何说明(易被当成"将被执行"),面板表单也把该框呈现为"Dockerfile 内容 *"。
- **修复(`168a375`)**:
- 新增 admin-tier `GET /api/v1/submissions/{id}/context`:评审下载与构建 Pod 同源同字节的 `context.tar.gz`;`Content-Disposition: attachment` + `nosniff`(攻击者提供的归档只下载、不渲染);审计 `submission.context.download`(actor=评审者、target=submission id)。
- 内部面取件 handler 共享 `openSubmissionContext`/`streamSubmissionContext`(行为不变,原测试锁定)。
- OpenAPI:新路由 + `/api/v1/images/build` 字段描述补全(明说"执行的是 context 根目录的 Dockerfile;`dockerfile` 仅审计")。
- 面板:SubmissionsPage 展开区新增「下载上下文」按钮(spinner/错误呈现,i18n en/zh);ImageBuildPage 表单补审计说明行。
- 测试:`TestAdminSubmissionContextRoute`(流式/404/503)+ admin-only 矩阵加该路由 + OpenAPI parity 强制文档;面板 117 单测 + 构建、go vet/go test 全绿。
- **真机验证(auditfix41 已部署;owner 会话经 op-login + 内部面代 approve 重铸)**:
- admin 下载 `sub-bf7dc18e9dd97dc2` → **200**,`attachment; filename="context.tar.gz"`、`application/gzip`、`nosniff`;sha256 `205496f2…` **与 uploads PVC blob 逐字节一致**;
- 内部面(Bearer=felis-service-token)同 blob → 200 + 同 sha256(重构未破坏构建取件路径);无 token → 401;
- 有效玩家会话(console host 隔离,仅 adminOnly 生效)→ **403**;匿名 → 401;不存在 id(admin)→ 404;
- 审计落账:`audit_logs` = `[email protected] | submission.context.download | sub-bf7dc18e9dd97dc2`;
- 面板产物:服务端 index.html 引用新构建 `index-CwFSKpgW.js`,bundle 内含新按钮逻辑(grep 命中 3 处)。
- **可达性追加**:#45 定级 ①——每一次真实的"用户提交 → 管理员审核"都会踩到(审核者此前无法查看将被执行的内容)。
- **hook 链补齐(可复用)**:staff 账号走邮件登录门会被设计拒绝(refuse staff)→ owner 会话铸法:`op-login/start`(email=felis-owner@example.com)→ VM 日志 grep `email-otp` 取码(no-Mailer fallback)→ 内部面 `op-login/{id}/approve`(Bearer=felis-service-token;body `approver_uuid`=owner 的 mc_uuid)→ `op-login/finish`(curl -c 存 cookie)。
### 本轮新增真机证据(第二十九批:镜像耐久落地 —— registry 托管 + 回环拉取路径;#46–#49)
- **背景(结论清单第 2 条)**:磁盘压力演练证明 kubelet 会 GC 掉"当前无人使用"的镜像 → ImagePullBackOff,恢复依赖人工重导入。本批让镜像自愈:自建镜像全部托管进内建 registry,节点侧 pull 经回环 hostPort(节点 containerd 到 Service VIP 是死路,实测 "Empty reply")。
- **平台侧 `a9b275a`**:registry 容器端口加 `hostPort 127.0.0.1:5000`;同提交修 **#46** —— registry 独立资源模板(1 CPU / 2Gi):旧模板 256Mi 在实测推 475MB 层时被 OOM kill(dmesg `oom-kill … registry, oom_score_adj=989`,上传中断),2Gi 下同一推送 2 秒完成。
- **安装器侧 `13d64e0` + 文档 `fa0e8d7`**:自建镜像规范 ref = `registry.felis.svc:5000/felis/{felis,limbo,lobby,paper}:demo`;import 进 containerd 就用该名(首启命中本地,免 registry round-trip),`deploy_bundle` 之后统一 `push_images_to_registry` 入仓(推 `127.0.0.1:5000`;registry 只认主机名之后的路径 —— 推/拉落点一致)。`configure_registry_mirror` 写 `registries.yaml`(`registry.felis.svc:5000 → http://127.0.0.1:5000`),内容不变不重启 k3s;`import_registry_image` 预缓存 registry:2(重跑走跳过分支)。迁移 **0021** 把 recommended 白名单重指到 registry ref(线上实查两行已落)。
- **真机三次重跑**(`/opt/felis/src` = 765a892 快照;`FELIS_SKIP_FETCH=1 FELIS_INSTALL_MODE=full FELIS_IMAGE=registry.felis.svc:5000/felis/felis:auditfix42`):
- run1 失败 = **#47**:Mac tar 的 `._*` 旁文件混入构建上下文,`._0004_*.sql` 被 //go:embed → 新二进制的 `felis migrate` 报 `non-numeric version "."`,安装器停在 run_migrations。修复 `5fa8b74`(.dockerignore 排除 `._*`/`.DS_Store`)。复验方式:故意在暂存树留 `._zz_probe_junk.sql` → 重建后 `migrations applied`(过滤生效)。
- run2 失败 = **#48**:每个镜像一对 `systemctl start/stop docker` 触发 systemd 限流(`Start request repeated too quickly / start-limit-hit`),第 4 个镜像(paper)未入仓。修复 `c7e585e`(整批一次 start/stop,单测锚定)。run3 零 `[fail]`:4 镜像全部入仓(push digest ×4 实收)。
- run3 收敛实查:api/operator/reaper = `registry.felis.svc:5000/felis/felis:auditfix42`(registry ref 首滚命中本地 import);registry 模板 1 CPU/2Gi 生效;reaper CronJob 同步换 ref 且 nodeSelector 保留;login/lobby Running 于 registry ref;迁移 applied。
- **GC 演练(本批验收本体)**:
- A 控制面:`ctr images rm …/felis/felis:auditfix42` + `ctr content prune references` → 本地 ref 消失 → `rollout restart felis-api` → 事件 `Pulling` → `Pulled … Successfully pulled image … in 25ms`,ref 恢复、pod Running。
- B 游戏:rm `…/felis/lobby:demo` → 删 `lobby-0` → `Successfully pulled … in 10ms … Image size: 182357358 bytes`,Running。
- #46 复验:整轮重建 + 4 推送期间 dmesg `oom-kill` 计数不变(仍 2,历史)。
- **构建 lane 复验**:`POST /images/build`(context=遗留 `sub-bf7dc18…`;ref `registry.felis.svc:5000/e2e/durable-check2:latest`)→ 202 → succeeded;registry `e2e/durable-check2` tags 落位;Job 事件见 felis/kaniko/trivy 三镜像就绪;白名单条目摘除(204)、Job 清。
- **#49(同一条升级路径的第二类静默回退)**:`write_felis_toml` 重写整表([registry] 仅 url+build_namespace、[archive] 仅 store+local_path)→ §8e 的 kaniko/trivy pin、构建上限、uploads 后端、[registry.s3]、reaper 的 retention/warn_before/max_local_bytes 在重跑时全部丢失(S3 安装切回 local、构建回退被 egress 拒绝的上游 executor)。修复 `765a892`+`b8e554d`(沿用 [smtp]/[[auth_source]] 的 carry 模式;url/build_namespace/store/local_path 保持安装器所有)。真机复验:预置 `retention = "30d"`,run3 后 host toml / pod toml / felis-config Secret 三层都在,且构建 lane 直接用 carry 的配置跑通(上条)。
- **可达性**:#46 ①(用户构建大层或安装器入仓即触发;实测 475MB 层);#47 ②(Mac 打包树构建路径,真机踩中);#48 ②(安装器重跑,真机踩中);#49 ②(升级=重跑安装器,真机踩中)。
- **口径/遗留**:`demo-up.sh` 未改(dev/demo 路径,本地 tag 导入维持原状);kaniko/trivy 编译默认值未动(§8e 改为"镜像进 registry"配方);registry 2Gi 为渲染常量(暂未开 flag);drill 残留:registry 里 `e2e/*` 小镜像留档,`durable-check2` 白名单条目已摘。
### 本轮新增真机证据(第三十批:缺陷 #50 —— `[smtp]` carry 吞掉下一节的注释块,每次重跑 +1)
- **发现路径**:为 `felis setup` 首装连续走查做前置盘点时读 live 配置——`/etc/felis/felis.host.toml` 已经堆了 **3 份**、`felis.pod.toml` **4 份**重复的 Yggdrasil 注释块(同一段文案逐次叠加);用脚本自带的提取器实测:一次重跑会把 3 份全部当作 `[smtp]` 内容带走,再叠一份模板注释 → **每次重跑 +1、无上界**(host/pod 增速不同步,现场 3 vs 4 即历史残迹)。
- **根因**:`persisted_smtp_block` 打印"`[smtp]` 到下一个 section header 之间"的**所有行**;generated 注释块正好落在这段区间里 → 被吞并。同文件的自称"cached on first call"缓存因写方是命令替换(子 shell)从未生效,pod 写实际上重复抽取刚被重写的 host,加剧了两文件的不同步。纯注释膨胀、无功能损失,但属 #49 同族 carry 语义缺陷(把不属于自己的内容也带走了)。
- **修复 `4d3c85f`**:carry 改为白名单(section header + 键行),与 #49 的 `[registry]`/`[archive]` 提取同型;删掉失效缓存说明。`deploy/bootstrap_test.sh` 新增用例:配置值被携带 / 不吞注释行 / 不越节 / 写回后二次抽取**字节稳定**(幂等)。
- **真机验证(auditfix43,两次连续全量重跑)**:
- run1(`bootstrap-auditfix43.log`):零 `[fail]`;host 注释块 **3→1**、pod **4→1**;与 run 前快照 diff 恰为 21/31 行(全部是被删掉的重复注释)——值零漂移(kaniko/trivy pin、`trivy_db_repository`、uploads local、`[registry.s3]` 空、`retention="30d"`、auth_source 原样、空 `[smtp]` 节保留;两文件 db host 仍分别为 127.0.0.1 / 10.211.55.6)。
- run2(`bootstrap-auditfix43b.log`,紧接再跑):零 `[fail]`,仍 **1/1** —— 收敛证明(旧代码此处会 1→2 继续增长)。
- 部署同步:api/operator = `registry.felis.svc:5000/felis/felis:auditfix43`;reaper CronJob 同 ref 且 nodeSelector 保留;registry `felis/felis` tags = auditfix41/42/43;panel 200;host 二进制已从新镜像提取。
- **可达性**:#50 ②——配置过 email(存在 `[smtp]` 节)的安装,按文档升级=重跑安装器即触发;危害=配置注释无限膨胀(每次 +1),无功能损失。
### 本轮新增真机证据(第三十一批:首装连续走查 + 缺陷 #51 —— 工作负载 `felis-config` 副本永不刷新)
**一、`felis setup` 首装单次连续走查(队列第 1 项,完成;0 缺陷)**
- **装置**:scratch 库 `felis_scratch`(新建 + `felis migrate up` 21 条迁移)+ scratch 配置(真实 pod toml 副本,仅换库名;root 0600);hook 直插 `account_link_codes` 一枚绑定码(等价 `/link` 内网端点写入,代替"进服拿码");tmux 驱动 `felis setup -config`;k8s 只读复用(登录门 Ready 等待通过)。
- **连续走查(一条会话走完)**:Preflight(自动:PG✓/迁移 21 applied/面板✓)→ **MC 绑定**(输码 → working →「✓ Owner account is ready.」+ 一次性 setup URL)→ **连接 chooser**(Local)→ **存储 chooser**(Local → working → ~20s 后「✓ Local storage configured.」,含 Secret 重渲染 + API rollout)→ **首装 Summary**(「✓ Setup complete.」+ owner/setup URL/access/storage/panel 卡片 + c/s/e 提示)→ **轨道回顾**(← 依次只读 recap Storage→Connection→Owner→Preflight,→/esc 回到前台)→ Enter 退出 → stdout 汇总(`Owner account … provisioned (passwordless)`、`Recorded as "root"`、setup URL、Admin console)→ **EXIT=0**。
- **结果**:移动端/文案/切换全部符合设计,**0 新缺陷**;scratch 库侧复核:owner(role=owner) 1 行、绑定码已消费(0)、setup_tokens 1、`local_auth_enabled=true`。
- **副作用与还原(如实记录)**:存储 apply 会把 `/etc/felis` 两文件重写为 Go encoder 形态(无注释、含空值键如 `[archive.s3]`,**值零漂移**:kaniko/trivy pin、uploads local、retention 30d、auth_source 原样),并重渲染控制面 Secret + 滚 API——这是该向导的既定行为;演练后按 pre 快照整文件还原(sha256 逐字节一致),两 ns Secret 重渲染复核一致,scratch 库/配置/hba 行/tmux 全部清理。
**二、缺陷 #51(工作负载 `felis-config` 副本永不刷新)**
- **发现路径**:上条还原核对时发现 minecraft ns 的 `felis-config` 是**旧形态**(encoder 式)而 felis ns 已是模板式 → 挖出 `ensureSecretReplica` 的「绝不覆盖既有副本」(凭据语义:防冲掉手工轮换值)把 **felis-config 也纳入只建不更**,而 bootstrap 只 apply 控制 ns。后果:改配置后(DB 凭据轮换、[archive] 保留策略调整、root domain 等)backup/restore/fileedit Job 与 reaper 永远读旧副本 → 静默失效(如备份认证失败)。
- **红证据(auditfix43,真机)**:向 minecraft 副本注入 `# drill-51-stale-marker` → 运行 `felis setup` → 输出 `- config (minecraft ns): skipped (already exists)`;副本 sha `5ec2ff6e…` 保持,控制面 `e4791fe1…` 不同(陈旧坐实)。
- **修复 `328e570`**:① setup 侧:`ensureSecretReplica` 增 `refreshExisting`(仅 felis-config 传 true)——源缺失降级 skip、内容一致 skip(`already current`)、不同则原地 Update;凭据类保持 create-if-absent;新增 `updated` 结果与「refreshed from the control namespace」文案。② bootstrap 侧:新增 `apply_felis_config_secrets()`,每次运行同时 apply 控制 ns + 工作负载 ns 两份(同渲染自最新 pod toml)。单测:Go 新增 4 例(陈旧刷新/一致跳过/空键补写/源缺失降级);`bootstrap_test.sh` 新增 4 断言(两 ns apply、同一 pod toml 渲染、恰 2 次 apply)。
- **绿证据(auditfix44,真机)**:A) 安装器重跑(标记仍在副本中)→ 日志出现 `secret/felis-config configured`(工作负载 ns 被刷新)→ 副本 sha 与控制面一致、标记 0;B) 再注入标记 → 运行**新** `felis setup` → 输出 `- config (minecraft ns): refreshed from the control namespace`、凭据仍 `skipped (already exists)`、副本 sha 一致、`EXIT=0`。部署=auditfix44(api/operator/reaper),panel 200,host 二进制随镜像 `docker cp` 刷新。
- **可达性**:#51 ②——升级=重跑安装器、或改完配置跑 setup 即触发;旧行为下 backup/reaper 静默使用旧配置(DB 轮换后备份全挂)。
**三、环境修复(非产品)**:现场 pg_hba 缺 `felis_pgint` 规则(按文档走 ssh 隧道跑 pgint 会 ident 失败)——补回 `host felis_pgint felis 127.0.0.1/32 scram-sha-256` 并复测连接成功。
### 本轮新增真机证据(第三十二批:S3 存储向导逐屏走查收尾 + 缺陷 #52 —— 向导 apply 不刷新工作负载 `felis-config` 镜像)
**一、S3 存储向导屏走查(队列第 2 项,完成;0 功能缺陷,走查自身暴露镜像缺口 → #52)**
- **装置**:VM 宿主 MinIO 容器(`quay.io/minio/minio`,`felis`/`felis-drill-9000`,:9000)+ bucket `felis-wizard-uploads`;tmux 驱动 `felis setup` 重跑向导;行动前先留 preS3 快照(两 toml + 两 ns Secret + sha256)。
- **负例**:错误凭据 → `✗ Could not save storage settings.` + `submit: s3 credentials rejected: The Access Key Id you provided does not exist`(`CheckS3Access` 预检先于一切写入);**零副作用**(无 Secret、两 toml sha 不变);`esc` 返回编辑时已填值保留(密钥掩码)✓
- **正例**:修正凭据 → ~10s working → `✓ Object storage configured.` → Enter → 状态屏 `storage s3://felis-wizard-uploads · http://10.211.55.6:9000` ✓
- **落地核对**:`felis-uploads-s3` Secret(access_key_id/secret_access_key);两 toml `user_uploads_context` + `[registry.s3]` endpoint/refs;API 滚动;启动日志仅既有的 smtp/jwks 警告 ✓
- **功能链(batch24 同款)**:player.test 邮箱 OTP 登录(hook 取码)→ `POST /api/v1/me/submissions` 201 → context 上传 200 → MinIO 桶实见 `sub-853a4e2ba4ba6443/context.tar.gz` → 内部面取回 200 + tar 内容正确 ✓
- **UI 回滚(`s` → Local)**:两 toml 归位(`/var/lib/felis/uploads`、`[registry.s3]` 归空)、控制面 Secret 更新、API 滚毕;与 preS3 快照的差异仅「s3 字段回环 + encoder 形态」(注释丢失属该向导既定行为)——**值零漂移** ✓
- **观察(不计缺陷)**:切回 Local 后 `felis-uploads-s3` Secret 残留(演练按清理流程删除;是否自动清理属产品取舍)。
**二、缺陷 #52(向导内 apply 只刷新控制面,工作负载镜像滞后到下一次运行)**
- **发现路径**:回滚后按计划复核「两 ns 重渲染」——minecraft 副本仍为 S3 内容(`4fb80ed8…`),而控制面与两 toml 已 local(`df206074…`)。
- **红证据(auditfix44)**:① S3 方向:S3 apply(20:18:44)后副本停在 local 内容(20:22:53 快照 = `e4791fe1…`)达 4 分钟;② Local 方向:回滚 apply(20:23:41)后副本停在 S3 内容。副本 managedFields 两笔写入(12:13:58Z `kubectl-client-side-apply` = 安装器 rerun 的双 ns apply;12:23:06Z manager `felis` = 下一次 setup 启动的 refresh)都不是 apply 时刻——**apply 本身不碰副本**。
- **根因**:`applyFelisConfigSecret`(storage/connection/edge 三条 apply 的公共出口)只 apply 控制 ns;镜像刷新只存在于 `felis setup` 启动(#51)与安装器。email 路径早有显式镜像刷新,storage/connection 是漏网的两条。
- **修复 `de7fb2c`**:镜像刷新移入 `applyFelisConfigSecret`(best-effort + stderr 警告;控制面-only 安装无工作负载 ns 时降级不阻塞);smtp helper 去掉重复块。门禁:`gofmt`/`go vet`/`go test ./...`/`bootstrap_test.sh` 全绿。
- **绿证据(`v0.0.0+fix52`,宿主二进制 sha `0bd49467…` 已装 `/usr/local/bin/felis`,旧版留 `/root/felis-auditfix44.bin`;真机双向)**:Run1 从 Local `s`→S3:apply 后 `control = mirror = podtoml = 4fb80ed8…`(S3 渲染;旧代码此刻镜像会停在 local);Run2 `s`→Local:`control = mirror = podtoml = df206074…`;副本 managedFields 写者 = `kubectl-client-side-apply` @ 12:33:40Z / 12:34:59Z(正是 apply 时刻)。Run1 启动块另见 `config (minecraft ns): refreshed from the control namespace`(#51 机制照常先收敛一次旧账)。
- **可达性**:#52 ②——任何 `s`/`c` 重配置即触发;危害等级低(镜像消费者 backup/restore/fileedit/reaper 当前不读被改动字段——`UserUploadsContext` 仅 `api.go` 消费——但「配置动了、镜像没动」正是 #51 要消灭的静默滞后类,且与 email 路径的既定行为不一致)。
**三、清理与还原**:MinIO 容器/卷/两镜像、`felis-uploads-s3` Secret、DB 行(`image_submissions` `sub-853a4e2ba4ba6443`)、/tmp 残留(player-cookies/drill-ctx/svc-tok 等)全清;docker 停;收尾 `control = mirror = df206074`(ALIGNED)、API 滚毕 Running、panel/healthz 200 ✓。
### 本轮新增真机证据(第三十三批:`/updates` 维护窗口 API+UI 走查全绿;#53 跟随仓库迁移;#54 update 升级指引全假)
**零、仓库迁移(背景)**:remote 已改 `[email protected]:FelisMC/Felis.git`(本批两个修复随 `2c6739a`、`397a400` 直推 main)。带 token 实测:旧 `MliroLirrorsIngenuity/Felis` 路径 301(GitHub rename redirect)、新路径 200——旧坐标当前仍能工作但全靠 redirect。新仓库**尚无 stable release**(`releases/latest` 带 token 也 404):felis-api 更新检查的 404 属发布流程事实,非代码缺陷。
**一、`/updates` 维护窗口 API 走查(完成;全绿)**
- 读:GET 未设置 → `{"not_before":null,"not_after":null}`。
- 负例全按预期拒绝:半设 / 倒序 / 相等 / 坏 JSON / 未知字段 → 400;缺 `Content-Type` → 415。
- 正例:写入 → 回读一致 → **API pod 重启后仍在**(已落 DB,非内存态)。
- 鉴权:无 cookie → 401;player 会话 → 403(新铸 player 会话,留 `/tmp/player-cookies.txt`)。
- 收尾:DB 回到 `{null,null}`。
**二、`/updates` 面板 UI 走查(CDP,完成;全绿零 console 错误)**
- 状态流转逐屏:生效中 → 过期 → 计划 → 未设置(截图 `/tmp/updates-{1..5}-*.png`)。
- 倒序提交 → 校验文案正确;清除后表单清空 + 「未设置」提示。
- 核账:DB 收尾 `{null,null}`;审计 `updates.window_set` 恰 3 笔(20:46:18 / :20 / :21)。
- 驱动:`/tmp/cdp-updates2.js`(bun + 原生 WebSocket;须 `Network.setCookie` 注入 owner 会话,否则新标签页 401 跳登录)。
- 观察(不计缺陷):窗口自身存取已验证;「窗口被 runner 消费」的端到端链路(Applier/Notifier)仍属 INTEGRATION-ONLY 设计(deferred-seams),不要当缺陷重复修。
**三、#53(旧仓库坐标残留)—— 提交 `2c6739a`**
- 红(`v0.0.0+fix52` 实机):`felis update` → `github: MliroLirrorsIngenuity/Felis releases/latest returned HTTP 404 — …`。
- 绿(`v0.0.0+fix53` 实机):同一命令 → `github: FelisMC/Felis releases/latest returned HTTP 404 — …`。
- 范围:`internal/updater/topology.go`、`deploy/bootstrap.sh`(默认 `FELIS_REPO_URL` + 两处 UA)、README ×2、两个测试夹具(6 文件 9 处);门禁四件套全绿。
- 可达性:②——默认安装 / 每次 `felis update` 都读该坐标;危害在 redirect 退休时兑现(安装与更新一起挂)。
**四、#54(`felis update` 的 apply 指引在既有安装上全是错的)—— 提交 `397a400`**
- 红(`v0.0.0+fix52` 实机文本):`--panel --force` → `run: sudo felis setup` + “felis setup is idempotent and re-runs the installer…” + felis-api「例外」块。
- 决定性证据(完成态安装):`felis setup` 跑 8s 退出,`/opt/felis/velocity/velocity.jar` mtime/hash 不变、零 bootstrap 输出;同刻安装器重跑日志有 `resolving the newest Velocity 3.5.1 build`。代码侧 `shouldRunHostBootstrapBeforeConfig` 仅在 4 个 marker 不全时进 bootstrap——既有安装上 `felis setup` 只开配置 TUI。
- 绿(`v0.0.0+fix54` 实机;宿主二进制已换 `/usr/local/bin/felis`,fix52 留档 `/root/felis-fix52.bin` sha `0bd49467…`):
- `--panel --force` / `--velocity --force` → `run: curl -fsSL https://raw.githubusercontent.com/FelisMC/Felis/main/deploy/bootstrap.sh | sudo bash` + 单条 trailer(channel 未持久化 caveat、私有仓库 token'd form、`felis setup is not this path`);
- `--mc` 无命令无 trailer;`--all` trailer 恰一次;`--force`/`LatestKnown` 语义不变。
- 文案同步:`docs/troubleshooting.md §15` 删掉 “or `sudo felis setup`”、补 channel caveat;测试改为 `TestApplyGuidancePointsEveryComponentAtTheInstaller`(钉安装器路径 + 禁 `run: sudo felis setup`)。
- 可达性:②(文档/指引;同 #38 型)。
### 本轮新增真机证据(第三十四批:`felis nano` 全链走查 60/60;缺陷 #55 —— 坏源静默挡住验证梯子)
**零、装置(全部在 VM,不进产品环境)**:`/srv/nanotest/nano-stub.py`=可控 Yggdrasil 源,路径即行为——`/ok/<name>`、`/okid/<name>/<id>`、`/props/<name>`、`/badname/<case>`(tooshort/toolong/badchar/section/noname)、`/none`=204、`/boom`=500、`/redir`=302、`/garbage`、`/emptyid`、`/slow/<secs>`;跑在 127.0.0.1:9900,临时单元 `nano-stub`,请求日志 `/srv/nanotest/stub.log`。驱动脚本:`nano-matrix.sh`(HTTP 矩阵 S1–S7)、`nano-service.sh`(systemd 层)、`nano-config.sh`(配置校验)、`nano-fw.sh`(firewalld);`nano-harness.sh` 从 HEAD 版 `bootstrap.sh` 提取产品函数在 scratch 路径跑(`felis-nano-test` 改名件,不碰产品单元)。
**一、HTTP 矩阵 S1–S7(60 项断言)**
- 红(修复前 `v0.0.0+audit-nano1`):`SUMMARY pass=47 fail=13`——13 红全在 #55 语义内:5 个坏名用例「应 503+日志、实得静默 204」(状态+日志 ×2=10),failover 3(坏源在前登录不落第二源 ×2、无日志 ×1)。留档 `matrix-red.r2.log`。
- 绿(fix55,同装置重跑):`SUMMARY pass=60 fail=0`。留档 `matrix-fix55.r2.log` 与 `matrix-fix55/`(更早原跑在 `matrix/`)。
- 覆盖面:S1 输入校验(缺参/超长→204、POST→405、未知路径→404、带体 GET→400、HEAD→204、400 带 `Connection: close`)、S2 三方登录全链(前缀 + felisAuthNS UUID 确定性、ip/serverId 转发保真)、S3 premium 撞名改名(`LS_`)与免费名不动、S4 失败模式(不可达/500/302/garbage/204 → 跳过、记日志、503)、S5 多源优先级 + 坏源后 failover、S6 日志纪律(长 URI 截断、控制字节转义)、S7 properties 转发。
**二、systemd 层(`nano-service.sh`,真 unit + 真 DynamicUser;重跑落盘 `nano-service.r2.log`,0 FAIL)**:装服务→起服务;重跑语义(配置字节不动、`resolve_nano_listen` 从 unit 读回端点);drain(stop 期间 in-flight 登录照样答完,实测 stop 等待 5335ms);SIGKILL 后自动重启;端点迁移 8081→8099 后新址应答、旧址关闭;三种启动文案(loopback「Bound to loopback」/ CIDR「admits」/ 无 CIDR「WARNING」)。
**三、配置层(`nano-config.sh`,LoadNano 16/16,落盘 `nano-config.r2.log`)**:未知键、空/重复/保留 tag、colon、空白、坏 prefix、重复 prefix(大小写不敏感)、非 http scheme、URL 带 query、明文公网 http、缺文件、`[server] listen` 忽略警告、Mojang-only 可服务。
**四、firewalld(`nano-fw.sh`,3/3,落盘 `nano-fw.r2.log`)**:关旧「开全源」8081/tcp、仅对 CIDR 开 rich rule、loopback 不开洞、无 CIDR 警告、快照清理还原。
**五、缺陷 #55(提交 `9dad61f`)**
- 红(fix54 实机):配置源回 200 但档案字段不可用(三方源名字进不了 MC 字符集;身份源 UUID 不解析)→ 静默 204、零日志;且筛查在 `handleHasJoined` 直接 `return` → [坏源, 好源] 顺序下 204,好源根本没被询问。
- 根因:两个 200-后筛查在 handler 层(200 已赢下梯子之后),而同类坏答案(200 无档案/非 200/不可达)在 `resolveHasJoined` 里是「记日志 + skip + failed」——一致性缺口。
- 修复:筛查移入 `resolveHasJoined`(身份源 `uuid.Parse`、三方源 `mcUsernameRe`)→ 命中记日志(`unusable profile name` / `unparseable profile id`)+ `failed=true` + `continue`;handler 守卫保留为最后防线(注释更新)。单测 +3(含 `log.Writer()` 捕获断言)。
- 可达性:#55 ②——需要配置了一个「回 200 但字段不可用」的源(马虎的自建/三方 Yggdrasil);坏源在前时经梯子的登录全部被吞(静默、零日志),安全侧(坏名不落玩家列表)保留。
**六、观察(不计缺陷)**:premium 冷查询 fail-closed 抖动——api.mojang.com 从 VM 首查偶发接近 2s 超时 → 偶按「疑似 premium」给免费名加前缀(`FelisNanoStub1` → `LS_FelisNanoStub`);符合 `isPremiumName` 既定取舍(误加前缀=外观代价,误放行=抢名),记观察不修。
### 本轮新增真机证据(第三十五批:NetworkPolicy 真机强制矩阵全绿 —— 端到端白名单闭环 + felis-velocity 刷新存活验证)
**零、装置与现场**:三张策略 live 于 `minecraft` ns(`felis-default-deny-ingress` / `felis-allow-rcon-from-control-plane` / `felis-allow-game-from-velocity`;apply 于 09-22T05:37:24Z,与渲染收敛 diff 零漂移);k3s 参数无 `--disable-network-policy`,**强制执行实测生效**;kube-router 机制实证:per-pod `KUBE-POD-FW-*` 链、未标记流量 `REJECT --reject-with icmp-port-unreachable`、每链首条 `--src-type LOCAL -j ACCEPT`(本节点豁免)、ipBlock 落 ipset `KUBE-SRC-*`(白名单成员可直接查)。探测法:**nsenter 进真实 pod 网络命名空间(源 IP=真实 pod IP)+ netns/veth 合成「转发型外部源」10.99.0.2(模拟 velocity 在另一台机器)**;全程不改产品代码。
**一、pod 源矩阵(源=真实 pod netns)**
- api(felis,命中 RCON 白名单标签)→ lobby:25575 = **OPEN**;同源 → lobby:25565 = **REFUSED**(端口级区分 ✓)
- registry(felis,非匹配)→ lobby:25575/25565 = **REFUSED**;同 pod → api:8081 = OPEN(对照:无策略命名空间不受限)
- coredns(kube-system)→ login:25565 = **REFUSED**
- 收尾复跑全矩阵与首轮逐行一致(`np-matrix-run1/2.log`)。
**二、host(=velocity 同机侧)**:→ login/lobby 的 ClusterIP 与 podIP :25565 **全 OPEN**(velocity 注册的后端路径实际可用);→ api-internal:8081 OPEN;→ lobby:25575 亦 OPEN——归因:kube-router 每 pod 链的 `--src-type LOCAL -j ACCEPT`(**本节点流量豁免,kube-router 设计行为**,kubelet 探针等依赖它;netpol 语义无法对节点自身收口,节点 root 本在 TCB 内)。**注记①:node-local 豁免。**
**三、转发型外部源(最严苛模拟)**
- 基线:netns(10.99.0.2) → 全部服务器端口(podIP 与 ClusterIP、25565/25575)= REFUSED,包级可见 netpol 的 icmp-port-unreachable(`td3.log`)。
- **白名单闭环**:临时把 10.99.0.0/24 加入 allow-game → ipset 即时生效(members:`10.99.0.0/24` + `10.211.55.6`)→ **lobby-svc:25565 与 login-svc:25565 = OPEN**(ClusterIP=velocity 实际拨号形态);25575 仍 REFUSED(端口维度不破)→ 回滚 → spec 哈希逐字节一致(md5 `e6b07440…`)、ipset 复原、复测全部 REFUSED。
- 层间归因:firewalld 规则含 `ct status dnat accept`(**DNAT 后的服务流量放行**;非 DNAT 转发走 forward policy 的 `admin-prohibited` 拒绝)——受支持拓扑(velocity 同机=LOCAL、ClusterIP、Mac→NodePort 面板 200)全部实测可用;「外部未经服务直连 podIP」不属于任何产品流。**注记②:firewalld 只放 DNAT 服务流。**
- 装置保养:veth 未归区时 firewalld 会拒其转发属装置噪声(已归因);public 区临时挂载已摘、netns/veth 已删、策略零残留(spec diff 为空)。
**四、felis-velocity 刷新循环存活(顺带验证)**:20:18–20:42 的 `server list refresh failed` 全部落在 #52 演练的 API rollout 窗口(成功不打日志属设计);tcpdump 450s 窗口抓到 52 条 `GET /api/v1/servers` 载荷行(成对出现=双抓包点看到同一请求,折算约 26 次 ≈ 每 15–17s 一次,与 `REGISTRATION_REFRESH=15s` 常量吻合)及对应 200 响应;"keeping current registrations" 为设计降级。非缺陷。
**五、留档(VM `/srv/npdrill/`)**:`np-matrix.sh`+`np-matrix-run1/2.log`、`np-netns.sh`、`netns-probe.py`、`np-cidr-test.sh/.log`、`spec-before/after.json`、`td3.log`(包级归因)、`tcpdump-8081.log`(刷新存活)、`iptables-save.txt` 与 `nft-rules.txt`(现场快照)。
### 本轮新增真机证据(第三十六批:面板错误文案全量本地化(#60)+ Run4c 管理面写操作复核 0 缺陷)
**零、现场**:三个缺陷批次(#56–#59)已按「一缺陷一 commit」推 main 并部署 `auditfix59`;本批完成 #60 后部署 `auditfix60`(api/operator/reaper 三处 + 宿主 CLI,`felis version` = v0.0.0+fix60)。面板门禁:typecheck 0 错、vitest 117/117、vite build 通过(注意项目测试是 `bun run test`=vitest run;裸 `bun test` 是 Bun 内置 runner,解析不了 `@/` 别名,41 个 fail 系假象,勿误报)。
**一、Run4c 管理面写操作复核(4 段全真机,最终 0 缺陷;3 处为测试脚本自身误报,均已自纠)**
1. **镜像添加+删除**:添加 `registry.felis.svc:5000/e2e/probe:1` → 列表出现、API 落库(`added_at=14:50:53Z`)✅。删除初测"未生效"系脚本用 `tr` 找行——该表是 `div` 网格,`tr` 选择器命中 0 → 从未点到按钮。换 `div[class*=grid-cols-12] → button[title]` 重测:confirm("确定删除此镜像吗?")自动接受 → 页面 1ms 内刷新、**API 侧同 ref 从 5 条降至 4 条**——删除通道无缺陷。
2. **构建负例**:外部 registry(`docker.io/...`)+ 不存在 context → 对话框红字拒绝 `build: invalid request: image reference … must target the internal registry "registry.felis.svc:5000"`(截图 `run4c-3`)。脚本"未捕获"系其正则先命中了侧边栏"镜像"二字——误报。
3. **创建重复用户**:对话框内正确显示 **"该用户名已被使用。"**、对话框保持打开(截图 `run6-1`)。此前 run4c 的"dialog-closed"系脚本点错页面(进到了用户详情页);且该轮实际是一次**正例**:真管理员的 username 是 UUID 串(`08595879-…`,role=`owner` 是角色名),字面用户名 `owner` 当时空闲、创建确实成功——测试件已删(DELETE→200,列表 7→6)。
4. **创建重复子域名**:正确拒绝 **"该子域名已被占用。"**(截图 `run4c-5`)✅
**二、缺陷 #60(提交 `c9af548`):37 个用户可达错误码显示生英文**
- 发现路径:run4c 复核 `email_taken` 时做了一次系统性对照——后端 `newError` 共 **71 个错误码**,面板 `humanizeError` 只映射 **23 个**;其余走 `default` 分支直接透传 `err.message`(多为 Go 包装嵌套的英文,如 `invalid server name: naming: invalid server name "BadName": must match ^[a-z0-9-]{3,32}$`)。
- 三个代表真机验证(修复前→修复后):
- `bad_name`:新建服务器"名称"填 `BadName`(前端只查非空)→ 前:对话框直出上述嵌套英文;后:**"服务器名称不合法:需为 3–32 位小写字母、数字或连字符,且不能使用保留名。"**(`run8-1`)
- `bad_subdomain`:子域名填 `ab`(前端 RE 允许 1–2 字符,后端要求 ≥3)→ 后:**"子域名不合法:…"**(`run8-2`)
- `email_taken`:DB hook 造"他人已验证邮箱" → 账户页真发码(no-mailer 日志取码)→ verify 409 → 后:**"该邮箱已在其他账户上完成验证;请直接用该邮箱登录,或换一个地址。"**(`run9-1`)
- 修复:api.ts +37 case、zh/en errors.json 各 +37 键(纯映射,无逻辑变更)。**剩 11 个故意不补**:`bad_request`/`conflict`/`internal`/`panic`/`not_found`(通用兜底)、`forbidden`/`not_admin`/`unauthorized`(状态码分支已在 default 覆盖)、`not_ready`(内部面)、`setup_required`(bootstrap 期 SPA 流控信号,403 文案可用)、`unsupported_media_type`(CSRF 门,面板永不可达)。
- 遗留观察(不计缺陷):构建负例的英文前缀 `build: invalid request:` 仍会透传——该错误无独立码、message 即最终文案;对管理员可读,记观察。
**三、观察(不计缺陷)**
- UserDetailPage 对 owner/对自己都显示删除按钮;真的点了会得 403 + 具体 message("the owner account cannot be deleted from the panel"),但前端 403 分支统一显示"你无权执行此操作"——笼统但不算错,记观察。
- 字面用户名 `owner` 可注册(用户名无保留名单;角色由服务端管理、无提权路径),记观察。
**四、留档(Mac)**:脚本 `/tmp/cdp-run5.js`(镜像删除复测)、`run6.js`(重复用户复测)、`run7/run8.js`(bad_name/bad_subdomain 前后对照)、`run9b.js`(email_taken,内置 ssh 取码);截图 `run5-1`/`run6-1`/`run7-1/2`/`run8-1/2`/`run9-1`。VM `/opt/felis/src` = `c9af548` 快照。
## 结论:离"生产可用"还差什么(按优先级)
1. ~~构建链路的上下文通道~~ ✅ **已修**(`f79e5eb`/`02fd2de`,真机全链路含拉回校验;Trivy DB 需按 §8e 镜像一次)。
2. ~~镜像耐久~~ ✅ **已完成**(第二十九批:`a9b275a`/`13d64e0`/`fa0e8d7` + `c7e585e`;三次真机重跑 + GC 两演练——控制面与游戏镜像被 GC 后自动回拉;同批修复并复验 #46/#47/#48/#49)。
3. ~~PG 级契约测试~~ ✅ **已落地**(`2a55a0d`):`internal/pgint`(`-tags pgint`,需 `FELIS_TEST_PG_URL` 指向名字含 `pgint` 的库,harness 会 drop schema + 重放真实迁移)已覆盖会话/OTP/op-login/绑定码/submission/build/owner 角色;**首跑即抓到 #20**(索引与 ErrEmailTaken 从未存在)。运行方式见 CONTRIBUTING.md。
4. ~~面板把 /jobs 显示出来~~ ✅ **已完成**(`97a64c8`,备份页「最近操作」卡,正是它把 #35 暴露出来的)。
5. ~~多节点回收~~ ✅ **已修**(`daf7602`:`--reaper-node` → CronJob pod `kubernetes.io/hostname` nodeSelector,真机 render/dry-run/收敛 diff 三连;单节点部署不传即维持原状)。附带核销缺陷 #38(渲染提示里过期的 uid-1000/setfacl 指导)。
6. ~~告警~~ ✅ **已完成**(`94f71ee` + `43df08b` + 第二十七批实弹演练:真实构建失败 → pending → 08:33:14Z firing;规则随 `deploy/alerts/` 交付)。
7. ~~玩家可见的构建结果~~ ✅ **已修**(`72c4aa3`,缺陷 #36:列表路由附 `build_status`/`build_error`,面板「我的提交」展开行呈现,真机双例验证)。
8. ~~面板文件编辑器入口~~ ✅ **已补**(`0a36b3f`,缺口补齐,真机 CDP 全链)。
9. ~~fleet 系统服务死操作~~ ✅ **已修**(`2f90851`,缺陷 #37)。
10. ~~S3 上传通道演练 + 存储向导逐屏~~ ✅ **已演练**(第二十四批:上传通道全链、0 缺陷;第三十二批:向导屏逐屏 + 上传取件 + UI 回滚,0 功能缺陷并修 #52)。
11. ~~breakGlass 控制台逐屏~~ ✅ **已演练**(第二十五批:菜单 4 操作 + bootstrap 分支,缺陷 #39–#43 全修全验)。~~`felis setup` 全屏向导逐屏~~ ✅ **重跑侧已逐屏**(第二十六批),**首装侧连续走查也已完成**(第三十一批:scratch 库单次连续走完全部屏幕 + 轨道回顾,0 缺陷)。
12. ~~`felis nano` 全链~~ ✅ **已走查**(第三十四批:HTTP 矩阵红 13→绿 60/60;systemd/配置/firewalld 三层重跑全绿;同批修复 #55——坏源静默挡住验证梯子)。
13. ~~NetworkPolicy 真机强制矩阵~~ ✅ **已演练**(第三十五批:pod 源端口级矩阵全绿、转发型外部源「拒→放→拒」白名单闭环、host/ClusterIP/NodePort 路径全通;两条已归因注记——node-local 流量豁免、firewalld 仅放 DNAT 服务流)。
14. ~~面板错误文案全量本地化~~ ✅ **已修**(第三十六批:#60,37 个用户可达码补齐双语映射,三类真机验证;管理面写操作 Run4c 复核 0 缺陷)。
## 剩余待演练队列(截至第三十六批)
- **reaper 多节点实机**:`--reaper-node` 渲染/dry-run/收敛已过(第二十三批);真实多节点调度行为需要第二台机器组双节点集群,单节点环境覆盖不到(已知缺口)。
- **面板交互级收尾**:全路由渲染级巡检(14 路由)第十三批已过;管理面写操作(images/builds/users)第三十六批复核 0 缺陷。剩:`/admin/submissions` approve/reject 交互、`/admin/updates` 深挖、用户会话撤销等按 CDP 逐页补完(此前只读加载均正常)。
- **`demo-up.sh`**(可选):dev/demo 路径,第三十二批口径未改。
(真实游戏客户端进服已按决定跳过,用内部 mint/approve hook 链替代。)
## 系统性观察
- **PGRepo 与接口契约/fake 漂移**(#16/#17/#18):`fakeRepo` 与接口注释是对的、PG 实现在细节上落后,单测全绿也发现不了。建议后续引入 PG 级契约测试(testcontainers 或针对关键写路径的集成测试),重点覆盖「接口注释承诺了字段/错误码/生命周期」的方法。
- 部署知识:交叉编译必须先把 `panel/dist` 拷进 `internal/panel/static` 再 `go build`,否则镜像内 SPA 缺失(页面显示 “assets were not built”)。本批镜像为 `felis:auditfix7`。
## 复现入口速查
- 面板会话 cookie:`/tmp/owner-cookies2.txt`(2026-09-23 铸;auditfix42 部署后仍有效;staff 邮件门被设计拒绝 → 走第二十八批的 op-login + 内部面 approve hook 链);API 助手:`/tmp/fcurl.sh`
- 测试服:`test-one`(minecraft ns,stopped);合法备份 `bk-47ee2e7e96e5a4ca9d0e51b805518bac`
- CDP 调试口:Mac `127.0.0.1:9333`(独立 Chrome,profile `/tmp/felis-chrome2`);WebAuthn 虚拟认证器需在**同一 CDP 会话**内完成仪式,且先 `Page.bringToFront`(否则 NotAllowedError: page does not have focus)
- 已部署到 VM:控制面(api/operator/reaper + `FELIS_IMAGE`)= `registry.felis.svc:5000/felis/felis:auditfix60`(含至 #60 的全部修复;镜像托管于内建 registry);系统服/推荐 ref = `registry.felis.svc:5000/felis/{limbo,lobby,paper}:demo`;源码快照 `/opt/felis/src`(`c9af548`;上一版在 `/opt/felis/src.bak`,更早 `src.old43`);host 二进制 = `/usr/local/bin/felis` = `v0.0.0+fix60`(含 #53–#60;旧件留档 `/root/felis-fix54.bin`(sha `a0b29153…`)、`/root/felis-fix52.bin`、`/root/felis-auditfix44.bin`);**docker 守护进程在本批构建后已停(用前 `sudo systemctl start docker`)**;**world 执行器以 root+DAC_OVERRIDE 运行**;reaper CronJob(minecraft ns)`suspend=false / …auditfix60 / nodeSelector=localhost.localdomain`;迁移 `schema_migrations` max=21;SMTP 密码 env 待「configure email」刷新时落地
- 安装器重跑配方(第三十批复用;先同步 `/opt/felis/src` 再跑):`cd /root && FELIS_SKIP_FETCH=1 FELIS_INSTALL_MODE=full FELIS_IMAGE=registry.felis.svc:5000/felis/felis:<tag> FELIS_WORLDS_HOST_PATH=/var/lib/rancher/k3s/storage nohup bash /opt/felis/src/deploy/bootstrap.sh > /root/bootstrap-<tag>.log 2>&1 &`;完成后 `grep -c '\[fail\]'` 应为 0
- 第三十一批 drill 留档:`/root/preTUI43/`(首装走查前快照:两 toml + 两 ns Secret + sha256、走查后 Secret 快照)、`/root/pre51-replica.toml`/`post51-replica.toml`/`post51b-replica.toml`(#51 红/绿副本证据);走查残留(scratch 库、scratch 配置、hba 行、tmux 会话)均已清理
- 第三十二批 drill 留档:`/root/preS3/`(S3 走查前快照:两 toml + 两 ns Secret + sha256 + deploys.txt)、`/root/s3wiz/`(posts3 快照、postroll-sha256、fix52-red.txt、fix52-green.txt)、`/root/felis-fix52`(含 #52 的宿主二进制);MinIO 容器+卷+两镜像、`felis-uploads-s3` Secret、DB 行(`image_submissions` `sub-853a4e2ba4ba6443`)、/tmp 残留均已清理,docker 已停
- 第三十三批 drill 留档(VM):`/tmp/felis-fix53`、`/tmp/felis-fix54`(宿主二进制;sha `9afd141d…` / `a0b29153…`)、`/root/felis-fix52.bin`;`/tmp/player-cookies.txt`(player 会话);面板 CDP 驱动脚本 `/tmp/cdp-updates2.js`(Mac 侧)
- 第三十四批 drill 留档(VM):`/srv/nanotest/`(nano 装置:`nano-stub.py` + 5 驱动脚本 + `matrix/` 红原跑 / `matrix-fix55/` 绿原跑)与四层重跑落盘 `matrix-red.r2.log`(`pass=47 fail=13`)/ `matrix-fix55.r2.log`(`pass=60 fail=0`)/ `nano-service.r2.log` / `nano-config.r2.log` / `nano-fw.r2.log`;`nano-stub` 临时单元仍在跑(收尾 `systemctl stop nano-stub`);宿主二进制 `/usr/local/bin/felis-nano-test` = fix55 改名件(sha `2c8c34fd…`,与 `/usr/local/bin/felis` 同物)
- 第三十五批 drill 留档(VM):`/srv/npdrill/`(netpol 装置全套:`np-matrix.sh` + `np-matrix-run1/2.log`、`np-netns.sh`、`netns-probe.py`、`np-cidr-test.sh/.log`、`spec-before/after.json`、`td3.log`(包级归因)、`tcpdump-8081.log`(刷新存活)、`iptables-save.txt` 与 `nft-rules.txt`);netns/veth、firewalld 临时挂载、策略改动全部已清理/复原(spec md5 `e6b07440…` 前后一致)
- pgint 正确跑法(走 VM 的 PG,勿在本机起容器):Mac 侧短隧道 `ssh -6 -i ~/.ssh/id_ed25519 -N -L 15433:127.0.0.1:5432 root@…` → `FELIS_TEST_PG_URL='postgres://felis:<pw>@localhost:15433/felis_pgint?sslmode=disable' go test -tags pgint ./internal/pgint/ -count=1`(pg_hba 需 `host felis_pgint felis 127.0.0.1/32 scram-sha-256`;第三十一批把现场丢失的这条补回;隧道用完即 kill)
- breakGlass TUI 驱动法:VM tmux `new-session -d -s bg -x 160 -y 45 "/root/felis-auditfixNN breakGlass"` + `set-window-option -t bg remain-on-exit on`(否则退出摘要读不到);send-keys/capture-pane 驱动;退出码 1 = 操作失败(卡上会显示原因)
- 构建链路 drill 现成条件:`felis-build` 里有 `felis-service-token`(Secret 复制);`felis-config` 的 kaniko/trivy pin 指向内建 registry 的 mirror 路径(`registry.felis.svc:5000/mirror/...`,见 §8e)——本批后安装器重跑会 **carry** 这些 pin(不再被写盘冲掉;#49 复验点);直构一行:`POST /api/v1/images/build`(context=`http://felis-api-internal.felis.svc.cluster.local:8081/api/v1/internal/submissions/sub-bf7dc18e9dd97dc2/context`)
- registry 工具:`curl -s http://127.0.0.1:5000/v2/_catalog`、`/v2/<repo>/tags/list`;**取 manifest 必须带 `Accept:`(OCI index/manifest list;缺 Accept 的 GET 会 404,别误判没推上)**;GC 演练配方:`k3s ctr images rm <ref>`(要清到 blob 级再加 `k3s ctr content prune references`)→ 删 pod / `rollout restart` → `kubectl describe` 看 `Pulled … in …ms`
- setup 重跑向导(第二十六批):状态屏 `c/s/e` 三流已走查;**reconfigure 非只读**——storage 选 Local / 提交 S3 即 apply + 滚 API(幂等),email 表单 esc 无副作用;从 S3 表单 esc 退回会重置为 Local 预选;存储屏(第三十二批补充):chooser 预选当前值(↑↓ 切换),Local 直接 enter 提交、S3 五字段 `enter next`/末字段 `enter submit`,提交后 10–40s(含 API rollout status 180s 上限)
- VM 内部面:`k3s kubectl -n felis port-forward svc/felis-api-internal 18081:8081`(Pod 重建后转发会悬死,需重启);reaper CronJob(minecraft ns)已应用且已收敛(`suspend=false`)