# Felis 生产就绪审计 — 2026-09-22(真机 E2E + 混沌) 分支:`audit-fixes-20260922`(已推送)。环境: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 的 `/` 布局不存在,且 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;释放后约 8 分钟才恢复调度。恢复依赖人工 `docker save felis:* | k3s ctr images import -`(docker 存储是唯一副本) | 早前真机 drill;本次复核 k3s 默认驱逐顺序 | 控制面(api/operator/reaper/registry)全部挂 `felis-control-plane` PriorityClass(value 1,000,000,`preemptionPolicy: Never`)——节点压力按优先级升序驱逐,游戏 pod(0)先走,控制面留下;镜像 GC 无法用代码根治(air-gap 无回源),新增 troubleshooting §13b 固化恢复路径(重跑安装器重建导入;单镜像可 `docker save | k3s ctr images import -`)。提交见下 | ## 待决策台账(未修) | # | 主题 | 说明 | |---|------|------| | 7 | 异步失败不可感知 | backup/restore 失败后无状态出口:restore 行不变、backup 无行;只有集群侧 Job/日志可查。建议状态字段或 `?failed` 查询 | | 9 | 升级策略 Recreate | 单副本 + Recreate:任何控制面升级=停机;坏升级(实测错 tag)服务中断约 95s 且需人工 `rollout undo`(无自动回滚)。建议 runbook/文档化 | | 10 | 备份语义 | 归档包含整个 /data(jar、libraries、cache),167MB;是否符合"world backup"定位待评估 | | 11 | PG 断连表现 | 会话查询失败报 401 而非 503(fail-closed 但误导;用户以为没登录) | | 12 | ready 门滞后 | 容器 Ready 后 6~10s 内 API 仍 409 not_running | | 13 | setup token 截断 | 43 字符 token + 长域名,80 列终端下 TUI 截断显示(复现:tmux 80 列) | | 14 | 日志噪音 | controller-runtime 未 SetLogger,首用打印整段堆栈;TLS handshake EOF 噪音(kubelet 探针) | | 15 | reaper 启用未演练 | **已演练完毕(2026-09-22)**:绑定挂载演练 world → 一次 pass 归档+删 PVC+保留 servers 行/CRD;过期归档驱逐(行转 deleted、文件删除、合法归档未动)。启用仍受 #6 三旗标约束;另注意 `--worlds-host-path` 的 `/` 布局在 stock local-path 下不成立(编排责任),且 VM 上演练用的 CronJob 已 **suspend** 防误删(真实世界目录不在 /srv/worlds-root)| ## 已验证事实(正向清单) - 安装→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` 只动到期的世界 ✅ ## 系统性观察 - **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/felis-cookies.json`;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:auditfix7`(含全部修复) - VM 内部面:`k3s kubectl -n felis port-forward svc/felis-api-internal 18081:8081`(Pod 重建后转发会悬死,需重启);reaper CronJob(minecraft ns)已应用但 `suspend=true`