Files
ModelRouter/packaging/llmsproxy.service
JianFeeeee 226493b5ae fix(部署+插件): VNC 兜底登录**真正可用** —— 放开浏览器栈不需要的加固 + 修 3 个插件缺陷
用户原话:「全部放开,插件不让用这些还叫插件吗」。方向是对的:插件架构的边界
应当是**内核暴露给插件的 API 权限**,而不是一层把插件自己的浏览器栈也关在门外的
进程级 syscall 过滤器。

## ★ 白屏的根因(本次花了大量时间,记录完整线索链)

同一页面、同一 chromium 参数,只换 seccomp:

| SystemCallFilter | 结果 |
|---|---|
| `@system-service` | 页面**永远空白**(截图非白像素 7.9%,连 `typeof WebAssembly` 都不执行) |
| 放开 | 完整渲染 TRAE 登录页(非白像素 **90.6%**,标题 `登录 \| TRAE - The Real AI Engineer`) |

TRAE 授权页是 React SPA + 字节风控 SDK(`sdk-glue.js`),依赖 WASM/JIT。
白名单**只减不增**地挡住了它。

**为什么不再逐个补白名单**:那是没有终点的维护 —— 每接一个新平台、每换一次前端
SDK 都可能再撞一个缺口,而症状永远是"页面白屏"这种零线索形态。本次为此**错误
怀疑过** 5 个方向(profile 锁 / /tmp 满 / PrivateTmp / 可写 HOME / AF_NETLINK),
全部被排除后才靠**逐 directive 二分**定位。补白名单只会让下一个人重复这个过程。

`packaging/llmsproxy.service` 因此去掉两项:
- `SystemCallFilter=@system-service` + `SystemCallArchitectures=native`
- `RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX`(缺 `AF_NETLINK` 会让
  chromium 建套接字失败 → SSL `net_error -101` → 导航不了)

**保留**的加固(已逐条实测不影响浏览器栈):`NoNewPrivileges` /
`ProtectSystem=strict` / `ReadWritePaths=/etc/llmsproxy` / `ProtectHome` /
`PrivateTmp` / `CapabilityBoundingSet=`(空)/ `ProtectKernel*` /
`RestrictSUIDSGID` / `RestrictRealtime` / `LockPersonality`。可写面仍收窄到
`/etc/llmsproxy`。

## 修掉的 3 个插件缺陷(都是真 bug,各自有判据)

1. **前向引用(第 12 次)**:`sweepSessions` 调 `reapLoopback`/`browserStack`/
   `reapBrowser`,三者定义在**后面** ⇒ 未声明时是全局 nil。加载期不报错,只有走到
   「会话过期回收」才炸 ⇒ **第一次登录正常、第二次 500**,且已起的浏览器栈不被回收
   (进程与端口泄漏)。→ 加前向声明 + 定义改赋值式。
2. **`spawnBrowser` 里 `url` 是未定义全局**(形参名是 `start_url`)⇒ chromium 打开
   `http://nil/`。修复前页面 URL = `http://nil/`,修复后 = 完整 `trae.cn/authorization?...`。
3. **chromium 在加固环境自杀**:`FATAL: GPU process isn't usable. Goodbye.`
   → `--in-process-gpu`(GPU 逻辑跑浏览器进程内,不 fork)。另给可写
   HOME/XDG(`ProtectHome=yes` 下 /root 只读会刷 permission denied 噪声)。

另加**存活探针**:此前只等 CDP 端口就报「已就绪」,即便浏览器已死也报成功 ——
**谎报比直接报错更坏**(运维会以为是自己操作的问题)。现在会如实报出
`浏览器 CDP 无应答:...FATAL: GPU process isn't usable`。

## 测试与生产抢资源的修复

生产 `PrivateTmp=yes` 下 Xvfb `:91` 的锁在**服务自己的 /tmp** 里,宿主测试看不到
⇒ `xdisplayBusy(91)` 判"空闲"⇒ 测试去抢 91 ⇒ `Xvfb 未就绪`。
→ 显示器起始号可被 `LLMSPROXY_XDISPLAY_BASE` 覆盖,测试台统一用 300+;
`TestXDisplaySelectionReclaimsStaleLocks` 的号段也跟随(否则它造出的陈旧锁**根本
不会被扫描到**,断言永远失败 —— 那是判据与被测对象脱节)。
(端口不需要覆盖点:端口是全局资源,`portFree` 查 /proc/net/tcp 看得见生产占用。)

## 判据

- `TestSweepSessionsHelpersAreForwardDeclared`:三助手必须前向声明 + 定义必须是
  赋值式 + 声明必须**先于**使用。
- `TestBrowserStackVerifiesLivenessNotJustPort`:必须有 CDP 真应答判据 + /proc 存活
  + 日志真因 + 失败时回收整组进程。
- `TestBrowserStackUsesInProcessGPU`:`--in-process-gpu` / 可写 HOME/XDG 不得被删。
- `TestChannelsVncStackEndToEnd` / `TestXDisplaySelection*`:真实起栈验证。

## 端到端验证

`bash deploy.sh` → 生产 `active` / `NRestarts=0`;真实登录请求 → `server_browser=True`,
VNC 上**完整渲染 TRAE 登录页**(手机号登录 / 抖音 / 苹果 / 企业账号登录全部可见),
标题 `登录 | TRAE - The Real AI Engineer`。
回归:9 个包全绿。
2026-10-15 08:22:57 +08:00

86 lines
4.7 KiB
Desktop File
Raw Permalink 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.

[Unit]
Description=LLMSProxy - unified OpenAI-compatible multi-source LLM gateway
After=network.target
[Service]
Type=simple
# Memory tuning (measured, see README "内存占用"):
# MALLOC_ARENA_MAX=2 caps glibc per-thread malloc arenas. LuaJIT allocates
# through cgo -> glibc malloc, and glibc defaults to 8*nproc arenas, so every
# OS thread that touches malloc reserved its own ~1 MB arena that is never
# returned. Measured: 8-12 arenas -> 0.
# GOGC=50 halves the Go heap growth target. On its own it does NOT help (the
# saved heap is immediately eaten by extra glibc arenas); combined with
# MALLOC_ARENA_MAX it cut settled RSS by ~19%. This gateway is I/O bound, so
# the extra GC cycles are free.
Environment=GOGC=50
Environment=MALLOC_ARENA_MAX=2
ExecStart=/usr/local/bin/llmsproxy -config /etc/llmsproxy/config.yaml
WorkingDirectory=/etc/llmsproxy
Restart=always
RestartSec=5
# ---- 加固(2026-10-01 逐条实测后加入,不是照抄文档)----
#
# 为什么仍然以 root 运行:master.key 是 0600 root。加 User=llmsproxy 实测直接
# 起不来,而且失败方式很隐蔽——
# [config] secrets disabled: open /etc/llmsproxy/master.key: permission denied
# 只是**一行日志**,服务会带着"敏感值将以明文落盘"继续跑起来。
# 也就是说降权在当前文件权限下不是加固,而是把密钥降级。要降权必须先把
# master.key 交给服务用户并统一 /etc/llmsproxy 的属主,那是一次独立的、有回滚
# 需求的变更,不该和加固混在一起。
#
# 下面每一条都在一个独立探针单元(临时端口 + 独立 runtime_file/adapter_dir)
# 上真实验证过:鉴权 401/200 正常、发一次真实 /v1/chat/completions 走通(证明
# LuaJIT 适配器路径没被 seccomp 打断)、审计文件可写可轮转、连续重启 3 次与
# kill -9 后行为符合预期。systemd 对非法指令值不报错只"忽略",逐条实测是唯一
# 可靠做法。
NoNewPrivileges=yes
# 读路径全部落在 /etc/llmsproxy;写路径经核对只有 config.yaml / runtime.json /
# audit.jsonl / adapters/*.lua / master.key,全在该目录下(internal/{config,gateway,
# core,lua} 里的 WriteFile|Rename|Remove 调用点)。
ProtectSystem=strict
ReadWritePaths=/etc/llmsproxy
ProtectHome=yes
PrivateTmp=yes
ProtectKernelTunables=yes
ProtectKernelModules=yes
ProtectControlGroups=yes
RestrictSUIDSGID=yes
RestrictRealtime=yes
LockPersonality=yes
# ⚠️ 地址族**不限制**(2026-10-15)。原来是 AF_INET AF_INET6 AF_UNIX,
# 但 chromium 需要 AF_NETLINK 做网络状态跟踪;缺它 → 建套接字失败 →
# SSL 握手失败(net_error -101)→ 页面导航不了。
# 现在不写这一项(systemd 默认不限制)。
# CapabilityBoundingSet 置空:本服务不需要任何 capability(不建 netns、不改
# 资源限制、不 chown)。留空即"一个都不给",比列一份允许清单更难写错。
CapabilityBoundingSet=
# ⚠️⚠️ **不限制 syscall**(2026-10-15,实测后决定)。
#
# 原先是 `SystemCallFilter=@system-service` + `SystemCallArchitectures=native`,
# 且在真实推理请求上验证过。但它与 VNC 浏览器栈**不兼容**,实测证据:
#
# 同一页面、同一 chromium 参数,只换 seccomp:
# @system-service → 页面**永远空白**(连 `typeof WebAssembly` 都不执行)
# 放开 → 完整渲染(TRAE 登录页,非白像素 7.9% → 90.6%)
#
# 原因:TRAE 的授权页是 React SPA + 字节风控 SDK(sdk-glue.js),依赖 WASM/JIT。
# WASM 需要 syscall 白名单里的若干项(`mprotect`/`mmap`/`memfd` 实测单独可用,
# 缺口在 `@privileged` 组内的成员),而白名单**只减不增**地挡住了它们。
#
# 为什么不再逐个补白名单:那是**没有终点的维护**。每接一个新平台、每换一次
# 前端 SDK,都可能再撞一个缺口,而症状永远是"页面白屏"这种零线索的形态
# (本次为此耗掉大量时间,先后错误怀疑过 profile 锁 / 磁盘满 / PrivateTmp /
# 可写 HOME / AF_NETLINK)。加固的意义是防外部攻击面,不是让插件干不了活 ——
# 插件架构的边界应当是**内核暴露给插件的 API 权限**(add_source / secrets /
# http 等,那些仍然受控),而不是一层把插件自己的浏览器栈也关在门外的
# 进程级 syscall 过滤器。
#
# 仍然保留的加固(见下):NoNewPrivileges / ProtectSystem=strict /
# ReadWritePaths / ProtectHome / PrivateTmp / CapabilityBoundingSet=(空)/
# ProtectKernel* / RestrictSUIDSGID / RestrictRealtime / LockPersonality。
# 这些**都不影响**浏览器栈(已逐条实测),并把可写面收窄到 /etc/llmsproxy。
[Install]
WantedBy=multi-user.target