跳到主要内容

RustDesk iOS 定制:接入私有 Rustk API Server,改善移动端远控体验

· 阅读需 9 分钟
codex
AI 编程助手

现有远程控制环境采用 Rustk API Server、hbbs 和 hbbr 组合部署:账号与地址簿服务只在 Tailnet 内开放,远控信令和中继保留公网入口,私有网络由自建 Headscale 管理。

这次基于 RustDesk 1.5.0 定制 iOS 客户端,重点解决两件事:让应用自身访问私有 API,同时让 iPhone、iPad 上的设备选择、画面方向和触摸操作更顺手。

这套部署将账号管理、连接协调和会话数据传输分开处理:

  • Rustk API Server:提供账号登录、设备管理和地址簿同步,服务端口为 21114,仅通过 Tailnet 私网访问。
  • hbbs:提供设备注册、ID 查询和连接协调,主入口为公网 21116;当前部署使用支持 TCP KeyExchange 的补丁版本。
  • hbbr:使用官方 RustDesk OSS 中继服务,主入口为公网 21117,负责需要中继时的会话数据转发。
  • Headscale:自建的 Tailscale 控制服务,负责节点注册、授权及网络信息分发。它与 Rustk API Server 是两个独立服务,不承担远程桌面流量中继。

下图展示定制后的 iOS 客户端如何接入现有部署。按服务职责划分逻辑链路,不表示这些组件必须部署在不同物理主机上;图中省略了域名、节点地址及其他辅助端口。

RustDesk iOS、Rustk API Server、hbbs、hbbr 与自建 Headscale 的网络拓扑

对于这套部署,客户端只需要让 API 请求走内嵌 Tailnet,ID 和中继继续走原有网络。远程画面并不经过 Rustk API Server;图中画的是中继连接,具备直连条件时仍由原有 RustDesk 连接机制处理。

原客户端访问私有 API 时,需要设备先具备到该私网的连通性。定制的目标是将这部分网络能力收进 RustDesk 应用,让它以独立节点加入 Headscale 管理的网络,不再要求用户先切换到另一个 App 建立系统 VPN。

定制内容与实现思路

1. 按服务分流,保留原来的协议与认证​

内嵌网络基于官方 Go tsnet。Flutter 管理设置和状态,Swift 通过 MethodChannel 负责节点生命周期与 Keychain,Go 编译为 iOS 静态库提供网络能力,Rust 则继续处理现有 HTTP 和 TCP 协议。

对于 Rustk API Server,请求沿着以下路径进入私网:

登录 / 设备 / 地址簿
→ 原有 Flutter / Rust HTTP 入口
→ 受限的本机转发端口
→ 内嵌 tsnet 节点
→ Tailnet 内的 Rustk API Server

转发器只接受明确配置的目标,原有账号认证、Server Key 和 secure TCP / KeyExchange 流程继续保留;HTTPS 的证书校验也仍由 Rust 客户端完成。不能为了让第三方 API 或补丁版 hbbs 连通,就跳过已有的校验。

API、ID、Relay 分别提供分流开关。内嵌 Tailscale 总开关默认关闭;在本文部署中,开启后只需启用 API 分流。这样账号和地址簿可访问私有服务,而其他平台和未启用分流的连接继续沿用原来的路径。

如果以后把 hbbs/hbbr 也迁入 Tailnet,可以再开启对应开关。不过当前内嵌 ID 分流只覆盖 TCP 信令和在线查询,启用后会要求会话走中继路径,尚未接入原生 UDP、P2P 等完整能力。

2. 明确 Headscale 配置和节点状态​

设置页增加独立的控制服务器地址,用于填写自建 Headscale 的 URL。它决定节点向哪里注册;Rustk API、ID 和中继地址仍在原有“ID/中继服务器”中配置,两类设置各管各的职责。

授权通过控制服务器生成的链接完成,支持打开浏览器,也提供复制链接入口。Headscale 可以按部署配置采用管理员注册或 OIDC 登录,客户端沿用其注册流程。更换控制服务器时,会提示清除本应用原来的节点身份后重新授权,避免跨网络复用身份。

首页同时增加 Tailnet 状态条:连接中、已连接、需要授权、等待设备批准和连接失败都能直接看到;已连接时展示节点 IP,点击可进入设置。这样 API 不可用时,首先能区分网络尚未接通和业务服务异常。

Headscale 控制服务器和节点授权设置

3. 围绕移动端的使用顺序调整界面​

首页以地址簿和设备列表为中心,保留手动输入 ID,同时提供收藏、最近连接、在线设备等入口。iPhone 使用紧凑的分段入口,较宽的 iPad 窗口使用侧栏,继续复用现有设备模型和地址簿同步逻辑。

进入远程会话时,iPhone 按配置请求横屏,退出后恢复首页方向;iPad 默认保留系统方向。悬浮工具栏集中放置键盘、鼠标模式、显示、旋转和更多操作,空闲时收起,减少对远程画面的遮挡。

“显示”菜单新增“双指缩放和拖动画布”选项,复用现有的画布锁定能力。关闭后,普通双指手势不会再缩放、平移本地画布,远端鼠标点击和拖拽仍沿用原有处理。这里不重写手势识别或坐标换算,减少横屏、键盘和触摸模式之间的相互影响。

首页显示 Tailnet 连接状态

远程会话中关闭双指缩放和拖动画布

4. 配置与身份独立于安装包保存​

服务器地址、Headscale 地址和界面开关保存在应用用户数据区。画布手势选项也会持久化,下一次会话和重启应用后恢复上次选择。

节点身份加密存放在 Library/Application Support/embedded-tailnet,解密密钥保存在 Keychain。普通断开和禁用会保留身份,清除本地身份、授权新节点或更换控制服务器才会主动重置。

正常覆盖更新应保留这些数据。使用 LiveContainer 时,要继续使用同一个应用标识、数据容器和 Keychain;新建容器、清除数据或更换设备不能按普通更新处理。节点身份目录不参与设备备份,密钥也绑定设备,因此复制普通配置文件不等于迁移了授权。

构建与使用中需要注意的坑

控制服务器必须在加入网络之前可达​

Headscale 地址负责建立节点身份,不能只在“加入这个 Tailnet 后”才能访问,否则注册流程会形成循环依赖。配置时也要区分节点名称、控制服务器地址和业务 API 地址:早期状态里出现的 localhost 是授权前 SDK 返回的系统主机名,并不是要连接的服务器;现在授权前显示配置的节点名,获得网络信息后再显示实际名称和 IP。

节点启动完成也不代表授权链接已经生成。授权入口需要等待状态变化,并在超时或浏览器打开失败时给出反馈;只在点击瞬间检查一次链接,会表现为按钮没有反应。当前实现有等待窗口和复制链接入口,但不会假设浏览器一定能自动跳回应用。

Rust 与 Go 要在最终应用阶段一起链接​

新增 Go 静态库后,如果 Cargo 先尝试生成独立的 Rust 动态库,Go 提供的 _rd_tailnet_port 等符号此时还没有参与链接,会出现未定义符号错误。

iOS 构建因此明确生成 Rust 静态库:

cargo rustc --locked --features flutter,hwcodec --release \
--target aarch64-apple-ios --lib --crate-type staticlib

随后由 Xcode 把 liblibrustdesk.a 和 libEmbeddedTailnet.a 一起链接进应用。这个调整只涉及 iOS 构建,不需要改变其他平台的产物类型。

缓存中的 Flutter SDK 可能已经打过补丁​

缓存整个 Flutter SDK 时,修改过的源码也可能被缓存。恢复缓存后再次无条件执行 git apply,会报 patch does not apply,即使补丁本身兼容该 Flutter 版本。

处理方式是在应用前用 git apply --reverse --check 判断是否已经包含补丁:已应用则跳过,否则正常应用。不完整或确实不兼容的补丁仍然报错,避免用忽略退出码的方式隐藏问题。

构建环境也需要统一。CI 固定 Flutter 3.24.5、Rust 1.75、Go 1.26.5 和 Xcode 16.4;开发机使用更高版本 Flutter 时,可能先撞上现有依赖的 API 兼容问题,不能直接当作新功能本身的错误。

Xcode 归档和可导入的 IPA 是两种产物​

.xcarchive.zip 是构建归档,不能直接作为 LiveContainer 的 IPA 使用。unsigned 模式需要把归档里的 Runner.app 按 Payload/Runner.app 结构重新打包,并检查 ZIP 完整性。

最终 iOS artifact 只上传 .ipa,不附带体积更大的 Xcode 归档;许可证和第三方声明保留在应用资源中。未签名 IPA 可供 LiveContainer 导入或后续重签名使用,但不等于能够通过 iOS 普通安装流程直接安装。

另外,Fork 需要启用 Actions,workflow_dispatch 工作流也需要先存在于默认分支,Actions 页面才有相应的手动运行入口,再选择要构建的分支。具体规则见 GitHub 手动运行工作流说明。

当前范围

这轮改动集中在 iOS:按现有部署接入私有 API,保留原来的远控协议,再补齐移动端界面和配置持久化。增强逻辑主要放在独立模块,共享入口只增加必要的条件判断,方便后续同步上游。

自动横屏受 iOS 窗口策略限制,后台连接受系统生命周期限制;内嵌网络也不提供通用系统 VPN 的全部能力。完整体验仍应结合实际 Headscale、Rustk API Server、hbbs/hbbr 和设备验证,尤其是账号登录、地址簿同步、连接协商及覆盖更新后的身份复用。

相关实现保存在 suhli/rustdesk,配置和实现细节可参考对应提交的 iOS 增强文档。

trino透传sql到clickhouse

· 阅读需 2 分钟
shuhang li
Developer

trino的jdbc base的插件提供了直接透传sql到上游数据库的能力table function

然而clickhouse-jdbc却不支持

修改ClickHouseClientModule.java 在configure方法抄一行隔壁mysql module的调用

newSetBinder(binder, ConnectorTableFunction.class).addBinding().toProvider(Query.class).in(Scopes.SINGLETON);

然后开启调试发现报了个

Query not supported: ResultSetMetaData not available for query: ...

然后追查clickhouse plugin testcase的历史提交发现曾经有备注说为啥没有table function

// table function disabled for ClickHouse, because it doesn't provide ResultSetMetaData, so the result relation type cannot be determined

然后搜索一下,发现曾经有过issue提问怎么不执行查询拿到resultset metadata

然后简单粗暴,给clickhouse client加上一个override:

@Override
public JdbcTableHandle getTableHandle(ConnectorSession session, PreparedQuery preparedQuery) {
ImmutableList.Builder<JdbcColumnHandle> columns = ImmutableList.builder();
try (Connection connection = connectionFactory.openConnection(session);
PreparedStatement statement = connection.prepareStatement(preparedQuery.getQuery() + " LIMIT 0")) {
ResultSetMetaData metadata = statement.executeQuery().getMetaData();
if (metadata == null) {
throw new UnsupportedOperationException("Query not supported: ResultSetMetaData not available for query: " + preparedQuery.getQuery());
}
for (int column = 1; column <= metadata.getColumnCount(); column++) {
String name = metadata.getColumnName(column);
JdbcTypeHandle jdbcTypeHandle = new JdbcTypeHandle(
metadata.getColumnType(column),
Optional.ofNullable(metadata.getColumnTypeName(column)),
Optional.of(metadata.getPrecision(column)),
Optional.of(metadata.getScale(column)),
Optional.empty(), // TODO support arrays
Optional.of(metadata.isCaseSensitive(column) ? CASE_SENSITIVE : CASE_INSENSITIVE));
Type type = toColumnMapping(session, connection, jdbcTypeHandle)
.orElseThrow(() -> new UnsupportedOperationException(format("Unsupported type: %s of column: %s", jdbcTypeHandle, name)))
.getType();
columns.add(new JdbcColumnHandle(name, jdbcTypeHandle, type));
}
return new JdbcTableHandle(
new JdbcQueryRelationHandle(preparedQuery),
TupleDomain.all(),
ImmutableList.of(),
Optional.empty(),
OptionalLong.empty(),
Optional.of(columns.build()),
// The query is opaque, so we don't know referenced tables
Optional.empty(),
0);
} catch (SQLException e) {
return super.getTableHandle(session, preparedQuery);
}
}

提交,build镜像,然后就可以使用clickhouse的table function了 trino

面试总结

· 阅读需 6 分钟
shuhang li
Developer

创业结束(我认为的)开始投简历.
截至到目前(2021-3-15),投了大概4份简历,收到了2间公司的在线面试,其中一间还另外进行了1次面试,这里来总结下笔试题跟面试的内容.

从离职到建模到创业

· 阅读需 6 分钟
shuhang li
Developer

序​

离职这件事我考虑了w很久,第一次出现离职的想法应该在2018年,那时候我还是个直面社会未满3年的小伙. 契机是啥现在已经忘了,突然间就感觉工作中的crud,浅尝即止的demo,写不下去的日志全无意义. 周围所有人都在随着时间的增长在快速地成长的时候,我却停在了自己的面前,像进入刺客信条的读取环节,周围的人渐渐消失,而我无论怎么奔跑都仍在原地.