## 缺陷(QA 观察 2,用户可感知的那一半)
服务端已把「500 + 永久丢设备」消灭了,但客户端从不读响应里的 `device_id`:
`agent.ts` 的心跳只取 `result.data.serverTime`。后果链:
1. 两台机器共用 settings.json → 撞 `idx_devices_uuid`;
2. 服务端把后到的那台降级为 `device_id = null`(不 500,在线状态保住)✅;
3. 客户端毫无反应 → 下一轮继续上报**同一个被占用的 id** → 永远 `null`;
4. 本机证书永远进不了对端 Sunshine 目录 → **每次串流都要手输 PIN,无自愈路径**。
免 PIN 是本项目核心目标,所以这条必须补。
## 修法
- `core/config.ts` 新增 `regenerateDeviceId()`:**无条件**换一个新 id 并持久化。
与 `ensureDeviceId()`(保证有 id、绝不覆盖)**职责严格分开** —— 给后者加
`force` 也能 work,但等于在唯一一个「不该覆盖」的入口上开一个能覆盖的开关。
- `agent.ts`:
- `AgentDeps` 新增 `regenerateDeviceId?()` 与 `readDeviceId?()`;
- 内部 `deviceId` 由「构造时快照」改为可变字段,心跳请求体用它;
- 心跳成功后调 `healDeviceIdIfSuppressed(result.data?.device_id)`;
- 判据严格 `=== null`:`undefined` 表示老服务端根本不回该字段,与「被降级」无关,
误触发会导致「每轮换一个 id、永远撞不完」;
- 换完立即持久化,下一轮心跳(≤30s)生效;不做忙等重试,新 id 若又撞则下一轮自然再换;
- 生成器未注入 / 返回同值 / 抛异常时**都不打挂心跳**(就地捕获 + warn)。
- `certs.ts`:`CertServiceDeps.deviceId` 允许传 `() => string`,每次调用现取。
否则换 id 后 `skipped_self` 仍用旧 id 比对,会把**自己的**证书当 peer 写进本机
Sunshine 目录,留下一个永远「受管」的自我证书文件(prune 清不掉)。
- `main.ts`:`currentDeviceId` 改为可变局部变量,向下传工厂函数而非快照。
## 测试(launcher 493 → 502)
`test/agent.test.ts` 新增 6 例:
1. `响应 device_id === null → 生成新 id 并持久化,且与旧 id 不同`
2. `下一轮心跳上报的是新 id(旧 id 不再出现在请求体里)`(断言第二次调用入参)
3. `响应是正常 id → 绝不触发重新生成(防「每轮换一个 id」的抖动)`
4. `响应没有 device_id 字段(老服务端兼容)→ 同样不触发重新生成`
5. `未注入 regenerateDeviceId → 只记 warn,不抛异常、心跳仍算成功`
6. `生成器返回与旧值相同的 id → 不切换(否则就是「假自愈」)`
`test/config.test.ts` 新增 3 例:真的写回设置文件、换完后 `ensureDeviceId` 返回新值、
原本无 id 时也能换。
## 顺带|观察 1 的口径统一(一行)
`insertDevicePresence` 第 ③ 层由无条件 `null` 改为
`racedAfterRetry.device_id ?? null`(保留原值),与 `existing` 分支
`deviceId ?? fallbackId` 的口径一致;避免将来多 worker 共享 DB 时把并发请求刚写入的
合法 id 抹掉一个 tick。
注释里补上 QA 指出的事实:**①③ 在单进程下不可达**(`node:sqlite` 是同步 API,
handler 内无 `await`,两次查询之间插不进别的请求)—— 这正好解释了旧代码为什么
**必然** 500(旧 catch 只有「重查同名」一条路,而它恒走不到)。保留为多 worker 预留。
## 文档
- `docs/troubleshooting.md` §16:新增「两台机器共用同一 `device_id` ⇒ 一个心跳周期内
自动恢复」的首条处置(含两条 warn 的原文与判据),并把
`本机 device_id 已被他人占用,已换新 id` 加入「现象」(注明是自愈动作不是故障)。
- `docs/api.md` R10 段:补「客户端必须读这个 `null` 并换 id,否则降级等于永久无 id」,
含 `=== null` 与 `undefined` 的区别。
- README 数字同步。
## 记录澄清
QA 指出 `30fc269` 的 message 把 `QA-R10f/g/h/i` 记成「新增」,实际是 QA 写在工作区、
我提交时一并收录。归属记错一格,在此说明;断言与代码无误,不改历史。
## 一并入库
`server/test/qa-r10-concurrency.test.mjs`(QA 第 3 轮并发对抗用例 6 例)。
验证:server 82/82(覆盖率 90.18%,smoke 通过);launcher 502/502
(typecheck / eslint / prettier 全绿,覆盖率 91.89%);gitleaks protect --staged 干净。
28 KiB
Only in One · 控制面 API 契约(v1)
本文以
server/server.js的路由表为唯一依据逐条核对,不是设计稿。 代码改动导致本文过期时,以代码为准并同步更新本文。端点数:
server.js中共 10 条路由注册,对应 9 个逻辑端点 + 1 个兼容别名(/api/health与/api/v1/health由同一个 handler 双注册)。 复核命令:grep -cE "^ app\.(get|post)\(" server/server.js→10。
- 基础地址:
http://<host>:<PORT>(默认0.0.0.0:3000,见server/config.js) - 版本前缀:
/api/v1 - 所有请求与响应均为
application/json; charset=utf-8,只有一个例外:308 重定向响应体是text/plain
1. 版本前缀与 308 重定向
/api/* 会被 legacyApiRedirect() 中间件 308 永久重定向到 /api/v1/*:
| 请求 | 响应 |
|---|---|
GET /api/devices |
308,Location: /api/v1/devices,body Moved Permanently to /api/v1/devices |
POST /api/auth/login |
308,Location: /api/v1/auth/login |
GET /api/v1/devices |
直接命中路由(不重定向) |
GET /api/health |
不重定向,由双注册直接处理 |
GET /api/health?x=1、GET /api/health/ |
同样不重定向(前缀豁免) |
设计原因:容器探针与监控不应依赖跟随重定向。客户端(启动器)一律直接打
/api/v1,不做兼容(launcher/src/api.ts 的 API_PREFIX)。
⚠️ 308 会保留原始方法与请求体,但中间件在
express.json()之前执行, 因此重定向响应不会解析 body —— 这正是我们想要的行为(不重复消费流)。
2. 统一响应信封
成功:
{ "ok": true, "data": { } }
data为undefined时序列化为null(sendOk兜底),因此永远有data字段。- 状态码默认
200,注册成功为201。
失败:
{
"ok": false,
"error": {
"code": "VALIDATION_FAILED",
"message": "请求参数不正确",
"hint": "检查输入后重试",
"details": { "field": "device_name" }
}
}
字段规则(由 buildErrorBody() 强制,键顺序固定):
| 字段 | 何时出现 | 说明 |
|---|---|---|
code |
总是 | 必须在错误码总表内;未知码回落 INTERNAL |
message |
总是 | 中文人话,由错误码表单向生成,业务代码不得手写 |
hint |
该码定义了处置建议时 | 中文处置建议 |
details |
仅 4xx 且调用方传了非空对象 | 结构化补充(如 {field:'password', reason:'密码不能为纯数字'}) |
requestId |
仅 5xx | 与响应头 X-Request-Id 一致,用于对日志 |
两条硬约束:
- 401 响应体逐字节稳定:
INVALID_CREDENTIALS不区分「用户不存在」与 「密码错误」,也不携带details—— 否则等于给出账号枚举接口。 - 5xx 绝不回显堆栈,只给
requestId。
3. 安全头、大小限制与 CORS
securityHeaders() 对所有响应(含 404/500)设置:
| 响应头 | 值 |
|---|---|
X-Request-Id |
传入的 x-request-id(≤128 字符)或新 UUID |
X-Content-Type-Options |
nosniff |
X-Frame-Options |
DENY |
Referrer-Policy |
no-referrer |
Content-Security-Policy |
default-src 'none'; frame-ancestors 'none'; base-uri 'none' |
Cache-Control |
no-store |
另外 app.disable('x-powered-by')(不暴露 Express 版本)。
- 请求体上限 64KB:
express.json({ limit: '64kb' })。超限返回413 PAYLOAD_TOO_LARGE;非法 JSON 返回400 VALIDATION_FAILED(details.reason = "body 不是合法 JSON")。 - CORS:服务不发送任何
Access-Control-Allow-*头 ⇒ 浏览器默认拒绝跨源读取。 启动器是 Electron 主进程发请求(无同源限制),因此无需 CORS 配置。 - 代理头:默认不信任
X-Forwarded-For。反向代理部署必须设TRUST_PROXY=1, 否则限流统计全部落在127.0.0.1上(详见docs/security.md)。
4. 鉴权模型
| 项 | 值 | 依据 |
|---|---|---|
| 算法 | HS256(显式限定,防算法混淆) |
tokens.js:14 |
| access token | 默认 2 小时(ACCESS_TOKEN_TTL_SECONDS=7200,允许 60–86400) |
config.js |
| refresh token | 默认 30 天(REFRESH_TOKEN_TTL_SECONDS=2592000,允许 3600–31536000) |
config.js |
| access payload | { sub, username, type:'access', jti, iat, exp } |
tokens.js |
| refresh payload | { sub, type:'refresh', jti, iat, exp } |
tokens.js |
| 吊销 | token_revocations(jti PRIMARY KEY, user_id, revoked_at, expires_at) |
migrations/002 |
传递方式:Authorization: Bearer <accessToken>。
校验顺序与对应错误码(verify()):
- 不是非空字符串 →
401 UNAUTHORIZED - 签名/格式错(
jsonwebtoken抛错但不是TokenExpiredError)→401 UNAUTHORIZED - 已过期(
TokenExpiredError)→401 TOKEN_EXPIRED payload.type与期望不符(拿 refresh 当 access 用)→401 UNAUTHORIZED- 缺
sub/ 缺jti→401 UNAUTHORIZED jti在吊销表中(查询失败按已吊销处理,fail-closed)→401 TOKEN_REVOKED
refresh 一次性轮换:POST /auth/refresh 在签发新 token 前先 revoke(旧 jti),
因此同一个 refreshToken 只能用一次。并发刷新会有一个成功、另一个收到
TOKEN_REVOKED(这是预期行为,客户端应串行刷新)。
登出语义:POST /auth/logout 吊销当前 access 的 jti;若 body 里带了
refreshToken 且 userId 匹配,一并吊销它的 jti。refresh 已过期/已吊销都不影响
登出返回 200 —— 登出的用户预期是「无论如何我都不再是登录状态」。
5. 端点清单(9 个逻辑端点)
| # | 方法 | 路径 | 鉴权 | 一句话 |
|---|---|---|---|---|
| 1 | GET |
/api/v1/health(别名 /api/health) |
无 | 存活与 DB 探活 |
| 2 | POST |
/api/v1/auth/register |
无 | 注册(成功 201) |
| 3 | POST |
/api/v1/auth/login |
无 | 登录,发 access + refresh |
| 4 | POST |
/api/v1/auth/refresh |
无(凭 body 里的 refreshToken) | 轮换令牌(一次性) |
| 5 | POST |
/api/v1/auth/logout |
Bearer | 吊销当前 access(可选吊销 refresh) |
| 6 | POST |
/api/v1/devices/heartbeat |
Bearer | 上报在线状态与 IP(设备注册入口) |
| 7 | GET |
/api/v1/devices |
Bearer | 本账号设备列表(含在线判定) |
| 8 | POST |
/api/v1/certs |
Bearer | 上传公钥证书(服务端算指纹) |
| 9 | GET |
/api/v1/certs |
Bearer | 拉取本账号全部 peer 证书 |
以下逐条给出请求/响应/错误码/curl。
5.1 GET /api/v1/health
无鉴权、无参数。用于容器探针与监控;/api/health 是同 handler 的别名。
成功 200
{ "ok": true, "data": { "version": "0.1.0", "uptimeMs": 123456, "dbOk": true } }
version取自server/package.jsonuptimeMs为进程内计时(Date.now() - STARTED_AT)dbOk:SELECT 1是否成功。DB 挂了仍返回 200,靠dbOk: false表达 (探针不该因为业务数据不可用而重启进程)
错误:无(除全局 500)。
curl -s http://127.0.0.1:3000/api/v1/health
curl -s http://127.0.0.1:3000/api/health # 别名,不重定向
5.2 POST /api/v1/auth/register
请求体
| 字段 | 类型 | 必填 | 校验规则 |
|---|---|---|---|
username |
string | ✅ | 3–32 位 [A-Za-z0-9_-];大小写不敏感去重(LOWER(username) 唯一索引) |
password |
string | ✅ | 10–200 位;不得纯数字、纯字母、全同字符;不在内置弱口令表内 |
email |
string | ❌ | 非空时:≤254 字符、无控制字符、形如 a@b.c。缺省/空串视为「未提供」 |
成功 201
{ "ok": true, "data": { "userId": 7 } }
失败
| 状态 | code | 触发条件 | details |
|---|---|---|---|
| 400 | INVALID_USERNAME |
用户名不合规 | {field:'username'} |
| 400 | WEAK_PASSWORD |
密码不合规 | {field:'password', reason:'密码不能为纯数字'} 等 |
| 400 | VALIDATION_FAILED |
email 不合规 / body 非法 JSON / 不支持的编码 |
{field:'email'} 或 {reason:...} |
| 409 | USER_EXISTS |
用户名已存在(含并发插入命中 UNIQUE) | {field:'username'} |
| 413 | PAYLOAD_TOO_LARGE |
请求体 > 64KB | — |
| 500 | INTERNAL |
未预期异常 | — |
curl -sS -X POST http://127.0.0.1:3000/api/v1/auth/register \
-H 'Content-Type: application/json' \
-d '{"username":"kou","password":"Only1nOne-Strong","email":"kou@example.com"}'
5.3 POST /api/v1/auth/login
请求体
| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
username |
string | ✅ | 大小写不敏感 |
password |
string | ✅ | 不落日志、不进 details |
限流前置:同一 IP 或同一用户名,60s 窗口内失败 ≥5 次 ⇒ 锁定 15 分钟。
锁定期间连正确密码也返回 429(details.retryAfter 为剩余秒数)。
成功 200
{
"ok": true,
"data": {
"accessToken": "eyJ...",
"refreshToken": "eyJ...",
"expiresIn": 7200,
"userId": 7,
"username": "kou",
"serverTime": 1730000000000
}
}
serverTime 供客户端校正时钟偏移(在线三态判定用)。
失败
| 状态 | code | 触发条件 | details |
|---|---|---|---|
| 401 | INVALID_CREDENTIALS |
用户不存在 或 密码错误 或 存储的哈希畸形(三种情况响应体逐字节一致) | — |
| 429 | RATE_LIMITED |
已锁定 | {retryAfter: 秒} |
| 400 | VALIDATION_FAILED |
body 非法 JSON | {reason:...} |
| 413 | PAYLOAD_TOO_LARGE |
> 64KB | — |
TOKEN_JSON=$(curl -sS -X POST http://127.0.0.1:3000/api/v1/auth/login \
-H 'Content-Type: application/json' \
-d '{"username":"kou","password":"Only1nOne-Strong"}')
ACCESS=$(node -e "process.stdout.write(JSON.parse(process.argv[1]).data.accessToken)" "$TOKEN_JSON")
5.4 POST /api/v1/auth/refresh
请求体
| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
refreshToken |
string | ✅ | 必须是一次性的旧 refresh |
成功 200(同时返回新的 refreshToken,旧的立即失效)
{ "ok": true, "data": { "accessToken": "eyJ...", "refreshToken": "eyJ...", "expiresIn": 7200, "serverTime": 1730000000000 } }
失败
| 状态 | code | 触发条件 |
|---|---|---|
| 401 | TOKEN_EXPIRED |
refresh 自然过期 |
| 401 | TOKEN_REVOKED |
该 refresh 已被用过(一次性轮换)或已被登出 |
| 401 | UNAUTHORIZED |
空/格式错/签名错/传了 access 而不是 refresh/用户已被删除 |
客户端约定:
TOKEN_EXPIRED且没有 refreshToken ⇒ 无法自救 ⇒ 触发登出 (launcher/src/api.ts#detectSessionInvalid)。
curl -sS -X POST http://127.0.0.1:3000/api/v1/auth/refresh \
-H 'Content-Type: application/json' \
-d "{\"refreshToken\":\"$REFRESH\"}"
5.5 POST /api/v1/auth/logout
鉴权:Authorization: Bearer <accessToken> 必填。
请求体(可选,可为空 body {})
| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
refreshToken |
string | ❌ | 一并吊销该 refresh 的 jti;校验失败静默忽略 |
成功 200:{ "ok": true, "data": {} }
失败:401 UNAUTHORIZED / 401 TOKEN_EXPIRED / 401 TOKEN_REVOKED(Bearer 校验)。
curl -sS -X POST http://127.0.0.1:3000/api/v1/auth/logout \
-H "Authorization: Bearer $ACCESS" \
-H 'Content-Type: application/json' \
-d "{\"refreshToken\":\"$REFRESH\"}"
5.6 POST /api/v1/devices/heartbeat
鉴权:Bearer 必填。这是设备注册的唯一入口(没有独立的 POST /devices)。
请求体
| 字段 | 类型 | 必填 | 校验规则 / 非法时的行为 |
|---|---|---|---|
device_name |
string | ✅ | [A-Za-z0-9][A-Za-z0-9._-]{0,63}:首字符必须是字母或数字,其后可含 . _ -,总长 1–64。非法直接 400 |
device_id |
string | ❌ | [A-Za-z0-9-]{8,64}(UUID 或同类稳定标识;与 launcher DEVICE_ID_PATTERN 逐字一致)。非法则忽略(保留上次值)。该值是对端证书文件名的主体(moonlight-<device_id>.pem),因此不能为空、不能短于 8 位:为空的设备被跳过写入(missing_device_id),格式不合法的被跳过(invalid_device_id),两者的证书都不会落到对端 |
platform |
string | ❌ | 枚举 win32/darwin/linux。非法则忽略 |
lan_ip |
string | ❌ | 必须是可路由 IPv4(拒回环/链路本地/组播/.0/.255)。非法则忽略 |
tailscale_ip |
string | ❌ | 同上。非法则忽略 |
与客户端的一致性(D1 缺陷的教训):本规则与 launcher 把设备名拼成磁盘文件名时 用的
SAFE_NAME_RE首字符口径完全一致(都必须字母/数字开头)。两端一旦不一致, 服务端会接受一个客户端永远写不出文件的设备名(如-dash),后果是该设备每次串流 都要手输 PIN,而日志里只有一条 warn —— 现场几乎排查不出来。 收紧方向只能是「服务端对齐客户端」,不得反过来放宽客户端白名单。
老数据兜底规则(重要):
device_id/platform/lan_ip/tailscale_ip四个字段非法或缺失时不报错,一律「忽略并保留数据库里的上一次值」。 原因:老客户端不知道这些字段,若是新校验把它们打挂,升级服务端就等于全量掉线。 唯一强制的是device_name(没有它无法定位设备行)。
插入/更新语义
- 按
(user_id, device_name)查行:存在则UPDATE(last_seen = now),否则INSERT。 - 并发下同名设备同时插入会命中
UNIQUE(user_id, device_name),服务端重查一次 按更新处理(不会给客户端返回 500)。 device_id撞唯一约束时降级为null写回/写入(R10)。devices上有UNIQUE INDEX idx_devices_uuid(user_id, device_id):某台客户端上报了与他人重复的device_id(克隆虚拟机、复制了旧机器的settings.json)时,写库会抛异常。 服务端不返回 500,而是把这一行的device_id写成null并记一条warn—— 保住「这台设备在线」这个事实(心跳的核心价值),代价是该设备本轮拿不到文件名键, 客户端按missing_device_id跳过写入,下一轮心跳拿到新 id 即自愈。 两条写路径都要兜:- UPDATE 分支(同名设备已存在,例如「复制了 settings.json 但没改名」);
- INSERT 分支(新设备名 + 已被占用的
device_id,例如「复制后又改了名」)。 INSERT 兜底时若降级插入再次撞UNIQUE(user_id, device_name)(并发同名), 要退回 UPDATE 分支而不是继续上抛 —— 否则降级路径会在并发下重新变成 500。 INSERT 漏兜的后果比 UPDATE 更重:行建不出来 ⇒ 下一次心跳仍然走 INSERT、 仍然 500,是永久性的,而不是一次性抖动。
- 客户端必须读这个
null并换 id,否则「降级」等于「永久无 id」:launcher/src/agent.ts的心跳在data.device_id === null时会调regenerateDeviceId()重新生成并持久化一个新 UUID,下一轮心跳(≤30s)即带新 id。 判据严格用=== null:字段缺失(undefined)表示老服务端根本不回这个值, 与「被降级」无关,不能误触发(否则会每轮换 id,永远撞不完)。
成功 200
{ "ok": true, "data": { "deviceId": 3, "device_id": "9f1c...", "serverTime": 1730000000000 } }
deviceId 是数据库自增主键(int),device_id 是客户端稳定 UUID(可能为 null)。
两者不要混用。响应里的 device_id 是本次实际写入库的值:正常情况等于请求体里的值,
device_id 撞唯一约束时为 null(见上方 R10 的降级规则,UPDATE / INSERT 两条路径都会降级),
请求体未带时为库里上一次的值。
失败
| 状态 | code | 触发条件 | details |
|---|---|---|---|
| 400 | VALIDATION_FAILED |
device_name 非法 |
{field:'device_name', reason:'需匹配 [A-Za-z0-9][A-Za-z0-9._-]{0,63}(首字符须为字母或数字)'} |
| 401 | UNAUTHORIZED / TOKEN_EXPIRED / TOKEN_REVOKED |
Bearer 校验 | — |
curl -sS -X POST http://127.0.0.1:3000/api/v1/devices/heartbeat \
-H "Authorization: Bearer $ACCESS" -H 'Content-Type: application/json' \
-d '{"device_name":"kou-mbp","device_id":"9f1c2b7e-2a4d-4c6b-9f0e-1a2b3c4d5e6f","platform":"darwin","lan_ip":"192.168.1.20"}'
5.7 GET /api/v1/devices
鉴权:Bearer 必填。无查询参数。
成功 200
{
"ok": true,
"data": {
"devices": [
{
"id": 3,
"device_id": "9f1c...",
"device_name": "kou-mbp",
"platform": "darwin",
"lan_ip": "192.168.1.20",
"tailscale_ip": null,
"last_seen": 1730000000000,
"online": true
}
]
}
}
- 排序:
last_seen DESC, id DESC online:now - last_seen <= 90_000(ONLINE_THRESHOLD_MS,与客户端常量一致)- 客户端不使用
online字段,而是用last_seen+ 客户端自己的clockOffset做三态判定(online90s /recent7d /stale)—— 服务端时钟不可假定可信
失败:仅 401 系列。
curl -sS http://127.0.0.1:3000/api/v1/devices -H "Authorization: Bearer $ACCESS"
5.8 POST /api/v1/certs
鉴权:Bearer 必填。
请求体
| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
device_name |
string | ✅ | 同 heartbeat 规则;不存在则自动创建一条 device 行 |
cert_pem |
string | ✅ | Moonlight 公钥证书 PEM。1B–8KB;必须 BEGIN/END CERTIFICATE;含 PRIVATE KEY 一律拒绝;必须能被 X509Certificate 解析 |
device_id |
string | ❌ | 服务端忽略本请求体里的值(客户端会带,仅作兼容):证书与设备的关联靠 device_name,证书条目的 device_id 取自该 device 行(由 heartbeat 写入),因此客户端想让它有值,必须先在 heartbeat 里上报 |
fingerprint |
string | ❌ | 服务端无条件忽略:指纹永远由服务端 sha256hex(归一化后的 pem) 重算 |
这也是「盲协调器但不盲信」的落地:客户端提交什么指纹都不影响入库值, 客户端侧再独立重算一次比对(
launcher/src/certs.ts#fingerprintMatches)。
成功 200
{ "ok": true, "data": { "deviceId": 3, "fingerprint": "b1946ac92492d2347c6235b4d2611184..." } }
fingerprint = 64 位小写 hex(SHA-256)。同一 (user_id, device_id) 重复上传为
UPSERT(覆盖 PEM 与指纹、刷新 created_at)。
失败
| 状态 | code | 触发条件 | details |
|---|---|---|---|
| 400 | VALIDATION_FAILED |
device_name 非法,或 cert_pem 为空 |
`{field:'device_name' |
| 400 | INVALID_CERT |
边界不合格 / 含私钥块 / 超 8KB / X509 解析失败 | {maxBytes:8192} 或 {reason:'无法被 X509 解析器识别'} |
| 413 | PAYLOAD_TOO_LARGE |
整个请求体 > 64KB | — |
| 401 | 401 系列 | Bearer 校验 | — |
# 上传本机公钥证书。注意本地路径:D3 之后 peer 证书文件按 device_id 命名,
# 所以 Sunshine 凭据目录里的文件名形如 moonlight-<device_id>.pem
# (老版本留下的 moonlight-<device_name>.pem 仍可读,只是不再由本工具生成)。
curl -sS -X POST http://127.0.0.1:3000/api/v1/certs \
-H "Authorization: Bearer $ACCESS" -H 'Content-Type: application/json' \
-d "$(node -e "const fs=require('fs');process.stdout.write(JSON.stringify({device_name:'kou-mbp',cert_pem:fs.readFileSync(process.argv[1],'utf8')}))" ~/.config/sunshine/credentials/moonlight-9f1c2b7e-2a4d-4c6b-9f0e-1a2b3c4d5e6f.pem)"
5.9 GET /api/v1/certs
鉴权:Bearer 必填。返回本账号全部设备的证书(含自己的)。
成功 200
{
"ok": true,
"data": {
"certs": [
{
"device_id": "9f1c...",
"device_name": "windesk",
"platform": "win32",
"lan_ip": "192.168.1.50",
"cert_pem": "-----BEGIN CERTIFICATE-----\n...",
"fingerprint": "b1946ac9...",
"updated_at": 1730000000000
}
]
}
}
- 排序:
created_at DESC device_id/platform/lan_ip/fingerprint/updated_at可能为null(老数据)- 不会返回私钥(服务端从不接收私钥)
客户端如何消费这份快照(D3/D4 之后的完整规则)
步骤 判定 不通过时的 reason① 跳过自己 entry.device_id === 本机 device_idskipped_self② 键可用性 device_id非空missing_device_id③ 键格式 匹配 [A-Za-z0-9-]{8,64}invalid_device_id④ 指纹自校验 本地 sha256(cert_pem)与服务端fingerprint一致fingerprint_mismatch⑤ 路径内敛 safeJoin(credDir, 'moonlight-<device_id>.pem')落在凭据目录内path_escape⑥ 原子写 同目录 *.tmp-<rand>→renameio_error(带code)文件名主体是
device_id而不是device_name,这不是风格问题:device_name是用户可改的展示字段,用它做文件名会让两端各维护一套白名单 ([A-Za-z0-9._-]vs[A-Za-z0-9-]),任何一处改动都会产生「服务端接受但客户端 写不出」的静默故障;且Peer-A/peer-a在大小写不敏感的文件系统上会互相覆盖 (服务端device_name是 BINARY collation,认为它们是两台设备)。清理(删除服务端已不存在的对端证书)在同一份快照上做,共五层防护,其中最后一层 与本文档直接相关:只有当「本该落盘的文件」全部确实在目录里时,才允许删除多余文件, 否则整轮跳过(
expected_missing)。这条不变量保证了「快照有、本地没有」这种 不可信状态下不会误删唯一可用的副本;同时让旧版本留下的moonlight-<device_name>.pem可以自然共存 —— 因此升级不需要迁移脚本。
失败:仅 401 系列。
curl -sS http://127.0.0.1:3000/api/v1/certs -H "Authorization: Bearer $ACCESS"
6. 错误码总表(15 条,与 server/errors.js#ERROR_TABLE 逐条对齐)
| # | code | HTTP | message | hint |
|---|---|---|---|---|
| 1 | VALIDATION_FAILED |
400 | 请求参数不正确 | 检查输入后重试 |
| 2 | INVALID_USERNAME |
400 | 用户名需为 3–32 位字母、数字、下划线或短横线 | 更换用户名 |
| 3 | WEAK_PASSWORD |
400 | 密码需至少 10 位,且不能是纯数字或纯字母 | 使用更长的混合密码 |
| 4 | INVALID_CERT |
400 | 证书格式不正确,请只上传 Moonlight 公钥证书(.pem/.crt) | 确认未上传含私钥的文件 |
| 5 | INVALID_ARGUMENT |
400 | 参数格式不合法 | 按提示格式输入 |
| 6 | UNAUTHORIZED |
401 | 未登录或登录已失效 | 重新登录 |
| 7 | INVALID_CREDENTIALS |
401 | 用户名或密码错误 | 检查大小写后重试 |
| 8 | TOKEN_EXPIRED |
401 | 登录已过期 | 自动续期中,若持续失败请重新登录 |
| 9 | TOKEN_REVOKED |
401 | 该登录已被登出 | 重新登录 |
| 10 | FORBIDDEN |
403 | 无权访问该资源 | 联系管理员 |
| 11 | NOT_FOUND |
404 | 资源不存在 | 刷新后重试 |
| 12 | USER_EXISTS |
409 | 该用户名已被注册 | 更换用户名或直接登录 |
| 13 | PAYLOAD_TOO_LARGE |
413 | 请求内容过大 | 请检查证书文件大小(≤8KB) |
| 14 | RATE_LIMITED |
429 | 尝试过于频繁,请 15 分钟后再试 | 等待后重试,或联系管理员清除锁定 |
| 15 | INTERNAL |
500 | 服务器内部错误 | 记录错误码并联系管理员 |
复核命令:
node -e "import('./server/errors.js').then(m=>console.log(m.ERROR_CODES.length, m.ERROR_CODES.join(',')))"
# 15 VALIDATION_FAILED,INVALID_USERNAME,WEAK_PASSWORD,INVALID_CERT,INVALID_ARGUMENT,
# UNAUTHORIZED,INVALID_CREDENTIALS,TOKEN_EXPIRED,TOKEN_REVOKED,FORBIDDEN,NOT_FOUND,
# USER_EXISTS,PAYLOAD_TOO_LARGE,RATE_LIMITED,INTERNAL
FORBIDDEN、INVALID_ARGUMENT、NOT_FOUND在当前 9 个端点里没有业务触发点 (NOT_FOUND由 404 兜底中间件触发,FORBIDDEN预留给 V2 的多租户/角色), 保留在表内是为了让客户端映射表 100% 覆盖、避免出现「未知码」。
6.1 端点 × 错误码矩阵
| 端点 | 400 | 401 | 404 | 409 | 413 | 429 | 500 |
|---|---|---|---|---|---|---|---|
GET /health |
— | — | — | — | — | — | ✅ |
POST /auth/register |
INVALID_USERNAME WEAK_PASSWORD VALIDATION_FAILED |
— | — | USER_EXISTS |
✅ | — | ✅ |
POST /auth/login |
VALIDATION_FAILED |
INVALID_CREDENTIALS |
— | — | ✅ | ✅ | ✅ |
POST /auth/refresh |
VALIDATION_FAILED |
TOKEN_EXPIRED TOKEN_REVOKED UNAUTHORIZED |
— | — | ✅ | — | ✅ |
POST /auth/logout |
VALIDATION_FAILED |
UNAUTHORIZED TOKEN_EXPIRED TOKEN_REVOKED |
— | — | ✅ | — | ✅ |
POST /devices/heartbeat |
VALIDATION_FAILED |
401 系列 | — | — | ✅ | — | ✅ |
GET /devices |
— | 401 系列 | — | — | — | — | ✅ |
POST /certs |
VALIDATION_FAILED INVALID_CERT |
401 系列 | — | — | ✅ | — | ✅ |
GET /certs |
— | 401 系列 | — | — | — | — | ✅ |
| 任意未匹配路径 | — | — | NOT_FOUND {path} |
— | — | — | ✅ |
404 NOT_FOUND 由 notFoundHandler() 触发,details 形如 {path:'/api/v1/nope'}。
7. 客户端实现对照(launcher/src/api.ts)
| 客户端方法 | 端点 |
|---|---|
login() |
POST /auth/login |
register() |
POST /auth/register |
refresh() |
POST /auth/refresh |
logout() |
POST /auth/logout |
heartbeat() |
POST /devices/heartbeat |
listDevices() |
GET /devices(把 data.devices 摊平成数组) |
listCerts() |
GET /certs(把 data.certs 摊平成数组) |
uploadCert() |
POST /certs(不传 fingerprint) |
三条客户端约定:
- 超时 8s(
AbortController+AbortSignal),超时/不可达 →NETWORK_UNREACHABLE, 绝不永久挂起。 device_name一律取os.hostname(),两端(heartbeat 与 certs)必须同名, 否则证书会关联到另一条设备行。- 落盘文件名只由
device_id决定(D3/D4):moonlight-<device_id>.pem。device_name是纯展示字段,不参与任何路径推导。由此派生的三条客户端行为:device_id为空 ⇒ 跳过写入,reason = missing_device_id(该设备从未心跳过, 因此没有lan_ip、本来就不可能被一键连接;≤30s 后下一轮心跳自愈);device_id不合[A-Za-z0-9-]{8,64}⇒ 跳过,reason = invalid_device_id;- 「跳过自己」只按
device_id比对,不回落到名字比对 (同名不同机会被误判成自己)。
- 401 处理:
TOKEN_REVOKED,或TOKEN_EXPIRED且无 refreshToken ⇒ 触发统一登出(reason: 'revoked' | 'expired'),并广播auth:logged-out。
8. 变更与兼容策略
- 路径版本号
/v1只在破坏性变更时递增;新增可选字段不升版本。 - 新增字段一律可选,服务端必须容忍缺失(
heartbeat的四个可选字段就是范例)。 - 删除/收紧校验(把「忽略」改成「报错」)视为破坏性变更 —— 会让老客户端全量掉线。
- 客户端对未知错误码的兜底:按 HTTP 状态码映射(
launcher/src/api.ts#codeFromStatus), 保证服务端加了新码也不会把 UI 打挂。