diff --git a/README.md b/README.md index 8307c87..b849890 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,428 @@ + +--- + +# 本仓库修改 + +## 编译与打包步骤 + +### 1. 编译服务器二进制(hbbs / hbbr / rustdesk-utils) + +使用持久化 Rust 容器编译: + +```sh +# 启动编译容器 +docker run -d --name rustbuild --network host \ + -v /root/rustdesk/lejianwen/rustdesk-server:/srv -w /srv rust:alpine sleep infinity + +# 容器内:换阿里云 apk 源 +printf 'https://mirrors.aliyun.com/alpine/v3.24/main\nhttps://mirrors.aliyun.com/alpine/v3.24/community\n' > /etc/apk/repositories + +# 安装依赖 +apk add build-base pkgconf openssl-dev openssl-libs-static git + +# 配置 cargo(rsproxy 源 + CLI git fetch) +# /usr/local/cargo/config.toml: +# [source.crates-io] replace-with = "rsproxy-sparse" +# [source.rsproxy-sparse] registry = "sparse+https://rsproxy.cn/index/" +# [net] git-fetch-with-cli = true + +# GitHub 加速(容器内系统级改写) +git config --system url.'https://gh-proxy.com/https://github.com/'.insteadOf 'https://github.com/' + +# 编译 +docker exec rustbuild cargo build --release +``` + +编译产物:`target/release/hbbs`、`target/release/hbbr`、`target/release/rustdesk-utils` + +### 2. 构建 s6 基础镜像 + +```sh +# 拷贝二进制到 rootfs +docker cp rustbuild:/srv/target/release/hbbs /root/rustdesk/build/s6img/rootfs/usr/bin/ +docker cp rustbuild:/srv/target/release/hbbr /root/rustdesk/build/s6img/rootfs/usr/bin/ +docker cp rustbuild:/srv/target/release/rustdesk-utils /root/rustdesk/build/s6img/rootfs/usr/bin/ + +# 构建镜像(必须 --no-cache,否则 COPY rootfs 走缓存) +docker build --no-cache \ + -f /root/rustdesk/build/s6img/Dockerfile.local \ + -t rustdesk-server-s6:1.1.17-forapi \ + /root/rustdesk/build/s6img +``` + +### 3. 编译 API 二进制(apimain) + +```sh +# 启动 Go 编译容器 +docker run -d --name gobuild --network host \ + -v /root/rustdesk/build/apibuild:/srv -w /srv golang:1.23-alpine sleep infinity + +# 安装 git + gcc(CGO 静态编译需要) +docker exec gobuild apk add --no-cache git gcc musl-dev + +# 设置 Go 代理(直连 GitHub 或国内镜像) +docker exec gobuild go env -w GOPROXY=https://goproxy.cn,direct + +# 编译(CGO 静态编译) +docker exec gobuild env CGO_ENABLED=1 GOOS=linux GOARCH=amd64 CC=gcc \ + go build -ldflags '-s -w' -o ./amd64/release/apimain ./cmd/apimain.go +``` + +### 4. 构建 admin 前端 + +```sh +# admin 前端来自 lejianwen/rustdesk-api-web +git clone --depth 1 -b master https://gh-proxy.com/https://github.com/lejianwen/rustdesk-api-web +docker run --rm -v $PWD:/srv -w /srv node:20-alpine sh -c \ + "npm config set registry https://registry.npmmirror.com && npm install && npm run build" +cp -a rustdesk-api-web/dist/. /root/rustdesk/build/apibuild/resources/admin/ +``` + +### 5. 打完整镜像 + +```sh +# 修改 Dockerfile_full_s6 首行,指向本地 s6 基础镜像 +cp /root/rustdesk/build/apibuild/Dockerfile_full_s6 /tmp/Dockerfile_full_s6 +sed -i 's|FROM rustdesk/rustdesk-server-s6:latest AS server|FROM rustdesk-server-s6:1.1.17-forapi AS server|' /tmp/Dockerfile_full_s6 + +# 构建(context 必须含 amd64/release) +docker build -f /tmp/Dockerfile_full_s6 \ + --build-arg BUILDARCH=amd64 \ + -t yangjieshao/rustdesk-api:full-s6-amd64 \ + /root/rustdesk/build/apibuild +``` + +### 6. 验证运行 + +```sh +docker run -d --name rustdesk-test -p 21114-21119:21114-21119 \ + -p 21116:21116/udp \ + -e RELAY= \ + -v :/data -v :/app/data \ + yangjieshao/rustdesk-api:full-s6-amd64 + +curl -s http://127.0.0.1:21114/_admin/ -o /dev/null -w '%{http_code}\n' +``` + +### 构建产物 + +| 镜像 | 说明 | +|---|---| +| `rustdesk-server-s6:1.1.17-forapi` | s6 基础镜像(hbbs + hbbr + rustdesk-utils) | +| `yangjieshao/rustdesk-api:full-s6-amd64` | 完整镜像(+ apimain + admin 前端,s6 托管) | + +| SINGLE_BANDWIDTH | hbbr | 单个连接的最大带宽(以Mb/s为单位) | +| TOTAL_BANDWIDTH | hbbr | 最大总带宽(以Mb/s为单位) | + +--- + +# 1.1.17 合并记录 + +> 日期:2026-09-05 +> 关联文档:[lejianwen/rustdesk-api README](https://github.com/lejianwen/rustdesk-api) +> 说明:将官方 `rustdesk/rustdesk-server` 1.1.17 合并到本仓库(forapi 分支),完整构建记录见 [lejianwen/rustdesk-api README](https://github.com/lejianwen/rustdesk-api)。 + +## 合并要点 + +- 官方 1.1.14(merge-base `c650217`)→ 1.1.17,共 43 个上游提交 +- **安全**:禁用 UDP 打洞反射/放大路径(`PunchHoleRequest`/`PunchHoleSent`/`LocalAddr` 只允许走 TCP/WS) +- **功能**:`-b/--bind` 绑定指定 IP;控制台监听独立绑到 127.0.0.1;`punch-requests(pr)` 连接日志控制台命令 +- **修复**:在线状态 `i32` 溢出(运行 24.9~49.7 天后误报在线);中继限速默认值 4Mb/s → 32Mb/s +- **依赖**:`hbb_common` 子模块推进到 `69cea8d`、新增 `flate2` + +## 冲突处理 + +| 冲突文件 | 处理方式 | +|---|---| +| `src/rendezvous_server.rs` | 保留 fork 的 JWT/必须登录/KeyExchange/加密 Sink/API 特性,叠加上游全部修复,共 4 处冲突块手动合并 | +| `src/main.rs` | 双方参数合并:上游的 `--bind` + DEPRECATED 标注,保留 fork 的 `--must-login` | +| `libs/hbb_common`(子模块) | 从 fork 的旧提交直接指到官方 1.1.17 用的 `69cea8d` | +| `Cargo.toml` / `Cargo.lock` | git 自动合并成功 | + +**注意点**: +- fork 侧 `check_cmd` 帮助文本比上游多一行 `must-login`,合并 `punch-requests` 时要同步把 format! 占位符从 7 个加到 8 个,**否则编译报 `argument never used`** +- 上游的 `peers_online_state` 是内联代码,fork 抽成了 helper(`handle_online_request` 复用),合并时保留 helper 并把 `as i32` 改 `as i64` + +--- + +# 自连拦截(A 远程连 A)服务器端方案 + +> 状态:**已实施并通过端到端验证**(2026-09-06) +> 日期:2026-09-05 设计 / 2026-09-06 实施 +> 关联文档:[lejianwen/rustdesk-api README](https://github.com/lejianwen/rustdesk-api) +> +> v2 变更:经用户提示,发现 API 的 `UserToken` 表**已存在 token→设备ID 映射**, +> 方案从"JWT 加 claim"改为"映射查询",JWT 结构完全不动。 +> 实施时额外修复:API 签发的 JWT 增加 `jti`(见 4.4)。 + +## 1. 问题描述 + +用户偶尔会误操作"用 A 电脑远程连接 A 电脑自己"(比如通讯录里点了自己、输错 ID),连接一旦成功,画面里包含自己的画面,形成无限嵌套(镜中镜)。 + +- 该问题正统修法在**客户端**(发起前检查目标 == 自己),但 rustdesk 客户端仓库太大、用户不希望动客户端 +- 因此考虑**服务器端**拦截:发现"自己连自己"时直接拒绝,并把原因返回给客户端弹窗 + +## 2. 服务器端能拦截的位置分析 + +连接建立的时序(hbbs = ID/信令服务器,hbbr = 中继): + +``` +主控端 A ──PunchHoleRequest──▶ hbbs ──PunchHoleResponse──▶ 主控端 A + │ + └──PunchHoleSent──▶ 被控端 B +(之后 A/B 直连打洞,或双方连 hbbr 中继) +``` + +**关键结论:拦截窗口只有 hbbs 处理 `PunchHoleRequest` / `RequestRelay` 这两步。** +一旦放行、两端直连建立(本机自连几乎必然直连成功),后续流量完全不经服务器,无法感知也无法切断。 + +API(Go 服务)不在打洞数据路径上(客户端直连 hbbs 的 21116/21118),所以 API 层没有拦截点;hbbr 也只在打洞失败后才登场。**唯一可行的服务端拦截点就是 hbbs。** + +## 3. 代码调查结论(证据) + +### 3.1 协议里没有"发起方设备 ID" + +`libs/hbb_common/protos/rendezvous.proto`: + +```proto +message PunchHoleRequest { + string id = 1; // 目标设备 ID(有) + NatType nat_type = 2; + string licence_key = 3; + ConnType conn_type = 4; + string token = 5; // ← 发起方的登录 token(fork 已在用) + ... +} // 没有任何"发起方自己 ID"字段 + +message RequestRelay { + string id = 1; // 目标设备 ID + ... + string token = 8; // ← 中继请求也带 token + ... +} +``` + +→ "目标 ID == 发起 ID 就拒"的规则本身正确,但服务器必须先知道**发起方是谁**。 + +### 3.2 token(JWT)里目前只有账号,但 API 数据库里已有 token→设备ID 映射 + +- hbbs 侧(`src/jwt.rs`):`Claims { user_id, exp }` —— JWT 本身不含设备 +- API 签发侧(`rustdesk-api/service/user.go:89`):`Jwt.GenerateToken(u.Id)` 只放 user_id +- 仅凭 user_id 对比目标设备归属 → 会误伤"同账号下 A 连自己的另一台设备 B"(合法主场景)❌ +- **但**:API 的 `model/userToken.go` `UserToken` 表已存 `Token + DeviceId + DeviceUuid + ExpiredAt`, + 且 `Login()`(`service/user.go:97`)签发时就写入 `DeviceId: llog.DeviceId`,`Logout` 时删行。 + 即 **token → 设备ID 的映射已经持久化在 API 的 sqlite 中**(v2 方案的基础)。 + +### 3.3 映射数据的来源:登录时 API 已经拿到发起方设备 ID + +`rustdesk-api/http/request/api/user.go:31`: + +```go +type LoginForm struct { + AutoLogin bool `json:"autoLogin"` + DeviceInfo DeviceInfoInLogin `json:"deviceInfo"` + Id string `json:"id"` // ← 客户端自己的 rustdesk ID + Type string `json:"type"` + Uuid string `json:"uuid"` + Username string `json:"username"` + Password string `json:"password,omitempty"` +} +``` + +桌面客户端和 web 客户端登录 API 都走这个接口并携带 `Id`,登录流程将其写入 `LoginLog.DeviceId` 并落到 `UserToken.DeviceId` —— **无需客户端任何改动,设备身份已经入库**。 + +### 3.4 MUST_LOGIN 只影响发起方(已确认) + +hbbs 中 MUST_LOGIN 的强制点**只有一处**:`src/rendezvous_server.rs:932`(`handle_punch_hole_request` 内),即只有发起打洞的会话要求 token。 + +| 消息 | 发送方 | MUST_LOGIN=Y 时 | +|---|---|---| +| `PunchHoleRequest` | 发起方 | 必须带有效 token | +| `RequestRelay` | 发起方(force_relay 场景) | **目前不检查(现状缺口)** | +| `RegisterPeer`(注册/心跳) | 被控端 | 不检查,不登录可被连 | +| `RegisterPk` | 被控端 | 不检查 | +| hbbr 中继连接 | 双方 | 无校验 | + +→ 被连接设备**不需要**登录;MUST_LOGIN=Y 恰好只保证"发起方必带 token",与自连拦截(只需要发起方身份)完全匹配。 + +### 3.5 错误原因回传客户端有现成通道 + +现有代码已经用 `PunchHoleResponse.other_failure` 给客户端弹窗: + +```rust +msg_out.set_punch_hole_response(PunchHoleResponse { + other_failure: String::from("Connection failed, please login!"), + ..Default::default() +}); +``` + +→ 自连拒绝提示用同一字段,客户端零改动。 + +## 4. 推荐方案(v2:映射查询,不改 JWT) + +数据流: + +``` +客户端登录 ──LoginForm{Id}──▶ API:签发JWT + UserToken{Token, DeviceId}(已有) +主控端发起连接 ──PunchHoleRequest{id, token}──▶ hbbs + hbbs:验JWT签名/有效期(逻辑不变) + → 按 token 查 UserToken 得 device_id(内存缓存 → 未命中走 API 内部接口) + → device_id == ph.id ? + 是 → 回 PunchHoleResponse{other_failure:"不能远程自己"},拒绝 + 否 → 正常打洞 +``` + +### 改动 1:API(rustdesk-api,Go,改动极小) + +- 确认登录时 `LoginLog.DeviceId` 取自 `LoginForm.Id`(rustdesk ID),对不上则补一行赋值 +- 新增内部查询接口(如 `GET /api/internal/token-device`,入参 token,返回 device_id), + **仅允许 localhost/容器内访问**(IP 判断 + 共享密钥头),不记日志避免 token 泄露 + +### 改动 2:hbbs(lejianwen/rustdesk-server,Rust,约 40 行) + +- `handle_punch_hole_request`:验签通过后查映射(`token 哈希 → device_id`, + hbbs 内置 HashMap 缓存 + TTL 几分钟;未命中调 API 内部接口), + `device_id == ph.id` → 拒绝并回 `other_failure` +- `RequestRelay` 分支(`handle_tcp`):**补上 token 校验**(顺带堵掉 force_relay 绕过 MUST_LOGIN 的口子)+ 同样的自连比对 +- **fail-open**:API 不可达/查询失败 → 视为"无设备信息",跳过自连检查(不拖累正常连接) + +### 兜底规则(已确认的设计决策):无 token 一律跳过校验 + +自连校验的触发条件是"请求携带有效 token 且查得到 device_id",**与 MUST_LOGIN 取值解耦**: + +| 场景 | 行为 | +|---|---| +| MUST_LOGIN=Y,任意发起方 | 必有 token → 校验生效,100% 拦截 | +| MUST_LOGIN=N,发起方已登录 | 带 token → 顺路校验,同样拦截(免费保护) | +| MUST_LOGIN=N,发起方未登录 | 无 token → **跳过校验直接放行**,保持现状行为(套娃风险被明确接受) | + +理由:没有 token 就没有设备身份,无从判断"是不是自己连自己";强行用 IP/user_id 猜 +只会引入误伤(见第 5 节)。跳过 = 该场景退化为现状,不比现在更差,已是改动范围内的最优解。 + +### 改动 3:开启 `MUST_LOGIN=Y` + +- 保证所有发起连接的会话都带 token → 拦截覆盖率 100% +- 代价即其既有语义:所有"主动发起连接"的设备必须登录;纯被连设备不受影响 + +### 明确不推荐:hbbs 直读 API 的 sqlite + +同容器共享 `/app/data` 技术上可行,但 Go gorm 与 Rust sqlx 双进程写同一 sqlite 有锁风险, +且 hbbs 从此耦合 API 表结构(API 升级改表即崩)——映射读取统一走 HTTP 内部接口。 + +### 4.4 实施补充:JWT 签发增加 jti + +实施中发现:JWT claims 只有 `{user_id, exp}`,同一账号**同一秒内**多次登录会产出 +字节级相同的 token,UserToken 表中无法区分设备(hbbs 查映射会命中错误设备)。 +修复:`lib/jwt/jwt.go` GenerateToken 的 RegisteredClaims 增加 `ID: uuid.NewString()`(jti), +每次登录 token 唯一。旧客户端/旧 token 不受影响(Rust 侧 serde 忽略未知字段)。 + +## 4.5 实际改动清单(已全部落地) + +**rustdesk-api(Go)** + +| 文件 | 改动 | +|---|---| +| `service/user.go` | 新增 `GetDeviceIdByToken(token)`:查 UserToken(校验 ExpiredAt)返回 DeviceId | +| `http/controller/api/internal.go` | 新增内部接口 `GET /api/internal/token-device?token=`:仅回环 IP + `X-Secret` 头必须等于 JWT 密钥,否则 403;查不到 404 | +| `http/router/api.go` | 注册路由(置于 RustAuth 中间件之前) | +| `lib/jwt/jwt.go` | GenerateToken 增加 jti | + +**lejianwen/rustdesk-server(Rust)** + +| 文件 | 改动 | +|---|---| +| `src/token_device.rs`(新增) | 映射查询模块:`get_device_id(token)`,内存缓存(命中 TTL 300s / 未命中 60s,容量上限 4096),minreq 在 `spawn_blocking` 中调 API,超时 3s,任何失败返回 None(fail-open) | +| `src/lib.rs` | 注册 `mod token_device` | +| `src/rendezvous_server.rs` `handle_punch_hole_request` | MUST_LOGIN 验签后:token 有效且 `get_device_id == ph.id` → 返回 `PunchHoleResponse{other_failure:"不能远程自己…/You are connecting to yourself"}`,不打洞 | +| `src/rendezvous_server.rs` `handle_tcp` RequestRelay 分支 | **补上缺失的 MUST_LOGIN token 校验**(堵 force_relay 绕过口子)+ 同样的自连比对,命中则丢弃中继请求并记 warn 日志 | +| `src/main.rs` | `--must-login` 帮助文本标注 default Y | +| `src/rendezvous_server.rs` `start_with_bind` | **MUST_LOGIN 默认值改为 Y**:仅当显式 `--must-login=N` 或 `MUST_LOGIN=N` 时关闭 | +| `docker-compose.yml` | 示例 env `MUST_LOGIN=N` → `Y` | + +**运行配置**:hbbs 与 API 同容器,`RUSTDESK_API_JWT_KEY` 双方共享(hbbs 的 jwt::SECRET 即 API 的 jwt.key); +API 内部地址可用 `RUSTDESK_API_INTERNAL_URL` 覆盖,默认 `http://127.0.0.1:21114`。 + +> ⚠️ **部署必设 `RUSTDESK_API_JWT_KEY`**(任意字符串)。未设置时 hbbs 的 JWT 密钥为空, +> token 校验和自连拦截**整体静默关闭**(fail-open 设计,日志无报错,连接照常放行)。 +> 新版本已在启动日志中显式告警:`RUSTDESK_API_JWT_KEY is not set: ... DISABLED`; +> 设置后日志显示 `Self-connection blocking: enabled`。修改该值后客户端需退出重新登录。 + +## 4.6 端到端验证结果(2026-09-06,全部通过) + +验证方法:Python 脚本按 hbb_common `bytes_codec` 帧格式与 rendezvous.proto 手工构造 +`PunchHoleRequest` 直连 hbbs 21116 端口(脚本:`/root/rustdesk/build/punch_test.py`)。 + +| 用例 | 结果 | +|---|---| +| 自连:token 属于设备 11010050,目标 11010050 | ✅ 拦截,客户端收到 `other_failure: "不能远程自己:发起方与目标是同一台设备 / You are connecting to yourself"`,hbbs 记 `Blocked self-connection attempt` warn 日志 | +| 误伤检查:token 属于设备 99999999,目标 11010050 | ✅ 放行(映射返回 99999999 ≠ 目标),无误拦 | +| 无 token(MUST_LOGIN=Y) | ✅ 拒绝:`Connection failed, please login!` | +| 内部接口:正确密钥 + 回环 | ✅ 返回 `{"device_id":"11010050"}` | +| 内部接口:错误密钥 / 非回环来源 | ✅ 均 403 | +| MUST_LOGIN 默认值 | ✅ 未配置任何 env 时日志显示 `MUST_LOGIN=Y` | + +注:验证容器中以 `-k ""` 跳过密钥交换直连明文路径(打洞处理代码与加密路径完全相同), +正式镜像仍按官方流程自动生成密钥对并对 TCP 连接先发 KeyExchange 阶段 1,由客户端正常完成握手。 + +## 5. 为什么其他做法不行 / 不选 + +| 做法 | 结论 | +|---|---| +| 拿 user_id 对比目标设备归属 | 误伤同账号多设备互连(RustDesk 的主场景)❌ | +| 拿来源 IP 对比目标注册 IP | 误伤整个局域网 A→B(同一 NAT 出口)❌ | +| API/前端层拦截 | API 不在打洞路径上,没有拦截点 ❌ | +| 连接建立后检测/切断 | 直连流量不经服务器,无法感知 ❌ | +| hbbs 直读 API 的 sqlite | 双进程双 ORM 同库有锁风险,hbbs 耦合 API 表结构 ❌(见 4 节末) | +| rustdesk-api-web 前端加"目标==自己"提示 | 可选退路:只覆盖 webclient 点击路径,桌面客户端管不到;本质仍是客户端补丁 ⚠️ | +| 客户端修复(正统) | 用户排除(客户端仓库太大)⛔ | +| 协议增强:`PunchHoleRequest` 加 `my_id` 字段 | 长期正解,proto3 加字段向后兼容,但需要改客户端;**远期项**,服务端将来可"有字段就校验、无字段跳过" | + +**JWT 加 claim(v1 方案)降为备选**:同样可行且无运行时查询,但改动面(改签发+改 Claims 结构) +比映射查询大,且登出不能即时失效设备身份;若将来想去掉对 API 的运行时依赖可切换。 + +| 对比项 | v2 映射查询(推荐) | v1 JWT 加 claim(备选) | +|---|---|---| +| JWT 结构 | 不动 | 加字段(向后兼容) | +| API 改动 | 加 1 个内部接口(映射已存在) | 改签发逻辑 | +| hbbs 改动 | 两处校验 + 查询缓存 | 两处校验 | +| 运行时依赖 | 查询时 API 参与一次(有缓存,fail-open) | 无 | +| 登出语义 | 设备身份即时失效(增强) | token 到期前一直有效 | + +## 6. 覆盖范围与边缘情况 + +- **MUST_LOGIN=Y**:webclient 与桌面客户端发起 → 100% 拦截 + (`LoginForm.Id` → `UserToken.DeviceId` 提供设备身份) +- **MUST_LOGIN=N**:已登录设备发起 → 拦截;未登录设备发起 → 放行(见第 4 节兜底规则, + 套娃风险被明确接受,行为与现状一致) +- **MUST_LOGIN=Y 时未登录客户端**(无 token)→ 本来就被拒绝("please login"),不存在漏网 +- API 暂时不可达 → fail-open,自连拦截暂时降级,正常连接不受影响 +- **边缘情况**:客户端 ID 变更(重装/清数据)到重新登录之间的短窗口内,映射里的旧 device_id + 与目标不匹配 → 可能漏拦或误拦一次,重新登录即恢复,影响很小 +- 实施时需抽查验证 `UserToken.DeviceId` 对各端(桌面/webclient/OAuth 登录)都已正确填充 +- 错误文案建议中英对照,走 `other_failure` + +## 7. 名词澄清:hbbs 的代码就在本机 + +"hbbs"不是另一个项目,是 `lejianwen/rustdesk-server` 仓库编译出的二进制名(`Cargo.toml` 里 `[[bin]] name = "hbbs"`),主体代码即 `src/rendezvous_server.rs`。本机已有该仓库(刚完成 1.1.17 合并)、依赖子模块 hbb_common、完整容器编译链路——改完重新打镜像约十几分钟,无需再拉取任何大仓库(不动的 rustdesk 客户端仓库才是大家伙)。 + +## 8. 实施决策记录 + +1. ✅ 按 v2 方案实施(API 内部查询接口 + hbbs 两处校验,JWT 结构不动,仅加 jti) +2. ✅ 错误文案:`不能远程自己:发起方与目标是同一台设备 / You are connecting to yourself` +3. ✅ 已顺带补上 `RequestRelay` 缺失的 token 校验(堵住 MUST_LOGIN 的绕过口子) +4. ✅ 查不到映射(API 不可达)时 fail-open:只影响自连拦截,不拖累正常连接 +5. ✅ MUST_LOGIN 默认值改为 Y(显式配置 N 可关闭) +6. ⏳ 远期项(未实施):`PunchHoleRequest` 增加 `my_id` 字段(需改客户端,proto3 向后兼容) +--- + +# 原始 README 内容 + +> 以下为 `lejianwen/rustdesk-server` 原始仓库的 README,本仓库在此基础上增加了 +> 自连拦截(A 远程连 A)服务器端方案和 1.1.17 合并支持。 + [![build](https://github.com/lejianwen/rustdesk-server/actions/workflows/build.yaml/badge.svg)](https://github.com/lejianwen/rustdesk-server/actions/workflows/build.yaml) - 解决当客户端登录了`API`账号时链接超时的问题 @@ -415,305 +837,3 @@ Secret Key: egAVd44u33ZEUIDTtksGcHeVeAwywarEdHmf99KM5ajwEsuG3NQFT9coAfiZ6nen4hf | PORT | hbbs/hbbr | 监听端口(hbbs为21116,hbbr为21117) | | RELAY_SERVERS | hbbs | 运行hbbr的机器的IP地址/DNS名称(用逗号分隔) | | RUST_LOG | all | 设置 debug level (error\|warn\|info\|debug\|trace) | -| SINGLE_BANDWIDTH | hbbr | 单个连接的最大带宽(以Mb/s为单位) | -| TOTAL_BANDWIDTH | hbbr | 最大总带宽(以Mb/s为单位) | - ---- - -# 1.1.17 合并记录 - -> 日期:2026-09-05 -> 关联文档:[lejianwen/rustdesk-api README](https://github.com/lejianwen/rustdesk-api) -> 说明:将官方 `rustdesk/rustdesk-server` 1.1.17 合并到本仓库(forapi 分支),完整构建记录见 [lejianwen/rustdesk-api README](https://github.com/lejianwen/rustdesk-api)。 - -## 合并要点 - -- 官方 1.1.14(merge-base `c650217`)→ 1.1.17,共 43 个上游提交 -- **安全**:禁用 UDP 打洞反射/放大路径(`PunchHoleRequest`/`PunchHoleSent`/`LocalAddr` 只允许走 TCP/WS) -- **功能**:`-b/--bind` 绑定指定 IP;控制台监听独立绑到 127.0.0.1;`punch-requests(pr)` 连接日志控制台命令 -- **修复**:在线状态 `i32` 溢出(运行 24.9~49.7 天后误报在线);中继限速默认值 4Mb/s → 32Mb/s -- **依赖**:`hbb_common` 子模块推进到 `69cea8d`、新增 `flate2` - -## 冲突处理 - -| 冲突文件 | 处理方式 | -|---|---| -| `src/rendezvous_server.rs` | 保留 fork 的 JWT/必须登录/KeyExchange/加密 Sink/API 特性,叠加上游全部修复,共 4 处冲突块手动合并 | -| `src/main.rs` | 双方参数合并:上游的 `--bind` + DEPRECATED 标注,保留 fork 的 `--must-login` | -| `libs/hbb_common`(子模块) | 从 fork 的旧提交直接指到官方 1.1.17 用的 `69cea8d` | -| `Cargo.toml` / `Cargo.lock` | git 自动合并成功 | - -**注意点**: -- fork 侧 `check_cmd` 帮助文本比上游多一行 `must-login`,合并 `punch-requests` 时要同步把 format! 占位符从 7 个加到 8 个,**否则编译报 `argument never used`** -- 上游的 `peers_online_state` 是内联代码,fork 抽成了 helper(`handle_online_request` 复用),合并时保留 helper 并把 `as i32` 改 `as i64` - ---- - -# 自连拦截(A 远程连 A)服务器端方案 - -> 状态:**已实施并通过端到端验证**(2026-09-06) -> 日期:2026-09-05 设计 / 2026-09-06 实施 -> 关联文档:[lejianwen/rustdesk-api README](https://github.com/lejianwen/rustdesk-api) -> -> v2 变更:经用户提示,发现 API 的 `UserToken` 表**已存在 token→设备ID 映射**, -> 方案从"JWT 加 claim"改为"映射查询",JWT 结构完全不动。 -> 实施时额外修复:API 签发的 JWT 增加 `jti`(见 4.4)。 - -## 1. 问题描述 - -用户偶尔会误操作"用 A 电脑远程连接 A 电脑自己"(比如通讯录里点了自己、输错 ID),连接一旦成功,画面里包含自己的画面,形成无限嵌套(镜中镜)。 - -- 该问题正统修法在**客户端**(发起前检查目标 == 自己),但 rustdesk 客户端仓库太大、用户不希望动客户端 -- 因此考虑**服务器端**拦截:发现"自己连自己"时直接拒绝,并把原因返回给客户端弹窗 - -## 2. 服务器端能拦截的位置分析 - -连接建立的时序(hbbs = ID/信令服务器,hbbr = 中继): - -``` -主控端 A ──PunchHoleRequest──▶ hbbs ──PunchHoleResponse──▶ 主控端 A - │ - └──PunchHoleSent──▶ 被控端 B -(之后 A/B 直连打洞,或双方连 hbbr 中继) -``` - -**关键结论:拦截窗口只有 hbbs 处理 `PunchHoleRequest` / `RequestRelay` 这两步。** -一旦放行、两端直连建立(本机自连几乎必然直连成功),后续流量完全不经服务器,无法感知也无法切断。 - -API(Go 服务)不在打洞数据路径上(客户端直连 hbbs 的 21116/21118),所以 API 层没有拦截点;hbbr 也只在打洞失败后才登场。**唯一可行的服务端拦截点就是 hbbs。** - -## 3. 代码调查结论(证据) - -### 3.1 协议里没有"发起方设备 ID" - -`libs/hbb_common/protos/rendezvous.proto`: - -```proto -message PunchHoleRequest { - string id = 1; // 目标设备 ID(有) - NatType nat_type = 2; - string licence_key = 3; - ConnType conn_type = 4; - string token = 5; // ← 发起方的登录 token(fork 已在用) - ... -} // 没有任何"发起方自己 ID"字段 - -message RequestRelay { - string id = 1; // 目标设备 ID - ... - string token = 8; // ← 中继请求也带 token - ... -} -``` - -→ "目标 ID == 发起 ID 就拒"的规则本身正确,但服务器必须先知道**发起方是谁**。 - -### 3.2 token(JWT)里目前只有账号,但 API 数据库里已有 token→设备ID 映射 - -- hbbs 侧(`src/jwt.rs`):`Claims { user_id, exp }` —— JWT 本身不含设备 -- API 签发侧(`rustdesk-api/service/user.go:89`):`Jwt.GenerateToken(u.Id)` 只放 user_id -- 仅凭 user_id 对比目标设备归属 → 会误伤"同账号下 A 连自己的另一台设备 B"(合法主场景)❌ -- **但**:API 的 `model/userToken.go` `UserToken` 表已存 `Token + DeviceId + DeviceUuid + ExpiredAt`, - 且 `Login()`(`service/user.go:97`)签发时就写入 `DeviceId: llog.DeviceId`,`Logout` 时删行。 - 即 **token → 设备ID 的映射已经持久化在 API 的 sqlite 中**(v2 方案的基础)。 - -### 3.3 映射数据的来源:登录时 API 已经拿到发起方设备 ID - -`rustdesk-api/http/request/api/user.go:31`: - -```go -type LoginForm struct { - AutoLogin bool `json:"autoLogin"` - DeviceInfo DeviceInfoInLogin `json:"deviceInfo"` - Id string `json:"id"` // ← 客户端自己的 rustdesk ID - Type string `json:"type"` - Uuid string `json:"uuid"` - Username string `json:"username"` - Password string `json:"password,omitempty"` -} -``` - -桌面客户端和 web 客户端登录 API 都走这个接口并携带 `Id`,登录流程将其写入 `LoginLog.DeviceId` 并落到 `UserToken.DeviceId` —— **无需客户端任何改动,设备身份已经入库**。 - -### 3.4 MUST_LOGIN 只影响发起方(已确认) - -hbbs 中 MUST_LOGIN 的强制点**只有一处**:`src/rendezvous_server.rs:932`(`handle_punch_hole_request` 内),即只有发起打洞的会话要求 token。 - -| 消息 | 发送方 | MUST_LOGIN=Y 时 | -|---|---|---| -| `PunchHoleRequest` | 发起方 | 必须带有效 token | -| `RequestRelay` | 发起方(force_relay 场景) | **目前不检查(现状缺口)** | -| `RegisterPeer`(注册/心跳) | 被控端 | 不检查,不登录可被连 | -| `RegisterPk` | 被控端 | 不检查 | -| hbbr 中继连接 | 双方 | 无校验 | - -→ 被连接设备**不需要**登录;MUST_LOGIN=Y 恰好只保证"发起方必带 token",与自连拦截(只需要发起方身份)完全匹配。 - -### 3.5 错误原因回传客户端有现成通道 - -现有代码已经用 `PunchHoleResponse.other_failure` 给客户端弹窗: - -```rust -msg_out.set_punch_hole_response(PunchHoleResponse { - other_failure: String::from("Connection failed, please login!"), - ..Default::default() -}); -``` - -→ 自连拒绝提示用同一字段,客户端零改动。 - -## 4. 推荐方案(v2:映射查询,不改 JWT) - -数据流: - -``` -客户端登录 ──LoginForm{Id}──▶ API:签发JWT + UserToken{Token, DeviceId}(已有) -主控端发起连接 ──PunchHoleRequest{id, token}──▶ hbbs - hbbs:验JWT签名/有效期(逻辑不变) - → 按 token 查 UserToken 得 device_id(内存缓存 → 未命中走 API 内部接口) - → device_id == ph.id ? - 是 → 回 PunchHoleResponse{other_failure:"不能远程自己"},拒绝 - 否 → 正常打洞 -``` - -### 改动 1:API(rustdesk-api,Go,改动极小) - -- 确认登录时 `LoginLog.DeviceId` 取自 `LoginForm.Id`(rustdesk ID),对不上则补一行赋值 -- 新增内部查询接口(如 `GET /api/internal/token-device`,入参 token,返回 device_id), - **仅允许 localhost/容器内访问**(IP 判断 + 共享密钥头),不记日志避免 token 泄露 - -### 改动 2:hbbs(lejianwen/rustdesk-server,Rust,约 40 行) - -- `handle_punch_hole_request`:验签通过后查映射(`token 哈希 → device_id`, - hbbs 内置 HashMap 缓存 + TTL 几分钟;未命中调 API 内部接口), - `device_id == ph.id` → 拒绝并回 `other_failure` -- `RequestRelay` 分支(`handle_tcp`):**补上 token 校验**(顺带堵掉 force_relay 绕过 MUST_LOGIN 的口子)+ 同样的自连比对 -- **fail-open**:API 不可达/查询失败 → 视为"无设备信息",跳过自连检查(不拖累正常连接) - -### 兜底规则(已确认的设计决策):无 token 一律跳过校验 - -自连校验的触发条件是"请求携带有效 token 且查得到 device_id",**与 MUST_LOGIN 取值解耦**: - -| 场景 | 行为 | -|---|---| -| MUST_LOGIN=Y,任意发起方 | 必有 token → 校验生效,100% 拦截 | -| MUST_LOGIN=N,发起方已登录 | 带 token → 顺路校验,同样拦截(免费保护) | -| MUST_LOGIN=N,发起方未登录 | 无 token → **跳过校验直接放行**,保持现状行为(套娃风险被明确接受) | - -理由:没有 token 就没有设备身份,无从判断"是不是自己连自己";强行用 IP/user_id 猜 -只会引入误伤(见第 5 节)。跳过 = 该场景退化为现状,不比现在更差,已是改动范围内的最优解。 - -### 改动 3:开启 `MUST_LOGIN=Y` - -- 保证所有发起连接的会话都带 token → 拦截覆盖率 100% -- 代价即其既有语义:所有"主动发起连接"的设备必须登录;纯被连设备不受影响 - -### 明确不推荐:hbbs 直读 API 的 sqlite - -同容器共享 `/app/data` 技术上可行,但 Go gorm 与 Rust sqlx 双进程写同一 sqlite 有锁风险, -且 hbbs 从此耦合 API 表结构(API 升级改表即崩)——映射读取统一走 HTTP 内部接口。 - -### 4.4 实施补充:JWT 签发增加 jti - -实施中发现:JWT claims 只有 `{user_id, exp}`,同一账号**同一秒内**多次登录会产出 -字节级相同的 token,UserToken 表中无法区分设备(hbbs 查映射会命中错误设备)。 -修复:`lib/jwt/jwt.go` GenerateToken 的 RegisteredClaims 增加 `ID: uuid.NewString()`(jti), -每次登录 token 唯一。旧客户端/旧 token 不受影响(Rust 侧 serde 忽略未知字段)。 - -## 4.5 实际改动清单(已全部落地) - -**rustdesk-api(Go)** - -| 文件 | 改动 | -|---|---| -| `service/user.go` | 新增 `GetDeviceIdByToken(token)`:查 UserToken(校验 ExpiredAt)返回 DeviceId | -| `http/controller/api/internal.go` | 新增内部接口 `GET /api/internal/token-device?token=`:仅回环 IP + `X-Secret` 头必须等于 JWT 密钥,否则 403;查不到 404 | -| `http/router/api.go` | 注册路由(置于 RustAuth 中间件之前) | -| `lib/jwt/jwt.go` | GenerateToken 增加 jti | - -**lejianwen/rustdesk-server(Rust)** - -| 文件 | 改动 | -|---|---| -| `src/token_device.rs`(新增) | 映射查询模块:`get_device_id(token)`,内存缓存(命中 TTL 300s / 未命中 60s,容量上限 4096),minreq 在 `spawn_blocking` 中调 API,超时 3s,任何失败返回 None(fail-open) | -| `src/lib.rs` | 注册 `mod token_device` | -| `src/rendezvous_server.rs` `handle_punch_hole_request` | MUST_LOGIN 验签后:token 有效且 `get_device_id == ph.id` → 返回 `PunchHoleResponse{other_failure:"不能远程自己…/You are connecting to yourself"}`,不打洞 | -| `src/rendezvous_server.rs` `handle_tcp` RequestRelay 分支 | **补上缺失的 MUST_LOGIN token 校验**(堵 force_relay 绕过口子)+ 同样的自连比对,命中则丢弃中继请求并记 warn 日志 | -| `src/main.rs` | `--must-login` 帮助文本标注 default Y | -| `src/rendezvous_server.rs` `start_with_bind` | **MUST_LOGIN 默认值改为 Y**:仅当显式 `--must-login=N` 或 `MUST_LOGIN=N` 时关闭 | -| `docker-compose.yml` | 示例 env `MUST_LOGIN=N` → `Y` | - -**运行配置**:hbbs 与 API 同容器,`RUSTDESK_API_JWT_KEY` 双方共享(hbbs 的 jwt::SECRET 即 API 的 jwt.key); -API 内部地址可用 `RUSTDESK_API_INTERNAL_URL` 覆盖,默认 `http://127.0.0.1:21114`。 - -> ⚠️ **部署必设 `RUSTDESK_API_JWT_KEY`**(任意字符串)。未设置时 hbbs 的 JWT 密钥为空, -> token 校验和自连拦截**整体静默关闭**(fail-open 设计,日志无报错,连接照常放行)。 -> 新版本已在启动日志中显式告警:`RUSTDESK_API_JWT_KEY is not set: ... DISABLED`; -> 设置后日志显示 `Self-connection blocking: enabled`。修改该值后客户端需退出重新登录。 - -## 4.6 端到端验证结果(2026-09-06,全部通过) - -验证方法:Python 脚本按 hbb_common `bytes_codec` 帧格式与 rendezvous.proto 手工构造 -`PunchHoleRequest` 直连 hbbs 21116 端口(脚本:`/root/rustdesk/build/punch_test.py`)。 - -| 用例 | 结果 | -|---|---| -| 自连:token 属于设备 11010050,目标 11010050 | ✅ 拦截,客户端收到 `other_failure: "不能远程自己:发起方与目标是同一台设备 / You are connecting to yourself"`,hbbs 记 `Blocked self-connection attempt` warn 日志 | -| 误伤检查:token 属于设备 99999999,目标 11010050 | ✅ 放行(映射返回 99999999 ≠ 目标),无误拦 | -| 无 token(MUST_LOGIN=Y) | ✅ 拒绝:`Connection failed, please login!` | -| 内部接口:正确密钥 + 回环 | ✅ 返回 `{"device_id":"11010050"}` | -| 内部接口:错误密钥 / 非回环来源 | ✅ 均 403 | -| MUST_LOGIN 默认值 | ✅ 未配置任何 env 时日志显示 `MUST_LOGIN=Y` | - -注:验证容器中以 `-k ""` 跳过密钥交换直连明文路径(打洞处理代码与加密路径完全相同), -正式镜像仍按官方流程自动生成密钥对并对 TCP 连接先发 KeyExchange 阶段 1,由客户端正常完成握手。 - -## 5. 为什么其他做法不行 / 不选 - -| 做法 | 结论 | -|---|---| -| 拿 user_id 对比目标设备归属 | 误伤同账号多设备互连(RustDesk 的主场景)❌ | -| 拿来源 IP 对比目标注册 IP | 误伤整个局域网 A→B(同一 NAT 出口)❌ | -| API/前端层拦截 | API 不在打洞路径上,没有拦截点 ❌ | -| 连接建立后检测/切断 | 直连流量不经服务器,无法感知 ❌ | -| hbbs 直读 API 的 sqlite | 双进程双 ORM 同库有锁风险,hbbs 耦合 API 表结构 ❌(见 4 节末) | -| rustdesk-api-web 前端加"目标==自己"提示 | 可选退路:只覆盖 webclient 点击路径,桌面客户端管不到;本质仍是客户端补丁 ⚠️ | -| 客户端修复(正统) | 用户排除(客户端仓库太大)⛔ | -| 协议增强:`PunchHoleRequest` 加 `my_id` 字段 | 长期正解,proto3 加字段向后兼容,但需要改客户端;**远期项**,服务端将来可"有字段就校验、无字段跳过" | - -**JWT 加 claim(v1 方案)降为备选**:同样可行且无运行时查询,但改动面(改签发+改 Claims 结构) -比映射查询大,且登出不能即时失效设备身份;若将来想去掉对 API 的运行时依赖可切换。 - -| 对比项 | v2 映射查询(推荐) | v1 JWT 加 claim(备选) | -|---|---|---| -| JWT 结构 | 不动 | 加字段(向后兼容) | -| API 改动 | 加 1 个内部接口(映射已存在) | 改签发逻辑 | -| hbbs 改动 | 两处校验 + 查询缓存 | 两处校验 | -| 运行时依赖 | 查询时 API 参与一次(有缓存,fail-open) | 无 | -| 登出语义 | 设备身份即时失效(增强) | token 到期前一直有效 | - -## 6. 覆盖范围与边缘情况 - -- **MUST_LOGIN=Y**:webclient 与桌面客户端发起 → 100% 拦截 - (`LoginForm.Id` → `UserToken.DeviceId` 提供设备身份) -- **MUST_LOGIN=N**:已登录设备发起 → 拦截;未登录设备发起 → 放行(见第 4 节兜底规则, - 套娃风险被明确接受,行为与现状一致) -- **MUST_LOGIN=Y 时未登录客户端**(无 token)→ 本来就被拒绝("please login"),不存在漏网 -- API 暂时不可达 → fail-open,自连拦截暂时降级,正常连接不受影响 -- **边缘情况**:客户端 ID 变更(重装/清数据)到重新登录之间的短窗口内,映射里的旧 device_id - 与目标不匹配 → 可能漏拦或误拦一次,重新登录即恢复,影响很小 -- 实施时需抽查验证 `UserToken.DeviceId` 对各端(桌面/webclient/OAuth 登录)都已正确填充 -- 错误文案建议中英对照,走 `other_failure` - -## 7. 名词澄清:hbbs 的代码就在本机 - -"hbbs"不是另一个项目,是 `lejianwen/rustdesk-server` 仓库编译出的二进制名(`Cargo.toml` 里 `[[bin]] name = "hbbs"`),主体代码即 `src/rendezvous_server.rs`。本机已有该仓库(刚完成 1.1.17 合并)、依赖子模块 hbb_common、完整容器编译链路——改完重新打镜像约十几分钟,无需再拉取任何大仓库(不动的 rustdesk 客户端仓库才是大家伙)。 - -## 8. 实施决策记录 - -1. ✅ 按 v2 方案实施(API 内部查询接口 + hbbs 两处校验,JWT 结构不动,仅加 jti) -2. ✅ 错误文案:`不能远程自己:发起方与目标是同一台设备 / You are connecting to yourself` -3. ✅ 已顺带补上 `RequestRelay` 缺失的 token 校验(堵住 MUST_LOGIN 的绕过口子) -4. ✅ 查不到映射(API 不可达)时 fail-open:只影响自连拦截,不拖累正常连接 -5. ✅ MUST_LOGIN 默认值改为 Y(显式配置 N 可关闭) -6. ⏳ 远期项(未实施):`PunchHoleRequest` 增加 `my_id` 字段(需改客户端,proto3 向后兼容) \ No newline at end of file