# 技术说明

这份文档描述当前代码里的几条核心机制。消息字段、帧格式和握手时序的完整表见 [线路协议](protocol.md)。

## 运维端和设备都向外连接 exchange

家用和企业分支的设备通常在 NAT 后面，exchange 无法主动拨进去。因此两边都向 exchange 发起 WebSocket：

- 设备连 `wss://<主机>/ws/device`（明文调试时是 `ws://`），在首帧里带上设备令牌注册
- 运维端连 `wss://<主机>/ws/operator`，握手使用运维令牌

exchange 自己只做鉴权、路由和留痕。监听地址由 `--listen` 或 `LUCI_LISTEN` 决定，TLS 由前面的反向代理终止。

## 两套会话编号

运维端和设备各自维护会话编号，两边的数字互不占用。运维端发出请求时就可以带上自己的编号，不必先等服务端分配再发后续帧。设备侧编号由这台设备当前这条连接单独分配，从 1 起递增，两个运维端即使都使用本地编号 1，设备上也会落到不同的编号。

编号 0 不分配给任何会话，留给连接级消息。运维侧编号为 0，或这条运维连接已经占用了该编号时，登记直接失败，不会写入路由表。

翻译表在 `crates/exchange/src/hub/session_table.rs`。`hub.rs` 把翻译结果挂到具体连接上：设备侧是「设备编号 → 运维编号」，运维侧是「运维编号 →（设备连接, 设备编号）」。

## 转发时只改写会话编号

PTY 字节和命令的 stdout/stderr 走二进制帧。帧头 9 字节：1 字节流类型、4 字节 `session_id`、4 字节载荷长度，后面是裸载荷。定义在 `crates/proto/src/frame.rs`。

exchange 转发这种帧时只替换 `session_id`：

- 运维端发往设备：`crates/exchange/src/ws/operator.rs` 用 `DataFrame::with_session_id` 换成设备侧编号，再把编码后的字节放进设备的发送队列
- 设备发往运维端：`crates/exchange/src/ws/device.rs` 的 `handle_data` 把设备侧编号换回运维端自己的编号。源码注释写的就是这一处改写

载荷原样通过。路由不根据 PTY 按键或命令输出的内容做判断；查不到会话时帧被丢弃。控制消息是 JSON 文本帧，打开和结束会话时同样换成对端那一套编号，业务字段保持原样。

## 设备重连会顶替旧连接

同一设备再次连上时，新连接写入在线表并顶替旧连接，旧连接交给调用方关闭。不能靠拒绝新连接来处理「重复连接」：半开的旧连接可能还挂在表里，拒绝会让设备一直等到旧连接超时。

每条设备连接有一个 `epoch`（UUID），在 `Hub::register_device` 里生成。断开清理走 `Hub::unregister_device`，只有表里当前记录的 epoch 与这次清理带来的 epoch 一致时才摘掉设备并标为离线。旧连接的清理若晚于新连接注册，epoch 对不上，函数返回空列表，新连接继续留在表里。

## 发送队列有界，写满就断开这条连接

每条 WebSocket 对应一条容量 512 的队列，实现在 `crates/exchange/src/hub/outbound.rs` 的 `channel`。路由路径上持有连接表的锁，所以投递只用 `Sender::try_send`，不在锁里等待对端。

队列已满时 `try_send` 返回 `SendError::Lagging`，并置位关闭信号。`Close` 控制消息不进队列，只置位同一信号，因此队列堵死时仍然能要求连接退出。设备侧与运维侧的接入循环都在 `tokio::select!` 里等待 `sender.closed()`（`crates/exchange/src/ws/device.rs`、`crates/exchange/src/ws/operator.rs`）。信号一到，转发循环结束，随后按 epoch 注销这台设备或清理这个运维端的会话。对端已经把接收端丢掉时返回 `SendError::Closed`，与「来不及消费」区分开。

## 令牌是前缀加密文，密文只存 argon2

明文形如 `<prefix>.<secret>`。`crates/exchange/src/auth.rs` 的 `split_token` 按第一个 `.` 拆开，空前缀或空密文都视为格式错误。

签发时前缀是 8 字节随机数的十六进制，密文是 32 字节随机数的 base64url。数据库只保存两列：

- `token_prefix`：明文，带唯一索引，校验时按它做一次查找
- `token_hash`：密文的 argon2id PHC 字符串，由 `hash_secret` 生成

设备和运维账号都按这个格式存。校验时用前缀取出哈希，再用 `verify_secret` 比对密文。明文只在签发当时返回给调用方，库里还原不出来。

argon2 参数是 8 MiB 内存、2 次迭代、并行度 1，低于库的默认档位。密文是 256 位随机值，慢哈希挡不住额外的穷举，却会把设备同时重连变成握手延迟。这组参数只适用于这种随机令牌。

## 相关文档

- [开发说明](development.md)
- [使用说明](usage.md)
- [线路协议](protocol.md)
- 部署指南在仓库 `docs/deployment.md`（含服务器地址，不在本站公开）
