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

84 KiB
Raw Blame History

Felis 生产就绪审计 — 2026-09-22(真机 E2E + 混沌)

分支:全部修复逐 commit 直接推 main(不再用功能分支)。环境: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 恢复 ✅
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–#45)——这些缺陷真实使用中到底谁能踩到

回应质疑"是不是全在测边界条件 / 只有内部 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)→ 盲批 ①

统计:① 19 | ② 16 | ③ 3+#23(a) | ④ 4 | 决策 3 = 45。第三批构建链另有 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 只动到期的世界 ✅

本轮新增真机证据(第二日)

  • 默认安装的备份闭环: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 protected])→ 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)。

结论:离"生产可用"还差什么(按优先级)

  1. 构建链路的上下文通道 ✅ 已修(f79e5eb/02fd2de,真机全链路含拉回校验;Trivy DB 需按 §8e 镜像一次)。
  2. 镜像耐久:kubelet 仍可能 GC 掉"当前无人使用"的镜像(游戏镜像已实测发生)。把控制面与游戏镜像推入内建 registry 并让 Deployment/StatefulSet 引用 registry 路径,可免去"事故后人工重导入"。本轮已用 runbook(troubleshooting §13b)兜住。
  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 上传通道演练 ✅ 已演练(第二十四批:MinIO 真机全链 + 回滚复核,0 缺陷)。
  11. breakGlass 控制台逐屏 ✅ 已演练(第二十五批:菜单 4 操作 + bootstrap 分支,缺陷 #39–#43 全修全验)。felis setup 全屏向导逐屏 ✅ 重跑侧已逐屏(第二十六批:状态屏 + c/s/e 三条 reconfigure 流,缺陷 #44 修复复验);首装侧各屏此前批次已定点覆盖,单次连续首装走查未做(本机已装机;如需可在 scratch 配置演练)。

系统性观察

  • 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(auditfix41 后新铸;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:felis-api/felis-operator/reaper 镜像 = felis:auditfix41(含 #20–#45 + 94f71ee 的 /metrics;FELIS_IMAGE 已同步);CLI 侧最新二进制 = /root/felis-auditfix41;world 执行器以 root+DAC_OVERRIDE 运行;reaper CronJob(minecraft ns)suspend=false / felis:auditfix41 / nodeSelector=localhost.localdomain;迁移 schema_migrations max=20;SMTP 密码 env 待 felis setup「configure email」刷新时落地;felis-api Role 已手动补 persistentvolumeclaims:get、operator Role 已手动补 minecraftservers:patch(felis install/setup 重渲染时收敛)
  • 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 的 felis/felis_pgint 规则已具备;隧道用完即 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_image/trivy_image 指向 k3s containerd 已导入的 pin tag、trivy_db_repository = registry.felis.svc:5000/mirror/trivy-db:2(镜像配方 §8e)——⚠ pin 值必须同时写在 /etc/felis/felis.host.toml + /etc/felis/felis.pod.toml;改动按 §8e 重渲染 Secret + roll。只热补 live Secret 会被任何 reconfigure 冲掉(第二十六批实测教训)
  • setup 重跑向导(第二十六批):状态屏 c/s/e 三流已走查;reconfigure 非只读——storage 选 Local / 提交 S3 即 apply + 滚 API(幂等),email 表单 esc 无副作用;从 S3 表单 esc 退回会重置为 Local 预选
  • VM 内部面:k3s kubectl -n felis port-forward svc/felis-api-internal 18081:8081(Pod 重建后转发会悬死,需重启);reaper CronJob(minecraft ns)已应用且已收敛(suspend=false)