Files
onlyinone/docs/api.md
T
jkooe 8903871919 feat(launcher): R10 客户端自愈——device_id 被降级为 null 时换新 id(免 PIN 闭环)
## 缺陷(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 干净。
2026-10-26 03:02:43 +08:00

28 KiB
Raw Blame History

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 一致,用于对日志

两条硬约束:

  1. 401 响应体逐字节稳定:INVALID_CREDENTIALS 不区分「用户不存在」与 「密码错误」,也不携带 details —— 否则等于给出账号枚举接口。
  2. 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()):

  1. 不是非空字符串 → 401 UNAUTHORIZED
  2. 签名/格式错(jsonwebtoken 抛错但不是 TokenExpiredError)→ 401 UNAUTHORIZED
  3. 已过期(TokenExpiredError)→ 401 TOKEN_EXPIRED
  4. payload.type 与期望不符(拿 refresh 当 access 用)→ 401 UNAUTHORIZED
  5. 缺 sub / 缺 jti → 401 UNAUTHORIZED
  6. 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.json
  • uptimeMs 为进程内计时(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 做三态判定(online 90s / recent 7d / 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_id skipped_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> → rename io_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)

三条客户端约定:

  1. 超时 8s(AbortController + AbortSignal),超时/不可达 → NETWORK_UNREACHABLE, 绝不永久挂起。
  2. device_name 一律取 os.hostname(),两端(heartbeat 与 certs)必须同名, 否则证书会关联到另一条设备行。
  3. 落盘文件名只由 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 比对,不回落到名字比对 (同名不同机会被误判成自己)。
  4. 401 处理:TOKEN_REVOKED,或 TOKEN_EXPIRED 且无 refreshToken ⇒ 触发统一登出(reason: 'revoked' | 'expired'),并广播 auth:logged-out。

8. 变更与兼容策略

  • 路径版本号 /v1 只在破坏性变更时递增;新增可选字段不升版本。
  • 新增字段一律可选,服务端必须容忍缺失(heartbeat 的四个可选字段就是范例)。
  • 删除/收紧校验(把「忽略」改成「报错」)视为破坏性变更 —— 会让老客户端全量掉线。
  • 客户端对未知错误码的兜底:按 HTTP 状态码映射(launcher/src/api.ts#codeFromStatus), 保证服务端加了新码也不会把 UI 打挂。