【开源软件移植】EasyTier 适配鸿蒙 PC 全流程实战 —— 从 ArkTS 快速落地到 Rust HAR

欢迎加入开源鸿蒙 PC 社区:https://harmonypc.csdn.net/

欢迎在PC社区平台申请新建项目:https://atomgit.com/OpenHarmonyPCDeveloper

场景限定:鸿蒙 PC,不展开手机、平板等其他设备类型。

构建环境:Arch Linux + HarmonyOS command-line-tools + ohrs。

移植产物仓库:https://atomgit.com/FrankHan2004/EasyTier

鸿蒙App仓库:https://atomgit.com/OpenHarmonyPCDeveloper/ohos_easytier

写在前面

把 EasyTier 移植到鸿蒙 PC,第一阶段的目标很朴素:先跑起来。

EasyTier 上游核心能力主要由 Rust 实现,但鸿蒙 PC 适配并不是一开始就把所有编排逻辑全部放进 Rust。最早为了快速落地,ArkTS 侧承担了大量工作,包括实例管理、TUN 编排、socket 通信、路由整理、配置解析、运行状态维护等。这样做很适合第一轮验证,开发快、反馈快,能尽快判断 EasyTier 在鸿蒙 PC 上能不能跑通。

后面功能越来越多,ArkTS 侧的问题就明显了:内存占用变高、状态同步变复杂、UI 代码和内核编排逻辑耦合越来越重。于是后续适配开始逐步把这些能力重新下沉到 Rust,只把必要的 UI 展示和系统侧能力衔接留在 ArkTS。

这次移植最终形成的路线是:

ArkTS 快速落地验证
        ↓
Rust NAPI 最小导出
        ↓
ohrs 生成 HarmonyOS HAR
        ↓
配置模型从 TOML 文本过渡到 NetworkConfig JSON
        ↓
配置管理、运行态聚合、TUN 请求、路由整理下沉到 Rust
        ↓
鸿蒙 PC 工程通过 ohpm install 接入验证

最终产物已经在本地成功生成:

  • dist/arm64-v8a/libeasytier_ohrs.so
  • package.har

在这里插入图片描述

ohrs build 成功日志

在这里插入图片描述

package.har 产物目录截图

一、整体架构与思路

EasyTier 适配鸿蒙 PC 后,整体结构可以分成三层:

┌──────────────────────────────────────────────┐
│               鸿蒙 PC 应用层                  │
│  ArkTS 页面 / 系统权限 / TUN fd / UI 展示       │
└───────────────────────┬──────────────────────┘
                        │ NAPI 调用
┌───────────────────────▼──────────────────────┐
│             easytier-ohrs HAR 包              │
│  index.ets → libeasytier_ohrs.so              │
│  配置 API / 运行态 API / TUN 请求 / socket 推送 │
└───────────────────────┬──────────────────────┘
                        │ Rust 调用
┌───────────────────────▼──────────────────────┐
│                EasyTier Rust Core             │
│  实例启动 / 网络配置 / 路由信息 / peer 状态      │
└──────────────────────────────────────────────┘

这个结构里,ArkTS 不再负责所有内核状态编排,而是主要负责页面展示、用户交互,以及 HarmonyOS 系统侧必须由应用层处理的能力。Rust 侧负责 EasyTier 自身更熟悉的逻辑,例如配置校验、实例管理、运行态聚合、路由整理和 TUN 请求生成。

这样的分层有两个好处:

  • ArkTS 侧更轻,减少内存和状态同步压力。
  • EasyTier 核心逻辑更集中,后续跟随上游演进时更容易维护。

二、环境部署:Rust、ohrs 和 command-line-tools

后面的 Native 导出、HAR 打包和真机验证都依赖基础环境,所以环境部署需要放在移植流程前面。

2.1 参考资料

Rust 基础环境建议直接使用官方安装方式,不建议把第三方博客里的安装命令作为主要依据:

2.2 安装 Rust 与 OHOS target

Rust 安装可以使用官方提供的 rustup

curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh

安装完成后确认版本:

rustc --version
cargo --version
rustup --version

HarmonyOS Rust 构建需要额外安装 OHOS target。鸿蒙 PC 侧 HAR 构建使用的是 aarch64-unknown-linux-ohos

rustup target add aarch64-unknown-linux-ohos

2.3 ohos-rs 和 ohrs 的作用

这里用到的 ohos-rs,可以理解为一套面向 HarmonyOS 原生模块开发的 Rust 工具链和生态封装。它解决的不是 EasyTier 自身的业务逻辑问题,而是 Rust 代码如何以 HarmonyOS 可以识别的形式完成编译、导出和打包。

在这次移植里,ohos-rs 主要承担三类工作:

  • 通过 napi-ohosnapi-derive-ohos 把 Rust 函数导出成 ArkTS 可以调用的 NAPI 接口。
  • 通过 napi_build_ohos::setup() 生成 HarmonyOS 原生模块构建所需的信息。
  • 通过 ohrs 命令完成 Rust Native 库编译和 HAR 产物生成。

简单说,EasyTier 的核心逻辑仍然来自 Rust 本体,ohos-rs 负责把这些 Rust 能力包装成 HarmonyOS 工程能加载、能调用、能安装的组件。

ohrs 是 ohos-rs 提供的命令行工具,用来简化 Rust 原生模块在 HarmonyOS 上的初始化、编译和 HAR 产物生成。这里主要使用它的三个命令:

  • ohrs doctor:检查 HarmonyOS Rust 构建环境。
  • ohrs build:编译 Rust Native 库。
  • ohrs artifact:生成可安装的 package.har

ohrs 可以通过 cargo 安装:

cargo install ohrs

安装完成后确认版本:

ohrs --version

2.4 准备 command-line-tools 和路径占位

HarmonyOS command-line-tools 需要从华为开发者官网下载安装包,并解压到需要存放 SDK 和工具链的目录。这里用 <command-line-tools-root> 表示这个解压后的实际目录,例如:

<command-line-tools-root>/bin
<command-line-tools-root>/sdk/default/openharmony

其中 <command-line-tools-root>/sdk/default/openharmony 是后续用于 OHOS_NDK_HOME 的 HarmonyOS SDK/NDK 根目录。

这里用 <repo-root> 表示 EasyTier 源码的实际存储路径,也就是包含 easytier/easytier-contrib/Cargo.toml 的 EasyTier 项目根目录。

本次实测环境中的路径示例为:

<command-line-tools-root> = /home/frankhan/Documents/UPG/et/command-line-tools
<repo-root> = /home/frankhan/Documents/UPG/et/EasyTier

2.5 实测环境与环境变量

实测构建环境如下:

项目 实际值
宿主机 Arch Linux
Rust rustc 1.94.0
cargo 1.94.0
ohrs 1.3.1
ohpm 6.1.1.830
command-line-tools <command-line-tools-root>
HarmonyOS SDK 根目录 <command-line-tools-root>/sdk/default/openharmony
目标架构 arm64-v8a
目标场景 鸿蒙 PC

基础环境准备完成后,先导出 SDK 路径:

export OHOS_NDK_HOME="<command-line-tools-root>/sdk/default/openharmony"
export PATH="<command-line-tools-root>/bin:${PATH}"

进入 EasyTier 的 HarmonyOS 适配目录:

cd <repo-root>/easytier-contrib/easytier-ohrs

加载环境脚本:

source env.sh

env.sh 主要做几件事:

  • 检查 OHOS_NDK_HOME 是否已经设置。
  • 检查 native/llvmnative/sysroot 是否存在。
  • 设置 CCCXXARLD 等交叉编译变量。
  • 设置 PKG_CONFIG_PATHPKG_CONFIG_LIBDIRPKG_CONFIG_SYSROOT_DIR

加载成功后可以看到类似输出:

OpenHarmonyOS environment is ready:
OHOS_NDK_HOME: /.../sdk/default/openharmony
OHOS_TOOLCHAIN_DIR: /.../native/llvm
OHOS_SYSROOT: /.../native/sysroot
PKG_CONFIG_PATH: /.../native/sysroot/usr/lib/pkgconfig:...

三、移植流程总览

参考实际适配过程,整个 EasyTier 鸿蒙 PC 移植可以拆成几个阶段:

0. 底层可行性确认
   rust-tun 编译适配 / ArkTS 创建 TUN / TUN fd 传递

1. 最小 Native 导出
   先暴露 setTunFd、runNetworkInstance、collectNetworkInfos 等核心接口

2. HAR 包交付
   补齐 package/ 结构,用 ohrs artifact 生成 package.har

3. 配置模型重构
   从 TOML 字符串过渡到 NetworkConfig JSON

4. 能力下沉
   配置仓库、运行态快照、TUN 请求、路由聚合、本地 socket 下沉到 Rust

5. 鸿蒙 PC 验证
   ohpm install 安装 HAR,ArkTS 做最小调用验证

这条路径不是一开始就设计完整的“最终架构”,而是先解决能不能跑,再解决怎么交付,再解决怎么维护。

四、阶段 0:先确认底层可行性

网络类项目迁移到鸿蒙 PC,最先要确认的不是 UI,而是底层能力。EasyTier 依赖 TUN、socket、路由、虚拟 IP、peer 状态等能力,如果这些底层能力没有基础,上层页面写得再完整也跑不起来。

这里的 TUN 适配不能简单理解为“把鸿蒙侧 TUN 管理完整接入 rust-tun”。由于 HarmonyOS 系统能力的特殊性,TUN 设备需要在 ArkTS 侧通过系统接口创建,再把拿到的 fd 传给 Rust 侧。Rust 侧做的是接收这个 fd,并把它交给 EasyTier 内核使用。

当前 EasyTier 使用的 TUN 依赖来自:

tun = { package = "tun-easytier", git = "https://github.com/EasyTier/rust-tun", ... }

这个 rust-tun 分支基于原版 https://github.com/meh/rust-tun 演进。早期使用上游更新分支时,OHOS 目标仍然无法直接编译通过,因此又针对 OHOS 做了部分补充和修正,让 EasyTier 现有 TUN 抽象在 OHOS target 下具备可编译的适配入口。

也就是说,rust-tun 这一层主要解决的是编译适配和抽象兼容问题,不是让 Rust 侧完整创建和管理 HarmonyOS 的 TUN 设备。真正的 TUN 创建仍然发生在 ArkTS 侧,Rust 侧通过 set_tun_fd 接收 fd。

EasyTier 核心里也有多处 OHOS 条件编译处理,例如:

  • easytier/build/main.rs
  • easytier/src/launcher.rs
  • easytier/src/instance/instance.rs
  • easytier/src/instance/virtual_nic.rs
  • easytier/src/connector/mod.rs
  • easytier/src/common/network.rs
  • easytier/src/common/global_ctx.rs
  • easytier/src/tunnel/common.rs
  • easytier/src/peers/peer_manager.rs

这些条件编译不是为了“让代码编过”随手加的,而是因为 OHOS 在几个关键网络路径上和普通 Linux、Android、macOS 都不完全一样。如果不把这些分支限制在对应平台上,要么会编译失败,要么会在运行时走错网络路径。

4.1 build/main.rs:把 OHOS 归入 mobile 类平台

EasyTier 的构建脚本里先定义了一个 mobile cfg alias:

cfg_aliases! {
    mobile: {
        any(
            target_os = "android",
            target_os = "ios",
            all(target_os = "macos", feature = "macos-ne"),
            target_env = "ohos"
        )
    }
}

这个定义很关键。OHOS 适配并不是完全走普通 Linux 桌面路径,而是更接近移动端/受限系统路径:TUN 不是 Rust 自己创建,而是外部系统能力创建后把 fd 传进来;部分网络能力也不能假设拥有完整桌面 Linux 权限。

有了这个 alias 后,后面的 #[cfg(mobile)] 就可以统一覆盖 Android、iOS、macOS Network Extension 和 OHOS。这样 OHOS 不需要到处单独复制一份逻辑,而是复用“外部创建 TUN、Rust 接收 fd、运行时再挂接 NIC”的移动端架构。

4.2 launcher.rs / instance.rs / virtual_nic.rs:TUN fd 从 ArkTS 侧注入

被归入 mobile 后,启动流程会多一条等待 TUN fd 的路径:

#[cfg(mobile)]
async fn run_routine_for_mobile(
    instance: &Instance,
    data: &EasyTierData,
    tasks: &mut JoinSet<()>,
) {
    let global_ctx = instance.get_global_ctx();
    let peer_mgr = instance.get_peer_manager();
    let nic_ctx = instance.get_nic_ctx();
    let peer_packet_receiver = instance.get_peer_packet_receiver();
    let mut tun_fd_receiver = data.tun_fd.1.lock().unwrap().take().unwrap();

    tasks.spawn(async move {
        loop {
            let Some(tun_fd) = tun_fd_receiver.recv().await.flatten() else {
                return;
            };
            let res = Instance::setup_nic_ctx_for_mobile(
                nic_ctx.clone(),
                global_ctx.clone(),
                peer_mgr.clone(),
                peer_packet_receiver.clone(),
                tun_fd,
            )
            .await;
        }
    });
}

真正创建 TUN 设备的逻辑在 HarmonyOS 系统侧,也就是 ArkTS 侧;Rust 侧收到 fd 后,用 raw_fd 把这个已存在的 TUN 设备接入 EasyTier:

#[cfg(mobile)]
pub async fn create_dev_for_mobile(
    &mut self,
    tun_fd: std::os::fd::RawFd,
) -> Result<Box<dyn Tunnel>, Error> {
    let mut config = Configuration::default();
    config.layer(Layer::L3);
    config.raw_fd(tun_fd);
    config.close_fd_on_drop(false);
    config.up();
    // ...
}

这里的条件编译限制非常必要。如果 OHOS 走普通桌面 Linux 的 tun::create() 路径,Rust 会尝试自己创建和配置 TUN 设备,这和鸿蒙侧实际的系统能力调用方式不匹配。正确做法是 ArkTS 负责申请系统 TUN,Rust 只接收 fd 并挂到 EasyTier 的转发链路上。

同时,instance.rs 里一些非移动端的静态 IP 检查、自动创建 NIC 流程也会被 #[cfg(not(mobile))] 排除。原因也是同一个:OHOS 的 TUN 生命周期由 ArkTS 和系统能力控制,不能直接套用桌面端“Rust 创建 TUN 并配置 IP”的流程。

4.3 instance.rs:ICMP proxy 失败不能阻断实例启动

instance.rs 里有一段和 ICMP proxy 启动相关的处理:

if let Err(e) = self.icmp_proxy.start().await {
    tracing::error!("start icmp proxy failed: {:?}", e);
    if cfg!(not(any(
        target_os = "android",
        any(
            target_os = "ios",
            all(target_os = "macos", feature = "macos-ne")
        ),
        target_env = "ohos"
    ))) {
        // android, ios and ohos not support icmp proxy
        return Err(e);
    }
}

ICMP proxy 这类能力通常依赖更底层的 raw socket 或系统网络权限。在 HarmonyOS 上,这条路径并不能按普通桌面 Linux 的方式工作。如果这里不对 OHOS 做特殊处理,icmp_proxy.start() 一旦失败,整个 EasyTier 实例就会启动失败。

但是对于鸿蒙 PC 适配来说,ICMP proxy 不是第一阶段跑通 EasyTier 的必要条件。TCP、UDP、TUN fd、实例启动、运行态收集这些核心链路仍然可以继续工作。因此这里的处理方式是:OHOS 上 ICMP proxy 启动失败只记录日志,不把错误继续向上抛,避免一个不支持的辅助能力阻断整个实例。

这个分支本质上是在做“能力降级”:平台不支持的部分不强行启用,但主流程继续跑。

4.4 virtual_nic.rs:TUN packet information header 的协议字段不同

virtual_nic.rs 里处理的是 TUN 包转换。这里有一个看起来很小但很关键的差异:不同平台的 packet information header 里,协议字段取值并不一样。

OHOS 被放在 Linux/Android 这一组:

#[cfg(any(target_os = "linux", target_os = "android", target_env = "ohos"))]
fn into_pi_field(self) -> Result<u16, io::Error> {
    use nix::libc;
    match self {
        PacketProtocol::IPv4 => Ok(libc::ETH_P_IP as u16),
        PacketProtocol::IPv6 => Ok(libc::ETH_P_IPV6 as u16),
        PacketProtocol::Other(_) => Err(io::Error::other("neither an IPv4 nor IPv6 packet")),
    }
}

而 macOS、iOS、FreeBSD 用的是另一套:

#[cfg(any(target_os = "macos", target_os = "ios", target_os = "freebsd"))]
fn into_pi_field(self) -> Result<u16, io::Error> {
    use nix::libc;
    match self {
        PacketProtocol::IPv4 => Ok(libc::PF_INET as u16),
        PacketProtocol::IPv6 => Ok(libc::PF_INET6 as u16),
        PacketProtocol::Other(_) => Err(io::Error::other("neither an IPv4 nor IPv6 packet")),
    }
}

这里不能混用。Linux 风格 TUN PI header 里写的是 ETH_P_IPETH_P_IPV6 这种以太网协议号;BSD/macOS/iOS 那边更接近地址族语义,用的是 PF_INETPF_INET6

鸿蒙侧虽然 TUN 是 ArkTS 创建的,但 Rust 侧仍然需要把 EasyTier 内部的 packet 转成写入 TUN fd 的字节流。如果这里把 OHOS 归到 macOS/iOS 那组,协议字段就会写错,后续 IPv4/IPv6 包识别可能出现异常。因此这里必须用 target_env = "ohos" 明确让 OHOS 走 Linux/Android 风格的协议字段。

4.5 connector/mod.rs:OHOS 不主动枚举本机地址给连接器绑定

连接 peer 时,桌面端可以根据本机网卡地址给 connector 设置可用的 bind address,这样在多网卡场景下可以控制连接从哪个地址发起。但 OHOS 被排除在这条路径之外:

async fn set_bind_addr_for_peer_connector(
    connector: &mut (impl TunnelConnector + ?Sized),
    is_ipv4: bool,
    global_ctx: &ArcGlobalCtx,
) {
    if cfg!(any(
        target_os = "android",
        any(
            target_os = "ios",
            all(target_os = "macos", feature = "macos-ne")
        ),
        target_env = "ohos"
    )) {
        return;
    }

    let ips = global_ctx.get_ip_collector().collect_ip_addrs().await;
    // 给 connector 设置本机 IPv4/IPv6 bind 地址
    // ...
}

这个分支的原因和系统网络模型有关。鸿蒙 PC 上存在系统网络、应用侧 TUN、EasyTier 虚拟网络等多层路径,简单枚举本机地址再强行绑定 connector,反而可能绑到不适合的地址,导致连接失败或走错出口。

所以 OHOS 这里更适合让系统路由和上层 TUN 协作路径决定连接出口,而不是复用桌面 Linux 那套“枚举所有本机 IP,再交给 connector 绑定”的逻辑。

4.6 common/network.rs:接口过滤不能套用 Linux /sys/class/net 规则

Linux 下 EasyTier 会过滤掉 loopback、down 状态、point-to-point、TUN/TAP 等接口,其中 TUN/TAP 判断会访问 /sys/class/net/{iface}/tun_flags

#[cfg(all(target_os = "linux", not(target_env = "ohos")))]
impl InterfaceFilter {
    async fn is_tun_tap_device(&self) -> bool {
        let path = format!("/sys/class/net/{}/tun_flags", self.iface.name);
        tokio::fs::metadata(&path).await.is_ok()
    }

    async fn filter_iface(&self) -> bool {
        !self.iface.is_point_to_point()
            && !self.iface.is_loopback()
            && self.iface.is_up()
            && self.iface.is_lower_up()
            && !self.is_tun_tap_device().await
            && self.has_valid_ip().await
    }
}

OHOS 则走移动端简化过滤:

#[cfg(any(
    target_os = "android",
    target_os = "ios",
    all(target_os = "macos", feature = "macos-ne"),
    target_env = "ohos"
))]
impl InterfaceFilter {
    async fn filter_iface(&self) -> bool {
        true
    }
}

原因是 OHOS 虽然 target 上接近 Linux,但它不是普通桌面 Linux 运行环境。/sys/class/net 这类路径和接口属性不一定能按标准 Linux 方式访问或解释,尤其 TUN 又是 ArkTS 侧通过系统能力创建的。如果继续套用 Linux 的过滤逻辑,可能把有效接口误过滤掉,或者因为访问系统路径失败引入额外问题。

因此这里专门写成 target_os = "linux", not(target_env = "ohos"),把 OHOS 从普通 Linux 过滤路径里拆出来。

4.7 tunnel/common.rs:socket 绑定网卡的系统 API 不同

tunnel/common.rs 里处理 socket bind。EasyTier 在一些场景下需要把 socket 绑定到指定网络接口,否则多网卡、多路由环境下可能会从错误的出口发包。

Linux、Android、Fuchsia、OHOS 走的是 bind_device 这一套:

#[cfg(any(
    target_os = "android",
    target_os = "fuchsia",
    target_os = "linux",
    target_env = "ohos"
))]
if let Some(dev_name) = bind_dev {
    tracing::trace!(dev_name = ?dev_name, "bind device");
    socket2_socket.bind_device(Some(dev_name.as_bytes()))?;
}

macOS/iOS 则是另外一套 IP_BOUND_IF,按网卡 index 绑定。

这个分支限制的原因也很直接:不同系统暴露的 socket 选项不一样。OHOS 更接近 Linux 这一侧,使用设备名绑定网卡;如果走 macOS/iOS 的分支,接口和语义都不对。如果完全不做绑定,在多网络环境下又可能出现“地址绑定成功,但实际发包接口不符合预期”的问题。

所以这里的 OHOS 条件编译是为了让 socket 出口选择走正确的系统 API。

4.8 common/global_ctx.rs:API 17 阶段的 TUN 绕过问题与出口兜底

global_ctx.rs 里有一个很短但影响很大的分支:

pub fn enable_exit_node(&self) -> bool {
    self.flags.load().enable_exit_node || cfg!(target_env = "ohos")
}

这段代码不能简单理解成“OHOS 用户默认开启出口节点功能”。它现在更多是一个兼容兜底分支,主要用于保证后续如果仍有老版本 OHOS 设备需要运行 EasyTier,相关网络功能不会因为历史系统行为差异而失效。

在 API 17 阶段,鸿蒙侧曾尝试通过 vpnExtensionprotect socket 方式让隧道连接绕过 TUN。理论上,隧道自身的 socket 被 protect 后,隧道流量不应该再回到 TUN 中。但实际运行时仍会出现少量包进入 TUN 的情况,这会导致虚拟网和外部网络之间的连接不稳定:一部分包按预期走隧道,一部分包又被系统重新送入 TUN,路径变得不可控。

为了解决这类老系统上的不稳定情况,当时采用过更保守的处理方式:不再完全依赖 protect socket 把部分包从 TUN 路径中“摘出去”,而是允许流量统一进入 TUN,再由 Rust 侧根据 EasyTier 内部路由决定如何处理。

在这个兼容策略下,如果一个包能匹配到 EasyTier 虚拟网络内部的 peer,就按虚拟网路由转发;如果内部路由失败,但目标又不属于当前虚拟网络,就把它按“出口节点”语义处理,从本机真实网络发出去。这样即使老版本 OHOS 设备上的 TUN 绕过行为不稳定,虚拟网络访问外部网络时仍然有一条可用出口路径。

所以 cfg!(target_env = "ohos") 这里的意义是保留一条 OHOS 兼容兜底路径。它主要是为了在以后需要兼容老 OHOS 设备时,仍然能够通过“进入 TUN 后按内部路由/出口兜底处理”的方式保障功能。

4.9 peer_manager.rs:OHOS 的回流路径不能套用普通平台的防回环逻辑

peer_manager.rs 里的 OHOS 分支就是上面这个兼容策略在发包路径上的落点。IPv4 目标找不到明确 peer,且目标不在当前网络内时,OHOS 上会做一个兜底处理:

#[cfg(target_env = "ohos")]
{
    if dst_peers.is_empty()
        && !self
            .global_ctx
            .is_ip_in_same_network(&std::net::IpAddr::V4(*ipv4_addr))
    {
        tracing::trace!("no peer id for ipv4: {}, set exit_node for ohos", ipv4_addr);
        dst_peers.push(self.my_peer_id.clone());
        is_exit_node = true;
    }
}

后面还有一段非 OHOS 平台才启用的防回环逻辑:

#[cfg(not(target_env = "ohos"))]
{
    if not_send_to_self
        && *peer_id == self.my_peer_id
        && !self.global_ctx.is_ip_local_virtual_ip(&ip_addr)
    {
        hdr.set_not_send_to_tun(true);
        hdr.set_no_proxy(true);
    }
}

普通平台上,发送给自己的包很容易形成代理回环,所以这里会设置 not_send_to_tunno_proxy,避免包再次进入 TUN 或 proxy 路径。

但在老 OHOS 设备的兼容场景里,目标正好不同:外部流量进入 TUN 后,如果 EasyTier 内部路由找不到目标 peer,需要允许它落到“本机作为出口”的路径上,再从本机真实网络发出。如果直接套用普通平台的防回环标记,就可能把这条出口兜底路径提前切断,导致外部网络访问失败。

因此这里用 target_env = "ohos" 单独限制:OHOS 可以在找不到目标 peer 时兜底到自身作为 exit node,同时不启用普通平台的 not_send_to_tun/no_proxy 防回环处理。这个分支配合前面的 enable_exit_node || cfg!(target_env = "ohos"),保留的是老 OHOS 设备可用的网络兜底能力,而不是当前架构必须依赖的唯一转发路径。

4.10 easytier-ohrs 日志:Rust tracing 转到 HiLog

除了 EasyTier core,easytier-ohrs 包装层也有 OHOS 条件编译。比如日志桥接里会在 OHOS 下把 Rust tracing 事件转发到 HiLog:

fn tracing_callback(event: &Event, fields: HashMap<String, String>) {
    let metadata = event.metadata();
    #[cfg(target_env = "ohos")]
    {
        let loc = metadata.target().split("::").last().unwrap();
        match *metadata.level() {
            Level::TRACE => hilog_debug!("[{}] {:?}", loc, fields.values().collect::<Vec<_>>()),
            Level::DEBUG => hilog_debug!("[{}] {:?}", loc, fields.values().collect::<Vec<_>>()),
            Level::INFO => hilog_info!("[{}] {:?}", loc, fields.values().collect::<Vec<_>>()),
            Level::WARN => hilog_warn!("[{}] {:?}", loc, fields.values().collect::<Vec<_>>()),
            Level::ERROR => hilog_error!("[{}] {:?}", loc, fields.values().collect::<Vec<_>>()),
        }
    }
}

这类条件编译不是网络链路的一部分,但对鸿蒙 PC 移植调试很重要。Rust 侧如果还停留在 stdout/stderr 或普通日志输出,真机排查会很不方便。接入 HiLog 后,NAPI 层、实例启动、TUN fd 注入、配置解析、运行态聚合这些问题都能在鸿蒙日志系统里统一查看。

这一阶段的判断标准很直接:ArkTS 侧能不能创建 TUN 并拿到 fd,Rust 侧能不能接收这个 fd,EasyTier 实例能不能启动并收集基础运行信息。

五、阶段 1:先做最小 Native 导出

第一版适配没有一上来就把配置仓库、运行态、路由聚合都放到 Rust。早期 Rust 侧只提供最小导出接口,用来验证核心链路。

当时比较关键的接口包括:

#[napi]
pub fn set_tun_fd(inst_id: String, fd: i32) -> bool

#[napi]
pub fn parse_config(cfg_str: String) -> bool

#[napi]
pub fn run_network_instance(cfg_str: String) -> bool

#[napi]
pub fn stop_network_instance(inst_names: Vec<String>)

#[napi]
pub fn collect_network_infos() -> Vec<KeyValuePair>

这几个接口解决了第一阶段最关键的问题:

  • ArkTS 侧可以把系统申请到的 TUN fd 交给 Rust。
  • Rust 侧可以解析配置并启动 EasyTier 实例。
  • ArkTS 侧可以拿到基础运行信息,用来判断实例是否启动成功。

这种设计很适合快速落地。早期 ArkTS 侧做更多编排不是问题,反而能加快试错速度。因为这时最重要的是验证“EasyTier 能不能在鸿蒙 PC 上真正跑起来”。

但这个阶段也埋下了后续需要重构的原因:配置、运行状态、路由和 TUN 请求都在 ArkTS 侧组织后,随着功能增加,上层代码会越来越重。

六、阶段 2:从 Native 库整理成 HAR 包

能生成 .so 只是第一步,鸿蒙工程最终需要的是可以安装、可以声明依赖、可以复用的包。

因此后续补齐了 HAR 包结构:

package/
├── oh-package.json5
├── index.ets
├── src/main/module.json5
├── README.md
├── LICENSE
└── CHANGELOG.md

其中 index.ets 很关键:

import * as api from 'libeasytier_ohrs.so';

export * from 'libeasytier_ohrs.so';
export default api;

这段代码看起来很短,但它解决的是包的入口问题。应用工程安装 HAR 后,只需要通过包名导入 EasyTier,不需要直接关心 libeasytier_ohrs.so 在 HAR 里的路径。

HAR 形态带来的价值很明显:

  • Native 库、ArkTS 入口、类型声明、包元信息放在一起。
  • 应用工程通过 ohpm install package.har 接入。
  • 后续 EasyTier 更新时,只需要重新构建并安装新 HAR。

这一步之后,EasyTier 才真正从“能编译的 Native 库”变成“鸿蒙 PC 工程能消费的组件”。

七、阶段 3:配置模型从 TOML 过渡到 NetworkConfig

早期接口直接接收 TOML 字符串:

#[napi]
pub fn run_network_instance(cfg_str: String) -> bool {
    let cfg = match TomlConfigLoader::new_from_str(&cfg_str) {
        Ok(cfg) => cfg,
        Err(e) => {
            hilog_error!("[Rust] parse config failed {}", e);
            return false;
        }
    };
    // 启动实例
}

这种方式适合第一版验证,因为 TOML 本来就是 EasyTier 的传统配置表达,Rust 侧可以直接复用 TomlConfigLoader

但当 ArkTS 侧需要做配置编辑、表单绑定、默认配置生成、导入导出时,直接维护一整段 TOML 文本就不太合适了。它不利于字段级编辑,也不利于 UI 层做结构化展示。

后续改成了 NetworkConfig JSON 边界:

#[napi]
pub fn run_network_instance(cfg_json: String) -> bool {
    let cfg = match serde_json::from_str::<NetworkConfig>(&cfg_json) {
        Ok(cfg) => match cfg.gen_config() {
            Ok(toml) => toml,
            Err(e) => {
                hilog_error!("[Rust] parse config failed {}", e);
                return false;
            }
        },
        Err(e) => {
            hilog_error!("[Rust] parse config failed {}", e);
            return false;
        }
    };
    // 启动实例
}

这次改动的核心不是“换一种序列化格式”,而是重新整理 ArkTS 和 Rust 的边界:

  • ArkTS 侧更适合操作 JSON 对象。
  • Rust 侧继续负责把 NetworkConfig 转成 EasyTier 内核真正需要的配置。
  • 配置校验、默认配置、导入导出可以逐步统一到 Rust。

后续配置能力继续下沉,就是从这里开始变得顺畅的。

八、阶段 4:把配置和运行态能力下沉到 Rust

随着功能增加,ArkTS 侧承载的逻辑越来越多:配置列表、配置字段编辑、导入导出、运行态展示、TUN 请求、路由信息整理等。如果这些都放在 ArkTS 侧,UI 代码会越来越重,也会产生更多中间状态。

后续适配把这些能力逐步移回 Rust,主要包括:

  • 配置仓库初始化、保存、查询、删除。
  • 配置字段级读写。
  • TOML 导入导出。
  • 旧配置迁移。
  • 配置 schema 和字段映射。
  • 分享链接生成与解析。
  • 运行态快照聚合。
  • TUN 请求和路由聚合。
  • 本地 socket 向 ArkTS 推送运行态与 TUN 请求。

8.1 配置仓库为什么要放到 Rust

配置不是单纯的 UI 状态,它和 EasyTier 实例启动、默认配置生成、字段校验、导入导出都有关系。

Rust 侧保存配置时,会先校验并规范化配置,再把字段拆出来存储:

pub fn save_config_record(
    config_id: String,
    display_name: String,
    config_json: String,
) -> Option<StoredConfigRecord> {
    let config = match validation::validate_config_json(&config_json, config_id.clone()) {
        Ok(config) => config,
        Err(e) => {
            hilog_error!("[Rust] save_config_record failed {}", e);
            return None;
        }
    };

    let fields = match validation::config_to_top_level_map(&config) {
        Some(fields) => fields,
        None => return None,
    };

    let conn = open_db()?;
    let tx = conn.unchecked_transaction().ok()?;
    // 保存配置 meta 和字段
    tx.commit().ok()?;
    // ...
}

这样改完后,ArkTS 侧不再需要关心配置文件怎么组织、字段怎么拆、旧配置怎么迁移,只需要调用 saveConfiggetConfigsetConfigField 这类接口。

8.2 运行态快照为什么要放到 Rust

运行信息来自 EasyTier 的 INSTANCE_MANAGER。如果 ArkTS 侧每次都自己拉取、拼装、判断 TUN 状态和路由信息,逻辑会非常散。

Rust 侧聚合运行态后,可以直接生成更适合 UI 消费的状态结构:

pub(crate) fn get_runtime_snapshot_inner() -> RuntimeAggregateState {
    let infos = match INSTANCE_MANAGER.collect_network_infos_sync() {
        Ok(infos) => infos,
        Err(err) => {
            hilog_error!("[Rust] collect network infos failed {}", err);
            return RuntimeAggregateState {
                instances: vec![],
                // ...
            };
        }
    };

    // 将 EasyTier 内核运行信息整理成 ArkTS 更容易消费的状态结构
    // ...
}

这种设计让 ArkTS 侧拿到的不是一堆零散底层信息,而是已经聚合好的 RuntimeAggregateState

8.3 为什么要增加本地 socket server

TUN 请求和运行态变化并不适合完全靠 ArkTS 主动轮询。Rust 侧更接近数据源,也更清楚什么时候需要通知上层。

因此后续增加了本地 socket server,用来向 ArkTS 侧广播 runtime_snapshottun_request

pub fn start_local_socket_server() -> bool {
    let socket_path = match kernel_socket_path() {
        Some(path) => path,
        None => {
            hilog_error!("[Rust] kernel socket path unavailable");
            return false;
        }
    };

    // 监听本地 socket,向 ArkTS 侧广播 runtime_snapshot 和 tun_request
    // ...
}

这样 ArkTS 侧不需要频繁主动拼装状态,也不需要自己判断路由变化是否需要重新发起 TUN 请求。Rust 侧负责聚合和去重,ArkTS 侧负责接收事件并调用系统能力。

这一阶段的重构,本质上是在解决早期快速落地后的架构债:逻辑从 ArkTS 侧收回到 Rust,接口边界变清晰,内存和耦合问题也更容易控制。

九、Linux 环境下编译 HAR

完成第二章的基础环境部署,并在 easytier-ohrs 目录执行 source env.sh 后,就可以开始编译 HAR。

执行 ohrs 构建命令:

ohrs doctor
ohrs build --release --arch aarch
ohrs artifact

三条命令分别对应:

  • ohrs doctor:检查 HarmonyOS Rust 构建环境。
  • ohrs build --release --arch aarch:编译 Rust 核心并生成 libeasytier_ohrs.so
  • ohrs artifact:把 Native 库、类型声明和 HAR 包结构整合成 package.har

本次环境里 ohrs doctor 会提示部分额外 target 未安装,但不影响 aarch 构建成功。最终判断标准还是看下面两个文件是否生成:

  • dist/arm64-v8a/libeasytier_ohrs.so
  • package.har

在这里插入图片描述

在这里插入图片描述

dist/arm64-v8a 与 HAR 产物截图

十、鸿蒙 PC 工程验证

HAR 生成后,在目标鸿蒙 PC 工程根目录安装:

ohpm install <repo-root>/easytier-contrib/easytier-ohrs/package.har

如果之前安装过旧版本,可以先卸载再安装:

ohpm uninstall easytier-ohrs
ohpm install <repo-root>/easytier-contrib/easytier-ohrs/package.har

在这里插入图片描述

ohpm install package.har 成功输出

ArkTS 侧先做最小验证:

import EasyTier from 'easytier-ohrs';

@Entry
@ComponentV2
struct Demo {
  version = EasyTier.easytierVersion();
  defaultConfig = EasyTier.defaultNetworkConfig();
  valid = EasyTier.parseNetworkConfig(this.defaultConfig);

  build() {
    Column() {
      Text(`EasyTier version: ${this.version}`)
      Text(`Default config valid: ${this.valid}`)
    }
  }
}

这个验证页主要确认四件事:

  1. HAR 是否成功安装。
  2. libeasytier_ohrs.so 是否能被加载。
  3. NAPI 导出函数是否能被 ArkTS 调用。
  4. 默认配置生成和配置校验是否正常。

在这里插入图片描述

鸿蒙 PC 侧 EasyTier 验证页

最小验证通过后,再继续验证实例启动、停止、运行状态查询和 TUN 交互。这样排查路径比较清楚,能区分是包加载问题、NAPI 调用问题、配置问题,还是 EasyTier 运行时问题。

十一、适配过程中遇到的几个问题

11.1 旧版基础库说明不一定还适用

旧版说明中曾提到构建前需要手工编译:

  • glib
  • libffi
  • pcre2
  • zlib

基于当前 EasyTier 源码版本和 SDK 的实测结果,不进行上面依赖的编译,直接执行下面命令已经可以完成构建:

ohrs build --release --arch aarch

所以复现时可以先按当前流程跑。如果遇到新的依赖问题,再根据具体报错补处理。

11.2 ohrs doctor 不一定要全绿

本次环境中,ohrs doctor 会提示部分 target 未安装,但 aarch 构建没有受到影响。

更稳妥的判断方式是:

1. 执行 ohrs build --release --arch aarch
2. 检查 dist/arm64-v8a/libeasytier_ohrs.so 是否生成
3. 执行 ohrs artifact
4. 检查 package.har 是否生成

只要这条链路完整跑通,当前目标架构的迁移构建就成立。

11.3 不要手工复制 .so

推荐链路是:

ohrs build --release --arch aarch
ohrs artifact

然后在应用工程中安装 package.har

如果直接复制 .so,很容易出现 Rust 侧重新编译了,但鸿蒙工程里仍然引用旧 Native 库的问题。HAR 至少能把 Native 库、ArkTS 入口、类型声明和包信息一起更新。

11.4 package.har 不是普通 zip 包

本次实测中,package.har 的实际文件格式是 gzip tar,不是普通 zip 包。

所以用 unzip 检查它可能会误判。这里按 ohrs artifact 的产物理解即可。

十二、完整复现命令

export OHOS_NDK_HOME="<command-line-tools-root>/sdk/default/openharmony"
export PATH="<command-line-tools-root>/bin:${PATH}"

cd <repo-root>/easytier-contrib/easytier-ohrs
source env.sh
ohrs build --release --arch aarch
ohrs artifact
ls dist/arm64-v8a/libeasytier_ohrs.so package.har

如果最后能看到 dist/arm64-v8a/libeasytier_ohrs.sopackage.har,说明 EasyTier 到鸿蒙 PC 的 HAR 构建链路已经跑通。

十三、复盘

EasyTier 这次鸿蒙 PC 适配的关键不是某一条编译命令,而是迁移顺序。

比较合理的路径是:

先用 ArkTS 快速落地,把核心链路跑通
        ↓
发现 ArkTS 侧状态和编排逻辑越来越重
        ↓
将配置管理、运行态聚合、TUN 请求、路由整理下沉到 Rust
        ↓
用 NAPI 重新整理 ArkTS 和 Rust 的边界
        ↓
用 HAR 作为最终交付形态

早期 ArkTS 侧承担更多逻辑,是为了快速验证;后续 Rust 侧承接更多能力,是为了解决内存占用、代码耦合和长期维护问题。

对于 EasyTier 这类网络工具来说,移植重点不只是能不能在页面里调用几个函数,而是 Native 层能不能形成稳定、可维护、可复用的适配边界。这个边界稳定之后,鸿蒙 PC 工程才能像依赖普通 HAR 一样复用 EasyTier 的核心能力。

Logo

作为“人工智能6S店”的官方数字引擎,为AI开发者与企业提供一个覆盖软硬件全栈、一站式门户。

更多推荐