mirror of
https://github.com/EasyTier/EasyTier.git
synced 2026-10-29 16:05:35 +00:00
Compare commits
215
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
62803a9509 | ||
|
|
10cc636d7f | ||
|
|
9136f08a9c | ||
|
|
eb59c0f51c | ||
|
|
dcb40b46d9 | ||
|
|
179187fcb5 | ||
|
|
374455cbc6 | ||
|
|
99db92a3ae | ||
|
|
7588062f7e | ||
|
|
da6d721b97 | ||
|
|
ec694f7cb8 | ||
|
|
8839b4b2f1 | ||
|
|
0ca2fde0fb | ||
|
|
d25ccd4c17 | ||
|
|
15a3d43039 | ||
|
|
76439ce562 | ||
|
|
36b4ffcc74 | ||
|
|
03078b4791 | ||
|
|
7c8ca07812 | ||
|
|
4d4b5ad8dc | ||
|
|
0da58e68df | ||
|
|
32dc7f1f40 | ||
|
|
99fce327d5 | ||
|
|
1c6992d6cb | ||
|
|
9bc1a0941f | ||
|
|
c01e2baafa | ||
|
|
d31396f04c | ||
|
|
4b4f0c3cb3 | ||
|
|
1649ec0013 | ||
|
|
f8a45ccc20 | ||
|
|
7592951b80 | ||
|
|
83baead059 | ||
|
|
250c2ad8d6 | ||
|
|
018750a785 | ||
|
|
24afb4ecb4 | ||
|
|
d96eaa668b | ||
|
|
bd06395573 | ||
|
|
db289e87bb | ||
|
|
2583d867c3 | ||
|
|
7fdfe90291 | ||
|
|
7af7bdc16a | ||
|
|
983afda177 | ||
|
|
4837468d43 | ||
|
|
eb32d2a930 | ||
|
|
728ba94b50 | ||
|
|
8b7f1f0196 | ||
|
|
d3a5ae684a | ||
|
|
6abc3731ae | ||
|
|
39dcd0908f | ||
|
|
92eecf1e73 | ||
|
|
651a8d9e25 | ||
|
|
a017dc133c | ||
|
|
6f68ac3758 | ||
|
|
c2f485d42a | ||
|
|
ed73d318bb | ||
|
|
cea877ca77 | ||
|
|
3da349f4ee | ||
|
|
ff3921ce68 | ||
|
|
eafd46b91b | ||
|
|
0a783c8e04 | ||
|
|
ebf0b947b9 | ||
|
|
8d200c26d7 | ||
|
|
0f3d8e4434 | ||
|
|
e313ba8efb | ||
|
|
7853ec8685 | ||
|
|
7223677264 | ||
|
|
5477d4bca2 | ||
|
|
286e0f4a0d | ||
|
|
dd013e6a2a | ||
|
|
c96b6c1961 | ||
|
|
e0bdb516b6 | ||
|
|
a7383114d0 | ||
|
|
19e5c49ba3 | ||
|
|
974c3270e8 | ||
|
|
3d0c9c3ca5 | ||
|
|
86d942ec8c | ||
|
|
44a0a17f68 | ||
|
|
993640b1dd | ||
|
|
0fba5769a3 | ||
|
|
38e2a621bb | ||
|
|
b1f87f025b | ||
|
|
e972307510 | ||
|
|
57afa290a4 | ||
|
|
abb9fd7e1a | ||
|
|
fe764ce45c | ||
|
|
f26c2aa147 | ||
|
|
56be71c7f9 | ||
|
|
f19bcfb400 | ||
|
|
164e2db6ae | ||
|
|
25f6e2dc5e | ||
|
|
4a10d1c2b9 | ||
|
|
abf03ca521 | ||
|
|
67f2270ee7 | ||
|
|
81487c363a | ||
|
|
3b2aadde8e | ||
|
|
b93e45a215 | ||
|
|
e130e9e4c7 | ||
|
|
4304b065cb | ||
|
|
9f231ee76c | ||
|
|
3fe427bc99 | ||
|
|
8794e12a26 | ||
|
|
62e4fd15e9 | ||
|
|
57eb6908f4 | ||
|
|
f64af13424 | ||
|
|
35845c14e6 | ||
|
|
282cd92f2c | ||
|
|
636390ec38 | ||
|
|
8c15941c44 | ||
|
|
52bb26680a | ||
|
|
0b27ac2885 | ||
|
|
23d55373a4 | ||
|
|
d375d7e455 | ||
|
|
1e40350c89 | ||
|
|
e31bde1836 | ||
|
|
86222771c5 | ||
|
|
d114cdd20f | ||
|
|
df874b85be | ||
|
|
8d475dc3fc | ||
|
|
40c857748f | ||
|
|
afbba5d928 | ||
|
|
fc3914baf5 | ||
|
|
4923a13c5d | ||
|
|
d55e63b88e | ||
|
|
7b506e25a7 | ||
|
|
7fb42c3b73 | ||
|
|
dc11298558 | ||
|
|
021f523431 | ||
|
|
346f32d3d0 | ||
|
|
f24735a86f | ||
|
|
7756a15cbe | ||
|
|
741460e1e4 | ||
|
|
13412895c5 | ||
|
|
425a24273b | ||
|
|
4e61612944 | ||
|
|
15e5d89f70 | ||
|
|
46f1b57367 | ||
|
|
9cb3833216 | ||
|
|
7205517160 | ||
|
|
be2034dd06 | ||
|
|
f0d00d6161 | ||
|
|
034f5066cd | ||
|
|
9869ddaa4b | ||
|
|
5ea6766238 | ||
|
|
5efbc8587f | ||
|
|
7632cd64da | ||
|
|
16b666ad25 | ||
|
|
8909e88484 | ||
|
|
5edc4cb1cd | ||
|
|
e7709f1cb5 | ||
|
|
c0f42ebe8c | ||
|
|
9d965cae64 | ||
|
|
da28c8badc | ||
|
|
e38b1354b3 | ||
|
|
793b57c2a1 | ||
|
|
4a25ca934b | ||
|
|
13f2ebfe12 | ||
|
|
9ba364ff60 | ||
|
|
ba653da9a0 | ||
|
|
64c4d73044 | ||
|
|
e0745f4bab | ||
|
|
e3ca7ffa54 | ||
|
|
df97f3a64d | ||
|
|
00957e5f9d | ||
|
|
bfa3383aaa | ||
|
|
73bea01f40 | ||
|
|
0378191783 | ||
|
|
d5fa6a608d | ||
|
|
811f151155 | ||
|
|
8428a89d2d | ||
|
|
513695297c | ||
|
|
bfbfa2ef8d | ||
|
|
8e1d079142 | ||
|
|
55f15bb6f0 | ||
|
|
96fd39649a | ||
|
|
74fc8b300d | ||
|
|
baeee40b79 | ||
|
|
4342c8d7a2 | ||
|
|
1178b312fa | ||
|
|
362aa7a9cd | ||
|
|
12a7b5a5c5 | ||
|
|
4eba9b07b6 | ||
|
|
1b48029bdc | ||
|
|
3542e944cb | ||
|
|
852d1c9e14 | ||
|
|
4958394469 | ||
|
|
41b6d65604 | ||
|
|
aae30894dd | ||
|
|
81d169abfc | ||
|
|
9c6c210e89 | ||
|
|
d1c6dcf754 | ||
|
|
97c8c4f55a | ||
|
|
ed8df2d58f | ||
|
|
f66010e6f9 | ||
|
|
d5c4700d32 | ||
|
|
969ecfc4ca | ||
|
|
8f862997eb | ||
|
|
b20075e3dc | ||
|
|
eb3b5aae51 | ||
|
|
af6b6ab6f1 | ||
|
|
5a1668c753 | ||
|
|
820d9095d3 | ||
|
|
2fb41ccbba | ||
|
|
b4666be696 | ||
|
|
4688ad74ad | ||
|
|
f7ea78d4f0 | ||
|
|
ac112440c3 | ||
|
|
958b246f05 | ||
|
|
263f4c3bc9 | ||
|
|
ffddc517e1 | ||
|
|
5cd0a3e846 | ||
|
|
f4319c4d4f | ||
|
|
0091a535d5 | ||
|
|
d7a5fb8d66 | ||
|
|
f63054e937 | ||
|
|
efc043abbb |
@@ -9,6 +9,14 @@ rustflags = ["-C", "link-arg=-fuse-ld=mold"]
|
||||
[target.'cfg(all(windows, target_env = "msvc"))']
|
||||
rustflags = ["-C", "target-feature=+crt-static"]
|
||||
|
||||
[target.wasm32-unknown-unknown]
|
||||
rustflags = [
|
||||
"-C",
|
||||
"opt-level=z",
|
||||
"--cfg",
|
||||
'getrandom_backend="wasm_js"',
|
||||
]
|
||||
|
||||
# region
|
||||
|
||||
# region CI
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
[profile.default]
|
||||
slow-timeout = { period = "60s", terminate-after = 1 }
|
||||
|
||||
[profile.default.junit]
|
||||
path = "junit.xml"
|
||||
|
||||
[profile.ci]
|
||||
test-threads = 32
|
||||
|
||||
[test-groups.subnet-proxy]
|
||||
max-threads = 8
|
||||
|
||||
[[profile.default.overrides]]
|
||||
filter = 'test(tests::three_node::subnet_proxy_three_node_test)'
|
||||
test-group = 'subnet-proxy'
|
||||
|
||||
# These tests modify or inspect host-wide interfaces, routes, or BPF state.
|
||||
# They must exclude all other tests, including the isolated network fixtures.
|
||||
# Run exclusive tests first so the parallel pool does not repeatedly drain.
|
||||
[[profile.default.overrides]]
|
||||
filter = 'test(tests::upnp_test) | test(common::ifcfg::netlink::tests) | test(instance::public_ipv6_provider::platform::tests) | test(instance::dns_server::tests) | test(socket::fake_tcp::netfilter::linux_bpf::tests)'
|
||||
threads-required = 'num-test-threads'
|
||||
priority = 100
|
||||
|
||||
# These assertions require a reply within 100 ms, on the first ping, or a
|
||||
# minimum measured throughput. Competing tests can delay packet processing.
|
||||
[[profile.default.overrides]]
|
||||
filter = 'test(tests::three_node::p2p_only_test) | test(tests::three_node::relay_peer_e2e_encryption) | test(tests::three_node::whitelist_test) | test(relay_bps_limit_test) | test(instance_recv_bps_limit_test)'
|
||||
threads-required = 'num-test-threads'
|
||||
priority = 100
|
||||
@@ -0,0 +1 @@
|
||||
*.sh text eol=lf
|
||||
@@ -0,0 +1,82 @@
|
||||
# CI
|
||||
|
||||
[中文](CI.zh-CN.md)
|
||||
|
||||
| Trigger | Core | GUI | Mobile |
|
||||
| --- | --- | --- | --- |
|
||||
| PR targeting `main`/`develop`, or push to either branch | Core/Web `cargo check`: Linux x64, Windows x64, macOS ARM64 | Linux frontend build and Rust check | Android ARM64 frontend build and Rust check |
|
||||
| Push to `releases/**`, or manual dispatch | 16 targets and Magisk artifacts | Installers for 7 targets | APKs for 4 architectures |
|
||||
|
||||
Test runs CI script tests, fmt, clippy, WASI, Cargo.lock validation, and
|
||||
Linux tests for `easytier`/`easytier-core`, including `three_node`. Full mode
|
||||
also checks each `easytier` feature except `macos-ne`.
|
||||
|
||||
Linux tests run on one runner with 32 concurrent nextest processes. Network
|
||||
integration tests use separate namespaces and bridges for each test. Subnet
|
||||
proxy cases run at most eight at a time. Tests that use host-wide network state,
|
||||
measure bandwidth, or make time-sensitive connectivity assertions run first,
|
||||
exclusively, before the parallel tests.
|
||||
|
||||
These four workflows skip build/test jobs for draft PRs. Path and duplicate
|
||||
content checks can skip ordinary builds and Rust jobs. The result jobs are
|
||||
`core-result`, `gui-result`, `mobile-result`, and `test`.
|
||||
|
||||
PR dashboard builds run only for frontend, Core configuration, schema, or
|
||||
build configuration changes. Ordinary branch pushes build the dashboard.
|
||||
Core checks run independently; Web embedding is compiled only in full mode.
|
||||
Test skips the frontend build in ordinary mode; its test archive builds
|
||||
only Rust packages.
|
||||
|
||||
PR runs can upload the test archive for reuse within CI. The dashboard is
|
||||
uploaded only in full mode.
|
||||
Core binaries, GUI installers, APKs, and Magisk artifacts require full mode.
|
||||
Compilation outputs are cached. Ordinary pushes do not cancel full builds.
|
||||
|
||||
## Nightly
|
||||
|
||||
Scheduled at 03:17 Asia/Shanghai, or started manually on `main`. Check Core,
|
||||
GUI, Mobile, and Test separately. Compare main HEAD with that workflow's latest
|
||||
successful `workflow_dispatch` run: skip matching commits; dispatch otherwise.
|
||||
Core's `sign_windows` is set to `false` for nightly builds.
|
||||
|
||||
Nightly waits for the dispatched runs, retrying failed jobs once. A second
|
||||
failure or a cancelled run fails Nightly and prevents publication. The build
|
||||
and wait job has a three-hour timeout. Once all four components succeed at the
|
||||
same commit, Nightly updates the rolling `nightly` prerelease using that commit,
|
||||
even if main advances while builds are running. If main changes between the
|
||||
dispatch requests and the builds target different commits, Nightly fails and
|
||||
must be started again.
|
||||
|
||||
Builds dispatched with `GITHUB_TOKEN` do not trigger downstream `workflow_run`
|
||||
jobs, so scheduled publication and retries run directly in Nightly. Completion
|
||||
events still handle full main builds dispatched manually by a maintainer.
|
||||
Nightly Release can also be started manually to publish existing main HEAD
|
||||
builds without rebuilding them.
|
||||
|
||||
Nightly uses the same asset names and archive layout as version releases.
|
||||
Core, Web, and Magisk ZIP names include the built commit's version (for example,
|
||||
`easytier-linux-x86_64-v2.7.0.zip`). GUI installers and Android APKs retain their
|
||||
original filenames without an extra ZIP wrapper. `SHA256SUMS` covers every
|
||||
download. Asset URLs can change when the project version changes; use the
|
||||
fixed `nightly` release page to find the current files.
|
||||
|
||||
## Manual builds and releases
|
||||
|
||||
Repository Write access is required. In Actions, run Core, GUI, and Mobile
|
||||
separately on the PR branch to obtain full artifacts. The branch must contain
|
||||
these workflow definitions. Fork PRs need a temporary branch in this repository.
|
||||
|
||||
Manual Core builds default to SignPath test signing (`sign_windows: true`).
|
||||
Full macOS GUI builds are signed and notarized.
|
||||
|
||||
Release is a separate manual workflow. Its Core/GUI/Mobile run IDs must refer
|
||||
to successful full builds from their respective workflows at the same commit.
|
||||
It downloads their artifacts and creates a draft release.
|
||||
|
||||
## Other workflows
|
||||
|
||||
- OHOS runs independently. Relevant PRs run bridge tests and check the bindings
|
||||
for the host and ARM64 OHOS. Non-PR runs build and upload HAR artifacts;
|
||||
publishing and downstream builds retain their existing main/manual conditions.
|
||||
- Go, JavaScript Hosts, and Nix use their own path filters.
|
||||
- Docker is manual; it downloads a specified Core run's artifacts and pushes images.
|
||||
@@ -0,0 +1,73 @@
|
||||
# CI
|
||||
|
||||
[English](CI.md)
|
||||
|
||||
| 触发方式 | Core | GUI | Mobile |
|
||||
| --- | --- | --- | --- |
|
||||
| 目标为 `main`/`develop` 的 PR,或 push 到这两个分支 | Linux x64、Windows x64、macOS ARM64 的 Core/Web `cargo check` | Linux 前端构建和 Rust 检查 | Android ARM64 前端构建和 Rust 检查 |
|
||||
| Push 到 `releases/**`,或手动触发 | 16 个目标和 Magisk 产物 | 7 个目标的安装包 | 4 个架构的 APK |
|
||||
|
||||
Test 运行 CI 脚本测试、fmt、clippy、WASI、Cargo.lock 检查,以及
|
||||
`easytier`/`easytier-core` 的 Linux 测试(含 `three_node`)。全量模式还逐个
|
||||
检查 `easytier` 的 feature,排除 `macos-ne`。
|
||||
|
||||
Linux 测试使用一个 runner,nextest 总并发度为 32。网络集成测试为每个测试
|
||||
创建独立的 namespace 和网桥。子网代理用例最多同时运行八个;带宽测试、
|
||||
使用宿主机全局网络状态的测试和时间敏感的连通性测试优先独占
|
||||
执行,完成后再运行并发测试。
|
||||
|
||||
这四个 workflow 的构建和测试任务跳过 draft PR。普通构建和 Rust 任务可因
|
||||
无关路径或重复内容而跳过。汇总检查为 `core-result`、`gui-result`、
|
||||
`mobile-result` 和 `test`。
|
||||
|
||||
PR 仅在前端、Core 配置源码、schema 或构建配置变化时构建 Web 前端。
|
||||
普通分支 push 仍构建 Web 前端。Core 检查独立运行;Web 的嵌入模式仅在
|
||||
全量构建中编译。
|
||||
Test 在普通模式下跳过前端构建,测试归档仅构建 Rust 包。
|
||||
|
||||
PR 可上传供 CI 内部复用的测试归档。Web 前端仅在全量模式上传。
|
||||
Core 二进制、GUI 安装包、APK 和 Magisk 产物仅在全量模式上传。编译产物
|
||||
启用缓存。普通 push 不会取消全量构建。
|
||||
|
||||
## 每日构建
|
||||
|
||||
定时为北京时间 03:17,也可以在 `main` 上手动运行。分别检查 Core、GUI、
|
||||
Mobile、Test:比较 main HEAD 与该 workflow 最近一次成功的
|
||||
`workflow_dispatch` 提交,相同则跳过,不同或无成功记录则触发该 workflow。
|
||||
每日 Core 构建设置 `sign_windows: false`。
|
||||
|
||||
Nightly 等待已触发的构建结束,失败任务自动重试一次。再次失败或取消会使
|
||||
Nightly 失败并阻止发布,构建和等待任务的超时为三小时。四个组件在同一提交
|
||||
上全部成功后,Nightly 使用该提交更新固定的 `nightly` 预发布版本,即使等待
|
||||
期间 main 已有新提交。如果 main 在各次触发请求之间变化,导致构建提交不
|
||||
一致,Nightly 会失败,需要重新运行。
|
||||
|
||||
`GITHUB_TOKEN` 触发的构建不会触发下游 `workflow_run` 任务,因此定时构建的
|
||||
发布和重试直接由 Nightly 执行。维护者手动触发的 main 全量构建仍由完成事件
|
||||
处理。也可手动运行 Nightly Release,使用 main HEAD 的现有成功构建直接发布。
|
||||
|
||||
Nightly 的产物名称和压缩包目录结构与版本发布保持一致。Core、Web 和 Magisk
|
||||
ZIP 文件名包含构建提交中的版本号,例如 `easytier-linux-x86_64-v2.7.0.zip`。
|
||||
GUI 安装包和 Android APK 保留原始文件名,不再套一层 ZIP。`SHA256SUMS`
|
||||
包含所有下载文件的校验值。项目版本变化时产物下载地址也会变化,可通过固定的
|
||||
`nightly` 发布页面查找当前文件。
|
||||
|
||||
## 手动构建与发布
|
||||
|
||||
需要仓库 Write 权限。在 Actions 分别运行 Core、GUI、Mobile,选择 PR 分支
|
||||
即可生成全量产物。该分支必须包含这些 workflow 定义。Fork PR 需要先在本
|
||||
仓库建立临时分支。
|
||||
|
||||
手动 Core 构建默认开启 SignPath 测试签名(`sign_windows: true`)。
|
||||
全量 macOS GUI 构建会签名和公证。
|
||||
|
||||
Release 是独立的手动 workflow。输入的 Core/GUI/Mobile run ID 必须分别来自
|
||||
正确的 workflow、全量构建成功,且提交相同。它下载这些产物并创建 draft
|
||||
release。
|
||||
|
||||
## 其他 workflow
|
||||
|
||||
- OHOS 独立运行。相关 PR 运行桥接单测,并检查宿主平台和 ARM64 OHOS 的绑定。
|
||||
非 PR 构建生成并上传 HAR;发布和下游构建仍按原有主线/手动触发条件执行。
|
||||
- Go、JavaScript Hosts、Nix 使用各自的路径过滤。
|
||||
- Docker 手动触发,下载指定 Core 构建的产物并推送镜像。
|
||||
@@ -0,0 +1,28 @@
|
||||
# Security Policy
|
||||
|
||||
[简体中文](SECURITY.zh-CN.md) | English
|
||||
|
||||
## Supported Versions
|
||||
|
||||
Security updates are provided for the latest EasyTier release. Before reporting a vulnerability, please verify it against the latest release or the current `main` branch when practical.
|
||||
|
||||
| Version | Supported |
|
||||
| --- | --- |
|
||||
| Latest release | Yes |
|
||||
| Older releases | No |
|
||||
|
||||
## Reporting a Vulnerability
|
||||
|
||||
Please report suspected vulnerabilities through GitHub's [private vulnerability reporting form](https://github.com/EasyTier/EasyTier/security/advisories/new).
|
||||
|
||||
Do not disclose the vulnerability in a public issue, discussion, or pull request. A useful report should include:
|
||||
|
||||
- the affected EasyTier version or commit;
|
||||
- the affected platform and relevant configuration, with secrets removed;
|
||||
- a description of the vulnerability and its potential impact;
|
||||
- reproducible steps or a minimal proof of concept; and
|
||||
- any known mitigations or workarounds.
|
||||
|
||||
The maintainers will assess the report and coordinate remediation and disclosure through the private advisory. Please allow reasonable time for a fix before publishing details.
|
||||
|
||||
When investigating, avoid accessing or modifying other users' data, disrupting services, or violating applicable laws.
|
||||
@@ -0,0 +1,28 @@
|
||||
# 安全策略
|
||||
|
||||
简体中文 | [English](SECURITY.md)
|
||||
|
||||
## 支持的版本
|
||||
|
||||
EasyTier 仅为最新正式版本提供安全更新。报告漏洞前,请尽可能在最新正式版本或当前 `main` 分支上确认问题仍然存在。
|
||||
|
||||
| 版本 | 是否支持 |
|
||||
| --- | --- |
|
||||
| 最新正式版本 | 是 |
|
||||
| 更早版本 | 否 |
|
||||
|
||||
## 报告安全漏洞
|
||||
|
||||
请通过 GitHub 的[私有漏洞报告表单](https://github.com/EasyTier/EasyTier/security/advisories/new)报告疑似安全漏洞。
|
||||
|
||||
请勿在公开 Issue、Discussion 或 Pull Request 中披露漏洞。一份有效的报告应包括:
|
||||
|
||||
- 受影响的 EasyTier 版本或提交;
|
||||
- 受影响的平台和相关配置,请移除其中的密钥等敏感信息;
|
||||
- 漏洞说明及其潜在影响;
|
||||
- 可复现的步骤或最小概念验证;
|
||||
- 已知的缓解措施或临时解决方案。
|
||||
|
||||
维护者将评估报告,并通过私有安全公告协调修复和披露。请在公开漏洞详情前为修复预留合理时间。
|
||||
|
||||
调查漏洞时,请勿访问或修改其他用户的数据、干扰服务或违反适用的法律法规。
|
||||
@@ -33,10 +33,24 @@ runs:
|
||||
sudo apt-get install -qqy build-essential mold musl-tools
|
||||
shell: bash
|
||||
|
||||
- name: Setup protoc
|
||||
uses: arduino/setup-protoc@v3
|
||||
with:
|
||||
version: '35.1'
|
||||
# GitHub repo token to use to avoid rate limiter
|
||||
repo-token: ${{ inputs.token }}
|
||||
|
||||
- name: Verify protoc version
|
||||
run: |
|
||||
version="$(protoc --version | tr -d '\r')"
|
||||
test "$version" = "libprotoc 35.1"
|
||||
shell: bash
|
||||
|
||||
- name: Setup Frontend Environment
|
||||
if: ${{ inputs.pnpm == 'true' }}
|
||||
uses: ./.github/actions/prepare-pnpm
|
||||
with:
|
||||
token: ${{ inputs.token }}
|
||||
build-filter: ${{ inputs.pnpm-build-filter }}
|
||||
|
||||
- name: Install GUI dependencies (Linux)
|
||||
@@ -82,9 +96,3 @@ runs:
|
||||
ar x libgcc.a _ctzsi2.o _clz.o _bswapsi2.o
|
||||
ar rcs libctz.a _ctzsi2.o _clz.o _bswapsi2.o
|
||||
shell: bash
|
||||
|
||||
- name: Setup protoc
|
||||
uses: arduino/setup-protoc@v3
|
||||
with:
|
||||
# GitHub repo token to use to avoid rate limiter
|
||||
repo-token: ${{ inputs.token }}
|
||||
@@ -3,6 +3,9 @@ author: Luna
|
||||
description: 'Setup Node.js, pnpm, and install dependencies'
|
||||
|
||||
inputs:
|
||||
token:
|
||||
description: 'GitHub token, used by setup-protoc action'
|
||||
required: false
|
||||
build-filter:
|
||||
description: 'The filter argument for pnpm build (e.g. ./easytier-web/*)'
|
||||
required: false
|
||||
@@ -11,6 +14,22 @@ inputs:
|
||||
runs:
|
||||
using: "composite"
|
||||
steps:
|
||||
- uses: actions-rust-lang/setup-rust-toolchain@v1
|
||||
with:
|
||||
toolchain: 1.95
|
||||
target: wasm32-unknown-unknown
|
||||
cache: false
|
||||
rustflags: ''
|
||||
|
||||
- uses: taiki-e/install-action@v2
|
||||
with:
|
||||
tool: wasm-pack
|
||||
|
||||
- uses: arduino/setup-protoc@v3
|
||||
with:
|
||||
version: '35.1'
|
||||
repo-token: ${{ inputs.token }}
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v5
|
||||
with:
|
||||
@@ -41,8 +60,16 @@ runs:
|
||||
pnpm -r install
|
||||
if [ -n "${{ inputs.build-filter }}" ]; then
|
||||
echo "Building with filter: ${{ inputs.build-filter }}"
|
||||
pnpm -r --filter "${{ inputs.build-filter }}" build
|
||||
pnpm -r --workspace-concurrency=1 --filter "${{ inputs.build-filter }}" build
|
||||
else
|
||||
echo "No build filter provided, building all packages"
|
||||
pnpm -r build
|
||||
pnpm -r --workspace-concurrency=1 build
|
||||
fi
|
||||
|
||||
- name: Bundle config generator with web frontend
|
||||
shell: bash
|
||||
run: |
|
||||
if [ -f easytier-web/frontend/dist/index.html ] && [ -f easytier-web/config-generator/dist/index.html ]; then
|
||||
mkdir -p easytier-web/frontend/dist/config-generator
|
||||
cp -R easytier-web/config-generator/dist/. easytier-web/frontend/dist/config-generator/
|
||||
fi
|
||||
|
||||
@@ -0,0 +1,204 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const { test } = require('node:test');
|
||||
const dispatch = require('./nightly-build.js');
|
||||
const validate = require('./validate-release-builds.js');
|
||||
|
||||
const context = { repo: { owner: 'EasyTier', repo: 'EasyTier' } };
|
||||
const core = { info() {} };
|
||||
const workflows = ['core.yml', 'gui.yml', 'mobile.yml', 'test.yml'];
|
||||
|
||||
function nightly(branches, successfulRuns = {}) {
|
||||
const f = { dispatched: [], retried: [], responses: {}, inspected: [], sleeps: 0 };
|
||||
const github = {
|
||||
paginate: async () => branches,
|
||||
rest: {
|
||||
repos: { listBranches() {} },
|
||||
actions: {
|
||||
async listWorkflowRuns(request) {
|
||||
assert.equal(request.event, 'workflow_dispatch');
|
||||
assert.equal(request.status, 'success');
|
||||
const sha = successfulRuns[`${request.branch}/${request.workflow_id}`];
|
||||
return { data: { workflow_runs: sha ? [{ head_sha: sha }] : [] } };
|
||||
},
|
||||
async createWorkflowDispatch(request) {
|
||||
assert.equal(request.headers['X-GitHub-Api-Version'], '2026-03-10');
|
||||
f.dispatched.push(request);
|
||||
return { data: { workflow_run_id: f.dispatched.length, html_url: `run/${f.dispatched.length}` } };
|
||||
},
|
||||
async getWorkflowRun({ run_id }) {
|
||||
const workflow = f.dispatched[run_id - 1].workflow_id;
|
||||
f.inspected.push({ workflow, dispatched: f.dispatched.length });
|
||||
return { data: {
|
||||
head_sha: branches.find(branch => branch.name === 'main').commit.sha,
|
||||
status: 'completed', conclusion: 'success', run_attempt: 1,
|
||||
...f.responses[workflow]?.shift(),
|
||||
} };
|
||||
},
|
||||
async reRunWorkflowFailedJobs({ run_id }) {
|
||||
f.retried.push(run_id);
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
f.run = () => dispatch({ github, context, core, sleep: async delay => {
|
||||
assert.equal(delay, 60_000);
|
||||
assert.ok(++f.sleeps < 10, 'nightly should eventually finish');
|
||||
} });
|
||||
return f;
|
||||
}
|
||||
|
||||
test('first nightly builds every workflow on an existing main branch', async () => {
|
||||
const f = nightly([{ name: 'main', commit: { sha: 'new' } }]);
|
||||
assert.equal(await f.run(), 'new');
|
||||
assert.deepEqual(f.dispatched.map(run => run.workflow_id), workflows);
|
||||
assert.ok(f.dispatched.every(run => run.ref === 'main'));
|
||||
assert.deepEqual(f.dispatched[0].inputs, { sign_windows: false });
|
||||
assert.ok(f.dispatched.slice(1).every(run => !run.inputs));
|
||||
assert.deepEqual(f.inspected.map(run => run.workflow), workflows);
|
||||
assert.ok(f.inspected.every(run => run.dispatched === 4));
|
||||
});
|
||||
|
||||
test('unchanged branches skip builds and unmonitored branches are ignored', async () => {
|
||||
const successfulRuns = Object.fromEntries(workflows.map(workflow => [`main/${workflow}`, 'same']));
|
||||
const f = nightly([
|
||||
{ name: 'main', commit: { sha: 'same' } },
|
||||
{ name: 'feature', commit: { sha: 'new' } },
|
||||
], successfulRuns);
|
||||
assert.equal(await f.run(), 'same');
|
||||
assert.deepEqual(f.dispatched, []);
|
||||
assert.equal(f.sleeps, 0);
|
||||
});
|
||||
|
||||
test('nightly retries a failed component using its last successful commit', async () => {
|
||||
const successfulRuns = Object.fromEntries(workflows.map(workflow => [`main/${workflow}`, 'new']));
|
||||
successfulRuns['main/gui.yml'] = 'old';
|
||||
const f = nightly([{ name: 'main', commit: { sha: 'new' } }], successfulRuns);
|
||||
assert.equal(await f.run(), 'new');
|
||||
assert.deepEqual(f.dispatched.map(run => run.workflow_id), ['gui.yml']);
|
||||
});
|
||||
|
||||
test('nightly only builds main even when other branches exist', async () => {
|
||||
const f = nightly([
|
||||
{ name: 'main', commit: { sha: 'new' } },
|
||||
{ name: 'develop', commit: { sha: 'new' } },
|
||||
{ name: 'feature', commit: { sha: 'new' } },
|
||||
]);
|
||||
await f.run();
|
||||
assert.deepEqual(f.dispatched.map(run => run.workflow_id), workflows);
|
||||
assert.ok(f.dispatched.every(run => run.ref === 'main'));
|
||||
});
|
||||
|
||||
test('nightly waits for every dispatched build before allowing publication', async () => {
|
||||
const f = nightly([{ name: 'main', commit: { sha: 'new' } }]);
|
||||
f.responses['core.yml'] = [{ status: 'queued' }, { status: 'in_progress' }, {}];
|
||||
f.responses['test.yml'] = [{ status: 'in_progress' }, {}];
|
||||
assert.equal(await f.run(), 'new');
|
||||
assert.equal(f.sleeps, 2);
|
||||
assert.deepEqual(f.inspected.map(run => run.workflow), [
|
||||
...workflows, 'core.yml', 'test.yml', 'core.yml',
|
||||
]);
|
||||
});
|
||||
|
||||
test('nightly retries failed jobs once and waits for the new attempt', async () => {
|
||||
const f = nightly([{ name: 'main', commit: { sha: 'new' } }]);
|
||||
f.responses['gui.yml'] = [
|
||||
{ conclusion: 'failure' },
|
||||
{ conclusion: 'failure' }, // The API still returns the first attempt.
|
||||
{ status: 'in_progress', run_attempt: 2 },
|
||||
{ run_attempt: 2 },
|
||||
];
|
||||
assert.equal(await f.run(), 'new');
|
||||
assert.deepEqual(f.retried, [2]);
|
||||
assert.equal(f.sleeps, 3);
|
||||
});
|
||||
|
||||
test('nightly fails instead of publishing if a retried build fails again', async () => {
|
||||
const f = nightly([{ name: 'main', commit: { sha: 'new' } }]);
|
||||
f.responses['mobile.yml'] = [{ conclusion: 'failure' }, { conclusion: 'failure', run_attempt: 2 }];
|
||||
await assert.rejects(f.run(), /mobile.yml: run 3 finished with failure/);
|
||||
assert.deepEqual(f.retried, [3]);
|
||||
});
|
||||
|
||||
test('nightly does not retry cancelled or timed-out builds', async () => {
|
||||
for (const conclusion of ['cancelled', 'timed_out']) {
|
||||
const f = nightly([{ name: 'main', commit: { sha: 'new' } }]);
|
||||
f.responses['test.yml'] = [{ conclusion }];
|
||||
await assert.rejects(f.run(), new RegExp(`test.yml: run 4 finished with ${conclusion}`));
|
||||
assert.deepEqual(f.retried, []);
|
||||
}
|
||||
});
|
||||
|
||||
test('nightly rejects a dispatch that races with a new main commit', async () => {
|
||||
const f = nightly([{ name: 'main', commit: { sha: 'new' } }]);
|
||||
f.responses['gui.yml'] = [{ head_sha: 'newer', status: 'queued' }];
|
||||
await assert.rejects(f.run(), /main changed while dispatching/);
|
||||
assert.deepEqual(f.retried, []);
|
||||
});
|
||||
|
||||
process.env.CORE_RUN_ID = '1';
|
||||
process.env.GUI_RUN_ID = '2';
|
||||
process.env.MOBILE_RUN_ID = '3';
|
||||
|
||||
function releaseRuns() {
|
||||
return ['core.yml', 'gui.yml', 'mobile.yml'].map(workflow => ({
|
||||
path: `.github/workflows/${workflow}`,
|
||||
conclusion: 'success',
|
||||
event: 'workflow_dispatch',
|
||||
head_branch: 'main',
|
||||
head_sha: 'same',
|
||||
}));
|
||||
}
|
||||
|
||||
async function release(runs) {
|
||||
const github = {
|
||||
rest: {
|
||||
actions: {
|
||||
async getWorkflowRun({ run_id }) {
|
||||
return { data: runs[run_id - 1] };
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
return validate({ github, context, core });
|
||||
}
|
||||
|
||||
test('release accepts successful full builds from one commit', async () => {
|
||||
await release(releaseRuns());
|
||||
});
|
||||
|
||||
test('release accepts full builds triggered by a release branch push', async () => {
|
||||
const runs = releaseRuns();
|
||||
runs[0].event = 'push';
|
||||
runs[0].head_branch = 'releases/2.7';
|
||||
runs[1].event = 'push';
|
||||
runs[1].head_branch = 'releases/2.7';
|
||||
await release(runs);
|
||||
});
|
||||
|
||||
test('release rejects PR builds and regular branch pushes', async () => {
|
||||
for (const event of ['pull_request', 'push']) {
|
||||
const runs = releaseRuns();
|
||||
runs[0].event = event;
|
||||
await assert.rejects(release(runs), /successful full build/);
|
||||
}
|
||||
});
|
||||
|
||||
test('release rejects failed and cancelled builds', async () => {
|
||||
for (const conclusion of ['failure', 'cancelled']) {
|
||||
const runs = releaseRuns();
|
||||
runs[1].conclusion = conclusion;
|
||||
await assert.rejects(release(runs), /successful full build/);
|
||||
}
|
||||
});
|
||||
|
||||
test('release rejects run IDs from the wrong workflow', async () => {
|
||||
const runs = releaseRuns();
|
||||
runs[1].path = runs[0].path;
|
||||
await assert.rejects(release(runs), /successful full build/);
|
||||
});
|
||||
|
||||
test('release rejects builds from different commits', async () => {
|
||||
const runs = releaseRuns();
|
||||
runs[2].head_sha = 'different';
|
||||
await assert.rejects(release(runs), /same commit/);
|
||||
});
|
||||
@@ -0,0 +1,70 @@
|
||||
const { setTimeout } = require('node:timers/promises');
|
||||
|
||||
module.exports = async ({ github, context, core, sleep = setTimeout }) => {
|
||||
const repository = context.repo;
|
||||
const branches = await github.paginate(github.rest.repos.listBranches, {
|
||||
...repository,
|
||||
per_page: 100,
|
||||
});
|
||||
|
||||
for (const branch of branches.filter(({ name }) => name === 'main')) {
|
||||
const pending = new Map();
|
||||
for (const workflow of ['core.yml', 'gui.yml', 'mobile.yml', 'test.yml']) {
|
||||
const { data } = await github.rest.actions.listWorkflowRuns({
|
||||
...repository,
|
||||
workflow_id: workflow,
|
||||
branch: branch.name,
|
||||
event: 'workflow_dispatch',
|
||||
status: 'success',
|
||||
per_page: 1,
|
||||
});
|
||||
if (data.workflow_runs[0]?.head_sha === branch.commit.sha) {
|
||||
core.info(`${workflow}: ${branch.name} is already built successfully.`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const { data: run } = await github.rest.actions.createWorkflowDispatch({
|
||||
...repository,
|
||||
workflow_id: workflow,
|
||||
ref: branch.name,
|
||||
// This API version returns the dispatched run ID.
|
||||
headers: { 'X-GitHub-Api-Version': '2026-03-10' },
|
||||
...(workflow === 'core.yml' ? { inputs: { sign_windows: false } } : {}),
|
||||
});
|
||||
pending.set(workflow, { id: run.workflow_run_id, attempt: 1 });
|
||||
core.info(`${workflow}: dispatched ${run.html_url} for ${branch.name}.`);
|
||||
}
|
||||
|
||||
// GITHUB_TOKEN dispatches do not trigger downstream workflow_run jobs.
|
||||
// Wait here so publication and retries do not depend on those events.
|
||||
while (pending.size) {
|
||||
for (const [workflow, build] of pending) {
|
||||
const { data: run } = await github.rest.actions.getWorkflowRun({
|
||||
...repository, run_id: build.id,
|
||||
});
|
||||
if (run.head_sha !== branch.commit.sha) {
|
||||
throw new Error(`${workflow}: main changed while dispatching; run Nightly again.`);
|
||||
}
|
||||
// A rerun request can return before the new attempt becomes visible.
|
||||
if (run.run_attempt < build.attempt || run.status !== 'completed') continue;
|
||||
if (run.conclusion === 'success') {
|
||||
core.info(`${workflow}: run ${build.id} succeeded.`);
|
||||
pending.delete(workflow);
|
||||
} else if (run.conclusion === 'failure' && run.run_attempt === 1) {
|
||||
await github.rest.actions.reRunWorkflowFailedJobs({
|
||||
...repository, run_id: build.id,
|
||||
});
|
||||
build.attempt = 2;
|
||||
core.info(`${workflow}: retrying failed jobs in run ${build.id}.`);
|
||||
} else {
|
||||
throw new Error(`${workflow}: run ${build.id} finished with ${run.conclusion}.`);
|
||||
}
|
||||
}
|
||||
if (pending.size) {
|
||||
core.info(`Waiting for ${[...pending.keys()].join(', ')} at ${branch.commit.sha}.`);
|
||||
await sleep(60_000);
|
||||
}
|
||||
}
|
||||
return branch.commit.sha;
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,241 @@
|
||||
const fs = require('node:fs/promises');
|
||||
const path = require('node:path');
|
||||
const { execFile } = require('node:child_process');
|
||||
const { createHash } = require('node:crypto');
|
||||
const { promisify } = require('node:util');
|
||||
|
||||
const exec = promisify(execFile);
|
||||
|
||||
const workflows = ['core', 'gui', 'mobile', 'test'];
|
||||
|
||||
function releaseNotes(repository, baseline, sha, commits, runs) {
|
||||
const url = `https://github.com/${repository.owner}/${repository.repo}`;
|
||||
const changes = [...commits]
|
||||
.sort((a, b) => Date.parse(b.commit.committer.date) - Date.parse(a.commit.committer.date))
|
||||
.map(commit => {
|
||||
const date = new Date(commit.commit.committer.date).toISOString().slice(0, 10);
|
||||
const subject = commit.commit.message.split('\n')[0];
|
||||
return `- ${date} — ${subject} ([${commit.sha.slice(0, 7)}](${commit.html_url}))`;
|
||||
});
|
||||
return [
|
||||
'# EasyTier Nightly',
|
||||
'',
|
||||
'Development builds from `main`. This prerelease is updated in place. Asset names follow version releases.',
|
||||
'',
|
||||
`Commit: [${sha}](${url}/commit/${sha})`,
|
||||
`Builds: ${runs.map(run => `[${run.name}](${run.html_url})`).join(' · ')}`,
|
||||
'',
|
||||
`## Changes since [${baseline.tag_name}](${baseline.html_url})`,
|
||||
'',
|
||||
'Dates are commit dates in UTC, newest first.',
|
||||
'',
|
||||
...changes,
|
||||
...(changes.length ? [] : ['No changes since this version.']),
|
||||
'',
|
||||
`[Full changelog](${url}/compare/${baseline.tag_name}...${sha})`,
|
||||
'',
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
async function prepare({ github, context, core, sha }) {
|
||||
const repository = context.repo;
|
||||
const { data: branch } = await github.rest.repos.getBranch({ ...repository, branch: 'main' });
|
||||
sha ||= context.payload.workflow_run?.head_sha || branch.commit.sha;
|
||||
const { data: ancestry } = await github.rest.repos.compareCommitsWithBasehead({
|
||||
...repository, basehead: `${sha}...${branch.commit.sha}`, per_page: 1,
|
||||
});
|
||||
if (!['ahead', 'identical'].includes(ancestry.status)) {
|
||||
core.info(`${sha} is no longer on main.`);
|
||||
return null;
|
||||
}
|
||||
|
||||
// Listing releases also finds an unfinished draft from the first upload.
|
||||
const releases = await github.paginate(github.rest.repos.listReleases, {
|
||||
...repository, per_page: 100,
|
||||
});
|
||||
const release = releases.find(item => item.tag_name === 'nightly');
|
||||
if (release?.immutable) throw new Error('The nightly release must allow asset and tag updates.');
|
||||
if (release && !release.draft) {
|
||||
const { data: comparison } = await github.rest.repos.compareCommitsWithBasehead({
|
||||
...repository, basehead: `nightly...${sha}`, per_page: 1,
|
||||
});
|
||||
if (!['ahead', 'identical'].includes(comparison.status)) {
|
||||
core.info('A newer nightly is already available; skipping this older build.');
|
||||
return null;
|
||||
}
|
||||
if (comparison.status === 'identical' && release.body?.includes(`Commit: [${sha}]`)) {
|
||||
core.info(`${sha} is already published.`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
const runs = [];
|
||||
const artifacts = {};
|
||||
for (const workflow of workflows) {
|
||||
const { data } = await github.rest.actions.listWorkflowRuns({
|
||||
...repository,
|
||||
workflow_id: `${workflow}.yml`,
|
||||
branch: 'main',
|
||||
head_sha: sha,
|
||||
event: 'workflow_dispatch',
|
||||
status: 'success',
|
||||
per_page: 1,
|
||||
});
|
||||
const run = data.workflow_runs[0];
|
||||
if (!run || run.head_sha !== sha || run.head_branch !== 'main' ||
|
||||
run.event !== 'workflow_dispatch' || run.conclusion !== 'success') {
|
||||
core.info(`Waiting for a successful full ${workflow} build at ${sha}.`);
|
||||
return null;
|
||||
}
|
||||
runs.push(run);
|
||||
if (workflow === 'test') continue;
|
||||
const uploaded = await github.paginate(github.rest.actions.listWorkflowRunArtifacts, {
|
||||
...repository, run_id: run.id, per_page: 100,
|
||||
});
|
||||
// Reruns can retain same-name artifacts from earlier attempts. Select the
|
||||
// newest ID, matching download-artifact's latest:true lookup.
|
||||
const byName = new Map();
|
||||
for (const artifact of uploaded) {
|
||||
if (!/^(easytier-[\w.-]+|Easytier-Magisk)$/.test(artifact.name)) continue;
|
||||
if (!byName.has(artifact.name) || artifact.id > byName.get(artifact.name).id) {
|
||||
byName.set(artifact.name, artifact);
|
||||
}
|
||||
}
|
||||
const files = [...byName.values()];
|
||||
if (!files.length || files.some(artifact => artifact.expired)) {
|
||||
throw new Error(`${workflow}: build artifacts are missing or expired.`);
|
||||
}
|
||||
artifacts[workflow] = files.map(artifact => ({ id: artifact.id, name: artifact.name }));
|
||||
}
|
||||
|
||||
// Version releases may be published by a maintainer from a bot-created draft,
|
||||
// and may themselves be prereleases. Neither author nor /releases/latest
|
||||
// identifies the last manually published version reliably.
|
||||
const baseline = releases.filter(item => !item.draft && /^v\d+\.\d+\.\d+(?:[-+].*)?$/.test(item.tag_name))
|
||||
.sort((a, b) => Date.parse(b.published_at) - Date.parse(a.published_at))[0];
|
||||
if (!baseline) throw new Error('No published version release found for the nightly changelog.');
|
||||
const commits = await github.paginate(github.rest.repos.compareCommitsWithBasehead, {
|
||||
...repository, basehead: `${baseline.tag_name}...${sha}`, per_page: 100,
|
||||
}, response => response.data.commits);
|
||||
const { data: config } = await github.rest.repos.getContent({
|
||||
...repository, path: 'easytier-gui/src-tauri/tauri.conf.json', ref: sha,
|
||||
});
|
||||
const { version } = JSON.parse(Buffer.from(config.content, 'base64').toString('utf8'));
|
||||
if (!/^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$/.test(version)) {
|
||||
throw new Error('The build must declare a valid release version.');
|
||||
}
|
||||
|
||||
return {
|
||||
sha,
|
||||
version: `v${version}`,
|
||||
release_id: release?.id,
|
||||
runs: Object.fromEntries(workflows.map((workflow, index) => [workflow, runs[index].id])),
|
||||
artifacts,
|
||||
body: releaseNotes(repository, baseline, sha, commits, runs),
|
||||
};
|
||||
}
|
||||
|
||||
async function packageAssets({ build, source, directory }) {
|
||||
directory = path.resolve(directory);
|
||||
await fs.mkdir(directory);
|
||||
const assets = [];
|
||||
for (const [workflow, artifacts] of Object.entries(build.artifacts)) {
|
||||
for (const artifact of artifacts) {
|
||||
const artifactPath = path.resolve(source, artifact.name);
|
||||
const files = [];
|
||||
for (const file of await fs.readdir(artifactPath, { recursive: true })) {
|
||||
if ((await fs.stat(path.join(artifactPath, file))).isFile()) files.push(file);
|
||||
}
|
||||
if (!files.length) throw new Error(`Empty artifact: ${artifact.name}`);
|
||||
if (workflow === 'core') {
|
||||
const name = `${artifact.name}-${build.version}.zip`;
|
||||
await exec('chmod', ['-R', '755', artifactPath]);
|
||||
// Match release.yml: only Magisk has its files at the ZIP root.
|
||||
const magisk = artifact.name === 'Easytier-Magisk';
|
||||
await exec('zip', ['-qr', path.join(directory, name), magisk ? '.' : artifact.name], {
|
||||
cwd: magisk ? artifactPath : path.resolve(source),
|
||||
});
|
||||
assets.push(name);
|
||||
} else {
|
||||
for (const file of files) {
|
||||
const name = path.basename(file);
|
||||
if (assets.includes(name)) throw new Error(`Duplicate release asset: ${name}`);
|
||||
await fs.copyFile(path.join(artifactPath, file), path.join(directory, name));
|
||||
assets.push(name);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
const checksums = [];
|
||||
for (const name of assets.sort()) {
|
||||
const data = await fs.readFile(path.join(directory, name));
|
||||
if (!data.length) throw new Error(`Empty asset: ${name}`);
|
||||
checksums.push(`${createHash('sha256').update(data).digest('hex')} ${name}\n`);
|
||||
}
|
||||
await fs.writeFile(path.join(directory, 'SHA256SUMS'), checksums.join(''));
|
||||
build.assets = [...assets, 'SHA256SUMS'].sort();
|
||||
}
|
||||
|
||||
async function publish({ github, context, core, build, directory }) {
|
||||
const repository = context.repo;
|
||||
const names = build.assets;
|
||||
const files = (await fs.readdir(directory)).sort();
|
||||
if (!names?.length || JSON.stringify(files) !== JSON.stringify(names)) {
|
||||
throw new Error('Packaged files do not match the selected nightly artifacts.');
|
||||
}
|
||||
for (const name of files) {
|
||||
if (!(await fs.stat(path.join(directory, name))).size) throw new Error(`Empty asset: ${name}`);
|
||||
}
|
||||
|
||||
const releases = await github.paginate(github.rest.repos.listReleases, {
|
||||
...repository, per_page: 100,
|
||||
});
|
||||
let release = releases.find(item => item.tag_name === 'nightly');
|
||||
if (release?.immutable) throw new Error('The nightly release must allow asset and tag updates.');
|
||||
if ((release?.id || undefined) !== build.release_id) {
|
||||
throw new Error('The nightly release changed during preparation; run the workflow again.');
|
||||
}
|
||||
if (!release) {
|
||||
const { data } = await github.rest.repos.createRelease({
|
||||
...repository, tag_name: 'nightly', target_commitish: build.sha,
|
||||
name: 'Nightly', draft: true, prerelease: true, make_latest: 'false',
|
||||
});
|
||||
release = data;
|
||||
}
|
||||
|
||||
const previousAssets = await github.paginate(github.rest.repos.listReleaseAssets, {
|
||||
...repository, release_id: release.id, per_page: 100,
|
||||
});
|
||||
for (const name of files) {
|
||||
const previous = previousAssets.find(asset => asset.name === name);
|
||||
if (previous) {
|
||||
await github.rest.repos.deleteReleaseAsset({ ...repository, asset_id: previous.id });
|
||||
}
|
||||
await github.rest.repos.uploadReleaseAsset({
|
||||
...repository, release_id: release.id, name,
|
||||
data: await fs.readFile(path.join(directory, name)),
|
||||
headers: { 'content-type': name.endsWith('.zip') ? 'application/zip' : 'application/octet-stream' },
|
||||
});
|
||||
}
|
||||
// Remove retired platforms only after all replacement assets are uploaded.
|
||||
for (const asset of previousAssets.filter(asset => !files.includes(asset.name))) {
|
||||
await github.rest.repos.deleteReleaseAsset({ ...repository, asset_id: asset.id });
|
||||
}
|
||||
|
||||
// A new draft does not necessarily have a tag yet.
|
||||
try {
|
||||
await github.rest.git.getRef({ ...repository, ref: 'tags/nightly' });
|
||||
await github.rest.git.updateRef({ ...repository, ref: 'tags/nightly', sha: build.sha, force: true });
|
||||
} catch (error) {
|
||||
if (error.status !== 404) throw error;
|
||||
await github.rest.git.createRef({ ...repository, ref: 'refs/tags/nightly', sha: build.sha });
|
||||
}
|
||||
await github.rest.repos.updateRelease({
|
||||
...repository, release_id: release.id, name: 'Nightly', body: build.body,
|
||||
target_commitish: build.sha,
|
||||
draft: false, prerelease: true, make_latest: 'false',
|
||||
});
|
||||
core.info(`Updated nightly at ${build.sha}: ${release.html_url}`);
|
||||
}
|
||||
|
||||
module.exports = { prepare, packageAssets, publish, releaseNotes };
|
||||
@@ -0,0 +1,398 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs/promises');
|
||||
const os = require('node:os');
|
||||
const path = require('node:path');
|
||||
const { execFileSync } = require('node:child_process');
|
||||
const { createHash } = require('node:crypto');
|
||||
const { test } = require('node:test');
|
||||
const { prepare, packageAssets, publish, releaseNotes } = require('./nightly-release.js');
|
||||
|
||||
const repository = { owner: 'EasyTier', repo: 'EasyTier' };
|
||||
const core = { info() {} };
|
||||
const notFound = () => Object.assign(new Error('Not found'), { status: 404 });
|
||||
|
||||
function fixture() {
|
||||
const calls = [];
|
||||
const state = {
|
||||
release: null,
|
||||
ancestry: 'ahead',
|
||||
publishedComparison: 'ahead',
|
||||
tagExists: false,
|
||||
version: '2.7.0',
|
||||
releases: [
|
||||
{ tag_name: 'v2.6.4', published_at: '2026-05-12', html_url: 'stable' },
|
||||
{ tag_name: 'v2.7.0', published_at: '2026-10-06', prerelease: true, html_url: 'version' },
|
||||
{ tag_name: 'v2.8.0', published_at: '2026-10-12', draft: true },
|
||||
],
|
||||
runs: Object.fromEntries(['core', 'gui', 'mobile', 'test'].map((name, index) => [name, {
|
||||
id: index + 1, name, head_sha: 'built', head_branch: 'main',
|
||||
event: 'workflow_dispatch', conclusion: 'success', html_url: `run/${index + 1}`,
|
||||
}])),
|
||||
artifacts: {
|
||||
1: [{ id: 10, name: 'easytier-linux-x86_64' }, { id: 11, name: 'Easytier-Magisk' },
|
||||
{ id: 12, name: 'signpath-unsigned-windows-x86_64' }],
|
||||
2: [{ id: 20, name: 'easytier-gui-windows-x86_64' }],
|
||||
3: [{ id: 30, name: 'easytier-mobile-android-aarch64' }],
|
||||
},
|
||||
assets: [],
|
||||
};
|
||||
const repos = {
|
||||
async getBranch() { return { data: { commit: { sha: 'main-head' } } }; },
|
||||
async getContent(request) {
|
||||
calls.push(['version', request.ref]);
|
||||
assert.equal(request.path, 'easytier-gui/src-tauri/tauri.conf.json');
|
||||
return { data: { content: Buffer.from(JSON.stringify({ version: state.version })).toString('base64') } };
|
||||
},
|
||||
async compareCommitsWithBasehead(request) {
|
||||
calls.push(['compare', request.basehead]);
|
||||
return { data: { status: request.basehead.startsWith('nightly...') ? state.publishedComparison : state.ancestry } };
|
||||
},
|
||||
listReleases() {},
|
||||
listReleaseAssets() {},
|
||||
async createRelease(request) {
|
||||
calls.push(['create', request]);
|
||||
state.release = { id: 100, draft: true, html_url: 'nightly' };
|
||||
return { data: state.release };
|
||||
},
|
||||
async deleteReleaseAsset(request) { calls.push(['delete', request.asset_id]); },
|
||||
async uploadReleaseAsset(request) {
|
||||
calls.push(['upload', request.name]);
|
||||
if (state.uploadFails) throw new Error('Upload failed');
|
||||
},
|
||||
async updateRelease(request) {
|
||||
calls.push(['update', request]);
|
||||
Object.assign(state.release, request);
|
||||
},
|
||||
};
|
||||
const actions = {
|
||||
async listWorkflowRuns(request) {
|
||||
assert.equal(request.branch, 'main');
|
||||
assert.equal(request.event, 'workflow_dispatch');
|
||||
assert.equal(request.status, 'success');
|
||||
const run = state.runs[request.workflow_id.replace('.yml', '')];
|
||||
return { data: { workflow_runs: run ? [run] : [] } };
|
||||
},
|
||||
listWorkflowRunArtifacts() {},
|
||||
};
|
||||
const github = {
|
||||
rest: {
|
||||
repos, actions,
|
||||
git: {
|
||||
async getRef() { if (!state.tagExists) throw notFound(); },
|
||||
async createRef(request) { calls.push(['createTag', request.sha]); state.tagExists = true; },
|
||||
async updateRef(request) { calls.push(['updateTag', request.sha]); },
|
||||
},
|
||||
},
|
||||
async paginate(method, request, map) {
|
||||
if (method === repos.listReleases) {
|
||||
return state.releases.concat(state.release ? [{ tag_name: 'nightly', ...state.release }] : []);
|
||||
}
|
||||
if (method === repos.listReleaseAssets) return state.assets;
|
||||
if (method === actions.listWorkflowRunArtifacts) return state.artifacts[request.run_id];
|
||||
if (method === repos.compareCommitsWithBasehead) {
|
||||
calls.push(['changelog', request.basehead]);
|
||||
assert.equal(request.per_page, 100);
|
||||
// Exercise the response mapper used to collect every comparison page.
|
||||
return map({ data: { commits: [commit('old', '2026-10-10T12:00:00Z')] } })
|
||||
.concat(map({ data: { commits: [commit('new', '2026-10-12T12:00:00Z')] } }));
|
||||
}
|
||||
throw new Error('Unexpected API call');
|
||||
},
|
||||
};
|
||||
const context = { repo: repository, payload: { workflow_run: { head_sha: 'built' } } };
|
||||
return { state, calls, github, context, core };
|
||||
}
|
||||
|
||||
function commit(sha, date) {
|
||||
return { sha, html_url: `commit/${sha}`, commit: { committer: { date }, message: `${sha} change\n\nDetails` } };
|
||||
}
|
||||
|
||||
test('nightly selects one main commit and uses the last published version, including prereleases', async () => {
|
||||
const f = fixture();
|
||||
f.state.release = { id: 99, published_at: '2026-10-13' };
|
||||
const build = await prepare(f);
|
||||
assert.equal(build.sha, 'built');
|
||||
assert.equal(build.version, 'v2.7.0');
|
||||
assert.ok(f.calls.some(call => call[0] === 'version' && call[1] === 'built'));
|
||||
assert.deepEqual(build.runs, { core: 1, gui: 2, mobile: 3, test: 4 });
|
||||
assert.deepEqual(build.artifacts.core.map(a => a.id), [10, 11]);
|
||||
assert.ok(f.calls.some(call => call[0] === 'changelog' && call[1] === 'v2.7.0...built'));
|
||||
assert.match(build.body, /Changes since \[v2.7.0\]/);
|
||||
assert.ok(build.body.indexOf('2026-10-12') < build.body.indexOf('2026-10-10'));
|
||||
});
|
||||
|
||||
test('asset versions come from the built commit rather than the changelog baseline', async () => {
|
||||
const f = fixture();
|
||||
f.state.version = '2.8.0-rc.1';
|
||||
assert.equal((await prepare(f)).version, 'v2.8.0-rc.1');
|
||||
f.state.version = '../invalid';
|
||||
await assert.rejects(prepare(f), /valid release version/);
|
||||
});
|
||||
|
||||
test('nightly waits for every component and the full test run', async () => {
|
||||
for (const workflow of ['core', 'gui', 'mobile', 'test']) {
|
||||
const f = fixture();
|
||||
delete f.state.runs[workflow];
|
||||
assert.equal(await prepare(f), null);
|
||||
}
|
||||
});
|
||||
|
||||
test('nightly rejects a mismatched commit, branch, event or failed result', async () => {
|
||||
for (const field of ['head_sha', 'head_branch', 'event', 'conclusion']) {
|
||||
const f = fixture();
|
||||
f.state.runs.gui[field] = 'different';
|
||||
assert.equal(await prepare(f), null);
|
||||
}
|
||||
});
|
||||
|
||||
test('nightly accepts successful failed-job reruns without requiring a new run ID', async () => {
|
||||
const f = fixture();
|
||||
f.state.runs.gui.run_attempt = 2;
|
||||
assert.equal((await prepare(f)).runs.gui, 2);
|
||||
});
|
||||
|
||||
test('nightly refuses missing or expired artifacts before changing a release', async () => {
|
||||
for (const artifacts of [[], [{ id: 20, name: 'easytier-gui-windows-x86_64', expired: true }]]) {
|
||||
const f = fixture();
|
||||
f.state.artifacts[2] = artifacts;
|
||||
await assert.rejects(prepare(f), /missing or expired/);
|
||||
}
|
||||
});
|
||||
|
||||
test('an older completed run cannot replace a newer nightly or a removed main commit', async () => {
|
||||
const f = fixture();
|
||||
f.state.ancestry = 'diverged';
|
||||
assert.equal(await prepare(f), null);
|
||||
f.state.ancestry = 'ahead';
|
||||
f.state.release = { id: 100 };
|
||||
for (const status of ['behind', 'diverged']) {
|
||||
f.state.publishedComparison = status;
|
||||
assert.equal(await prepare(f), null);
|
||||
}
|
||||
});
|
||||
|
||||
test('duplicate completion events skip a fully published commit', async () => {
|
||||
const f = fixture();
|
||||
f.state.release = { id: 100, body: 'Commit: [built](url)' };
|
||||
f.state.publishedComparison = 'identical';
|
||||
assert.equal(await prepare(f), null);
|
||||
});
|
||||
|
||||
test('nightly can resume an initial draft or an interrupted release-note update', async () => {
|
||||
const f = fixture();
|
||||
f.state.release = { id: 100, draft: true };
|
||||
assert.ok(await prepare(f));
|
||||
assert.ok(!f.calls.some(call => call[1] === 'nightly...built'));
|
||||
f.state.release.draft = false;
|
||||
f.state.release.body = 'Commit: [old](url)';
|
||||
f.state.publishedComparison = 'identical';
|
||||
assert.ok(await prepare(f));
|
||||
});
|
||||
|
||||
test('manual publication checks the current main commit', async () => {
|
||||
const f = fixture();
|
||||
f.context.payload = {};
|
||||
for (const run of Object.values(f.state.runs)) run.head_sha = 'main-head';
|
||||
assert.equal((await prepare(f)).sha, 'main-head');
|
||||
});
|
||||
|
||||
test('scheduled publication uses the completed commit even after main advances', async () => {
|
||||
const f = fixture();
|
||||
f.context.payload = {};
|
||||
const build = await prepare({ ...f, sha: 'built' });
|
||||
assert.equal(build.sha, 'built');
|
||||
assert.ok(f.calls.some(call => call[0] === 'compare' && call[1] === 'built...main-head'));
|
||||
assert.deepEqual(build.runs, { core: 1, gui: 2, mobile: 3, test: 4 });
|
||||
});
|
||||
|
||||
test('nightly requires a published version baseline and a mutable release', async () => {
|
||||
const f = fixture();
|
||||
f.state.releases = [];
|
||||
await assert.rejects(prepare(f), /No published version/);
|
||||
f.state.release = { immutable: true };
|
||||
await assert.rejects(prepare(f), /must allow asset and tag updates/);
|
||||
});
|
||||
|
||||
test('notes date every entry in UTC and sort by timestamp, not input order', () => {
|
||||
const notes = releaseNotes(repository, { tag_name: 'v2.7.0', html_url: 'version' }, 'built', [
|
||||
commit('middle', '2026-10-12T15:00:00Z'),
|
||||
commit('oldest', '2026-10-12T01:00:00+08:00'),
|
||||
commit('newest', '2026-10-12T19:00:00Z'),
|
||||
], []);
|
||||
assert.match(notes, /2026-10-11 — oldest change/);
|
||||
assert.ok(notes.indexOf('newest change') < notes.indexOf('middle change'));
|
||||
assert.ok(notes.indexOf('middle change') < notes.indexOf('oldest change'));
|
||||
assert.ok(!notes.includes('Details'));
|
||||
});
|
||||
|
||||
async function packageFiles(t, f) {
|
||||
const build = await prepare(f);
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'easytier-nightly-test-'));
|
||||
t.after(() => fs.rm(root, { recursive: true, force: true }));
|
||||
const source = path.join(root, 'artifacts');
|
||||
const directory = path.join(root, 'assets');
|
||||
const contents = {
|
||||
'easytier-linux-x86_64': ['easytier-core'],
|
||||
'Easytier-Magisk': ['module.prop', 'bin/easytier-core'],
|
||||
'easytier-gui-windows-x86_64': ['easytier-gui_2.7.0_x64-setup.exe'],
|
||||
'easytier-mobile-android-aarch64': ['app-arm64-release.apk'],
|
||||
...f.files,
|
||||
};
|
||||
for (const artifact of Object.values(build.artifacts).flat()) {
|
||||
for (const name of contents[artifact.name]) {
|
||||
const file = path.join(source, artifact.name, name);
|
||||
await fs.mkdir(path.dirname(file), { recursive: true });
|
||||
await fs.writeFile(file, `contents of ${name}`, { mode: 0o644 });
|
||||
}
|
||||
}
|
||||
await packageAssets({ build, source, directory });
|
||||
return { ...f, build, source, directory };
|
||||
}
|
||||
|
||||
test('nightly exposes native installers and matches version-release ZIP names and layout', async t => {
|
||||
const f = fixture();
|
||||
f.state.artifacts[1].push({ id: 14, name: 'easytier-web-dashboard' });
|
||||
f.state.artifacts[2].push(
|
||||
{ id: 21, name: 'easytier-gui-macos-aarch64' },
|
||||
{ id: 22, name: 'easytier-gui-linux-x86_64' },
|
||||
);
|
||||
f.files = {
|
||||
'easytier-web-dashboard': ['index.html', 'assets/app.js'],
|
||||
'easytier-gui-macos-aarch64': ['easytier-gui_2.7.0_aarch64.dmg'],
|
||||
'easytier-gui-linux-x86_64': [
|
||||
'easytier-gui_2.7.0_amd64.deb',
|
||||
'easytier-gui-2.7.0-1.x86_64.rpm',
|
||||
'easytier-gui_2.7.0_amd64.AppImage',
|
||||
],
|
||||
};
|
||||
const { build, directory } = await packageFiles(t, f);
|
||||
assert.deepEqual(build.assets, [
|
||||
'Easytier-Magisk-v2.7.0.zip',
|
||||
'SHA256SUMS',
|
||||
'app-arm64-release.apk',
|
||||
'easytier-gui-2.7.0-1.x86_64.rpm',
|
||||
'easytier-gui_2.7.0_aarch64.dmg',
|
||||
'easytier-gui_2.7.0_amd64.AppImage',
|
||||
'easytier-gui_2.7.0_amd64.deb',
|
||||
'easytier-gui_2.7.0_x64-setup.exe',
|
||||
'easytier-linux-x86_64-v2.7.0.zip',
|
||||
'easytier-web-dashboard-v2.7.0.zip',
|
||||
]);
|
||||
for (const [name, entry] of [
|
||||
['easytier-linux-x86_64', 'easytier-linux-x86_64/easytier-core'],
|
||||
['easytier-web-dashboard', 'easytier-web-dashboard/assets/app.js'],
|
||||
['Easytier-Magisk', 'bin/easytier-core'],
|
||||
]) {
|
||||
const archive = path.join(directory, `${name}-v2.7.0.zip`);
|
||||
const files = execFileSync('unzip', ['-Z1', archive], { encoding: 'utf8' }).trim().split('\n');
|
||||
assert.ok(files.includes(entry));
|
||||
assert.ok(!files.some(file => file.startsWith('Easytier-Magisk/')));
|
||||
assert.match(execFileSync('zipinfo', ['-l', archive], { encoding: 'utf8' }), /-rwxr-xr-x/);
|
||||
}
|
||||
assert.equal(await fs.readFile(path.join(directory, 'app-arm64-release.apk'), 'utf8'),
|
||||
'contents of app-arm64-release.apk');
|
||||
const sums = await fs.readFile(path.join(directory, 'SHA256SUMS'), 'utf8');
|
||||
assert.equal(sums.trim().split('\n').length, build.assets.length - 1);
|
||||
for (const line of sums.trim().split('\n')) {
|
||||
const [checksum, name] = line.split(' ');
|
||||
const data = await fs.readFile(path.join(directory, name));
|
||||
assert.equal(checksum, createHash('sha256').update(data).digest('hex'));
|
||||
}
|
||||
});
|
||||
|
||||
test('packaging refuses missing, empty or duplicate installer artifacts', async t => {
|
||||
const f = fixture();
|
||||
const args = await packageFiles(t, f);
|
||||
const artifactPath = path.join(args.source, 'easytier-mobile-android-aarch64');
|
||||
await fs.rm(args.directory, { recursive: true });
|
||||
await fs.rm(artifactPath, { recursive: true });
|
||||
await assert.rejects(packageAssets(args), /ENOENT/);
|
||||
await fs.rm(args.directory, { recursive: true });
|
||||
await fs.mkdir(artifactPath);
|
||||
await assert.rejects(packageAssets(args), /Empty artifact/);
|
||||
await fs.rm(args.directory, { recursive: true });
|
||||
await fs.writeFile(path.join(artifactPath, 'easytier-gui_2.7.0_x64-setup.exe'), 'duplicate');
|
||||
await assert.rejects(packageAssets(args), /Duplicate release asset/);
|
||||
});
|
||||
|
||||
test('reruns publish only the newest artifact for each name, ignoring expired older attempts', async t => {
|
||||
const f = fixture();
|
||||
f.state.artifacts[1].push(
|
||||
{ id: 13, name: 'easytier-linux-x86_64' },
|
||||
{ id: 9, name: 'easytier-linux-x86_64', expired: true },
|
||||
);
|
||||
const args = await packageFiles(t, f);
|
||||
assert.deepEqual(args.build.artifacts.core.map(a => a.id), [13, 11]);
|
||||
await publish(args);
|
||||
assert.equal(f.calls.filter(call => call[0] === 'upload' && call[1] === 'easytier-linux-x86_64-v2.7.0.zip').length, 1);
|
||||
});
|
||||
|
||||
test('first publication creates one draft, uploads assets and then publishes a prerelease', async t => {
|
||||
const f = fixture();
|
||||
await publish(await packageFiles(t, f));
|
||||
const creation = f.calls.find(call => call[0] === 'create')[1];
|
||||
assert.equal(creation.draft, true);
|
||||
assert.equal(creation.prerelease, true);
|
||||
assert.equal(creation.make_latest, 'false');
|
||||
const update = f.calls.at(-1);
|
||||
assert.equal(update[0], 'update');
|
||||
assert.equal(update[1].release_id, 100);
|
||||
assert.equal(update[1].draft, false);
|
||||
assert.equal(update[1].make_latest, 'false');
|
||||
assert.ok(f.calls.some(call => call[0] === 'createTag'));
|
||||
});
|
||||
|
||||
test('updates keep the release ID and remove obsolete assets only after uploads finish', async t => {
|
||||
const f = fixture();
|
||||
f.state.release = { id: 99, draft: false, target_commitish: 'first-build' };
|
||||
f.state.tagExists = true;
|
||||
f.state.assets = [
|
||||
{ id: 50, name: 'easytier-linux-x86_64-v2.7.0.zip' },
|
||||
{ id: 51, name: 'retired-platform.zip' },
|
||||
];
|
||||
await publish(await packageFiles(t, f));
|
||||
assert.ok(!f.calls.some(call => call[0] === 'create' || call[0] === 'createTag'));
|
||||
assert.equal(f.calls.at(-1)[1].release_id, 99);
|
||||
assert.equal(f.state.release.target_commitish, 'built');
|
||||
const lastUpload = f.calls.findLastIndex(call => call[0] === 'upload');
|
||||
assert.ok(f.calls.findIndex(call => call[0] === 'delete' && call[1] === 51) > lastUpload);
|
||||
});
|
||||
|
||||
test('incomplete packaging cannot mutate the published release', async t => {
|
||||
const f = fixture();
|
||||
const args = await packageFiles(t, f);
|
||||
await fs.unlink(path.join(args.directory, 'Easytier-Magisk-v2.7.0.zip'));
|
||||
await assert.rejects(publish(args), /do not match/);
|
||||
assert.ok(!f.calls.some(call => ['create', 'delete', 'upload', 'update'].includes(call[0])));
|
||||
});
|
||||
|
||||
test('a failed upload does not advance the tag, notes or draft publication', async t => {
|
||||
const f = fixture();
|
||||
const args = await packageFiles(t, f);
|
||||
f.state.uploadFails = true;
|
||||
await assert.rejects(publish(args), /Upload failed/);
|
||||
assert.equal(f.state.release.draft, true);
|
||||
assert.ok(!f.calls.some(call => ['createTag', 'updateTag', 'update'].includes(call[0])));
|
||||
});
|
||||
|
||||
test('a retry resumes the initial draft after an upload failure without creating another release', async t => {
|
||||
const f = fixture();
|
||||
const args = await packageFiles(t, f);
|
||||
f.state.uploadFails = true;
|
||||
await assert.rejects(publish(args), /Upload failed/);
|
||||
f.state.uploadFails = false;
|
||||
const retry = await prepare(f);
|
||||
assert.equal(retry.release_id, 100);
|
||||
await fs.rm(args.directory, { recursive: true });
|
||||
await packageAssets({ ...args, build: retry });
|
||||
await publish({ ...args, build: retry });
|
||||
assert.equal(f.calls.filter(call => call[0] === 'create').length, 1);
|
||||
assert.equal(f.state.release.draft, false);
|
||||
});
|
||||
|
||||
test('publication stops if the release was replaced during artifact downloads', async t => {
|
||||
const f = fixture();
|
||||
const args = await packageFiles(t, f);
|
||||
f.state.release = { id: 999 };
|
||||
await assert.rejects(publish(args), /changed during preparation/);
|
||||
});
|
||||
@@ -0,0 +1,25 @@
|
||||
module.exports = async ({ github, context, core }) => {
|
||||
const builds = [
|
||||
['core.yml', process.env.CORE_RUN_ID],
|
||||
['gui.yml', process.env.GUI_RUN_ID],
|
||||
['mobile.yml', process.env.MOBILE_RUN_ID],
|
||||
];
|
||||
let commit;
|
||||
|
||||
for (const [workflow, runId] of builds) {
|
||||
const { data: run } = await github.rest.actions.getWorkflowRun({
|
||||
...context.repo,
|
||||
run_id: Number(runId),
|
||||
});
|
||||
const fullBuild = run.event === 'workflow_dispatch' ||
|
||||
(run.event === 'push' && run.head_branch.startsWith('releases/'));
|
||||
if (run.path !== `.github/workflows/${workflow}` || run.conclusion !== 'success' || !fullBuild) {
|
||||
throw new Error(`${workflow}: run ${runId} must be a successful full build.`);
|
||||
}
|
||||
if (commit && run.head_sha !== commit) {
|
||||
throw new Error('Core, GUI and Mobile must be built from the same commit.');
|
||||
}
|
||||
commit = run.head_sha;
|
||||
core.info(`${workflow}: validated full build ${runId} at ${commit}.`);
|
||||
}
|
||||
};
|
||||
@@ -42,4 +42,7 @@ EXPOSE 11011/tcp
|
||||
# wss
|
||||
EXPOSE 11012/tcp
|
||||
|
||||
HEALTHCHECK --interval=30s --timeout=10s --start-period=60s --retries=5 \
|
||||
CMD ["/usr/local/bin/easytier-cli", "--rpc-portal", "127.0.0.1:15888", "--output", "json", "node", "info"]
|
||||
|
||||
ENTRYPOINT ["/sbin/tini", "--", "easytier-core"]
|
||||
|
||||
@@ -0,0 +1,216 @@
|
||||
name: Validate BPF veth initialization
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: ["diagnose/bpf-2710"]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
actions: read
|
||||
|
||||
jobs:
|
||||
diagnose:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- name: Wait for the fixed test artifact
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
for attempt in $(seq 1 180); do
|
||||
if gh api repos/EasyTier/EasyTier/actions/runs/38869099758/artifacts --jq '.artifacts[].name' | grep -qx tests; then
|
||||
exit 0
|
||||
fi
|
||||
sleep 5
|
||||
done
|
||||
exit 1
|
||||
- uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: tests
|
||||
run-id: 38869099758
|
||||
github-token: ${{ github.token }}
|
||||
- name: Prepare fixed CI binary
|
||||
run: |
|
||||
mkdir -p diagnostics
|
||||
tar --zstd -xf tests.tar.zst
|
||||
python3 - <<'PY'
|
||||
import pathlib, re
|
||||
binaries = [p for p in pathlib.Path('target/debug/deps').iterdir() if re.fullmatch(r'easytier-[0-9a-f]+', p.name)]
|
||||
assert len(binaries) == 1, binaries
|
||||
pathlib.Path('diagnostics/binary-path').write_text(str(binaries[0].resolve()))
|
||||
PY
|
||||
sudo apt-get update -qq
|
||||
sudo apt-get install -y -qq strace tcpdump
|
||||
uname -a > diagnostics/environment.txt
|
||||
systemctl is-active systemd-udevd systemd-networkd NetworkManager >> diagnostics/environment.txt || true
|
||||
sudo ip -ts -s -d monitor link > diagnostics/links.log &
|
||||
sudo tcpdump -i any -U -w diagnostics/frames.pcap 'tcp port 23456' > diagnostics/tcpdump.log 2>&1 &
|
||||
sudo bash -c 'echo "p:bpf_noop noop_enqueue" >> /sys/kernel/tracing/kprobe_events; echo 1 > /sys/kernel/tracing/events/kprobes/bpf_noop/enable' || true
|
||||
sudo cat /sys/kernel/tracing/trace_pipe > diagnostics/kernel-trace.log &
|
||||
- name: Build send observation hook
|
||||
run: |
|
||||
cat > diagnostics/send-observer.c <<'EOF'
|
||||
#define _GNU_SOURCE
|
||||
#include <dlfcn.h>
|
||||
#include <stdio.h>
|
||||
#include <sys/socket.h>
|
||||
#include <netpacket/packet.h>
|
||||
#include <net/if.h>
|
||||
#include <errno.h>
|
||||
#include <time.h>
|
||||
static ssize_t (*real_sendto)(int,const void*,size_t,int,const struct sockaddr*,socklen_t);
|
||||
__attribute__((constructor)) static void initialize(void) {
|
||||
real_sendto = dlsym(RTLD_NEXT, "sendto");
|
||||
}
|
||||
static void read_value(const char *name, const char *key, char *out) {
|
||||
char path[256];
|
||||
snprintf(path, sizeof(path), "/sys/class/net/%s/%s", name, key);
|
||||
FILE *f = fopen(path, "r");
|
||||
if (f) { if (!fgets(out, 64, f)) out[0] = 0; fclose(f); }
|
||||
}
|
||||
ssize_t sendto(int fd, const void *buf, size_t len, int flags,
|
||||
const struct sockaddr *addr, socklen_t addrlen) {
|
||||
ssize_t ret = real_sendto(fd, buf, len, flags, addr, addrlen);
|
||||
int saved_errno = errno;
|
||||
if (addr && addr->sa_family == AF_PACKET) {
|
||||
const struct sockaddr_ll *ll = (const struct sockaddr_ll *)addr;
|
||||
char name[IF_NAMESIZE] = {0}, drops[64] = {0}, state[64] = {0}, carrier[64] = {0};
|
||||
char packets[64] = {0};
|
||||
struct timespec timestamp;
|
||||
clock_gettime(CLOCK_REALTIME, ×tamp);
|
||||
if_indextoname(ll->sll_ifindex, name);
|
||||
read_value(name, "statistics/tx_dropped", drops);
|
||||
read_value(name, "operstate", state);
|
||||
read_value(name, "carrier", carrier);
|
||||
read_value(name, "statistics/tx_packets", packets);
|
||||
fprintf(stderr, "SEND_OBSERVE time=%ld.%09ld dev=%s len=%zu ret=%zd errno=%d tx_dropped=%s operstate=%s carrier=%s tx_packets=%s\n", timestamp.tv_sec, timestamp.tv_nsec, name, len, ret, saved_errno, drops, state, carrier, packets);
|
||||
}
|
||||
errno = saved_errno;
|
||||
return ret;
|
||||
}
|
||||
EOF
|
||||
gcc -shared -fPIC -O2 diagnostics/send-observer.c -ldl -o diagnostics/send-observer.so
|
||||
- name: Repeat fixed test with post-send observation
|
||||
run: |
|
||||
sudo -E python3 -u - <<'PY'
|
||||
import os, pathlib, subprocess, time
|
||||
root = pathlib.Path.cwd()
|
||||
env = os.environ.copy()
|
||||
env['EASYTIER_LINUX_BPF_INTEGRATION'] = 'required'
|
||||
env['LD_PRELOAD'] = str(root / 'diagnostics/send-observer.so')
|
||||
binary = (root / 'diagnostics/binary-path').read_text()
|
||||
case = 'socket::fake_tcp::netfilter::linux_bpf::tests::linux_bpf_tun_sends_ipv4_frame_to_ethernet_interface'
|
||||
start = time.monotonic()
|
||||
failures = 0
|
||||
timeouts = 0
|
||||
for i in range(1, 21):
|
||||
result = subprocess.run([binary, case, '--exact', '--nocapture'], env=env, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True)
|
||||
if i <= 3 or result.returncode:
|
||||
(root / f'diagnostics/attempt-{i}.log').write_text(result.stdout)
|
||||
if result.returncode:
|
||||
failures += 1
|
||||
print('REPRODUCED', i, 'elapsed', time.monotonic() - start, flush=True)
|
||||
print(result.stdout, flush=True)
|
||||
if 'timed out waiting for Ethernet frame' in result.stdout:
|
||||
timeouts += 1
|
||||
elif 'assertion `left == right` failed' not in result.stdout:
|
||||
break
|
||||
if timeouts == 3:
|
||||
break
|
||||
if i % 100 == 0:
|
||||
print('PASS', i, 'elapsed', time.monotonic() - start, flush=True)
|
||||
else:
|
||||
print('COMPLETED 20 ATTEMPTS', flush=True)
|
||||
print('TOTAL FAILURES', failures, 'TIMEOUTS', timeouts, flush=True)
|
||||
assert failures == 0
|
||||
PY
|
||||
- name: Repeat fixed binary without observation hooks
|
||||
run: |
|
||||
sudo -E python3 -u - <<'PY'
|
||||
import os, pathlib, subprocess, time
|
||||
root = pathlib.Path.cwd()
|
||||
env = os.environ.copy()
|
||||
env['EASYTIER_LINUX_BPF_INTEGRATION'] = 'required'
|
||||
binary = (root / 'diagnostics/binary-path').read_text()
|
||||
case = 'socket::fake_tcp::netfilter::linux_bpf::tests::linux_bpf_tun_sends_ipv4_frame_to_ethernet_interface'
|
||||
start = time.monotonic()
|
||||
failures = 0
|
||||
for i in range(1, 3001):
|
||||
result = subprocess.run([binary, case, '--exact', '--nocapture'], env=env, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True)
|
||||
if result.returncode:
|
||||
failures += 1
|
||||
(root / f'diagnostics/baseline-{i}.log').write_text(result.stdout)
|
||||
if 'timed out waiting for Ethernet frame' in result.stdout:
|
||||
print('ORIGINAL TIMEOUT', i, 'elapsed', time.monotonic() - start, flush=True)
|
||||
print(result.stdout, flush=True)
|
||||
break
|
||||
if 'assertion `left == right` failed' not in result.stdout:
|
||||
print('UNEXPECTED', i, result.stdout, flush=True)
|
||||
break
|
||||
if i % 500 == 0:
|
||||
print('BASELINE', i, 'failures', failures, 'elapsed', time.monotonic() - start, flush=True)
|
||||
else:
|
||||
print('COMPLETED 3000 FIXED ATTEMPTS', flush=True)
|
||||
assert failures == 0
|
||||
PY
|
||||
- name: Validate fixed fixture under worker scheduling pressure
|
||||
run: |
|
||||
cat > diagnostics/cpu-pressure.c <<'EOF'
|
||||
#define _GNU_SOURCE
|
||||
#include <sched.h>
|
||||
#include <stdlib.h>
|
||||
#include <time.h>
|
||||
#include <stdio.h>
|
||||
int main(int argc, char **argv) {
|
||||
cpu_set_t mask;
|
||||
CPU_ZERO(&mask);
|
||||
CPU_SET(atoi(argv[1]), &mask);
|
||||
if (sched_setaffinity(0, sizeof(mask), &mask)) { perror("affinity"); return 1; }
|
||||
struct sched_param param = {.sched_priority = 10};
|
||||
if (sched_setscheduler(0, SCHED_FIFO, ¶m)) { perror("scheduler"); return 1; }
|
||||
struct timespec start, now;
|
||||
clock_gettime(CLOCK_MONOTONIC, &start);
|
||||
do { clock_gettime(CLOCK_MONOTONIC, &now); } while (now.tv_sec - start.tv_sec < 300);
|
||||
return 0;
|
||||
}
|
||||
EOF
|
||||
gcc -O2 diagnostics/cpu-pressure.c -o diagnostics/cpu-pressure
|
||||
sudo -E python3 -u - <<'PY'
|
||||
import os, pathlib, subprocess, time
|
||||
root = pathlib.Path.cwd()
|
||||
env = os.environ.copy()
|
||||
env['EASYTIER_LINUX_BPF_INTEGRATION'] = 'required'
|
||||
env['LD_PRELOAD'] = str(root / 'diagnostics/send-observer.so')
|
||||
binary = (root / 'diagnostics/binary-path').read_text()
|
||||
case = 'socket::fake_tcp::netfilter::linux_bpf::tests::linux_bpf_tun_sends_ipv4_frame_to_ethernet_interface'
|
||||
hogs = []
|
||||
try:
|
||||
os.sched_setscheduler(0, os.SCHED_FIFO, os.sched_param(50))
|
||||
for cpu in sorted(os.sched_getaffinity(0)):
|
||||
hogs.append(subprocess.Popen([str(root / 'diagnostics/cpu-pressure'), str(cpu)]))
|
||||
failures = 0
|
||||
timeouts = 0
|
||||
for i in range(1, 51):
|
||||
assert all(proc.poll() is None for proc in hogs), 'CPU load ended early'
|
||||
result = subprocess.run(['chrt', '-f', '20', binary, case, '--exact', '--nocapture'], env=env, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, timeout=10)
|
||||
(root / f'diagnostics/pressure-fixed-{i}.log').write_text(result.stdout)
|
||||
failures += bool(result.returncode)
|
||||
timeouts += 'timed out waiting for Ethernet frame' in result.stdout
|
||||
print('PRESSURE fixed', i, result.returncode, result.stdout, flush=True)
|
||||
assert 'tx_dropped=0\n' in result.stdout
|
||||
assert all(proc.poll() is None for proc in hogs), 'CPU load ended early'
|
||||
print('PRESSURE TOTAL fixed', 'failures', failures, 'timeouts', timeouts, flush=True)
|
||||
assert failures == 0
|
||||
finally:
|
||||
for proc in hogs:
|
||||
proc.kill()
|
||||
for proc in hogs:
|
||||
proc.wait()
|
||||
os.sched_setscheduler(0, os.SCHED_OTHER, os.sched_param(0))
|
||||
PY
|
||||
- uses: actions/upload-artifact@v4
|
||||
if: always()
|
||||
with:
|
||||
name: bpf-diagnostics
|
||||
path: diagnostics
|
||||
+171
-28
@@ -1,6 +1,12 @@
|
||||
name: EasyTier Core
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
sign_windows:
|
||||
description: Submit Windows artifacts for SignPath test signing
|
||||
type: boolean
|
||||
default: true
|
||||
push:
|
||||
branches: [ "develop", "main", "releases/**" ]
|
||||
pull_request:
|
||||
@@ -8,7 +14,8 @@ on:
|
||||
types: [ opened, synchronize, reopened, ready_for_review ]
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
|
||||
# A new PR or branch push must not cancel a full build.
|
||||
group: ${{ github.workflow }}-${{ (github.event_name == 'workflow_dispatch' || startsWith(github.ref_name, 'releases/')) && 'full' || 'check' }}-${{ github.event.pull_request.number || github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
env:
|
||||
@@ -21,35 +28,115 @@ defaults:
|
||||
|
||||
jobs:
|
||||
pre_job:
|
||||
# continue-on-error: true # Uncomment once integration is finished
|
||||
runs-on: ubuntu-latest
|
||||
if: github.event_name != 'pull_request' || !github.event.pull_request.draft
|
||||
# Map a step output to a job output
|
||||
env:
|
||||
RELEVANT_PATHS: >-
|
||||
["Cargo.toml", "Cargo.lock", "rust-toolchain.toml", ".cargo/**",
|
||||
"pnpm-lock.yaml", "pnpm-workspace.yaml", "package.json", "easytier/**",
|
||||
"easytier-core/**", "easytier-proto/**", "easytier-rpc-build/**",
|
||||
"easytier-web/**", "easytier-contrib/easytier-magisk/**",
|
||||
".github/workflows/core.yml", ".github/actions/**"]
|
||||
FRONTEND_PATHS: >-
|
||||
["easytier-web/frontend/**", "easytier-web/frontend-lib/**",
|
||||
"easytier-web/config-generator/**", "easytier-proto/proto/**",
|
||||
"easytier-core/src/config/**", "easytier-core/src/lib.rs",
|
||||
"pnpm-lock.yaml", "pnpm-workspace.yaml", "package.json",
|
||||
"Cargo.toml", "Cargo.lock", "rust-toolchain.toml", ".cargo/**",
|
||||
"easytier-core/Cargo.toml", "easytier-proto/Cargo.toml",
|
||||
".github/workflows/core.yml", ".github/actions/prepare-pnpm/**"]
|
||||
outputs:
|
||||
# do not skip push on branch starts with releases/
|
||||
should_skip: ${{ steps.skip_check.outputs.should_skip == 'true' && !startsWith(github.ref_name, 'releases/') }}
|
||||
full_build: ${{ github.event_name == 'workflow_dispatch' || startsWith(github.ref_name, 'releases/') }}
|
||||
frontend_changed: ${{ steps.path_filter.outputs.frontend }}
|
||||
# Release builds must run even if they only change release metadata.
|
||||
should_skip: >-
|
||||
${{
|
||||
(
|
||||
steps.skip_check.outputs.should_skip == 'true' ||
|
||||
(
|
||||
github.event_name == 'pull_request' &&
|
||||
steps.path_filter.outputs.relevant != 'true'
|
||||
)
|
||||
) &&
|
||||
github.event_name != 'workflow_dispatch' &&
|
||||
!startsWith(github.ref_name, 'releases/')
|
||||
}}
|
||||
steps:
|
||||
- id: skip_check
|
||||
if: github.event_name != 'workflow_dispatch' && !startsWith(github.ref_name, 'releases/')
|
||||
uses: fkirc/skip-duplicate-actions@v5
|
||||
with:
|
||||
# All of these options are optional, so you can remove them if you are happy with the defaults
|
||||
concurrent_skipping: 'same_content_newer'
|
||||
skip_after_successful_duplicate: 'true'
|
||||
cancel_others: 'true'
|
||||
paths: '["Cargo.toml", "Cargo.lock", "easytier/**", ".github/workflows/core.yml", ".github/actions/**", "easytier-web/**"]'
|
||||
paths: ${{ env.RELEVANT_PATHS }}
|
||||
|
||||
- id: path_filter
|
||||
if: >-
|
||||
github.event_name == 'pull_request' &&
|
||||
steps.skip_check.outputs.should_skip != 'true'
|
||||
uses: dorny/paths-filter@v4
|
||||
with:
|
||||
filters: |
|
||||
relevant: ${{ env.RELEVANT_PATHS }}
|
||||
frontend: ${{ env.FRONTEND_PATHS }}
|
||||
|
||||
check:
|
||||
name: Check (${{ matrix.TARGET }})
|
||||
needs: pre_job
|
||||
if: needs.pre_job.outputs.should_skip != 'true' && needs.pre_job.outputs.full_build != 'true'
|
||||
strategy:
|
||||
fail-fast: true
|
||||
matrix:
|
||||
include:
|
||||
- TARGET: x86_64-unknown-linux-musl
|
||||
OS: ubuntu-24.04
|
||||
FEATURES: jemalloc
|
||||
- TARGET: aarch64-apple-darwin
|
||||
OS: macos-latest
|
||||
FEATURES: jemalloc
|
||||
- TARGET: x86_64-pc-windows-msvc
|
||||
OS: windows-latest
|
||||
FEATURES: mimalloc
|
||||
runs-on: ${{ matrix.OS }}
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
|
||||
- uses: ./.github/actions/prepare-build
|
||||
with:
|
||||
target: ${{ matrix.TARGET }}
|
||||
gui: false
|
||||
pnpm: false
|
||||
token: ${{ github.token }}
|
||||
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
shared-key: core-check-${{ matrix.TARGET }}
|
||||
save-if: ${{ github.event_name != 'pull_request' }}
|
||||
|
||||
- name: Check Core and Web
|
||||
run: cargo check --locked --all-targets --target ${{ matrix.TARGET }} --features ${{ matrix.FEATURES }}
|
||||
|
||||
build_web:
|
||||
runs-on: ubuntu-latest
|
||||
needs: pre_job
|
||||
if: needs.pre_job.outputs.should_skip != 'true'
|
||||
if: >-
|
||||
needs.pre_job.outputs.should_skip != 'true' &&
|
||||
(
|
||||
needs.pre_job.outputs.full_build == 'true' ||
|
||||
github.event_name != 'pull_request' ||
|
||||
needs.pre_job.outputs.frontend_changed == 'true'
|
||||
)
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
|
||||
- name: Setup Frontend Environment
|
||||
uses: ./.github/actions/prepare-pnpm
|
||||
with:
|
||||
token: ${{ github.token }}
|
||||
build-filter: './easytier-web/*'
|
||||
|
||||
- name: Archive artifact
|
||||
if: needs.pre_job.outputs.full_build == 'true'
|
||||
uses: actions/upload-artifact@v5
|
||||
with:
|
||||
name: easytier-web-dashboard
|
||||
@@ -57,7 +144,7 @@ jobs:
|
||||
easytier-web/frontend/dist/*
|
||||
build:
|
||||
strategy:
|
||||
fail-fast: true
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- TARGET: x86_64-unknown-linux-musl
|
||||
@@ -125,7 +212,7 @@ jobs:
|
||||
needs:
|
||||
- pre_job
|
||||
- build_web
|
||||
if: needs.pre_job.outputs.should_skip != 'true'
|
||||
if: needs.pre_job.outputs.should_skip != 'true' && needs.pre_job.outputs.full_build == 'true'
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
|
||||
@@ -143,8 +230,8 @@ jobs:
|
||||
uses: ./.github/actions/prepare-build
|
||||
with:
|
||||
target: ${{ matrix.TARGET }}
|
||||
gui: true
|
||||
pnpm: true
|
||||
gui: false
|
||||
pnpm: false
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
@@ -152,16 +239,22 @@ jobs:
|
||||
# The prefix cache key, this can be changed to start a new cache manually.
|
||||
# default: "v0-rust"
|
||||
prefix-key: ""
|
||||
shared-key: "core-registry"
|
||||
cache-targets: "false"
|
||||
shared-key: core-release-${{ matrix.TARGET }}
|
||||
cache-targets: "true"
|
||||
|
||||
- uses: mlugg/setup-zig@v2
|
||||
if: ${{ contains(matrix.OS, 'ubuntu') }}
|
||||
with:
|
||||
version: 0.16.0
|
||||
use-cache: true
|
||||
|
||||
- uses: taiki-e/install-action@v2
|
||||
if: ${{ contains(matrix.OS, 'ubuntu') }}
|
||||
with:
|
||||
tool: cargo-zigbuild
|
||||
# v0.23.3 emits -mcpu=generic+v6+strict_align for
|
||||
# arm-unknown-linux-musleabi, which zig 0.16.0 rejects;
|
||||
# unpin only together with a zig bump.
|
||||
tool: cargo-zigbuild@0.23.2
|
||||
|
||||
- name: Build
|
||||
if: ${{ !contains(matrix.TARGET, 'mips') }}
|
||||
@@ -178,7 +271,7 @@ jobs:
|
||||
BUILD=zigbuild
|
||||
fi
|
||||
|
||||
if [[ "$TARGET" =~ ^(riscv64|loongarch64|aarch64).*$ || "$TARGET" =~ windows ]]; then
|
||||
if [[ "$TARGET" =~ ^(riscv64|loongarch64|aarch64).*$ || "$TARGET" =~ (freebsd|windows) ]]; then
|
||||
FEATURES="mimalloc"
|
||||
else
|
||||
FEATURES="jemalloc"
|
||||
@@ -227,7 +320,7 @@ jobs:
|
||||
*) UPX_ARCH="amd64" ;;
|
||||
esac
|
||||
|
||||
UPX_VERSION=5.1.1
|
||||
UPX_VERSION=4.2.4
|
||||
UPX_PKG="upx-${UPX_VERSION}-${UPX_ARCH}_linux"
|
||||
curl -L "https://github.com/upx/upx/releases/download/v${UPX_VERSION}/${UPX_PKG}.tar.xz" -s | tar xJvf -
|
||||
cp "${UPX_PKG}/upx" .
|
||||
@@ -254,10 +347,51 @@ jobs:
|
||||
path: |
|
||||
./artifacts/*
|
||||
|
||||
- name: Prepare SignPath test artifact
|
||||
if: ${{ github.event_name == 'workflow_dispatch' && inputs.sign_windows && contains(matrix.TARGET, 'windows') }}
|
||||
run: |
|
||||
mkdir -p ./signpath-artifacts
|
||||
cp \
|
||||
./artifacts/easytier-core.exe \
|
||||
./artifacts/easytier-cli.exe \
|
||||
./artifacts/easytier-web.exe \
|
||||
./artifacts/easytier-web-embed.exe \
|
||||
./signpath-artifacts/
|
||||
|
||||
- name: Upload unsigned SignPath test artifact
|
||||
if: ${{ github.event_name == 'workflow_dispatch' && inputs.sign_windows && contains(matrix.TARGET, 'windows') }}
|
||||
id: signpath-upload
|
||||
uses: actions/upload-artifact@v5
|
||||
with:
|
||||
name: signpath-unsigned-${{ matrix.ARTIFACT_NAME }}
|
||||
path: ./signpath-artifacts/*
|
||||
if-no-files-found: error
|
||||
|
||||
- name: Submit SignPath test-signing request
|
||||
if: ${{ github.event_name == 'workflow_dispatch' && inputs.sign_windows && contains(matrix.TARGET, 'windows') }}
|
||||
uses: signpath/github-action-submit-signing-request@v2
|
||||
with:
|
||||
api-token: ${{ secrets.SIGNPATH_API_TOKEN }}
|
||||
organization-id: 368cf38d-86d5-4bfe-a1fa-82a50dce7e9f
|
||||
project-slug: EasyTier
|
||||
signing-policy-slug: test-signing
|
||||
artifact-configuration-slug: initial
|
||||
github-artifact-id: ${{ steps.signpath-upload.outputs.artifact-id }}
|
||||
wait-for-completion: true
|
||||
output-artifact-directory: ./signpath-signed-artifacts
|
||||
|
||||
- name: Upload signed SignPath test artifact
|
||||
if: ${{ github.event_name == 'workflow_dispatch' && inputs.sign_windows && contains(matrix.TARGET, 'windows') }}
|
||||
uses: actions/upload-artifact@v5
|
||||
with:
|
||||
name: signpath-signed-${{ matrix.ARTIFACT_NAME }}
|
||||
path: ./signpath-signed-artifacts/*
|
||||
if-no-files-found: error
|
||||
|
||||
build_magisk:
|
||||
runs-on: ubuntu-latest
|
||||
needs: [ pre_job, build_web, build ]
|
||||
if: needs.pre_job.result == 'success' && needs.pre_job.outputs.should_skip != 'true' && !cancelled()
|
||||
if: needs.pre_job.result == 'success' && needs.pre_job.outputs.should_skip != 'true' && needs.pre_job.outputs.full_build == 'true' && !cancelled()
|
||||
steps:
|
||||
- name: Checkout Code
|
||||
uses: actions/checkout@v5 # 必须先检出代码才能获取模块配置
|
||||
@@ -269,13 +403,22 @@ jobs:
|
||||
name: easytier-linux-aarch64
|
||||
path: ./downloaded-binaries/ # 独立目录避免冲突
|
||||
|
||||
# 将二进制文件复制到 Magisk 模块目录
|
||||
- name: Prepare binaries
|
||||
- name: Download Magisk WebUI
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: easytier-web-dashboard
|
||||
path: ./downloaded-webui/
|
||||
|
||||
# 将二进制文件和 WebUI 复制到 Magisk 模块目录
|
||||
- name: Prepare Magisk module
|
||||
run: |
|
||||
mkdir -p ./easytier-contrib/easytier-magisk/
|
||||
cp ./downloaded-binaries/easytier-core ./easytier-contrib/easytier-magisk/
|
||||
cp ./downloaded-binaries/easytier-cli ./easytier-contrib/easytier-magisk/
|
||||
cp ./downloaded-binaries/easytier-web ./easytier-contrib/easytier-magisk/
|
||||
module_dir=./easytier-contrib/easytier-magisk
|
||||
mkdir -p "$module_dir/webroot"
|
||||
cp ./downloaded-binaries/easytier-core "$module_dir/"
|
||||
cp ./downloaded-binaries/easytier-cli "$module_dir/"
|
||||
cp ./downloaded-binaries/easytier-web "$module_dir/"
|
||||
cp -R ./downloaded-webui/config-generator/. "$module_dir/webroot/"
|
||||
test -f "$module_dir/webroot/index.html"
|
||||
|
||||
# 上传生成的模块
|
||||
- name: Upload Magisk Module
|
||||
@@ -290,9 +433,9 @@ jobs:
|
||||
|
||||
core-result:
|
||||
runs-on: ubuntu-latest
|
||||
needs: [ pre_job, build_web, build, build_magisk ]
|
||||
if: needs.pre_job.result == 'success' && needs.pre_job.outputs.should_skip != 'true' && !cancelled()
|
||||
needs: [ pre_job, check, build_web, build, build_magisk ]
|
||||
if: ${{ always() }}
|
||||
steps:
|
||||
- name: Mark result as failed
|
||||
if: contains(needs.*.result, 'failure')
|
||||
if: contains(needs.*.result, 'failure') || contains(needs.*.result, 'cancelled')
|
||||
run: exit 1
|
||||
|
||||
@@ -11,7 +11,7 @@ on:
|
||||
image_tag:
|
||||
description: 'Tag for this image build'
|
||||
type: string
|
||||
default: 'v2.6.0'
|
||||
default: 'v2.7.0'
|
||||
required: true
|
||||
mark_latest:
|
||||
description: 'Mark this image as latest'
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
name: EasyTier Go
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: ["develop", "main", "releases/**"]
|
||||
paths:
|
||||
- "easytier-go/**"
|
||||
- ".github/workflows/go.yml"
|
||||
pull_request:
|
||||
branches: ["develop", "main"]
|
||||
types: [opened, synchronize, reopened, ready_for_review]
|
||||
paths:
|
||||
- "easytier-go/**"
|
||||
- ".github/workflows/go.yml"
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
test:
|
||||
name: Test Go modules
|
||||
if: github.event_name != 'pull_request' || !github.event.pull_request.draft
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
|
||||
- uses: actions/setup-go@v6
|
||||
with:
|
||||
go-version: "1.25.x"
|
||||
cache-dependency-path: |
|
||||
easytier-go/go.sum
|
||||
easytier-go/examples/tun/go.sum
|
||||
|
||||
- name: Test main module
|
||||
working-directory: easytier-go
|
||||
run: go test ./...
|
||||
|
||||
- name: Test TUN example module
|
||||
working-directory: easytier-go/examples/tun
|
||||
run: go test ./...
|
||||
+147
-15
@@ -1,6 +1,7 @@
|
||||
name: EasyTier GUI
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
branches: ["develop", "main", "releases/**"]
|
||||
pull_request:
|
||||
@@ -8,7 +9,8 @@ on:
|
||||
types: [opened, synchronize, reopened, ready_for_review]
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
|
||||
# A new PR or branch push must not cancel a full build.
|
||||
group: ${{ github.workflow }}-${{ (github.event_name == 'workflow_dispatch' || startsWith(github.ref_name, 'releases/')) && 'full' || 'check' }}-${{ github.event.pull_request.number || github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
env:
|
||||
@@ -21,21 +23,72 @@ defaults:
|
||||
|
||||
jobs:
|
||||
pre_job:
|
||||
# continue-on-error: true # Uncomment once integration is finished
|
||||
runs-on: ubuntu-latest
|
||||
if: github.event_name != 'pull_request' || !github.event.pull_request.draft
|
||||
# Map a step output to a job output
|
||||
env:
|
||||
RELEVANT_PATHS: >-
|
||||
["Cargo.toml", "Cargo.lock", "rust-toolchain.toml", ".cargo/**",
|
||||
"pnpm-lock.yaml", "pnpm-workspace.yaml", "package.json", "easytier/**",
|
||||
"easytier-core/**", "easytier-proto/**", "easytier-rpc-build/**",
|
||||
"easytier-gui/**", "tauri-plugin-vpnservice/**",
|
||||
"easytier-web/frontend-lib/**", ".github/workflows/gui.yml",
|
||||
".github/actions/**"]
|
||||
outputs:
|
||||
should_skip: ${{ steps.skip_check.outputs.should_skip == 'true' && !startsWith(github.ref_name, 'releases/') }}
|
||||
full_build: ${{ github.event_name == 'workflow_dispatch' || startsWith(github.ref_name, 'releases/') }}
|
||||
# Release builds must run even if they only change release metadata.
|
||||
should_skip: >-
|
||||
${{
|
||||
(
|
||||
steps.skip_check.outputs.should_skip == 'true' ||
|
||||
(
|
||||
github.event_name == 'pull_request' &&
|
||||
steps.path_filter.outputs.relevant != 'true'
|
||||
)
|
||||
) &&
|
||||
github.event_name != 'workflow_dispatch' &&
|
||||
!startsWith(github.ref_name, 'releases/')
|
||||
}}
|
||||
steps:
|
||||
- id: skip_check
|
||||
if: github.event_name != 'workflow_dispatch' && !startsWith(github.ref_name, 'releases/')
|
||||
uses: fkirc/skip-duplicate-actions@v5
|
||||
with:
|
||||
# All of these options are optional, so you can remove them if you are happy with the defaults
|
||||
concurrent_skipping: 'same_content_newer'
|
||||
skip_after_successful_duplicate: 'true'
|
||||
cancel_others: 'true'
|
||||
paths: '["Cargo.toml", "Cargo.lock", "easytier/**", "easytier-gui/**", ".github/workflows/gui.yml", ".github/actions/**", "easytier-web/frontend-lib/**"]'
|
||||
paths: ${{ env.RELEVANT_PATHS }}
|
||||
|
||||
- id: path_filter
|
||||
if: >-
|
||||
github.event_name == 'pull_request' &&
|
||||
steps.skip_check.outputs.should_skip != 'true'
|
||||
uses: dorny/paths-filter@v4
|
||||
with:
|
||||
filters: |
|
||||
relevant: ${{ env.RELEVANT_PATHS }}
|
||||
|
||||
check:
|
||||
name: Check GUI (Linux)
|
||||
needs: pre_job
|
||||
if: needs.pre_job.outputs.should_skip != 'true' && needs.pre_job.outputs.full_build != 'true'
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
|
||||
- uses: ./.github/actions/prepare-build
|
||||
with:
|
||||
gui: true
|
||||
pnpm: true
|
||||
pnpm-build-filter: easytier-gui...
|
||||
token: ${{ github.token }}
|
||||
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
shared-key: gui-check-x86_64-unknown-linux-gnu
|
||||
save-if: ${{ github.event_name != 'pull_request' }}
|
||||
|
||||
- name: Check GUI
|
||||
run: cargo check --locked --all-targets --package easytier-gui --features custom-protocol
|
||||
|
||||
build-gui:
|
||||
strategy:
|
||||
fail-fast: true
|
||||
@@ -80,7 +133,7 @@ jobs:
|
||||
GUI_TARGET: ${{ matrix.GUI_TARGET }}
|
||||
OSS_BUCKET: ${{ secrets.ALIYUN_OSS_BUCKET }}
|
||||
needs: pre_job
|
||||
if: needs.pre_job.outputs.should_skip != 'true'
|
||||
if: needs.pre_job.outputs.should_skip != 'true' && needs.pre_job.outputs.full_build == 'true'
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
|
||||
@@ -94,7 +147,7 @@ jobs:
|
||||
target: ${{ matrix.TARGET }}
|
||||
gui: true
|
||||
pnpm: true
|
||||
pnpm-build-filter: ''
|
||||
pnpm-build-filter: easytier-gui...
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
@@ -102,8 +155,8 @@ jobs:
|
||||
# The prefix cache key, this can be changed to start a new cache manually.
|
||||
# default: "v0-rust"
|
||||
prefix-key: ""
|
||||
shared-key: "gui-registry"
|
||||
cache-targets: "false"
|
||||
shared-key: gui-release-${{ matrix.GUI_TARGET }}
|
||||
cache-targets: "true"
|
||||
|
||||
- name: copy correct DLLs
|
||||
if: ${{ contains(matrix.GUI_TARGET, 'windows') }}
|
||||
@@ -117,13 +170,92 @@ jobs:
|
||||
find "./easytier/third_party/${ARCH_DIR}" -maxdepth 1 -type f \( -name "*.dll" -o -name "*.sys" \) -exec cp {} ./easytier-gui/src-tauri/ \;
|
||||
fi
|
||||
|
||||
- name: Validate macOS signing secrets
|
||||
if: ${{ contains(matrix.GUI_TARGET, 'darwin') && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository) }}
|
||||
env:
|
||||
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
|
||||
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
|
||||
APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }}
|
||||
APPLE_ID: ${{ secrets.APPLE_ID }}
|
||||
APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }}
|
||||
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
|
||||
run: |
|
||||
missing=()
|
||||
for name in APPLE_CERTIFICATE APPLE_CERTIFICATE_PASSWORD APPLE_SIGNING_IDENTITY APPLE_ID APPLE_PASSWORD APPLE_TEAM_ID; do
|
||||
if [[ -z "${!name}" ]]; then
|
||||
missing+=("$name")
|
||||
fi
|
||||
done
|
||||
if (( ${#missing[@]} )); then
|
||||
printf 'Missing macOS signing secret(s): %s\n' "${missing[*]}" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Build GUI
|
||||
if: ${{ matrix.GUI_TARGET != '' }}
|
||||
if: ${{ matrix.GUI_TARGET != '' && (!contains(matrix.GUI_TARGET, 'darwin') || (github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name != github.repository)) }}
|
||||
uses: tauri-apps/tauri-action@v0
|
||||
with:
|
||||
projectPath: ./easytier-gui
|
||||
args: --verbose --target ${{ matrix.GUI_TARGET }}
|
||||
|
||||
- name: Build GUI (signed and notarized)
|
||||
if: ${{ contains(matrix.GUI_TARGET, 'darwin') && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository) }}
|
||||
timeout-minutes: 60
|
||||
uses: tauri-apps/tauri-action@v0
|
||||
env:
|
||||
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
|
||||
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
|
||||
APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }}
|
||||
APPLE_ID: ${{ secrets.APPLE_ID }}
|
||||
APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }}
|
||||
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
|
||||
with:
|
||||
projectPath: ./easytier-gui
|
||||
args: --verbose --target ${{ matrix.GUI_TARGET }}
|
||||
|
||||
- name: Notarize and staple macOS DMG
|
||||
if: ${{ contains(matrix.GUI_TARGET, 'darwin') && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository) }}
|
||||
timeout-minutes: 45
|
||||
env:
|
||||
APPLE_ID: ${{ secrets.APPLE_ID }}
|
||||
APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }}
|
||||
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
dmg_dir="./target/$GUI_TARGET/release/bundle/dmg"
|
||||
if [[ ! -d "$dmg_dir" ]]; then
|
||||
printf 'macOS DMG directory not found: %s\n' "$dmg_dir" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
dmgs=()
|
||||
while IFS= read -r dmg; do
|
||||
dmgs+=("$dmg")
|
||||
done < <(find "$dmg_dir" -maxdepth 1 -type f -name "*.dmg" | sort)
|
||||
if (( ${#dmgs[@]} == 0 )); then
|
||||
printf 'No macOS DMG found in %s\n' "$dmg_dir" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
for dmg in "${dmgs[@]}"; do
|
||||
printf 'Verifying signed DMG: %s\n' "$dmg"
|
||||
codesign --verify --verbose=4 "$dmg"
|
||||
codesign -dv --verbose=4 "$dmg"
|
||||
|
||||
printf 'Notarizing DMG: %s\n' "$dmg"
|
||||
xcrun notarytool submit "$dmg" \
|
||||
--apple-id "$APPLE_ID" \
|
||||
--password "$APPLE_PASSWORD" \
|
||||
--team-id "$APPLE_TEAM_ID" \
|
||||
--wait \
|
||||
--timeout 40m
|
||||
|
||||
printf 'Stapling DMG: %s\n' "$dmg"
|
||||
xcrun stapler staple "$dmg"
|
||||
xcrun stapler validate "$dmg"
|
||||
done
|
||||
|
||||
- name: Collect artifact
|
||||
run: |
|
||||
mkdir -p ./artifacts/objects/
|
||||
@@ -157,9 +289,9 @@ jobs:
|
||||
|
||||
gui-result:
|
||||
runs-on: ubuntu-latest
|
||||
needs: [ pre_job, build-gui ]
|
||||
if: needs.pre_job.result == 'success' && needs.pre_job.outputs.should_skip != 'true' && !cancelled()
|
||||
needs: [ pre_job, check, build-gui ]
|
||||
if: ${{ always() }}
|
||||
steps:
|
||||
- name: Mark result as failed
|
||||
if: contains(needs.*.result, 'failure')
|
||||
if: contains(needs.*.result, 'failure') || contains(needs.*.result, 'cancelled')
|
||||
run: exit 1
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
name: EasyTier JavaScript Hosts
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: ["develop", "main", "releases/**"]
|
||||
paths:
|
||||
- "Cargo.toml"
|
||||
- "Cargo.lock"
|
||||
- "easytier-core/**"
|
||||
- "easytier-proto/**"
|
||||
- "easytier-js/**"
|
||||
- ".github/workflows/js.yml"
|
||||
pull_request:
|
||||
branches: ["develop", "main"]
|
||||
types: [opened, synchronize, reopened, ready_for_review]
|
||||
paths:
|
||||
- "Cargo.toml"
|
||||
- "Cargo.lock"
|
||||
- "easytier-core/**"
|
||||
- "easytier-proto/**"
|
||||
- "easytier-js/**"
|
||||
- ".github/workflows/js.yml"
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
check:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
|
||||
- uses: actions-rust-lang/setup-rust-toolchain@v1
|
||||
with:
|
||||
toolchain: 1.95
|
||||
target: wasm32-wasip1
|
||||
cache: false
|
||||
rustflags: ''
|
||||
|
||||
- uses: arduino/setup-protoc@v3
|
||||
with:
|
||||
version: '35.1'
|
||||
repo-token: ${{ github.token }}
|
||||
|
||||
- uses: actions/setup-node@v5
|
||||
with:
|
||||
node-version: 22
|
||||
|
||||
- uses: pnpm/action-setup@v5
|
||||
with:
|
||||
version: 10
|
||||
run_install: false
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm --dir easytier-js install --frozen-lockfile
|
||||
|
||||
- name: Build and test
|
||||
run: pnpm --dir easytier-js check
|
||||
@@ -1,6 +1,7 @@
|
||||
name: EasyTier Mobile
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
branches: ["develop", "main", "releases/**"]
|
||||
pull_request:
|
||||
@@ -8,7 +9,8 @@ on:
|
||||
types: [opened, synchronize, reopened, ready_for_review]
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
|
||||
# A new PR or branch push must not cancel a full build.
|
||||
group: ${{ github.workflow }}-${{ (github.event_name == 'workflow_dispatch' || startsWith(github.ref_name, 'releases/')) && 'full' || 'check' }}-${{ github.event.pull_request.number || github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
env:
|
||||
@@ -21,38 +23,58 @@ defaults:
|
||||
|
||||
jobs:
|
||||
pre_job:
|
||||
# continue-on-error: true # Uncomment once integration is finished
|
||||
runs-on: ubuntu-latest
|
||||
if: github.event_name != 'pull_request' || !github.event.pull_request.draft
|
||||
# Map a step output to a job output
|
||||
env:
|
||||
RELEVANT_PATHS: >-
|
||||
["Cargo.toml", "Cargo.lock", "rust-toolchain.toml", ".cargo/**",
|
||||
"pnpm-lock.yaml", "pnpm-workspace.yaml", "package.json", "easytier/**",
|
||||
"easytier-core/**", "easytier-proto/**", "easytier-rpc-build/**",
|
||||
"easytier-gui/**", "tauri-plugin-vpnservice/**",
|
||||
"easytier-web/frontend-lib/**", ".github/workflows/mobile.yml",
|
||||
".github/actions/**"]
|
||||
outputs:
|
||||
should_skip: ${{ steps.skip_check.outputs.should_skip == 'true' && !startsWith(github.ref_name, 'releases/') }}
|
||||
full_build: ${{ github.event_name == 'workflow_dispatch' || startsWith(github.ref_name, 'releases/') }}
|
||||
# Release builds must run even if they only change release metadata.
|
||||
should_skip: >-
|
||||
${{
|
||||
(
|
||||
steps.skip_check.outputs.should_skip == 'true' ||
|
||||
(
|
||||
github.event_name == 'pull_request' &&
|
||||
steps.path_filter.outputs.relevant != 'true'
|
||||
)
|
||||
) &&
|
||||
github.event_name != 'workflow_dispatch' &&
|
||||
!startsWith(github.ref_name, 'releases/')
|
||||
}}
|
||||
steps:
|
||||
- id: skip_check
|
||||
if: github.event_name != 'workflow_dispatch' && !startsWith(github.ref_name, 'releases/')
|
||||
uses: fkirc/skip-duplicate-actions@v5
|
||||
with:
|
||||
# All of these options are optional, so you can remove them if you are happy with the defaults
|
||||
concurrent_skipping: 'same_content_newer'
|
||||
skip_after_successful_duplicate: 'true'
|
||||
cancel_others: 'true'
|
||||
paths: '["Cargo.toml", "Cargo.lock", "easytier/**", "easytier-gui/**", "tauri-plugin-vpnservice/**", ".github/workflows/mobile.yml", ".github/actions/**"]'
|
||||
paths: ${{ env.RELEVANT_PATHS }}
|
||||
|
||||
- id: path_filter
|
||||
if: >-
|
||||
github.event_name == 'pull_request' &&
|
||||
steps.skip_check.outputs.should_skip != 'true'
|
||||
uses: dorny/paths-filter@v4
|
||||
with:
|
||||
filters: |
|
||||
relevant: ${{ env.RELEVANT_PATHS }}
|
||||
|
||||
build-mobile:
|
||||
strategy:
|
||||
fail-fast: true
|
||||
matrix:
|
||||
include:
|
||||
- TARGET: aarch64-linux-android
|
||||
ARCH: aarch64
|
||||
- TARGET: armv7-linux-androideabi
|
||||
ARCH: armv7
|
||||
- TARGET: i686-linux-android
|
||||
ARCH: i686
|
||||
- TARGET: x86_64-linux-android
|
||||
ARCH: x86_64
|
||||
ARCH: ${{ fromJSON(needs.pre_job.outputs.full_build == 'true' && '["aarch64", "armv7", "i686", "x86_64"]' || '["aarch64"]') }}
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
NAME: easytier
|
||||
TARGET: ${{ matrix.TARGET }}
|
||||
TARGET: ${{ matrix.ARCH == 'armv7' && 'armv7-linux-androideabi' || format('{0}-linux-android', matrix.ARCH) }}
|
||||
ARCH: ${{ matrix.ARCH }}
|
||||
OSS_BUCKET: ${{ secrets.ALIYUN_OSS_BUCKET }}
|
||||
needs: pre_job
|
||||
@@ -84,10 +106,10 @@ jobs:
|
||||
- name: Prepare build environment
|
||||
uses: ./.github/actions/prepare-build
|
||||
with:
|
||||
target: ${{ matrix.TARGET }}
|
||||
target: ${{ env.TARGET }}
|
||||
gui: false
|
||||
pnpm: true
|
||||
pnpm-build-filter: ''
|
||||
pnpm-build-filter: easytier-gui...
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
@@ -95,15 +117,22 @@ jobs:
|
||||
# The prefix cache key, this can be changed to start a new cache manually.
|
||||
# default: "v0-rust"
|
||||
prefix-key: ""
|
||||
shared-key: "gui-registry"
|
||||
cache-targets: "false"
|
||||
shared-key: mobile-${{ env.TARGET }}-${{ needs.pre_job.outputs.full_build }}
|
||||
cache-targets: "true"
|
||||
save-if: ${{ github.event_name != 'pull_request' }}
|
||||
|
||||
- name: Check Android ARM64
|
||||
if: needs.pre_job.outputs.full_build != 'true'
|
||||
run: cargo check --locked --lib --target "$TARGET" --package easytier-gui --features custom-protocol
|
||||
|
||||
- name: Build
|
||||
if: needs.pre_job.outputs.full_build == 'true'
|
||||
run: |
|
||||
cd easytier-gui
|
||||
pnpm tauri android build --apk --target "$ARCH" --split-per-abi
|
||||
|
||||
- name: Collect artifact
|
||||
if: needs.pre_job.outputs.full_build == 'true'
|
||||
run: |
|
||||
mkdir -p ./artifacts/objects/
|
||||
mv easytier-gui/src-tauri/gen/android/app/build/outputs/apk/*/release/*.apk ./artifacts/objects/
|
||||
@@ -118,6 +147,7 @@ jobs:
|
||||
rm -rf ./artifacts/objects/
|
||||
|
||||
- name: Archive artifact
|
||||
if: needs.pre_job.outputs.full_build == 'true'
|
||||
uses: actions/upload-artifact@v5
|
||||
with:
|
||||
name: easytier-mobile-android-${{ matrix.ARCH }}
|
||||
@@ -127,8 +157,8 @@ jobs:
|
||||
mobile-result:
|
||||
runs-on: ubuntu-latest
|
||||
needs: [ pre_job, build-mobile ]
|
||||
if: needs.pre_job.result == 'success' && needs.pre_job.outputs.should_skip != 'true' && !cancelled()
|
||||
if: ${{ always() }}
|
||||
steps:
|
||||
- name: Mark result as failed
|
||||
if: contains(needs.*.result, 'failure')
|
||||
if: contains(needs.*.result, 'failure') || contains(needs.*.result, 'cancelled')
|
||||
run: exit 1
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
name: EasyTier Nightly Release
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
sha:
|
||||
description: Commit whose full builds the caller has waited for
|
||||
required: true
|
||||
type: string
|
||||
workflow_run:
|
||||
workflows: [EasyTier Core, EasyTier GUI, EasyTier Mobile, EasyTier Test]
|
||||
branches: [main]
|
||||
types: [completed]
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: easytier-nightly-release
|
||||
cancel-in-progress: false
|
||||
|
||||
permissions:
|
||||
actions: read
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
release:
|
||||
if: >-
|
||||
((github.event_name == 'workflow_dispatch' || github.event_name == 'schedule') &&
|
||||
github.ref == 'refs/heads/main') ||
|
||||
(github.event.workflow_run.event == 'workflow_dispatch' &&
|
||||
github.event.workflow_run.conclusion == 'success' &&
|
||||
github.event.workflow_run.head_repository.full_name == github.repository)
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
with:
|
||||
ref: main
|
||||
|
||||
- name: Select complete main builds and prepare release notes
|
||||
id: prepare
|
||||
uses: actions/github-script@v8
|
||||
env:
|
||||
BUILD_SHA: ${{ inputs.sha }}
|
||||
with:
|
||||
script: |
|
||||
const fs = require('node:fs');
|
||||
const { prepare } = require('./.github/scripts/nightly-release.js');
|
||||
const build = await prepare({ github, context, core, sha: process.env.BUILD_SHA });
|
||||
if (!build) return;
|
||||
fs.writeFileSync('nightly-release.json', JSON.stringify(build));
|
||||
for (const workflow of ['core', 'gui', 'mobile']) {
|
||||
core.setOutput(`${workflow}_run_id`, build.runs[workflow]);
|
||||
core.setOutput(`${workflow}_artifact_ids`, build.artifacts[workflow].map(a => a.id).join(','));
|
||||
// download-artifact extracts a single artifact directly into path.
|
||||
const suffix = build.artifacts[workflow].length === 1 ? `/${build.artifacts[workflow][0].name}` : '';
|
||||
core.setOutput(`${workflow}_path`, `nightly-artifacts${suffix}`);
|
||||
}
|
||||
core.setOutput('ready', 'true');
|
||||
|
||||
- name: Download Core artifacts
|
||||
if: steps.prepare.outputs.ready == 'true'
|
||||
uses: actions/download-artifact@v5
|
||||
with:
|
||||
github-token: ${{ github.token }}
|
||||
run-id: ${{ steps.prepare.outputs.core_run_id }}
|
||||
artifact-ids: ${{ steps.prepare.outputs.core_artifact_ids }}
|
||||
path: ${{ steps.prepare.outputs.core_path }}
|
||||
|
||||
- name: Download GUI artifacts
|
||||
if: steps.prepare.outputs.ready == 'true'
|
||||
uses: actions/download-artifact@v5
|
||||
with:
|
||||
github-token: ${{ github.token }}
|
||||
run-id: ${{ steps.prepare.outputs.gui_run_id }}
|
||||
artifact-ids: ${{ steps.prepare.outputs.gui_artifact_ids }}
|
||||
path: ${{ steps.prepare.outputs.gui_path }}
|
||||
|
||||
- name: Download Mobile artifacts
|
||||
if: steps.prepare.outputs.ready == 'true'
|
||||
uses: actions/download-artifact@v5
|
||||
with:
|
||||
github-token: ${{ github.token }}
|
||||
run-id: ${{ steps.prepare.outputs.mobile_run_id }}
|
||||
artifact-ids: ${{ steps.prepare.outputs.mobile_artifact_ids }}
|
||||
path: ${{ steps.prepare.outputs.mobile_path }}
|
||||
|
||||
- name: Package assets like version releases
|
||||
if: steps.prepare.outputs.ready == 'true'
|
||||
uses: actions/github-script@v8
|
||||
with:
|
||||
script: |
|
||||
const fs = require('node:fs');
|
||||
const { packageAssets } = require('./.github/scripts/nightly-release.js');
|
||||
const build = require(`${process.env.GITHUB_WORKSPACE}/nightly-release.json`);
|
||||
await packageAssets({ build, source: 'nightly-artifacts', directory: 'nightly-assets' });
|
||||
fs.writeFileSync('nightly-release.json', JSON.stringify(build));
|
||||
|
||||
- name: Update the fixed nightly prerelease
|
||||
if: steps.prepare.outputs.ready == 'true'
|
||||
uses: actions/github-script@v8
|
||||
with:
|
||||
script: |
|
||||
const { publish } = require('./.github/scripts/nightly-release.js');
|
||||
const build = require(`${process.env.GITHUB_WORKSPACE}/nightly-release.json`);
|
||||
await publish({ github, context, core, build, directory: 'nightly-assets' });
|
||||
@@ -0,0 +1,28 @@
|
||||
name: EasyTier Nightly Retry
|
||||
|
||||
on:
|
||||
workflow_run:
|
||||
workflows: [EasyTier Core, EasyTier GUI, EasyTier Mobile, EasyTier Test]
|
||||
branches: [main]
|
||||
types: [completed]
|
||||
|
||||
permissions:
|
||||
actions: write
|
||||
|
||||
jobs:
|
||||
retry:
|
||||
if: >-
|
||||
github.event.workflow_run.event == 'workflow_dispatch' &&
|
||||
github.event.workflow_run.conclusion == 'failure' &&
|
||||
github.event.workflow_run.run_attempt == 1 &&
|
||||
github.event.workflow_run.head_repository.full_name == github.repository
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Retry failed jobs once
|
||||
uses: actions/github-script@v8
|
||||
with:
|
||||
script: |
|
||||
await github.rest.actions.reRunWorkflowFailedJobs({
|
||||
...context.repo,
|
||||
run_id: context.payload.workflow_run.id,
|
||||
});
|
||||
@@ -0,0 +1,42 @@
|
||||
name: EasyTier Nightly
|
||||
|
||||
on:
|
||||
schedule:
|
||||
# 03:17 Asia/Shanghai; avoid the busy start of the hour.
|
||||
- cron: '17 19 * * *'
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: easytier-nightly
|
||||
cancel-in-progress: false
|
||||
|
||||
permissions:
|
||||
actions: write
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
dispatch:
|
||||
if: github.ref == 'refs/heads/main'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 180
|
||||
outputs:
|
||||
sha: ${{ steps.build.outputs.sha }}
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
|
||||
- name: Build main and wait for successful results
|
||||
id: build
|
||||
uses: actions/github-script@v8
|
||||
with:
|
||||
script: |
|
||||
const dispatch = require('./.github/scripts/nightly-build.js');
|
||||
core.setOutput('sha', await dispatch({ github, context, core }));
|
||||
|
||||
release:
|
||||
needs: dispatch
|
||||
permissions:
|
||||
actions: read
|
||||
contents: write
|
||||
uses: ./.github/workflows/nightly-release.yml
|
||||
with:
|
||||
sha: ${{ needs.dispatch.outputs.sha }}
|
||||
+208
-191
@@ -1,246 +1,263 @@
|
||||
name: EasyTier OHOS
|
||||
name: ohos
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: ["develop", "main", "releases/**"]
|
||||
branches: [develop, main, "releases/**", "ohos/**"]
|
||||
tags:
|
||||
- 'v*'
|
||||
- '!*-pre'
|
||||
- "v*"
|
||||
- "!*-pre"
|
||||
pull_request:
|
||||
branches: ["develop", "main"]
|
||||
branches: [develop, main, "ohos/**"]
|
||||
types: [opened, synchronize, reopened, ready_for_review]
|
||||
paths:
|
||||
- "Cargo.toml"
|
||||
- "Cargo.lock"
|
||||
- "rust-toolchain.toml"
|
||||
- ".cargo/**"
|
||||
- "easytier/**"
|
||||
- "easytier-core/**"
|
||||
- "easytier-proto/**"
|
||||
- "easytier-rpc-build/**"
|
||||
- "easytier-contrib/easytier-ohrs/**"
|
||||
- ".github/workflows/ohos.yml"
|
||||
- ".github/actions/**"
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
publish:
|
||||
description: Publish this non-main branch and dispatch downstream builds
|
||||
required: false
|
||||
default: false
|
||||
type: boolean
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
|
||||
cancel-in-progress: true
|
||||
permissions:
|
||||
actions: write
|
||||
contents: read
|
||||
pull-requests: read
|
||||
|
||||
env:
|
||||
CARGO_TERM_COLOR: always
|
||||
|
||||
defaults:
|
||||
run:
|
||||
# necessary for windows
|
||||
shell: bash
|
||||
|
||||
jobs:
|
||||
cargo_fmt_check:
|
||||
if: github.event_name != 'pull_request' || !github.event.pull_request.draft
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
|
||||
- name: Prepare build environment
|
||||
uses: ./.github/actions/prepare-build
|
||||
with:
|
||||
gui: false
|
||||
pnpm: false
|
||||
|
||||
- uses: actions-rust-lang/setup-rust-toolchain@v1
|
||||
with:
|
||||
components: rustfmt
|
||||
|
||||
- name: Check formatting
|
||||
working-directory: ./easytier-contrib/easytier-ohrs
|
||||
run: cargo fmt --all -- --check
|
||||
|
||||
pre_job:
|
||||
# continue-on-error: true # Uncomment once integration is finished
|
||||
runs-on: ubuntu-latest
|
||||
if: github.event_name != 'pull_request' || !github.event.pull_request.draft
|
||||
# Map a step output to a job output
|
||||
outputs:
|
||||
# do not skip push on branch starts with releases/
|
||||
should_skip: ${{ steps.skip_check.outputs.should_skip == 'true' && !startsWith(github.ref_name, 'releases/') }}
|
||||
should_skip: ${{ steps.skip_check.outputs.should_skip == 'true' && github.ref_type != 'tag' && github.ref_name != 'main' && !startsWith(github.ref_name, 'releases/') }}
|
||||
steps:
|
||||
- id: skip_check
|
||||
uses: fkirc/skip-duplicate-actions@v5
|
||||
with:
|
||||
# All of these options are optional, so you can remove them if you are happy with the defaults
|
||||
concurrent_skipping: "same_content_newer"
|
||||
skip_after_successful_duplicate: "true"
|
||||
cancel_others: "true"
|
||||
paths: '["Cargo.toml", "Cargo.lock", "easytier/**", "easytier-contrib/easytier-ohrs/**", ".github/workflows/ohos.yml", ".github/actions/**"]'
|
||||
concurrent_skipping: 'same_content_newer'
|
||||
skip_after_successful_duplicate: 'true'
|
||||
cancel_others: 'true'
|
||||
paths: '["Cargo.toml", "Cargo.lock", "rust-toolchain.toml", ".cargo/**", "easytier/**", "easytier-core/**", "easytier-proto/**", "easytier-contrib/easytier-ohrs/**", ".github/workflows/ohos.yml", ".github/actions/**"]'
|
||||
|
||||
build-ohos:
|
||||
runs-on: ubuntu-latest
|
||||
ohos:
|
||||
name: ohos
|
||||
needs: pre_job
|
||||
env:
|
||||
OHPM_PUBLISH_CODE: ${{ secrets.OHPM_PUBLISH_CODE }}
|
||||
if: needs.pre_job.outputs.should_skip != 'true'
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -qq \
|
||||
build-essential \
|
||||
wget \
|
||||
unzip \
|
||||
git \
|
||||
pkg-config curl libgl1-mesa-dev expect
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v5
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Resolve easytier version
|
||||
run: |
|
||||
set -e
|
||||
|
||||
UPSTREAM_REPO="https://github.com/EasyTier/EasyTier.git"
|
||||
|
||||
git remote add upstream "$UPSTREAM_REPO" 2>/dev/null || true
|
||||
git fetch --unshallow upstream main || git fetch upstream main
|
||||
git fetch --tags upstream --force
|
||||
|
||||
# 读取 cargo 版本
|
||||
CARGO_VERSION=$(cargo metadata --format-version 1 --no-deps --manifest-path easytier/Cargo.toml \
|
||||
| jq -r '.packages[0].version')
|
||||
|
||||
# 获取 upstream/main 最新 tag
|
||||
LAST_TAG=$(git describe --tags --abbrev=0 upstream/main 2>/dev/null || echo "")
|
||||
LAST_TAG_VERSION="${LAST_TAG#v}"
|
||||
|
||||
# 语义版本比较
|
||||
version_gt() {
|
||||
[ "$(printf '%s\n' "$1" "$2" | sort -V | tail -n1)" = "$1" ] && [ "$1" != "$2" ]
|
||||
}
|
||||
|
||||
if [ -z "$LAST_TAG_VERSION" ]; then
|
||||
BASE_VERSION="$CARGO_VERSION"
|
||||
DIFF_COUNT=$(git rev-list --count upstream/main)
|
||||
elif version_gt "$CARGO_VERSION" "$LAST_TAG_VERSION"; then
|
||||
BASE_VERSION="$CARGO_VERSION"
|
||||
DIFF_COUNT=0
|
||||
else
|
||||
BASE_VERSION="$LAST_TAG_VERSION"
|
||||
DIFF_COUNT=$(git rev-list --count "${LAST_TAG}..upstream/main")
|
||||
fi
|
||||
|
||||
COMMIT_HASH=$(git rev-parse --short upstream/main)
|
||||
EASYTIER_VERSION="${BASE_VERSION}-${DIFF_COUNT}-${COMMIT_HASH}"
|
||||
|
||||
echo "EASYTIER_VERSION=$EASYTIER_VERSION"
|
||||
echo "EASYTIER_VERSION=$EASYTIER_VERSION" >> $GITHUB_ENV
|
||||
|
||||
cd ./easytier-contrib/easytier-ohrs/package
|
||||
jq --arg v "$EASYTIER_VERSION" '.version = $v' oh-package.json5 > oh-package.tmp.json5
|
||||
mv oh-package.tmp.json5 oh-package.json5
|
||||
- name: Set up Rust
|
||||
uses: ./.github/actions/prepare-build
|
||||
with:
|
||||
target: aarch64-unknown-linux-ohos
|
||||
gui: false
|
||||
pnpm: false
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
|
||||
- name: Generate CHANGELOG.md for current commit
|
||||
working-directory: ./easytier-contrib/easytier-ohrs/package
|
||||
run: |
|
||||
{
|
||||
echo "## easytier-ohrs ${EASYTIER_VERSION}"
|
||||
echo
|
||||
git log -1 --pretty=format:"- %s"
|
||||
echo
|
||||
} > CHANGELOG.md
|
||||
|
||||
- name: Setup HarmonyOS CLI tools
|
||||
- name: Set up HarmonyOS
|
||||
uses: ErBWs/setup-ohos@v1
|
||||
|
||||
- name: Download and Extract Custom SDK
|
||||
run: |
|
||||
wget https://github.com/FrankHan052176/Easytier-OHOS-sdk/releases/download/v1/ohos-sdk.zip -O /tmp/ohos-sdk.zip
|
||||
sudo unzip -o /tmp/ohos-sdk.zip -d /tmp/custom-sdk
|
||||
sudo cp -rf /tmp/custom-sdk/linux/native/* $OHOS_NDK_HOME/native
|
||||
echo "Custom SDK files deployed to $OHOS_NDK_HOME/native"
|
||||
ls -a $OHOS_NDK_HOME/native
|
||||
|
||||
- name: Setup build environment
|
||||
run: |
|
||||
echo "TARGET_ARCH=aarch64-linux-ohos" >> $GITHUB_ENV
|
||||
|
||||
rustup install stable
|
||||
rustup default stable
|
||||
|
||||
rustup target add aarch64-unknown-linux-ohos
|
||||
|
||||
- uses: taiki-e/install-action@v2
|
||||
- name: Install ohrs
|
||||
if: github.event_name != 'pull_request'
|
||||
uses: taiki-e/install-action@v2
|
||||
with:
|
||||
tool: ohrs
|
||||
|
||||
- name: Create clang wrapper script
|
||||
- name: Prepare OHOS tools
|
||||
run: |
|
||||
sudo mkdir -p $OHOS_NDK_HOME/native/llvm
|
||||
sudo tee $OHOS_NDK_HOME/native/llvm/aarch64-unknown-linux-ohos-clang.sh > /dev/null <<'EOF'
|
||||
set -euo pipefail
|
||||
sudo apt-get install -qqy \
|
||||
pkg-config curl libgl1-mesa-dev expect llvm clang lldb lld
|
||||
rustup component add rustfmt
|
||||
|
||||
sudo mkdir -p "$OHOS_NDK_HOME/native/llvm"
|
||||
sudo tee "$OHOS_NDK_HOME/native/llvm/aarch64-unknown-linux-ohos-clang.sh" >/dev/null <<'EOF'
|
||||
#!/bin/sh
|
||||
exec $OHOS_NDK_HOME/native/llvm/bin/clang \
|
||||
exec "$OHOS_NDK_HOME/native/llvm/bin/clang" \
|
||||
-target aarch64-linux-ohos \
|
||||
--sysroot=$OHOS_NDK_HOME/native/sysroot \
|
||||
-D__MUSL__ \
|
||||
"$@"
|
||||
--sysroot="$OHOS_NDK_HOME/native/sysroot" \
|
||||
-D__MUSL__ "$@"
|
||||
EOF
|
||||
sudo chmod +x $OHOS_NDK_HOME/native/llvm/aarch64-unknown-linux-ohos-clang.sh
|
||||
sudo chmod +x \
|
||||
"$OHOS_NDK_HOME/native/llvm/aarch64-unknown-linux-ohos-clang.sh"
|
||||
|
||||
- name: Build latest Har
|
||||
working-directory: ./easytier-contrib/easytier-ohrs
|
||||
- name: Check formatting
|
||||
run: |
|
||||
cargo fmt --all --manifest-path \
|
||||
easytier-contrib/easytier-ohrs/Cargo.toml -- --check
|
||||
|
||||
- name: Test OHOS bridge
|
||||
run: |
|
||||
cargo test --locked --manifest-path \
|
||||
easytier-contrib/easytier-ohrs/Cargo.toml \
|
||||
-p easytier-ohos-core -p easytier-ohos-features \
|
||||
--lib -- --test-threads=1
|
||||
|
||||
- name: Check OHOS bindings
|
||||
run: |
|
||||
cargo check --locked --manifest-path \
|
||||
easytier-contrib/easytier-ohrs/Cargo.toml \
|
||||
-p easytier-ohrs --tests
|
||||
|
||||
- name: Check OHOS target
|
||||
if: github.event_name == 'pull_request'
|
||||
working-directory: easytier-contrib/easytier-ohrs
|
||||
run: |
|
||||
sudo apt-get install -y llvm clang lldb lld
|
||||
sudo apt-get install -y protobuf-compiler
|
||||
source env.sh
|
||||
ohrs doctor
|
||||
ohrs build --release --arch aarch
|
||||
ohrs artifact
|
||||
mv package.har easytier-ohrs.har
|
||||
cargo check --locked -p easytier-ohrs --tests \
|
||||
--target aarch64-unknown-linux-ohos
|
||||
|
||||
- name: Build Release Package
|
||||
if: startsWith(github.ref, 'refs/tags/')
|
||||
working-directory: ./easytier-contrib/easytier-ohrs
|
||||
- name: Build HAR
|
||||
if: github.event_name != 'pull_request'
|
||||
id: package
|
||||
env:
|
||||
DEFAULT_BRANCH: ${{ github.event.repository.default_branch }}
|
||||
run: |
|
||||
echo "🎉 Official Release detected. Building easytier-release..."
|
||||
TAG_NAME="${{ github.ref_name }}"
|
||||
TAG_VERSION="${TAG_NAME#v}"
|
||||
echo "Release Version: $TAG_VERSION"
|
||||
cd package
|
||||
jq --arg v "$TAG_VERSION" '.name = "easytier-release" | .version = $v' oh-package.json5 > oh-package.tmp.json5 && mv oh-package.tmp.json5 oh-package.json5
|
||||
cd ..
|
||||
ohrs build --release --arch aarch
|
||||
cd dist/arm64-v8a
|
||||
mv libeasytier_ohrs.so libeasytier_release.so
|
||||
cd ../..
|
||||
ohrs artifact
|
||||
mv package.har easytier-release.har
|
||||
set -euo pipefail
|
||||
cargo_version=$(cargo metadata --format-version 1 --no-deps \
|
||||
--manifest-path easytier/Cargo.toml | jq -r '.packages[0].version')
|
||||
last_tag=$(git describe --tags --abbrev=0 HEAD 2>/dev/null || true)
|
||||
if [ -n "$last_tag" ]; then
|
||||
base_version=$(printf '%s\n' "$cargo_version" "${last_tag#v}" \
|
||||
| sort -V | tail -n 1)
|
||||
commit_count=$(git rev-list --count "$last_tag..HEAD")
|
||||
else
|
||||
base_version=$cargo_version
|
||||
commit_count=0
|
||||
fi
|
||||
|
||||
- name: Upload artifact
|
||||
source_branch=${GITHUB_HEAD_REF:-}
|
||||
if [ -z "$source_branch" ]; then
|
||||
if [ "$GITHUB_REF_TYPE" = branch ]; then
|
||||
source_branch=$GITHUB_REF_NAME
|
||||
else
|
||||
source_branch=${DEFAULT_BRANCH:-main}
|
||||
fi
|
||||
fi
|
||||
branch_id=$(printf '%s' "$source_branch" \
|
||||
| tr '[:upper:]' '[:lower:]' \
|
||||
| sed -E 's/[^a-z0-9-]+/-/g; s/^-+//; s/-+$//' \
|
||||
| cut -c1-64)
|
||||
branch_id=${branch_id:-main}
|
||||
|
||||
package_name=easytier-ohrs
|
||||
package_version="${base_version}-${branch_id}-${commit_count}-${GITHUB_RUN_NUMBER}-${GITHUB_RUN_ATTEMPT}-g$(git rev-parse --short=8 HEAD)"
|
||||
echo "name=$package_name" >> "$GITHUB_OUTPUT"
|
||||
echo "EASYTIER_PACKAGE_NAME=$package_name" >> "$GITHUB_ENV"
|
||||
echo "EASYTIER_VERSION=$package_version" >> "$GITHUB_ENV"
|
||||
|
||||
package_dir=easytier-contrib/easytier-ohrs/package
|
||||
jq --arg name "$package_name" --arg version "$package_version" \
|
||||
'.name = $name | .version = $version' \
|
||||
"$package_dir/oh-package.json5" > "$package_dir/oh-package.tmp.json5"
|
||||
mv "$package_dir/oh-package.tmp.json5" "$package_dir/oh-package.json5"
|
||||
{
|
||||
echo "## $package_name $package_version"
|
||||
echo
|
||||
echo "- Core version: $base_version"
|
||||
echo "- Core commit: $GITHUB_SHA"
|
||||
git log -1 --pretty=format:'- %s'
|
||||
echo
|
||||
} > "$package_dir/CHANGELOG.md"
|
||||
|
||||
cd easytier-contrib/easytier-ohrs
|
||||
source env.sh
|
||||
ohrs build --release --arch aarch
|
||||
ohrs artifact
|
||||
mv package.har "$package_name.har"
|
||||
|
||||
- name: Upload HAR
|
||||
if: github.event_name != 'pull_request'
|
||||
uses: actions/upload-artifact@v5
|
||||
with:
|
||||
name: easytier-ohos
|
||||
path: |
|
||||
./easytier-contrib/easytier-ohrs/easytier-ohrs.har
|
||||
name: ${{ steps.package.outputs.name }}
|
||||
path: easytier-contrib/easytier-ohrs/${{ steps.package.outputs.name }}.har
|
||||
retention-days: 5
|
||||
if-no-files-found: error
|
||||
|
||||
- name: Publish To Center Ohpm
|
||||
working-directory: ./easytier-contrib/easytier-ohrs
|
||||
- name: Publish and dispatch
|
||||
if: >-
|
||||
(github.event_name == 'push' &&
|
||||
github.ref_type == 'branch' &&
|
||||
github.ref_name == 'main' &&
|
||||
github.event.forced != true) ||
|
||||
(github.event_name == 'workflow_dispatch' &&
|
||||
github.ref_type == 'branch' &&
|
||||
(github.ref_name == 'main' || inputs.publish))
|
||||
working-directory: easytier-contrib/easytier-ohrs
|
||||
env:
|
||||
OHPM_PRIVATE_KEY: ${{ secrets.OHPM_PRIVATE_KEY }}
|
||||
OHPM_KEY_PASSPHRASE: ${{ secrets.OHPM_KEY_PASSPHRASE }}
|
||||
if: ${{ env.OHPM_PUBLISH_CODE != '' && github.event_name == 'push' }}
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
CODEARTS_PRIVATE_OHPM: ${{ secrets.CODEARTS_PRIVATE_OHPM }}
|
||||
DOWNSTREAM_DISPATCH_TOKEN: ${{ secrets.DOWNSTREAM_DISPATCH_TOKEN }}
|
||||
run: |
|
||||
ohpm config set publish_id "$OHPM_PUBLISH_CODE"
|
||||
ohpm config set publish_registry https://ohpm.openharmony.cn/ohpm
|
||||
TMP_DIR=$(mktemp -d)
|
||||
PRIVATE_KEY_FILE="$TMP_DIR/private_key"
|
||||
printf '%s' "$OHPM_PRIVATE_KEY" > "$PRIVATE_KEY_FILE"
|
||||
chmod 600 "$PRIVATE_KEY_FILE"
|
||||
ohpm config set key_path $PRIVATE_KEY_FILE
|
||||
unzip ohpm_crypto.zip -d /home/runner/work/
|
||||
ohpm config set crypto_path /home/runner/work/ohpm_crypto
|
||||
chmod 755 /home/runner/work/ohpm_crypto/*
|
||||
PASSPHRASE="$(printf '%s' "$OHPM_KEY_PASSPHRASE" | tr -d '\r\n')"
|
||||
ohpm config set key_passphrase "$PASSPHRASE"
|
||||
ohpm publish easytier-ohrs.har
|
||||
|
||||
- name: Publish To Private Ohpm
|
||||
working-directory: ./easytier-contrib/easytier-ohrs
|
||||
if: ${{ env.OHPM_PUBLISH_CODE != '' && github.event_name == 'push' }}
|
||||
run: |
|
||||
printf '%s' "${{ secrets.CODEARTS_PRIVATE_OHPM }}" > ~/.ohpm/.ohpmrc
|
||||
ohpm config set strict_ssl false
|
||||
ohpm publish easytier-ohrs.har
|
||||
if [ -f "easytier-release.har" ]; then
|
||||
echo "🚀 Publishing Release package..."
|
||||
ohpm publish easytier-release.har
|
||||
set -euo pipefail
|
||||
if [ "$GITHUB_EVENT_NAME" = push ]; then
|
||||
pull_requests=$(gh api \
|
||||
-H "Accept: application/vnd.github+json" \
|
||||
"/repos/$GITHUB_REPOSITORY/commits/$GITHUB_SHA/pulls")
|
||||
if ! jq -e \
|
||||
--arg repository "$GITHUB_REPOSITORY" \
|
||||
--arg branch "$GITHUB_REF_NAME" \
|
||||
--arg sha "$GITHUB_SHA" \
|
||||
'any(.[];
|
||||
.merged_at != null and
|
||||
.base.repo.full_name == $repository and
|
||||
.base.ref == $branch and
|
||||
.merge_commit_sha == $sha)' \
|
||||
<<< "$pull_requests" >/dev/null; then
|
||||
echo "Direct push: HAR built without publishing."
|
||||
exit 0
|
||||
fi
|
||||
fi
|
||||
curl --header "Content-Type: application/json" --request POST --data "{}" ${{ secrets.CODEARTS_WEBHOOKS }}
|
||||
|
||||
mkdir -p "$HOME/.ohpm"
|
||||
umask 077
|
||||
printf '%s' "$CODEARTS_PRIVATE_OHPM" > "$HOME/.ohpm/.ohpmrc"
|
||||
trap 'rm -f "$HOME/.ohpm/.ohpmrc"' EXIT
|
||||
ohpm publish "$EASYTIER_PACKAGE_NAME.har"
|
||||
|
||||
payload=$(jq -nc \
|
||||
--arg repository "$GITHUB_REPOSITORY" \
|
||||
--arg ref "refs/heads/$GITHUB_REF_NAME" \
|
||||
--arg package "$EASYTIER_PACKAGE_NAME" \
|
||||
'{
|
||||
event_type: "core-har-published",
|
||||
client_payload: {
|
||||
core_repository: $repository,
|
||||
core_ref: $ref,
|
||||
package_name: $package
|
||||
}
|
||||
}')
|
||||
for repository in \
|
||||
FrankHan052176/EasyTier-ArkTS \
|
||||
FrankHan052176/easytier-pro-app; do
|
||||
curl --fail-with-body --silent --show-error \
|
||||
-X POST \
|
||||
-H "Accept: application/vnd.github+json" \
|
||||
-H "Authorization: Bearer $DOWNSTREAM_DISPATCH_TOKEN" \
|
||||
-H "X-GitHub-Api-Version: 2022-11-28" \
|
||||
"$GITHUB_API_URL/repos/$repository/dispatches" \
|
||||
--data "$payload"
|
||||
done
|
||||
|
||||
@@ -18,7 +18,7 @@ on:
|
||||
version:
|
||||
description: 'Version for this release'
|
||||
type: string
|
||||
default: 'v2.6.0'
|
||||
default: 'v2.7.0'
|
||||
required: true
|
||||
make_latest:
|
||||
description: 'Mark this release as latest'
|
||||
@@ -27,6 +27,7 @@ on:
|
||||
required: true
|
||||
|
||||
permissions:
|
||||
actions: read
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
@@ -37,6 +38,17 @@ jobs:
|
||||
name: Checkout
|
||||
uses: actions/checkout@v5
|
||||
|
||||
- name: Validate complete builds
|
||||
uses: actions/github-script@v8
|
||||
env:
|
||||
CORE_RUN_ID: ${{ inputs.core_run_id }}
|
||||
GUI_RUN_ID: ${{ inputs.gui_run_id }}
|
||||
MOBILE_RUN_ID: ${{ inputs.mobile_run_id }}
|
||||
with:
|
||||
script: |
|
||||
const validate = require('./.github/scripts/validate-release-builds.js');
|
||||
await validate({ github, context, core });
|
||||
|
||||
- name: Download Core Artifact
|
||||
uses: dawidd6/action-download-artifact@v11
|
||||
with:
|
||||
@@ -92,4 +104,4 @@ jobs:
|
||||
files: |
|
||||
./zipped_assets/*
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
tag_name: ${{ inputs.version }}
|
||||
tag_name: ${{ inputs.version }}
|
||||
|
||||
+100
-38
@@ -1,13 +1,16 @@
|
||||
name: EasyTier Test
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
branches: [ "develop", "main" ]
|
||||
branches: [ "develop", "main", "releases/**" ]
|
||||
pull_request:
|
||||
branches: [ "develop", "main" ]
|
||||
types: [ opened, synchronize, reopened, ready_for_review ]
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
|
||||
# A new PR or branch push must not cancel a full build.
|
||||
group: ${{ github.workflow }}-${{ (github.event_name == 'workflow_dispatch' || startsWith(github.ref_name, 'releases/')) && 'full' || 'check' }}-${{ github.event.pull_request.number || github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
env:
|
||||
@@ -21,20 +24,62 @@ defaults:
|
||||
shell: bash
|
||||
|
||||
jobs:
|
||||
pre_job:
|
||||
# continue-on-error: true # Uncomment once integration is finished
|
||||
ci-scripts:
|
||||
name: Test CI scripts
|
||||
runs-on: ubuntu-latest
|
||||
# Map a step output to a job output
|
||||
if: github.event_name != 'pull_request' || !github.event.pull_request.draft
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
|
||||
- uses: actions/setup-node@v5
|
||||
with:
|
||||
node-version: 22
|
||||
|
||||
- run: node --test .github/scripts/*.test.js
|
||||
|
||||
pre_job:
|
||||
runs-on: ubuntu-latest
|
||||
if: github.event_name != 'pull_request' || !github.event.pull_request.draft
|
||||
env:
|
||||
RELEVANT_PATHS: >-
|
||||
["Cargo.toml", "Cargo.lock", "rust-toolchain.toml", ".cargo/**",
|
||||
".config/nextest.toml",
|
||||
"pnpm-lock.yaml", "pnpm-workspace.yaml", "package.json", "easytier/**",
|
||||
"easytier-core/**", "easytier-proto/**", "easytier-rpc-build/**",
|
||||
"easytier-web/**", "easytier-gui/src-tauri/**",
|
||||
"tauri-plugin-vpnservice/**", "easytier-contrib/**",
|
||||
".github/workflows/test.yml", ".github/actions/**"]
|
||||
outputs:
|
||||
should_skip: ${{ steps.skip_check.outputs.should_skip }}
|
||||
full_build: ${{ github.event_name == 'workflow_dispatch' || startsWith(github.ref_name, 'releases/') }}
|
||||
should_skip: >-
|
||||
${{
|
||||
(
|
||||
steps.skip_check.outputs.should_skip == 'true' ||
|
||||
(
|
||||
github.event_name == 'pull_request' &&
|
||||
steps.path_filter.outputs.relevant != 'true'
|
||||
)
|
||||
) &&
|
||||
github.event_name != 'workflow_dispatch' &&
|
||||
!startsWith(github.ref_name, 'releases/')
|
||||
}}
|
||||
steps:
|
||||
- id: skip_check
|
||||
if: github.event_name != 'workflow_dispatch' && !startsWith(github.ref_name, 'releases/')
|
||||
uses: fkirc/skip-duplicate-actions@v5
|
||||
with:
|
||||
# All of these options are optional, so you can remove them if you are happy with the defaults
|
||||
concurrent_skipping: 'never'
|
||||
skip_after_successful_duplicate: 'true'
|
||||
paths: '["Cargo.toml", "Cargo.lock", "easytier/**", ".github/workflows/test.yml", ".github/actions/**"]'
|
||||
paths: ${{ env.RELEVANT_PATHS }}
|
||||
|
||||
- id: path_filter
|
||||
if: >-
|
||||
github.event_name == 'pull_request' &&
|
||||
steps.skip_check.outputs.should_skip != 'true'
|
||||
uses: dorny/paths-filter@v4
|
||||
with:
|
||||
filters: |
|
||||
relevant: ${{ env.RELEVANT_PATHS }}
|
||||
|
||||
check:
|
||||
name: Run linters & check
|
||||
@@ -48,33 +93,50 @@ jobs:
|
||||
uses: ./.github/actions/prepare-build
|
||||
with:
|
||||
gui: true
|
||||
pnpm: true
|
||||
pnpm: ${{ needs.pre_job.outputs.full_build == 'true' }}
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- uses: actions-rust-lang/setup-rust-toolchain@v1
|
||||
with:
|
||||
components: rustfmt,clippy
|
||||
target: wasm32-wasip1
|
||||
cache: false
|
||||
rustflags: ''
|
||||
|
||||
- uses: taiki-e/install-action@cargo-hack
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
shared-key: test-check
|
||||
save-if: ${{ github.event_name != 'pull_request' }}
|
||||
|
||||
- name: Check Cargo.lock is up to date
|
||||
run: |
|
||||
if ! cargo metadata --format-version 1 --locked --no-deps > /dev/null; then
|
||||
echo "::error::Cargo.lock is out of date. Run cargo generate-lockfile or cargo build locally, then commit Cargo.lock."
|
||||
exit 1
|
||||
fi
|
||||
- uses: taiki-e/install-action@cargo-hack
|
||||
if: needs.pre_job.outputs.full_build == 'true'
|
||||
|
||||
- name: Check formatting
|
||||
if: ${{ !cancelled() }}
|
||||
run: cargo fmt --all -- --check
|
||||
|
||||
- name: Check Clippy
|
||||
if: ${{ !cancelled() }}
|
||||
run: cargo clippy --all-targets --features full --all -- -D warnings
|
||||
|
||||
- name: Check features
|
||||
if: ${{ !cancelled() }}
|
||||
if: ${{ !cancelled() && needs.pre_job.outputs.full_build == 'true' }}
|
||||
run: cargo hack check --package easytier --each-feature --exclude-features macos-ne --verbose
|
||||
|
||||
- name: Check WASI
|
||||
if: ${{ !cancelled() }}
|
||||
run: >-
|
||||
cargo check --package easytier-core --lib --target wasm32-wasip1
|
||||
--features management-rpc,proxy-smoltcp-stack,ring-crypto,wasi-crypto-offload
|
||||
|
||||
- name: Check Cargo.lock is up to date
|
||||
if: ${{ !cancelled() }}
|
||||
run: |
|
||||
if ! cargo metadata --format-version 1 --locked > /dev/null; then
|
||||
echo "::error::Cargo.lock is out of date. Run cargo generate-lockfile or cargo build locally, then commit Cargo.lock."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
pre-test:
|
||||
name: Build test
|
||||
runs-on: ubuntu-latest
|
||||
@@ -86,8 +148,8 @@ jobs:
|
||||
- name: Prepare build environment
|
||||
uses: ./.github/actions/prepare-build
|
||||
with:
|
||||
gui: true
|
||||
pnpm: true
|
||||
gui: false
|
||||
pnpm: false
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
@@ -95,7 +157,9 @@ jobs:
|
||||
- uses: taiki-e/install-action@nextest
|
||||
|
||||
- name: Archive test
|
||||
run: cargo nextest archive --archive-file tests.tar.zst --package easytier --features full
|
||||
run: >-
|
||||
cargo nextest archive --archive-file tests.tar.zst
|
||||
--package easytier --package easytier-core --features full
|
||||
|
||||
- uses: actions/upload-artifact@v5
|
||||
with:
|
||||
@@ -103,32 +167,29 @@ jobs:
|
||||
path: tests.tar.zst
|
||||
retention-days: 1
|
||||
|
||||
test_matrix:
|
||||
name: Test (${{ matrix.name }})
|
||||
run-tests:
|
||||
name: Run tests
|
||||
runs-on: ubuntu-latest
|
||||
needs: [ pre_job, pre-test ]
|
||||
if: needs.pre_job.outputs.should_skip != 'true'
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- name: "easytier"
|
||||
opts: "-E 'not test(tests::three_node)' --test-threads 1 --no-fail-fast"
|
||||
|
||||
- name: "three_node"
|
||||
opts: "-E 'test(tests::three_node) and not test(subnet_proxy_three_node_test)' --test-threads 1 --no-fail-fast"
|
||||
|
||||
- name: "three_node::subnet_proxy_three_node_test"
|
||||
opts: "-E 'test(subnet_proxy_three_node_test)' --test-threads 1 --no-fail-fast"
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
|
||||
- name: Setup tools for test
|
||||
run: sudo apt install bridge-utils
|
||||
- name: Setup upnpd for test
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo DEBIAN_FRONTEND=noninteractive apt-get install -y miniupnpd miniupnpd-iptables iptables
|
||||
|
||||
- name: Setup system for test
|
||||
run: |
|
||||
sudo modprobe br_netfilter
|
||||
sudo modprobe tun
|
||||
if [ ! -e /dev/net/tun ]; then
|
||||
sudo mkdir -p /dev/net
|
||||
sudo mknod /dev/net/tun c 10 200
|
||||
fi
|
||||
sudo sysctl net.bridge.bridge-nf-call-iptables=0
|
||||
sudo sysctl net.bridge.bridge-nf-call-ip6tables=0
|
||||
sudo sysctl net.ipv6.conf.lo.disable_ipv6=0
|
||||
@@ -144,13 +205,14 @@ jobs:
|
||||
- name: Run tests
|
||||
run: |
|
||||
sudo prlimit --pid $$ --nofile=1048576:1048576
|
||||
sudo -E env "PATH=$PATH" cargo nextest run --archive-file tests.tar.zst ${{ matrix.opts }}
|
||||
sudo -E env "PATH=$PATH" EASYTIER_LINUX_BPF_INTEGRATION=required \
|
||||
cargo nextest run --archive-file tests.tar.zst --profile ci --no-fail-fast
|
||||
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
needs: [ pre_job, check, test_matrix ]
|
||||
if: needs.pre_job.result == 'success' && needs.pre_job.outputs.should_skip != 'true' && !cancelled()
|
||||
needs: [ pre_job, ci-scripts, check, pre-test, run-tests ]
|
||||
if: ${{ always() }}
|
||||
steps:
|
||||
- name: Mark result as failed
|
||||
if: contains(needs.*.result, 'failure')
|
||||
if: contains(needs.*.result, 'failure') || contains(needs.*.result, 'cancelled')
|
||||
run: exit 1
|
||||
|
||||
@@ -34,6 +34,9 @@ easytier-panic.log
|
||||
|
||||
# web
|
||||
node_modules
|
||||
easytier-web/frontend-lib/src/generated/
|
||||
easytier-web/config-generator/dist/
|
||||
easytier-web/config-generator/src/generated/
|
||||
|
||||
.vite
|
||||
|
||||
@@ -43,3 +46,7 @@ easytier-gui/src-tauri/*.sys
|
||||
|
||||
.direnv
|
||||
.flake-profile
|
||||
|
||||
# contrib
|
||||
go.sum
|
||||
.test-env/
|
||||
|
||||
+132
@@ -0,0 +1,132 @@
|
||||
# EasyTier Domain Context
|
||||
|
||||
## Module layers
|
||||
|
||||
`easytier-core` layers dependencies from `foundation` upward through the
|
||||
portable networking domains. `foundation` contains infrastructure Modules
|
||||
that have no dependency on a networking domain and may be used by any higher
|
||||
layer.
|
||||
|
||||
## Operation broker
|
||||
|
||||
An operation broker owns the lifecycle of asynchronous work submitted by an
|
||||
external caller to core. It allocates opaque operation IDs, arbitrates
|
||||
completion, cancellation, and disposal, retains terminal outcomes, and
|
||||
publishes a batch-drainable completion queue.
|
||||
|
||||
The broker does not interpret operation kinds, outcomes, resources, wire
|
||||
formats, or domain errors. Each domain Module owns those semantics and composes
|
||||
the broker under the same lock as any state that must change atomically with an
|
||||
operation transition.
|
||||
|
||||
Host capability operations use a separate seam. They turn Host readiness into
|
||||
Rust task wakeups and do not share the caller-to-core broker state machine.
|
||||
|
||||
## Credential grant
|
||||
|
||||
A credential grant contains the authorization constraints shared by generated,
|
||||
imported, managed, and attached-peer credentials: ACL groups, relay permission,
|
||||
allowed proxy CIDRs, and whether concurrent reuse is allowed. It does not own
|
||||
credential identity, key material, lifetime, persistence, or runtime ownership.
|
||||
Each credential intake path normalizes the grant before installing it.
|
||||
|
||||
## Peer Relay advertisement
|
||||
|
||||
A platform peer may prefer an eligible directly connected credential relay by
|
||||
omitting covered credential-leaf edges from only its own advertised OSPF
|
||||
connection row. Its local route calculation still uses the complete physical
|
||||
adjacency so direct-destination fallback remains available. Other peers'
|
||||
source-owned rows and versions are never rewritten, cached for promotion, or
|
||||
otherwise changed by this projection.
|
||||
|
||||
Before a graceful Instance stop, the owner publishes a new-version empty
|
||||
connection row while keeping its physical adjacencies available for route
|
||||
synchronization. It waits for the current direct route Sessions to acknowledge
|
||||
that withdrawal up to a bounded deadline, then continues shutdown. Abrupt
|
||||
process loss cannot publish this withdrawal and retains the normal route
|
||||
expiry behavior.
|
||||
|
||||
Relay eligibility comes from the transport-authenticated credential identity
|
||||
and grant, not self-reported route metadata. The advertisement Module does not
|
||||
support changing a credential's relay permission in place; such a permission
|
||||
change is a credential revocation and new authenticated Session.
|
||||
|
||||
## Attached peer
|
||||
|
||||
An attached peer is an ordinary `PeerManagerCore` connected to another
|
||||
`PeerManagerCore` through an authenticated in-process transport. Each
|
||||
authenticated portal client owns one complete peer manager. The managers are
|
||||
protocol peers; `attached` describes only the local transport and its trusted
|
||||
ingress provenance, not a parent/child peer role.
|
||||
|
||||
An attached peer owns one complete IPv4 CIDR (for example `10.144.0.5/16`).
|
||||
Its address and advertised network are independent of the network manager's
|
||||
own static or DHCP address. A VPN portal derives the attached peer route and
|
||||
the external client's allowed network from that single CIDR; it does not infer
|
||||
either value from the portal-hosting instance.
|
||||
|
||||
An external portal client uses that same IPv4 address on its native tunnel
|
||||
interface. The portal validates the source address and forwards IPv4 packets
|
||||
unchanged between the native tunnel and the attached peer; it does not assign
|
||||
a second tunnel-only address or perform address translation.
|
||||
|
||||
Each manager owns its ACL execution state, route service, RPC endpoint, secure
|
||||
sessions, packet processing, and lifecycle. Portal code supplies raw packets
|
||||
and peer configuration but does not build, reload, or coordinate ACL filters.
|
||||
|
||||
When the network manager uses Secure Mode, an attached peer authenticates as a
|
||||
credential peer. Its portal-owned, in-memory credential grant carries ACL
|
||||
groups and is revoked with the attached runtime; the peer never receives the
|
||||
network secret or ACL group secrets. A non-Secure-Mode network retains the
|
||||
legacy admin-attached identity for compatibility. A credential peer cannot host
|
||||
a portal because it cannot issue credential grants. Each live portal Session
|
||||
owns a fresh attached-peer identity, while the external client key remains
|
||||
stable across Sessions; a replacement Session must never reuse the previous
|
||||
non-reusable credential identity.
|
||||
|
||||
## Compact compatibility Host
|
||||
|
||||
A compact compatibility Host retains accepted values in the authoritative TOML
|
||||
model for management readback, while the shared host-aware normalization path
|
||||
omits capabilities that the compact runtime cannot execute. Omitted settings
|
||||
are silent no-ops and must not be advertised as live network capabilities.
|
||||
|
||||
## Embedded logging
|
||||
|
||||
Logging belongs to the WASI runtime, which installs its subscriber at module
|
||||
startup. The independent logger ABI and process-level `LoggerRpc` control
|
||||
the same module-wide level, including before any Instance exists. Instance
|
||||
configuration and lifecycle never initialize or reset logging. The Host owns
|
||||
the output sink through WASI stdout; output errors do not interrupt Core.
|
||||
|
||||
## Web compatibility Host
|
||||
|
||||
The Web compatibility Host runs the portable EasyTier guest in JavaScript
|
||||
runtimes that provide WebAssembly JSPI. Its shared runtime Module owns guest
|
||||
lifecycle, Host capability operations, data-plane resources, and WebSocket
|
||||
message handling. Browser and Cloudflare Adapters own only the platform-specific
|
||||
way that WebSockets are dialed or accepted and the matching guest artifact.
|
||||
|
||||
The Browser Adapter is an outbound-only EasyTier instance with a smoltcp TCP
|
||||
data plane. The Cloudflare Adapter is an inbound-only relay hosted by one named
|
||||
Durable Object. Their public configuration exposes only capabilities each Host
|
||||
can execute; guest ABI details and serialized TOML remain internal.
|
||||
|
||||
## Web configuration consumers
|
||||
|
||||
Central network management and an external Console are alternative consumers
|
||||
of `ClientManager`. An external webhook selects Console mode; otherwise the
|
||||
central service registers devices through an in-process webhook handler and
|
||||
publishes each device's complete compiled configuration through the existing
|
||||
Full reconcile API. `ClientManager` owns persistence, offline replay, and
|
||||
runtime reconciliation without knowing central network business state.
|
||||
|
||||
The central service owns network intent, device registration and bans, and
|
||||
Gateway runtimes. Central HTTP mutations and direct public configuration writes
|
||||
share its mutation lock so ownership checks cannot race with enrollment.
|
||||
Internal Console APIs retain their upstream behavior and central routes and
|
||||
publication workers are disabled in Console mode.
|
||||
|
||||
Device deletion uses the upstream disconnect semantics: an already in-flight
|
||||
validation may register the device again. A persisted ban rejects subsequent
|
||||
validations. Deletion does not introduce device generations or session fences.
|
||||
+12
-1
@@ -113,6 +113,17 @@ cargo build --release --target x86_64-pc-windows-msvc # Windows x86_64
|
||||
|
||||
Build artifacts: `target/[target-triple]/release/`
|
||||
|
||||
### Building the WASI core
|
||||
|
||||
```bash
|
||||
script/build-wasi-core.sh
|
||||
```
|
||||
|
||||
This builds the `easytier-core` Go-host profile for `wasm32-wasip1`, then
|
||||
optimizes it with the pinned official Binaryen release. Binaryen is downloaded
|
||||
once into `target/binaryen/` and verified by SHA-256; set `WASM_OPT` to use an
|
||||
existing matching binary.
|
||||
|
||||
### Building GUI
|
||||
|
||||
```bash
|
||||
@@ -222,4 +233,4 @@ Feel free to:
|
||||
- Join our community discussions
|
||||
- Reach out to maintainers
|
||||
|
||||
Thank you for contributing to EasyTier!
|
||||
Thank you for contributing to EasyTier!
|
||||
|
||||
Generated
+3521
-3059
File diff suppressed because it is too large
Load Diff
+94
-5
@@ -1,23 +1,107 @@
|
||||
[workspace]
|
||||
resolver = "2"
|
||||
members = [
|
||||
"easytier-core",
|
||||
"easytier-proto",
|
||||
"easytier",
|
||||
"easytier-gui/src-tauri",
|
||||
"easytier-rpc-build",
|
||||
"easytier-web",
|
||||
"easytier-gui/src-tauri",
|
||||
"easytier-contrib/easytier-mini",
|
||||
"easytier-contrib/easytier-ffi",
|
||||
"easytier-contrib/easytier-uptime",
|
||||
"easytier-contrib/easytier-android-jni",
|
||||
"easytier-contrib/easytier-ios",
|
||||
"easytier-contrib/easytier-ohrs", # it needs ohrs sdk
|
||||
"easytier-contrib/easytier-ohrs/crates/*",
|
||||
]
|
||||
default-members = ["easytier", "easytier-web"]
|
||||
exclude = [
|
||||
"easytier-contrib/easytier-ohrs", # it needs ohrs sdk
|
||||
]
|
||||
exclude = []
|
||||
|
||||
[workspace.package]
|
||||
edition = "2024"
|
||||
rust-version = "1.95"
|
||||
|
||||
[workspace.dependencies]
|
||||
optionize = "0.5"
|
||||
# Shared versions and sources; members select features and optional dependencies.
|
||||
anyhow = "1.0"
|
||||
arc-swap = "1.9"
|
||||
async-trait = "0.1.92"
|
||||
atomic-shim = "0.2.0"
|
||||
auto_impl = "1.3.0"
|
||||
axum = "0.8"
|
||||
# Match pbjson 0.9's base64 dependency; upgrade together to avoid two codecs.
|
||||
base64 = "0.22.1"
|
||||
bon = "3.10.1"
|
||||
bytecodec = "0.5.0"
|
||||
bytes = "1.12.1"
|
||||
chrono = "0.4.45"
|
||||
cidr = "0.3.2"
|
||||
clap = "4.6.7"
|
||||
crossbeam = "0.8.5"
|
||||
crossbeam-utils = "0.8.23"
|
||||
dashmap = "6.2.1"
|
||||
easytier = { version = "2.7.0", path = "easytier", default-features = false }
|
||||
easytier-core = { version = "2.7.0", path = "easytier-core", default-features = false }
|
||||
easytier-ffi = { version = "0.1.0", path = "easytier-contrib/easytier-ffi", default-features = false }
|
||||
easytier-ohos-core = { version = "0.1.0", path = "easytier-contrib/easytier-ohrs/crates/easytier-ohos-core", default-features = false }
|
||||
easytier-ohos-features = { version = "0.1.0", path = "easytier-contrib/easytier-ohrs/crates/easytier-ohos-features", default-features = false }
|
||||
easytier-proto = { version = "2.7.0", path = "easytier-proto", default-features = false }
|
||||
futures = { version = "0.3", default-features = false }
|
||||
gethostname = "1.1"
|
||||
guarden = "0.3"
|
||||
# Keep HMAC/SHA2 on digest 0.10, shared by stun_codec 0.4 and snow 0.10.
|
||||
# Upgrade this crypto family together when those upstream constraints move.
|
||||
hmac = "0.12.1"
|
||||
http-body-util = "0.1"
|
||||
hyper = { version = "1", default-features = false }
|
||||
hyper-util = { version = "0.1", default-features = false }
|
||||
indoc = "2.0.7"
|
||||
log = "0.4"
|
||||
mimalloc = "*"
|
||||
once_cell = "1.21"
|
||||
parking_lot = "0.12.5"
|
||||
percent-encoding = "2.3.2"
|
||||
pin-project-lite = "0.2.17"
|
||||
prost = "0.14.4"
|
||||
prost-types = "0.14.4"
|
||||
quanta = "0.12"
|
||||
# rand 0.10 / x25519-dalek 3 require a coordinated RNG trait migration.
|
||||
rand = "0.8.8"
|
||||
reqwest = { version = "0.13.5", default-features = false }
|
||||
rust-i18n = "4"
|
||||
rustls = { version = "0.23.45", default-features = false }
|
||||
# tower-sessions-sqlx-store 0.15 requires SQLx 0.8; SeaORM 2 requires SQLx 0.9.
|
||||
sea-orm = "1.1.20"
|
||||
sea-orm-migration = "1.1.20"
|
||||
serde = "1.0.229"
|
||||
serde_json = "1.0"
|
||||
# See the HMAC constraint above; SHA2 must use the same digest generation.
|
||||
sha2 = "0.10.9"
|
||||
smoltcp = { version = "0.14.0", default-features = false }
|
||||
sqlx = "0.8.6"
|
||||
strum = "0.28.0"
|
||||
stun_codec = "0.4.0"
|
||||
sys-locale = "0.3"
|
||||
tauri = "2.11.5"
|
||||
tempfile = "3.27.0"
|
||||
thiserror = "2.0"
|
||||
thunk-rs = { git = "https://github.com/EasyTier/thunk.git", default-features = false }
|
||||
tokio = { version = "1.53", default-features = false }
|
||||
tokio-rustls = { version = "0.26", default-features = false }
|
||||
tokio-util = "0.7.19"
|
||||
toml = "1.1.6"
|
||||
tower-http = "0.7"
|
||||
tracing = "0.1"
|
||||
tracing-subscriber = { version = "0.3", default-features = false }
|
||||
url = "2.5"
|
||||
uuid = "1.26.1"
|
||||
winapi = "0.3.9"
|
||||
windows = "0.62"
|
||||
x25519-dalek = "2.0.1"
|
||||
# zerocopy 0.8 requires migrating the packet layout and casting APIs.
|
||||
zerocopy = "0.7.35"
|
||||
|
||||
[profile.dev]
|
||||
panic = "unwind"
|
||||
debug = 2
|
||||
@@ -28,3 +112,8 @@ lto = true
|
||||
codegen-units = 1
|
||||
opt-level = 3
|
||||
strip = true
|
||||
|
||||
[profile.mini]
|
||||
inherits = "release"
|
||||
opt-level = "z"
|
||||
strip = "symbols"
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
[](https://github.com/EasyTier/EasyTier/actions/workflows/core.yml)
|
||||
[](https://github.com/EasyTier/EasyTier/actions/workflows/gui.yml)
|
||||
[](https://github.com/EasyTier/EasyTier/actions/workflows/test.yml)
|
||||
[](https://deepwiki.com/EasyTier/EasyTier)
|
||||
[<img src="https://devin.ai/assets/askdeepwiki.png" alt="Ask DeepWiki" height="20"/>](https://deepwiki.com/EasyTier/EasyTier)
|
||||
|
||||
[简体中文](/README_CN.md) | [English](/README.md)
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
<img src="assets/running-page.png" width="300" alt="running page">
|
||||
</p>
|
||||
|
||||
📚 **[Full Documentation](https://easytier.cn/en/)** | 🖥️ **[Web Console](https://easytier.cn/web)** | 📝 **[Download Releases](https://github.com/EasyTier/EasyTier/releases)** | 🧩 **[Third Party Tools](https://easytier.cn/en/guide/installation_gui.html#third-party-graphical-interfaces)** | ❤️ **[Sponsor](#sponsor)**
|
||||
📚 **[Full Documentation](https://easytier.cn/en/)** | 🖥️ **[Web Console](https://easytier.cn/web)** | 📝 **[Download Releases](https://github.com/EasyTier/EasyTier/releases)** | **[Nightly](https://github.com/EasyTier/EasyTier/releases/tag/nightly)** | 🧩 **[Third Party Tools](https://easytier.cn/en/guide/installation_gui.html#third-party-graphical-interfaces)** | ❤️ **[Sponsor](#sponsor)**
|
||||
|
||||
## Features
|
||||
|
||||
@@ -69,8 +69,17 @@ Install via cargo (Latest development version):
|
||||
cargo install --git https://github.com/EasyTier/EasyTier.git easytier
|
||||
```
|
||||
|
||||
Install prebuilt CLI tools with [cargo-binstall](https://github.com/cargo-bins/cargo-binstall) (specify an existing release version):
|
||||
```bash
|
||||
cargo binstall --git https://github.com/EasyTier/EasyTier.git --version 2.6.4 easytier
|
||||
```
|
||||
|
||||
This installs `easytier-core` and `easytier-cli` using the repository's metadata, independently of crates.io publication. On Windows, also copy the DLL and driver files from the matching release ZIP beside the installed binaries; binstall only installs executables.
|
||||
|
||||
[Install pre-built binary](https://github.com/EasyTier/EasyTier/releases) (Recommended, All platforms supported)
|
||||
|
||||
For a smaller executable, try [easytier-mini](https://github.com/EasyTier/easytier-mini), which supports basic TCP/UDP networking. See its documentation for supported features and usage.
|
||||
|
||||
[Install via Docker](https://easytier.cn/en/guide/installation.html#installation-methods)
|
||||
|
||||
[Install OpenWrt ipk package](https://github.com/EasyTier/luci-app-easytier)
|
||||
@@ -108,9 +117,9 @@ After successful execution, you can check the network status using `easytier-cli
|
||||
```text
|
||||
| ipv4 | hostname | cost | lat_ms | loss_rate | rx_bytes | tx_bytes | tunnel_proto | nat_type | id | version |
|
||||
| ------------ | -------------- | ----- | ------ | --------- | -------- | -------- | ------------ | -------- | ---------- | --------------- |
|
||||
| 10.126.126.1 | abc-1 | Local | * | * | * | * | udp | FullCone | 439804259 | 2.6.0-70e69a38~ |
|
||||
| 10.126.126.2 | abc-2 | p2p | 3.452 | 0 | 17.33 kB | 20.42 kB | udp | FullCone | 390879727 | 2.6.0-70e69a38~ |
|
||||
| | PublicServer_a | p2p | 27.796 | 0.000 | 50.01 kB | 67.46 kB | tcp | Unknown | 3771642457 | 2.6.0-70e69a38~ |
|
||||
| 10.126.126.1 | abc-1 | Local | * | * | * | * | udp | FullCone | 439804259 | 2.6.2-70e69a38~ |
|
||||
| 10.126.126.2 | abc-2 | p2p | 3.452 | 0 | 17.33 kB | 20.42 kB | udp | FullCone | 390879727 | 2.6.2-70e69a38~ |
|
||||
| | PublicServer_a | p2p | 27.796 | 0.000 | 50.01 kB | 67.46 kB | tcp | Unknown | 3771642457 | 2.6.2-70e69a38~ |
|
||||
```
|
||||
|
||||
You can test connectivity between nodes:
|
||||
@@ -252,8 +261,12 @@ ios <-.-> nodea <--> nodeb <-.-> id1
|
||||
1. Start EasyTier with WireGuard portal enabled:
|
||||
|
||||
```bash
|
||||
# Listen on 0.0.0.0:11013 and use 10.14.14.0/24 subnet for WireGuard clients
|
||||
sudo easytier-core -i 10.144.144.1 --vpn-portal wg://0.0.0.0:11013/10.14.14.0/24
|
||||
# Register one WireGuard client as virtual peer 10.144.144.3
|
||||
sudo easytier-core -i 10.144.144.1 \
|
||||
--network-secret portal-secret \
|
||||
--vpn-portal wg://0.0.0.0:11013 \
|
||||
--vpn-portal-private-key "$(wg genkey)" \
|
||||
--vpn-portal-client phone=10.144.144.3
|
||||
```
|
||||
|
||||
2. Get WireGuard client configuration:
|
||||
@@ -263,10 +276,10 @@ sudo easytier-core -i 10.144.144.1 --vpn-portal wg://0.0.0.0:11013/10.14.14.0/24
|
||||
easytier-cli vpn-portal
|
||||
```
|
||||
|
||||
3. In the output configuration:
|
||||
- Set `Interface.Address` to an available IP from the WireGuard subnet
|
||||
- Set `Peer.Endpoint` to the public IP/domain of your EasyTier node
|
||||
- Import the modified configuration into your WireGuard client
|
||||
3. In the output configuration, replace a wildcard `Peer.Endpoint` with the
|
||||
public IP/domain of your EasyTier node, then import it. `Interface.Address`
|
||||
is local to that WireGuard client and may be changed to any IPv4 address;
|
||||
EasyTier translates it to the registered virtual-peer address.
|
||||
|
||||
#### Self-Hosted Public Shared Node
|
||||
|
||||
@@ -296,6 +309,12 @@ sudo easytier-core --network-name mysharednode --network-secret mysharednode
|
||||
|
||||
EasyTier is released under the [LGPL-3.0](https://github.com/EasyTier/EasyTier/blob/main/LICENSE).
|
||||
|
||||
## Responsible Use
|
||||
|
||||
Use EasyTier only for lawful purposes and in compliance with applicable laws
|
||||
and regulations. You are responsible for ensuring that you are authorized to
|
||||
connect to and administer the networks and devices involved.
|
||||
|
||||
## Sponsor
|
||||
|
||||
CDN acceleration and security protection for this project are sponsored by Tencent EdgeOne.
|
||||
@@ -307,6 +326,7 @@ CDN acceleration and security protection for this project are sponsored by Tence
|
||||
</p>
|
||||
|
||||
Special thanks to [Langlang Cloud](https://langlangy.cn/?i26c5a5) and [RainCloud](https://www.rainyun.com/NjM0NzQ1_) for sponsoring our public servers.
|
||||
We also thank [Linfeng Cloud (林枫云)](https://www.dkdun.cn/aff/RZGBFYSW) for supporting EasyTier.
|
||||
|
||||
<p align="center">
|
||||
<a href="https://langlangy.cn/?i26c5a5" target="_blank">
|
||||
@@ -315,6 +335,9 @@ Special thanks to [Langlang Cloud](https://langlangy.cn/?i26c5a5) and [RainClou
|
||||
<a href="https://langlangy.cn/?i26c5a5" target="_blank">
|
||||
<img src="assets/raincloud.png" width="200">
|
||||
</a>
|
||||
<a href="https://www.dkdun.cn/aff/RZGBFYSW" target="_blank">
|
||||
<img src="assets/linfengyun.png" width="200" alt="林枫云">
|
||||
</a>
|
||||
</p>
|
||||
|
||||
|
||||
|
||||
+31
-11
@@ -7,7 +7,7 @@
|
||||
[](https://github.com/EasyTier/EasyTier/actions/workflows/core.yml)
|
||||
[](https://github.com/EasyTier/EasyTier/actions/workflows/gui.yml)
|
||||
[](https://github.com/EasyTier/EasyTier/actions/workflows/test.yml)
|
||||
[](https://deepwiki.com/EasyTier/EasyTier)
|
||||
[<img src="https://devin.ai/assets/askdeepwiki.png" alt="Ask DeepWiki" height="20"/>](https://deepwiki.com/EasyTier/EasyTier)
|
||||
|
||||
[简体中文](/README_CN.md) | [English](/README.md)
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
<img src="assets/running-page.png" width="300" alt="运行页面">
|
||||
</p>
|
||||
|
||||
📚 **[完整文档](https://easytier.cn)** | 🖥️ **[Web 控制台](https://easytier.cn/web)** | 📝 **[下载发布版本](https://github.com/EasyTier/EasyTier/releases)** | 🧩 **[第三方工具](https://easytier.cn/guide/installation_gui.html#%E7%AC%AC%E4%B8%89%E6%96%B9%E5%9B%BE%E5%BD%A2%E7%95%8C%E9%9D%A2)** | ❤️ **[赞助](#赞助)**
|
||||
📚 **[完整文档](https://easytier.cn)** | 🖥️ **[Web 控制台](https://easytier.cn/web)** | 📝 **[下载发布版本](https://github.com/EasyTier/EasyTier/releases)** | **[Nightly](https://github.com/EasyTier/EasyTier/releases/tag/nightly)** | 🧩 **[第三方工具](https://easytier.cn/guide/installation_gui.html#%E7%AC%AC%E4%B8%89%E6%96%B9%E5%9B%BE%E5%BD%A2%E7%95%8C%E9%9D%A2)** | ❤️ **[赞助](#赞助)**
|
||||
|
||||
## 特性
|
||||
|
||||
@@ -69,8 +69,17 @@ irm "https://github.com/EasyTier/EasyTier/blob/main/script/install.ps1?raw=true"
|
||||
cargo install --git https://github.com/EasyTier/EasyTier.git easytier
|
||||
```
|
||||
|
||||
通过 [cargo-binstall](https://github.com/cargo-bins/cargo-binstall) 安装预编译命令行工具(请指定已有 release 的版本):
|
||||
```bash
|
||||
cargo binstall --git https://github.com/EasyTier/EasyTier.git --version 2.6.4 easytier
|
||||
```
|
||||
|
||||
此方式使用仓库中的 metadata 安装 `easytier-core` 和 `easytier-cli`,不依赖 crates.io 发布。在 Windows 上,还需将对应 release ZIP 中的 DLL 和驱动文件复制到安装后的程序旁;binstall 只安装可执行文件。
|
||||
|
||||
[下载预编译文件](https://github.com/EasyTier/EasyTier/releases)(推荐,支持所有平台)
|
||||
|
||||
需要更小的可执行文件,可使用 [easytier-mini](https://github.com/EasyTier/easytier-mini):支持基础 TCP/UDP 组网,功能范围与使用方式见其文档。
|
||||
|
||||
[通过 Docker 安装](https://easytier.cn/guide/installation.html#%E5%AE%89%E8%A3%85%E6%96%B9%E5%BC%8F)
|
||||
|
||||
[安装 OpenWrt ipk 软件包](https://github.com/EasyTier/luci-app-easytier)
|
||||
@@ -108,9 +117,9 @@ sudo easytier-core -d --network-name abc --network-secret abc -p tcp://<共享
|
||||
```text
|
||||
| ipv4 | hostname | cost | lat_ms | loss_rate | rx_bytes | tx_bytes | tunnel_proto | nat_type | id | version |
|
||||
| ------------ | -------------- | ----- | ------ | --------- | -------- | -------- | ------------ | -------- | ---------- | --------------- |
|
||||
| 10.126.126.1 | abc-1 | Local | * | * | * | * | udp | FullCone | 439804259 | 2.6.0-70e69a38~ |
|
||||
| 10.126.126.2 | abc-2 | p2p | 3.452 | 0 | 17.33 kB | 20.42 kB | udp | FullCone | 390879727 | 2.6.0-70e69a38~ |
|
||||
| | PublicServer_a | p2p | 27.796 | 0.000 | 50.01 kB | 67.46 kB | tcp | Unknown | 3771642457 | 2.6.0-70e69a38~ |
|
||||
| 10.126.126.1 | abc-1 | Local | * | * | * | * | udp | FullCone | 439804259 | 2.6.2-70e69a38~ |
|
||||
| 10.126.126.2 | abc-2 | p2p | 3.452 | 0 | 17.33 kB | 20.42 kB | udp | FullCone | 390879727 | 2.6.2-70e69a38~ |
|
||||
| | PublicServer_a | p2p | 27.796 | 0.000 | 50.01 kB | 67.46 kB | tcp | Unknown | 3771642457 | 2.6.2-70e69a38~ |
|
||||
```
|
||||
|
||||
您可以测试节点之间的连通性:
|
||||
@@ -250,8 +259,12 @@ ios <-.-> nodea <--> nodeb <-.-> id1
|
||||
1. 启动启用 WireGuard 门户的 EasyTier:
|
||||
|
||||
```bash
|
||||
# 在 0.0.0.0:11013 上监听,并使用 10.14.14.0/24 子网作为 WireGuard 客户端
|
||||
sudo easytier-core -i 10.144.144.1 --vpn-portal wg://0.0.0.0:11013/10.14.14.0/24
|
||||
# 将一个 WireGuard 客户端注册为虚拟 peer 10.144.144.3
|
||||
sudo easytier-core -i 10.144.144.1 \
|
||||
--network-secret portal-secret \
|
||||
--vpn-portal wg://0.0.0.0:11013 \
|
||||
--vpn-portal-private-key "$(wg genkey)" \
|
||||
--vpn-portal-client phone=10.144.144.3
|
||||
```
|
||||
|
||||
2. 获取 WireGuard 客户端配置:
|
||||
@@ -261,10 +274,9 @@ sudo easytier-core -i 10.144.144.1 --vpn-portal wg://0.0.0.0:11013/10.14.14.0/24
|
||||
easytier-cli vpn-portal
|
||||
```
|
||||
|
||||
3. 在输出配置中:
|
||||
- 将 `Interface.Address` 设置为 WireGuard 子网中的可用 IP
|
||||
- 将 `Peer.Endpoint` 设置为您的 EasyTier 节点的公网 IP/域名
|
||||
- 将修改后的配置导入到您的 WireGuard 客户端
|
||||
3. 如果输出配置中的 `Peer.Endpoint` 是通配地址,将其替换为 EasyTier
|
||||
节点的公网 IP/域名后即可导入。`Interface.Address` 只是客户端本地地址,
|
||||
可以改为任意 IPv4 地址;EasyTier 会把它转换成已注册的虚拟 peer 地址。
|
||||
|
||||
#### 自建公共共享节点
|
||||
|
||||
@@ -296,6 +308,10 @@ sudo easytier-core --network-name mysharednode --network-secret mysharednode
|
||||
|
||||
EasyTier 在 [LGPL-3.0](https://github.com/EasyTier/EasyTier/blob/main/LICENSE) 许可下发布。
|
||||
|
||||
## 使用规范
|
||||
|
||||
请仅将 EasyTier 用于合法用途,并遵守适用的法律法规。使用者有责任确保其已获授权连接和管理相关网络与设备。
|
||||
|
||||
## 赞助
|
||||
|
||||
本项目的 CDN 加速和安全防护由腾讯云 EdgeOne 赞助。
|
||||
@@ -307,6 +323,7 @@ EasyTier 在 [LGPL-3.0](https://github.com/EasyTier/EasyTier/blob/main/LICENSE)
|
||||
</p>
|
||||
|
||||
特别感谢 [浪浪云](https://langlangy.cn/?i26c5a5) 和 [雨云](https://www.rainyun.com/NjM0NzQ1_) 赞助我们的公共服务器。
|
||||
也感谢 [林枫云](https://www.dkdun.cn/aff/RZGBFYSW) 对 EasyTier 的支持。
|
||||
|
||||
<p align="center">
|
||||
<a href="https://langlangy.cn/?i26c5a5" target="_blank">
|
||||
@@ -315,6 +332,9 @@ EasyTier 在 [LGPL-3.0](https://github.com/EasyTier/EasyTier/blob/main/LICENSE)
|
||||
<a href="https://langlangy.cn/?i26c5a5" target="_blank">
|
||||
<img src="assets/raincloud.png" width="200">
|
||||
</a>
|
||||
<a href="https://www.dkdun.cn/aff/RZGBFYSW" target="_blank">
|
||||
<img src="assets/linfengyun.png" width="200" alt="林枫云">
|
||||
</a>
|
||||
</p>
|
||||
|
||||
如果您觉得 EasyTier 有帮助,请考虑赞助我们。软件开发和维护需要大量的时间和精力,您的赞助将帮助我们更好地维护和改进 EasyTier。
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 33 KiB |
@@ -0,0 +1,209 @@
|
||||
# 中央网络管理 E2E 验证记录
|
||||
|
||||
## 范围与执行约定
|
||||
|
||||
- 被测 PR:`feat/web-central-console-v2`;初始代码提交:后端 `5f66627d`,前端 `57a15acb`。
|
||||
- 测试日期:2026-09-30。先建立本清单,再逐项执行和回填结果。
|
||||
- 主验证链路:真实浏览器 / HTTP API → 独立 SQLite 数据库 → Web 中央服务 → Docker 内真实 Core → 配置文件 / peer / route / 实际数据包。
|
||||
- 数据面测试使用隔离 network namespace 和测试专属进程;不修改现有业务节点。
|
||||
- `通过` 必须有实际执行证据;单元测试、mock 页面测试不能冒充真实 E2E。覆盖不完整标记 `部分通过`,环境障碍标记 `阻塞`,缺陷标记 `失败`,未执行保持 `待测`。
|
||||
- 首轮只记录缺陷;用户随后批准修复,修复后的专项和受影响链路回归见下文,保留首轮失败证据。
|
||||
- 本文记录中央网络相关功能;第三方 OIDC/验证码供应商、其他平台原生 GUI 不纳入本轮完整 E2E 承诺。
|
||||
|
||||
## 环境与复现入口
|
||||
|
||||
- 宿主机 Web:`cargo build -p easytier-web --features embed`,Docker `rust` 中 Core/CLI:`cargo build -p easytier --bins`;版本 `2.7.0-57a15acb`。
|
||||
- `rust` 容器具备 root、TUN、iproute2、原生 wg、ping、Python。每次数据面测试创建独立 bridge 和四个 network namespace,清理时只删除本次资源。未修改容器全局 IP forwarding。
|
||||
- 数据面复现:构建后在 `easytier-web/frontend` 执行 `node tests/central-coverage.mjs`。独立 SQLite、随机端口和进程日志保存在 `.test-env/central-e2e-*`。
|
||||
- 真实 baseline 使用原 `central-e2e.test.mjs`,本次临时副本仅跳过重复构建并增加截图,结束已删除。
|
||||
|
||||
## 用例与结果
|
||||
|
||||
### 访问与运行模式
|
||||
|
||||
| ID | 测试点 | 步骤 / 预期断言 | 状态 | 结果与证据 |
|
||||
|---|---|---|---|---|
|
||||
| A01 | 登录与会话 | 真实浏览器登录,未登录访问中央接口被拒绝,登出后会话失效 | 通过 | 真实 Chromium 双账号登录;匿名与登出后中央接口 401。security results。 |
|
||||
| A02 | 控制台元信息 | 用户名、配置服务器协议/端口、Gateway 开关/地址与启动参数一致 | 通过 | 真实 console-info 核对用户名、协议/端口及模式;Gateway启用/关闭的 peer_url 与 relay_data 均对照启动参数。 |
|
||||
| A03 | 中央与 Console 模式隔离 | 外部 webhook 模式不暴露中央网络路由;原有内部 Full/Patch API 可用 | 通过 | 外部 webhook 模式中央 GET 404/POST 405;真实 Core internal Full/Patch、Web 重启与删除 TOML 通过。 |
|
||||
| A04 | Gateway 启动参数校验 | 无 Gateway 时禁止创建 Gateway 网络;外部 webhook 与 Gateway 同时配置被拒绝 | 通过 | 无 Gateway 建网 400;webhook+Gateway、监听协议不匹配、非法地址启动 exit 2。 |
|
||||
| A05 | 跨租户隔离 | 另一租户不能读写网络、成员、凭据、ACL、设备,也不能代理其节点 RPC | 通过 | 双租户网络/成员/配置/ACL/凭据/设备/在线代理 RPC 全部隔离;另一网络使用原凭据不能接入。 |
|
||||
|
||||
### 设备管理
|
||||
|
||||
| ID | 测试点 | 步骤 / 预期断言 | 状态 | 结果与证据 |
|
||||
|---|---|---|---|---|
|
||||
| D01 | 设备登记 | 真实 Core 使用登记命令连接,机器 ID、主机名、版本、在线状态可见 | 通过 | 隔离 netns 内三个真实 Core 登记,API 返回机器 ID、版本和在线信息。 |
|
||||
| D02 | 设备展示 | 搜索、排序、列表/卡片、详情及设备所属网络正确 | 通过 | 独立真实浏览器:两 Core 的搜索、排序、列表/卡片、详情与所属网络;刷新一致。 |
|
||||
| D03 | 别名 | 保存、清空、超长拒绝;刷新和服务重启后保持 | 通过 | 别名设置/清空/超长拒绝;真实 Web 重启后 Persisted Alias 仍在。 |
|
||||
| D04 | 离线与重连 | Core 停止后显示离线但保留记录,重连后恢复在线且不重复登记 | 通过 | 真实停止/重启 Core,离线变更在重连后实际生效;设备不重复。 |
|
||||
| D05 | 删除设备 | 移除设备及其成员关系、运行配置和引用;不封禁时允许重新登记 | 通过 | 真实删除并移除成员;非封禁删除后同 machine ID 重新登记成功。 |
|
||||
| D06 | 封禁与解封 | 删除并封禁后拒绝重连,封禁列表记录尝试;解封后可重新登记 | 通过 | 删除封禁后重连尝试被记录;解封后真实 Core 重登记且不恢复旧成员。 |
|
||||
|
||||
### 网络生命周期
|
||||
|
||||
| ID | 测试点 | 步骤 / 预期断言 | 状态 | 结果与证据 |
|
||||
|---|---|---|---|---|
|
||||
| N01 | Gateway 网络创建 | 浏览器创建安全 Gateway 网络,真实成员连接到正确 Gateway | 通过 | 真实浏览器建安全 Gateway 网络;三个 TUN 成员实际 ping 互通。 |
|
||||
| N02 | Manual 网络 | 指定自建真实 peer,配置下发后成功连接 | 通过 | 真实自建 A 作为 Manual peer;B 下发相同 URL 并实际 ping A。 |
|
||||
| N03 | PublicServer 网络 | 使用隔离自建公开 peer 验证 URL 下发和连接,不依赖公网公共节点 | 通过 | PublicServer 指向隔离自建 A;Core 编译后 peer URL 一致并实际互通。运行配置通用表示为 Manual,不要求保留 UI 模式名。 |
|
||||
| N04 | Standalone 网络 | 允许创建并下发,不自动配置外部 peer | 通过 | Standalone 保存后真实 Core peer_urls 清空,不自动连外部 peer。 |
|
||||
| N05 | 基础设置与密钥 | 修改显示名、网络名、网段和网络密钥;成员运行配置随之更新 | 通过 | 显示名/网络名/密钥旋转实际 Core 配置收敛并恢复 ping;网段相关见 R03。 |
|
||||
| N06 | 安全模式 | 安全/非安全网络均可组网,切换后重新收敛 | 通过 | 安全→非安全→安全均实际收敛,期间分别验证 ping。 |
|
||||
| N07 | 模式切换 | Gateway 与其他模式切换,旧 Gateway 退出、新配置生效 | 通过 | 完整模式切换及实际 ping 通过;独立 unmanaged probe 验证切 Standalone 后旧 Gateway 断开且新连接被拒,切回 Gateway 恢复。 |
|
||||
| N08 | 网络删除 | 在线/离线成员配置最终清除,删除最后一个网络不残留 Gateway | 通过 | 最后网络删除后在线/离线成员实例与 TOML 清除;独立 probe 确认旧 Gateway 断开且新 probe 无法接入,全程 Web 不重启。 |
|
||||
| N09 | 非法及重复配置 | 空名字、错误 URL/CIDR、重复网络身份被拒绝且不破坏原配置 | 通过 | 修复后 Manual/PublicServer 在空网创建和已有成员更新时均拒绝 bogus://;合法 discovery 协议仍可保存。原缺陷与修复证据见 F01。 |
|
||||
| N10 | 多网络汇总 | 同一设备加入两个网络,修改/删除其一不影响另一个 | 通过 | 同一 Core 两实例;删除第二网络后第一个实例保留且运行。 |
|
||||
|
||||
### 成员配置
|
||||
|
||||
| ID | 测试点 | 步骤 / 预期断言 | 状态 | 结果与证据 |
|
||||
|---|---|---|---|---|
|
||||
| M01 | 批量添加/移除 | 真实节点加入/离开;重复/不存在/跨租户设备的批量请求不部分提交 | 通过 | 三真实节点添加;重复 ID 409、部分未知/外租户 404,成员与实例均无部分提交;移除另见 D05/N08。 |
|
||||
| M02 | 主机名与静态 IP | 设置和清空成员主机名、IP,运行节点反映修改;重复/越界 IP 被拒绝 | 通过 | 真实成员主机名/IP 设置与清空、重复/越界拒绝;配置生效后实际 ping 通过。 |
|
||||
| M03 | 代理子网 | IPv4 子网配置、路由广播及转发一致;当前不支持的 IPv6 输入原子拒绝 | 通过 | IPv4 代理子网真实路由和 ping 通过;IPv6 输入保存前400,数据库及两Core TOML保持。原缺陷见 F02。 |
|
||||
| M04 | 高级配置 | 读取、保存、重置覆盖配置;运行与持久化一致 | 通过 | 高级 MTU 1300 下发;读取覆盖、清空重置均经真实 API 验证。 |
|
||||
| M05 | 中央所有权 | 高级配置不能覆盖中央身份、凭据、ACL;直接 REST/RPC 修改中央实例被拒绝 | 通过 | 独立实测伪造/清空 identity、secure_mode、managed_credentials、ACL 均保留中央值且 MTU 生效;真实 TOML/RPC 核对。直接 run/save/delete 与凭据写 RPC 均 409。 |
|
||||
| M06 | 离线下发 | 设备离线期间修改/删除,重连后恢复最新完整配置 | 通过 | 离线修改主机名 → Web 重启 → Core 重连读到最新值并能 ping;baseline另验证离线删除 TOML。 |
|
||||
| M07 | 并发操作 | 同时修改不同成员/网络,最终完整配置包含全部成功变更 | 通过 | 同时修改两个真实成员主机名,最终成员配置同时保留两次成功修改。 |
|
||||
| M08 | 成员编辑竞态 | 迟到的 A 成员配置响应不能污染 B 成员表单和保存目标 | 通过 | route.fetch 取得真实 A 响应后延迟交付;切到 B,迟到响应未覆盖 B,PUT 与持久化目标均正确。 |
|
||||
|
||||
### 凭据及临时节点
|
||||
|
||||
| ID | 测试点 | 步骤 / 预期断言 | 状态 | 结果与证据 |
|
||||
|---|---|---|---|---|
|
||||
| C01 | 凭据生成与展示 | 生成、列表、复制/接入信息、TTL 和复用属性一致,刷新仍可查看 | 通过 | 真实浏览器生成一小时/nonreusable凭据;读取剪贴板核对 secret/CLI/TOML;刷新保持,执行复制命令成功接入。 |
|
||||
| C02 | 多节点复用 | 两个真实临时 Core 使用同一 reusable 凭据,成员/凭据视图均展示 | 通过 | 真实 baseline 两个独立 Core 使用同一 reusable 凭据;成员和凭据页均展示两个 peer。 |
|
||||
| C03 | 非复用约束 | 同一 non-reusable 凭据的并发连接受限 | 通过 | 两个真实 Core 共用不可复用凭据,仅一个身份进入管理员路由;停止赢家后另一节点接替。控制连接数量不是路由独占的判断标准。 |
|
||||
| C04 | 撤销 | 撤销后既有连接断开且不能重新连接,视图清理 | 通过 | 真实 baseline 撤销后两 Core 断开,等待 6 秒仍不能重连,临时节点视图清空。 |
|
||||
| C05 | 过期 | 短 TTL 凭据到期后连接被清理,不能再次接入 | 通过 | 15 秒 TTL 到期后实际远端路由移除;用同一凭据重启 Core,6.5 秒仍不能重连。 |
|
||||
| C06 | 临时托管成员 | 临时成员收到专属凭据而非网络密钥,IP/子网与权限一致 | 通过 | 真实 TOML/credential grant 校验;临时 C→B 指定 ACL 允许而 A 被阻断;C 代理子网实际访问按 ACL 区分 A/B。 |
|
||||
| C07 | 受引用与非法凭据 | 被成员引用的凭据不可单独撤销,非法 TTL/重复 ID 不破坏原状态 | 通过 | 被临时成员引用撤销 409;TTL 0/超一年 400、负值 422、重复 ID 409且原列表保持。 |
|
||||
|
||||
### ACL 策略
|
||||
|
||||
| ID | 测试点 | 步骤 / 预期断言 | 状态 | 结果与证据 |
|
||||
|---|---|---|---|---|
|
||||
| L01 | 编辑流程 | 浏览器新增、编辑、禁用、排序、删除规则,刷新后保持 | 通过 | 真实浏览器 ACL 新建、编辑、禁用、拖拽排序、删除;API 与页面刷新均确认持久化。 |
|
||||
| L02 | 默认策略 | 真实数据包验证默认 allow/deny,而不只检查保存成功 | 通过 | 实际 ICMP:默认 allow 可达 → deny 阻断 → allow 恢复。 |
|
||||
| L03 | 成员与组选择 | 真实安全组成员匹配,未授权成员不能借用另一成员权限 | 通过 | 实际安全成员组:指定 A→B 允许,未授权 C→B 被阻断。 |
|
||||
| L04 | 协议与端口 | 实际 TCP/UDP/ICMP 流量验证允许/拒绝及端口范围 | 通过 | 五个真 echo 端口先确认存活;TCP 18080 通/18082 断;UDP 范围两端 18081、18082 通/18083 断;跨协议和 ICMP 另有实测。 |
|
||||
| L05 | 子网目标 | 验证成员代理子网规则的编译和真实转发效果 | 通过 | IPv4 代理子网实际 ICMP;仅允许子网时节点 VIP 不可达;临时成员代理权限另独立实测通过。IPv6输入缺陷见 F02。 |
|
||||
| L06 | 统计 | 产生流量后节点 ACL 统计可查询并对应命中规则 | 通过 | 产生 ping 后真实 ACL RPC stats 返回对应规则 ID、命中包和字节计数。 |
|
||||
| L07 | 删除引用与非法规则 | 移除成员清理引用;错误选择器/端口被拒绝且旧策略保持 | 通过 | 真实 baseline 移除成员清理 ACL 引用;14 种非法规则全部 400,每次读回旧策略完全相等。 |
|
||||
|
||||
### WireGuard 管理
|
||||
|
||||
| ID | 测试点 | 步骤 / 预期断言 | 状态 | 结果与证据 |
|
||||
|---|---|---|---|---|
|
||||
| W01 | 启用与关闭 | 成员高级配置启用 WireGuard;关闭保留私钥和客户端,再启用恢复 | 通过 | 原生 wg 客户端实际流量:关闭阻断;服务端私钥和客户端保留;重新启用恢复。 |
|
||||
| W02 | 客户端增删与清空 | 中央成员通过配置保存管理客户端;普通实例热更新同步持久化 | 通过 | 中央完整配置添加/清空;普通 Console 模式 RPC 添加/移除/清空,对照实际 Core、Web SQLite、TOML 与重启恢复。 |
|
||||
| W03 | 真实 WireGuard 接入 | 原生 wg 客户端完成握手,通过隧道访问真实网络节点 | 通过 | 原生 Linux wg latest-handshakes 非零;通过 Portal 实际 ping 另一真实 Core。 |
|
||||
| W04 | 地址/权限校验 | 重复/非法地址与未声明组被拒绝,已有有效客户端不受损 | 通过 | 中央配置重复地址/名称、非法IP、网络/广播/节点地址、未知组均400,原配置及流量保持;普通实例验证同前。见 F02。 |
|
||||
| W05 | 撤销与重启持久化 | 移除客户端后不能访问;保留客户端在节点/服务重启后仍能接入 | 通过 | 原生 wg 清空客户端后实际 ping 被阻断;有效客户端跨 Core 重启可重新握手和访问。 |
|
||||
|
||||
### 运行详情与数据面
|
||||
|
||||
| ID | 测试点 | 步骤 / 预期断言 | 状态 | 结果与证据 |
|
||||
|---|---|---|---|---|
|
||||
| R01 | 节点详情 | 真实节点 peer、route、TOML、版本/错误等展示与 RPC 一致 | 通过 | 真实浏览器 peer/route IP/CIDR、连接计数、版本与 RPC 一致;导出 TOML 与真实 RPC 逐字相同。 |
|
||||
| R02 | 日志级别 | 读取/设置真实节点日志级别并读回确认 | 通过 | 真实浏览器逐级设置六种日志级别、RPC读回与整页刷新回显均正确;省略字段显示Disabled,中文选项正确。见 F03。 |
|
||||
| R03 | DHCP 全动态网络 | 两个全 DHCP 节点在无静态 IP 时分配不同地址并能互通 | 通过 | 三个全 DHCP Core 无静态成员,获得三个不同 10.126.126.x 地址并实际互通。 |
|
||||
| R04 | DHCP 地址保持 | 已有地址节点暂时失去其他 IPv4 广播,不切回默认网段 | 通过 | 另独立验证 A DHCP 从 B 继承非默认10.88.99.1/24;停B、route只剩无IPv4 Gateway后,18秒9次读回均保留原地址。 |
|
||||
| R05 | Gateway 同端口分流 | 管理连接和普通/Noise 组网连接共用端口,相互不串流 | 通过 | 同一 TCP端口承载三 Core 的配置登记及 Gateway secure/普通组网,切换安全模式后实际 ping 均通过。 |
|
||||
| R06 | Gateway 中继开关 | 以只经 Gateway 的拓扑确认 relay_data 开关效果 | 通过 | 真实 Gateway 拓扑禁用成员 P2P;relay_data 开→ping通,重启关→ping断,重启开→恢复。 |
|
||||
|
||||
### 恢复与兼容性
|
||||
|
||||
| ID | 测试点 | 步骤 / 预期断言 | 状态 | 结果与证据 |
|
||||
|---|---|---|---|---|
|
||||
| P01 | Web 服务重启 | 网络、成员、凭据、ACL、Gateway 与设备别名恢复,运行配置不丢失 | 通过 | 真实 Web 重启恢复网络/成员/凭据/Gateway/别名及离线新配置;另保持 default deny 的 UDP 范围 ACL 重启,GET 完全相同且实际允许/拒绝均保持。 |
|
||||
| P02 | Core 重启 | 持久化 TOML 恢复并与最新中央配置收敛 | 通过 | 真实 Core 重启后最新中央配置恢复;WireGuard 保留客户重新接入。 |
|
||||
| P03 | 普通实例兼容 | 非中央实例已有保存/运行/停止及 WireGuard 操作保持可用 | 通过 | 外部 Console webhook 模式真实普通实例保存/启动/停止/重启/删除及 WG 热更新;核对 Web SQLite 与 Core TOML。 |
|
||||
| P04 | HTTP 安全随机 | 普通 HTTP 无 randomUUID 时 ACL ID 和网络密钥仍用安全随机源 | 通过 | loopback HTTP 页面手动禁用 randomUUID,观测 getRandomValues;ACL ID 与密钥成功生成,新密钥落入真实 Core TOML。 |
|
||||
| P05 | 浏览器交互补充 | 导航/刷新、暗色/多语言/移动端及错误恢复;明确区分真实 E2E 与 mock UI 测试 | 通过 | 真实中英/明暗/桌面移动布局、导航刷新及 Web 真停机→Retry→重启重登录恢复;另有16项mock UI补充。 |
|
||||
|
||||
## 执行记录与证据索引
|
||||
|
||||
首次执行 59 个功能条目:**55 通过、4 失败、0 待测**。当时失败的 N09、M03、W04、R02 归为下述三组发现;不把同一无效配置造成的后续失败重复计数。主数据面基线 22/22 通过不代表整体验收通过;审查补强和定向复测单独列出,未隐藏测试等待条件问题。
|
||||
|
||||
原始日志、截图、临时密钥/DB 和辅助脚本保留在本机忽略目录 `.test-env/`,不提交测试密钥和数据库。下表路径相对于仓库根目录;辅助报告中保留原 `/tmp` 来源路径,但其内容也已复制归档。
|
||||
|
||||
| 证据 | 最终执行 | 归档位置 |
|
||||
|---|---|---|
|
||||
| 主真实数据面 | 22/22 组通过 | `.test-env/central-e2e-2316549/results.json` 与各进程日志;复现脚本 `easytier-web/frontend/tests/central-coverage.mjs` |
|
||||
| 审查后加强端口断言的主套件 | 21/22 通过;M02 等待条件修正后定向4/4通过 | `.test-env/central-e2e-2745102/results.json`;`.test-env/central-e2e-2971552/results.json` |
|
||||
| 原真实 baseline | 1/1 通过,18.24 秒 | `.test-env/central-e2e-evidence-20260930/central-e2e-baseline-evidence/baseline.log`,3 张真实截图 |
|
||||
| Dashboard mock 补充 | 16/16 通过,33.56 秒 | 同上 `dashboard.log`;此项不计为真实后端验证 |
|
||||
| 访问/隔离/Console/非法 URL | 14 组通过,N09 失败 | `.test-env/central-e2e-evidence-20260930/central-security-e2e-vM8P3H/` |
|
||||
| Gateway 在线生命周期 | 3/3 通过,Web PID 全程不变 | `.test-env/central-e2e-evidence-20260930/central-security-e2e-CY4Q6S/` |
|
||||
| 非法 ACL | 14 个请求全部正确拒绝 | `.test-env/central-e2e-evidence-20260930/central-security-e2e-qw5Fsn/` |
|
||||
| 凭据生命周期 | 1 综合场景通过,55.39 秒 | `.test-env/central-e2e-evidence-20260930/central-e2e-credential-evidence/` |
|
||||
| 批次原子性/浏览器凭据复制 | 1 综合场景通过,6.46 秒 | `.test-env/central-e2e-evidence-20260930/central-e2e-batch-evidence/` |
|
||||
| 同协议端口范围/非默认 ACL 重启 | 4/4 通过 | `.test-env/central-e2e-port-range-2757460/results.json` |
|
||||
| 中央 override 所有权 | 2/2 通过 | `.test-env/central-e2e-evidence-20260930/central-security-e2e-eOCOeK/` |
|
||||
| 临时成员真实包权限 | 3/3 通过 | `.test-env/central-e2e-temporary-2280549/results.json` |
|
||||
| 非默认 DHCP 地址保持 | 1/1 通过,18 秒连续观察 | `.test-env/central-e2e-dhcp-hold-2439398/results.json` |
|
||||
| 真实 UI/竞态/随机数/故障恢复 | 6/6 通过 | `.test-env/central-e2e-evidence-20260930/central-ui-e2e-YhcgUD/`,16 张截图 |
|
||||
| 普通实例/WG/IPv6诊断 | 12/12 组通过 | `.test-env/central-e2e-evidence-20260930/central-security-e2e-F7VjL0/` |
|
||||
| 中央 WG 非法配置 | 3 个非法请求均暴露 F02 | `.test-env/central-e2e-evidence-20260930/central-security-e2e-HvHrqo/` |
|
||||
| 真实节点详情/日志级别 UI | R01 通过、R02 失败 | `.test-env/central-e2e-evidence-20260930/central-node-ui-e2e-5UwmgY/`,6 张截图 |
|
||||
|
||||
执行过程中修正过测试假设:CIDR 地址不能直接作为 ping 目标;连续配置发布后需等待实例收敛;protobuf JSON enum 返回名称且默认零值省略;Core 通用 peer 配置不保留 PublicServer UI 名称;切到 DHCP 需清除之前固定的成员 IP;Docker 创建的 600 权限 TOML 通过容器读取;不可复用凭据检查可路由身份而非物理连接数量。这些不是产品缺陷。早期数据面记录保留于 `.test-env/central-e2e-audit-*`、`.test-env/central-e2e-2067723/`,主套件完整正向基线使用 `2316549`,端口范围同时由 `2745102` 与独立 `2757460` 实测。`2745102` 的 M02 在地址已上报但路由尚未收敛时立即 ping 失败;脚本改为等待实际可达后,原步骤定向重跑 `2971552` 4/4 通过;IPv6 阻塞证据仍使用早期运行,并由独立 Core 请求复核。
|
||||
|
||||
## 首轮缺陷及修复状态
|
||||
|
||||
### F01:不支持的 peer 协议可保存并触发连续失败(已修复)
|
||||
|
||||
- **major / high confidence**;对应 N09。
|
||||
- 有效网络已有真实 Core,PATCH 网络为 Manual,`peer_urls=["bogus://127.0.0.1:9999"]`。
|
||||
- 实际 HTTP 200,SQLite 网络意图被替换;真实 Core 报 `unsupported core manual connector URL`。18 秒观察内有 292 次实例创建失败重试。
|
||||
- 预期保存前拒绝并保持有效配置。根因入口 `easytier-web/src/central_network/compiler.rs:232`,目前仅检查 URL 能否解析。
|
||||
- 证据:归档 `central-security-e2e-vM8P3H/results.json` 的 `N09-protocol` / `N09-runtime-evidence`,`central.log:1221` 起。
|
||||
- `http://` 和 `https://` 是 Core 支持的 discovery connector,不在非法协议之列。
|
||||
|
||||
### F02:中央完整配置缺少 Core 可执行性校验(已修复已发现路径)
|
||||
|
||||
- **major / high confidence**;对应 M03、W04。属中央编译/发布边界的局部架构问题,不能把后续每次下发失败作为新 bug。
|
||||
- 成员 PATCH `proxy_cidrs=["198.18.240.0/24","2001:db8:240::/64"]` 返回 200,但 Core 的代理网段转换只接受 IPv4;该设备 TOML 保持旧值,后续主机名/网络身份更新也不收敛。独立验证开启 IPv6 同样失败,移除 IPv6 网段成功,排除测试环境关闭 IPv6 的影响。
|
||||
- 中央成员完整配置 PUT 分别加入重复 WireGuard IP、`virtual_ip="invalid-ip"`、`groups=["not-declared"]`,都返回 200;真实 Core 分别报 duplicate IP、parse error、unknown ACL group。三轮均从有效基线独立开始并恢复。
|
||||
- 普通实例的 WG 客户端 PATCH 对上述非法输入会拒绝,且 Web DB/运行配置保持。因此普通热更新验证不能替代中央 Full 的验证。
|
||||
- 证据:`.test-env/central-e2e-audit-1689346/` 的成员快照、DB 与旧 TOML;归档 `central-security-e2e-F7VjL0/results.json`;`central-security-e2e-HvHrqo/results.json` 和 `central-full-validation.log:29` 起。
|
||||
- 修复已在中央编译阶段复用 Core 配置转换、可移植配置规范化和 Portal 客户端校验;Core 只开放原有纯校验函数。
|
||||
|
||||
### F03:日志级别页面回显错误(已修复)
|
||||
|
||||
- **minor / high confidence**;对应 R02;实际设置日志级别能够生效。
|
||||
- 六个下拉选项显示翻译函数源码;初始 DISABLED RPC 返回 `{}`,页面误显示 Info;设置 WARNING 后 RPC 返回 `{"level":"WARNING"}`,重开详情显示 Loading。
|
||||
- 两处局部问题:`easytier-web/frontend/src/components/NetworkDetail.vue:456-463` 使用函数作为 option label;`easytier-web/frontend/src/modules/api.ts:457-463` 未转换 protobuf 字符串枚举且零值默认不正确。
|
||||
- 证据:归档 `central-node-ui-e2e-5UwmgY/results.json` 的 R02-initial/options/after-set/reopen 与四张 R02 截图。修复后六级设置、刷新和翻译均经真实浏览器与 RPC 重新验证。
|
||||
|
||||
## 修复回归(2026-09-30)
|
||||
|
||||
**当前清单 59 项均为通过,0 项待测、0 项未关闭缺陷。** 原55项的证据保留;本次重跑受影响的主数据面、原浏览器生命周期及专项,未声称将首轮所有独立脚本再执行一遍。
|
||||
|
||||
- 后端:在数据库写事务开始前的中央 `compile()` 里检查候选配置。网络级 URL 复用 Core 支持规则,覆盖无成员的网络;成员配置先合并中央字段与 override,再执行 Core 转换及 Portal 客户端校验。失败返回400,旧数据库和运行状态不变。
|
||||
- Core:只公开现有 `validate_manual_url`、`validate_clients`,没有改变实例运行逻辑、增加 RPC 或读取远端设备状态;不执行主机能力校验。
|
||||
- 前端:protobuf 日志枚举转换为数字,省略字段对应 Disabled=0;下拉项使用随语言更新的翻译字符串。
|
||||
- IPv6 代理子网按当前 Core 转换能力拒绝,本轮未扩展 IPv6 代理功能。
|
||||
|
||||
| 回归 | 结果 | 本机证据 |
|
||||
|---|---|---|
|
||||
| 后端完整测试 | 232/232 通过,含协议/成员配置/事务不变回归 | `.test-env/central-fix-20260930/central-fix-web-tests.log` |
|
||||
| 前端构建与 Web embed 构建 | 通过 | `.test-env/central-fix-20260930/central-fix-web-build.log` |
|
||||
| Dashboard 浏览器回归 | 17/17 通过,含日志枚举专项;mock API | `.test-env/central-fix-20260930/central-fix-dashboard.log` |
|
||||
| 真实主数据面 | 22/22 通过 | `.test-env/central-e2e-6121215/results.json` |
|
||||
| 原真实浏览器生命周期 | 1/1 通过,15.92 秒 | `.test-env/central-fix-20260930/central-fix-baseline.log` |
|
||||
| 配置拒绝与原子性专项 | 14/14 通过 | `.test-env/central-validation-6182364/results.json`;脚本 `easytier-web/frontend/tests/central-validation.mjs` |
|
||||
| 真实日志级别 UI/RPC | R01/R02 均通过;六级逐次设置、刷新、中文选项 | `.test-env/central-fix-logger-20260930/evidence/results.json` |
|
||||
|
||||
专项的12个非法请求均返回400:对照3张中央意图表完整行、网络与成员配置、两个真实 Core 的 `show_node_info` TOML,全部不变;拒绝期间每条连续15个 ping,共180包零丢包。合法 HTTP/HTTPS/TXT/SRV × Manual/PublicServer 共8次空网保存成功;IPv4代理路由/实际ping、有效WireGuard客户端和清空均通过。
|
||||
|
||||
原 baseline 最初在旧断言“IPv6代理保存200”处失败,这正是本次修复改变的行为。现改为先断言IPv6返回400,再用受支持的IPv4代理子网完成浏览器ACL流程,重跑通过;旧输出保留在 `central-fix-baseline-old-expectation.log`,不计为新的产品回退。
|
||||
|
||||
### 覆盖边界
|
||||
|
||||
- 本轮覆盖所列中央管理功能在 Linux + Chromium + TCP Gateway 下的真实链路;不据此声称所有操作系统、浏览器、底层传输协议组合或长时间压力场景均通过。
|
||||
- PublicServer 使用隔离自建真实 peer;外部 Console 仅模拟第三方 webhook 的 validate-token 响应,其 Web、Core、SQLite、Full/Patch 路径真实执行。
|
||||
- HTTP 随机数用例在 loopback origin 手动禁用 randomUUID,实际调用 getRandomValues;不冒充公网非安全 origin 的浏览器兼容矩阵。
|
||||
- 所有测试专属 Web/Core、bridge 和 network namespace 均已清理;保留忽略目录证据。修复只涉及中央编译器、Core 校验函数可见性、日志页面及回归测试;未修改 ClientManager,未 push。
|
||||
@@ -0,0 +1,667 @@
|
||||
# EasyTier Core Architecture
|
||||
|
||||
## Status and scope
|
||||
|
||||
This document describes the current architecture after the portable-core
|
||||
refactor. It is the source of truth for ownership, dependency direction,
|
||||
feature boundaries, and validation. It intentionally records the resulting
|
||||
design rather than the migration history.
|
||||
|
||||
The refactor has three principal crate roles:
|
||||
|
||||
- `easytier-core` owns portable EasyTier configuration, protocol state,
|
||||
routing, peer state, connectivity orchestration, packet processing, and
|
||||
instance lifecycle.
|
||||
- `easytier` is the native composition root. It owns operating-system
|
||||
resources, native protocol engines, process integration, CLI and native
|
||||
presentation.
|
||||
- `easytier-proto` owns generated protobuf and RPC types, descriptor data, and
|
||||
the feature slices needed by core and presentation users.
|
||||
|
||||
`easytier-core` is designed to compile without direct operating-system network
|
||||
access. It supports native hosts through Rust traits and has a target-only WASI
|
||||
adapter and ABI implementation under `easytier-core/src/wasi`.
|
||||
|
||||
This architecture does not require compatibility with old internal module
|
||||
paths. Wire compatibility, configuration compatibility, management semantics,
|
||||
and externally used application behaviour remain compatibility requirements.
|
||||
|
||||
## Architectural vocabulary
|
||||
|
||||
The following terms have specific meanings in this document:
|
||||
|
||||
- **Module**: an interface and the implementation hidden behind it.
|
||||
- **Host**: the process or runtime embedding core and owning platform
|
||||
resources.
|
||||
- **Host capability**: an operation core may request but must not implement
|
||||
with direct OS calls.
|
||||
- **Adapter**: a concrete implementation of a Host capability or protocol
|
||||
extension.
|
||||
- **Composition root**: code that creates core configuration, Host Adapters,
|
||||
instances, and process-level services.
|
||||
- **Runtime configuration**: the authoritative normalized state used after an
|
||||
instance starts.
|
||||
- **Packet plane**: portable packet classification, routing, transformation,
|
||||
proxy/NAT state, and forwarding decisions.
|
||||
|
||||
New abstractions should pass a deletion test: deleting a useful deep Module
|
||||
should force non-trivial policy or lifecycle logic to reappear in multiple
|
||||
callers. A pass-through wrapper with no independent invariant is not an
|
||||
architectural boundary.
|
||||
|
||||
## Crate dependency direction
|
||||
|
||||
The principal dependency direction is:
|
||||
|
||||
```text
|
||||
easytier-proto <- easytier-core <- easytier
|
||||
```
|
||||
|
||||
Presentation crates and platform integrations consume these crates. Portable
|
||||
policy must not move outward merely because one current consumer is native.
|
||||
Conversely, core must not absorb an OS mechanism or a protocol engine whose
|
||||
dependencies cannot satisfy the core target contract.
|
||||
|
||||
### `easytier-proto`
|
||||
|
||||
The protobuf crate is split by public Cargo features:
|
||||
|
||||
- `core` provides the common wire messages, peer RPC messages, generated RPC
|
||||
runtime, and descriptor bytes needed by core.
|
||||
- `api` adds management API messages.
|
||||
- protocol-specific features add only their generated message modules.
|
||||
- `json-rpc` enables the well-known protobuf JSON types used by the management
|
||||
plane.
|
||||
- `full` is the compatibility aggregate used by complete products.
|
||||
|
||||
The core crate depends on `easytier-proto` with default features disabled and
|
||||
enables only `core`, adding API or JSON-RPC types through its own management
|
||||
features.
|
||||
|
||||
The main core/native path has no `prost-reflect` dependency. OSPF route
|
||||
reflection uses the focused wire editor in
|
||||
`peers/route/route_peer_wire.rs`. It retains the original encoded
|
||||
`RoutePeerInfo`, replaces only the fields credential filtering is allowed to
|
||||
change, and leaves all other top-level and nested fields intact. This is
|
||||
required so unknown fields survive mixed-version, multi-hop propagation.
|
||||
Generated Rust types remain responsible for normal message construction and
|
||||
validation.
|
||||
|
||||
Descriptor sets are still generated and embedded by `easytier-proto`; removing
|
||||
runtime reflection did not remove descriptor data used by configuration and
|
||||
RPC tooling. The OHOS integration has its own schema service and dependency
|
||||
policy and is outside this replacement.
|
||||
|
||||
### `easytier-core`
|
||||
|
||||
Core owns portable behaviour and exposes capability seams. Its normal
|
||||
dependencies use Tokio runtime, time, synchronization, and I/O traits without
|
||||
requiring the full Tokio feature set.
|
||||
|
||||
Core may depend on optional portable engines when their owning feature is
|
||||
enabled. It does not create real native TCP/UDP sockets, alter routes, open a
|
||||
TUN device, enter a network namespace, configure system DNS, manage a service,
|
||||
or invoke UPnP/NAT-PMP directly.
|
||||
|
||||
### `easytier`
|
||||
|
||||
The native crate owns:
|
||||
|
||||
- process startup, shutdown, signals, service management, and allocators;
|
||||
- filesystem configuration input and persistence;
|
||||
- real TCP/UDP, DNS, TUN, raw-socket, route, interface, namespace, and socket
|
||||
option operations;
|
||||
- UPnP and NAT-PMP operations;
|
||||
- Unix and FakeTCP resources;
|
||||
- WebSocket/WSS, QUIC, WireGuard, and KCP concrete engines;
|
||||
- native Magic DNS serving and system DNS integration;
|
||||
- CLI, web, GUI, FFI, and native management presentation.
|
||||
|
||||
Native code may translate values and assemble Adapters. It must not maintain a
|
||||
second peer graph, reproduce core routing or hole-punch policy, or invent an
|
||||
alternative instance lifecycle.
|
||||
|
||||
## Internal core layers
|
||||
|
||||
The physical module layout follows this downward order:
|
||||
|
||||
```text
|
||||
foundation
|
||||
<- config / packet
|
||||
<- socket
|
||||
<- host
|
||||
<- tunnel
|
||||
<- listener / connectivity
|
||||
<- peers / rpc
|
||||
<- gateway
|
||||
<- instance
|
||||
<- management
|
||||
```
|
||||
|
||||
`process_runtime` is a process- or module-scoped owner shared by instances.
|
||||
`wasi` is target integration and is compiled only for tests or the WASI target;
|
||||
it is not an additional portable domain layer.
|
||||
|
||||
### Foundation
|
||||
|
||||
`foundation/` contains task supervision, the time facade, rate limiting,
|
||||
statistics primitives, and the domain-neutral external operation broker. The
|
||||
broker owns asynchronous operation lifecycle and completion storage while the
|
||||
calling domain owns operation kinds, outcomes, resources, and errors.
|
||||
Foundation must not depend on a domain layer.
|
||||
|
||||
### Configuration and packets
|
||||
|
||||
`config/` owns:
|
||||
|
||||
- the complete `TomlConfig` model;
|
||||
- parsing, serialization, and validation;
|
||||
- OS-independent defaults;
|
||||
- peer, encryption, gateway, and API input models;
|
||||
- normalized runtime snapshots and the live runtime configuration store.
|
||||
|
||||
The Host supplies platform facts through `CoreInstanceHostConfig`. Core applies
|
||||
the policy that combines those facts with TOML input. This is especially
|
||||
important for a WASI build: the compile-time guest target cannot be used as a
|
||||
proxy for the Host operating system.
|
||||
|
||||
`packet/` owns EasyTier packet structures, compression, STUN and hole-punch
|
||||
wire codecs. It does not own socket I/O or connection policy.
|
||||
|
||||
### Socket and Host seams
|
||||
|
||||
`socket/` contains transport-neutral primitives:
|
||||
|
||||
- `SocketContext`, including IP-family policy, optional socket mark, and an
|
||||
opaque network-namespace token;
|
||||
- virtual TCP socket, listener, and factory traits;
|
||||
- virtual UDP socket and factory traits;
|
||||
- UDP session multiplexing, classification, and lifecycle;
|
||||
- in-process Ring sockets.
|
||||
|
||||
UDP sessions expose a scoped hole-punch source observer keyed by transaction
|
||||
ID. Listeners carry a registration handle for their existing receive loop, which
|
||||
reports the first matching source; dropping the observer removes the registration.
|
||||
Cone clients optionally prepare that transaction in the listener-selection RPC;
|
||||
the response echoes its ID only after registration. The registration retains
|
||||
early UDP sources until the cone-sending RPC claims it. Unclaimed registrations
|
||||
expire after 30 seconds through the existing listener cleanup loop, covering
|
||||
local socket and STUN preparation without a separate receive or sending task.
|
||||
Clients omitting the optional transaction retain registration at the sending RPC;
|
||||
older servers omit the echoed ID and do not confirm early registration.
|
||||
Connectivity owns preparation expiry, the active RPC lifetime, candidate selection
|
||||
and bounded sending to the signaled and observed addresses.
|
||||
Cone clients start the existing UDP handshake without requiring a reverse
|
||||
hole-punch packet, and the session layer remains the sole receiver.
|
||||
|
||||
`host/` is the single home of Host capability seams:
|
||||
|
||||
- DNS and DNS record resolution;
|
||||
- connector environment observations;
|
||||
- packet ingress and egress;
|
||||
- Host socket operation bridges and handle-based TCP/UDP/listener adapters;
|
||||
- host-owned, message-preserving tunnel endpoints.
|
||||
|
||||
Core owns scheduling, backpressure, cancellation, UDP session state, and
|
||||
protocol state even when each actual operation crosses a Host Adapter. A Host
|
||||
Adapter owns the real resource and performs the OS operation.
|
||||
|
||||
For host-owned tunnels, ownership crosses the guest ABI only after a bounded
|
||||
Host listener queue accepts the tunnel. The queue is registered through
|
||||
`CoreHostAdapters` and consumed by the normal `CoreListenerRuntime`, so the
|
||||
tunnel still reaches `PeerAcceptedTunnelHandler` and cannot bypass peer
|
||||
handshake, admission, events, or routing policy. Each Host receive produces
|
||||
one complete `DummyTunnel` payload; no stream framing is added. The Host owns
|
||||
transport-specific message validation and maps a clean close to tunnel EOF.
|
||||
|
||||
Hosts without outbound sockets select `CoreConnectivityMode::InboundOnly` for
|
||||
one instance. That startup plan retains listeners and the peer/router while
|
||||
omitting STUN, outbound connectors, and hole punching. Cargo features only
|
||||
compile Host Adapters; they do not change `CoreInstance` fields, lifecycle, or
|
||||
management semantics.
|
||||
|
||||
The native `NativeHostRuntime` is process-wide and does not retain an instance
|
||||
`GlobalCtx`, namespace guard, socket mark, or connectivity state. Differences
|
||||
between instances travel in each request's `SocketContext`. A narrow
|
||||
instance-host projection may expose listener and interface facts, but it does
|
||||
not become another socket factory.
|
||||
|
||||
### Tunnel and listener
|
||||
|
||||
A socket is a raw communication endpoint. A Tunnel is an EasyTier connection
|
||||
created by adding framing, metadata, handshakes, and protocol lifecycle.
|
||||
|
||||
Core owns:
|
||||
|
||||
- raw TCP framing and upgrade;
|
||||
- UDP tunnel/session framing and classification;
|
||||
- Ring Tunnel identity and registry state;
|
||||
- encryption and secure-datagram policy that is portable;
|
||||
- client/server protocol selection interfaces;
|
||||
- listener planning, optional/required listener policy, retry, accept
|
||||
scheduling, running-listener registry, and orderly shutdown.
|
||||
|
||||
Native protocol Adapters own WebSocket/WSS, QUIC, WireGuard, and KCP engines.
|
||||
Unix and FakeTCP are socket resources that feed a core protocol upgrader; they
|
||||
are not independent owners of EasyTier peer state.
|
||||
|
||||
Each protocol registration must provide a coherent client/server Adapter.
|
||||
Unavailable configured transports must be rejected during validation or
|
||||
protocol selection in the standard runtime, rather than silently falling back
|
||||
to another transport. A compact compatibility Host may instead retain the
|
||||
desired value for management readback and omit it from normalized runtime
|
||||
state; it must not advertise or partially activate the unavailable transport.
|
||||
|
||||
### Connectivity
|
||||
|
||||
`connectivity/` owns:
|
||||
|
||||
- manual connection and endpoint discovery policy;
|
||||
- direct candidate selection;
|
||||
- retry, backoff, blacklists, and listener reuse;
|
||||
- STUN requests, responses, probing, NAT inference, and published endpoint
|
||||
state;
|
||||
- TCP and UDP hole-punch state machines;
|
||||
- UDP port-mapping policy and lease lifecycle;
|
||||
- conversion of successful sockets into protocol-upgrade requests.
|
||||
|
||||
The Host owns DNS execution, socket syscalls, interface enumeration, bind
|
||||
device/mark/namespace operations, and concrete UPnP/NAT-PMP calls. STUN-only
|
||||
hole punching remains available when the Host does not supply a port-mapping
|
||||
Adapter.
|
||||
|
||||
Some connectivity files intentionally implement peer-facing adapter traits for
|
||||
`PeerManagerCore`. These are localized integration edges between adjacent
|
||||
domains, not permission for lower socket or Host layers to depend on peers.
|
||||
|
||||
### Peers and RPC
|
||||
|
||||
`peers/` is the authoritative owner of:
|
||||
|
||||
- admission and connection sessions;
|
||||
- peer maps and connection lifecycle;
|
||||
- ACL and whitelist decisions;
|
||||
- OSPF route calculation and graph algorithms;
|
||||
- peer and credential RPC registration;
|
||||
- foreign-network admission, identity, relay, and lifecycle;
|
||||
- peer-center state and public IPv6 policy;
|
||||
- traffic metrics and peer snapshots.
|
||||
|
||||
Submodules progress from kernel types and utilities, through ACL/context,
|
||||
connection state, route state, manager services, and finally foreign-network
|
||||
and peer-center composition. Callers consume the public surface declared by
|
||||
the domain rather than reaching into a parallel native peer owner.
|
||||
|
||||
`rpc/` owns the peer-flavoured RPC transport, packet fragmentation, client and
|
||||
server lifecycle, handler registry, and standalone listener/client lifecycle.
|
||||
Generated service descriptors and message types remain in `easytier-proto`.
|
||||
|
||||
### Gateway
|
||||
|
||||
`gateway/` owns portable packet-plane features:
|
||||
|
||||
- proxy CIDR state and monitoring policy;
|
||||
- packet parsing, reassembly, NAT/proxy state, and TCP/UDP/ICMP decisions;
|
||||
- the smoltcp-backed portable dataplane selected by its feature;
|
||||
- SOCKS5 framing, authentication, association, routing, and session state;
|
||||
- wrapped-transport planning and session state used by KCP and QUIC Adapters;
|
||||
- DHCP allocation policy;
|
||||
- Magic DNS route and response policy;
|
||||
- VPN portal client/session policy;
|
||||
- UDP broadcast classification and rewrite policy.
|
||||
|
||||
Each VPN portal client is normalized to one attached-peer IPv4 CIDR. The
|
||||
portable gateway owns that client address and prefix; the hosting network
|
||||
manager's DHCP or static address is not a source of portal client routing
|
||||
facts.
|
||||
|
||||
TUN, raw sockets, transparent-destination lookup, concrete protocol engines,
|
||||
native DNS servers, namespace operations, and route application stay in native
|
||||
Adapters.
|
||||
|
||||
Optional gateway capabilities are selected by cohesive Modules. Disabled
|
||||
implementations retain stable lifecycle calls and report unsupported
|
||||
configuration in the standard runtime. A compact compatibility Host may
|
||||
silently normalize those settings to no-ops while preserving the desired TOML
|
||||
model; disabled implementations do not duplicate portable policy.
|
||||
|
||||
The instance-scoped `DataPlaneSession` composes the foundation operation broker
|
||||
under the same session lock as its resource and quota state. The broker owns
|
||||
generic completion, cancellation, free, drain, and take transitions. The data
|
||||
plane retains TCP/UDP resource ownership, operation metadata, route deadlines,
|
||||
and error semantics.
|
||||
|
||||
The proposed restructuring of the smoltcp data plane, SOCKS5 and port-forward
|
||||
Adapters, portable KCP engine, event-driven FFI/WASI completion model, and Go
|
||||
Host integration is tracked in
|
||||
[`data-plane-runtime-plan.md`](data-plane-runtime-plan.md). That document is a
|
||||
future implementation plan; this document remains the source of truth for the
|
||||
currently implemented architecture until the plan is completed.
|
||||
|
||||
### Instance and management
|
||||
|
||||
`CoreInstance::new(CoreInstanceConfig, CoreHostAdapters)` is the sole direct
|
||||
construction path for a normalized instance. `CoreInstance::from_toml` uses
|
||||
the same normalization and construction path. Core constructs the peer graph,
|
||||
runtime store, STUN collector, connectivity managers, listener runtime, packet
|
||||
plane, gateway runtimes, and lifecycle owners.
|
||||
|
||||
A core instance:
|
||||
|
||||
- owns all mutable portable state for one network;
|
||||
- is one-shot after `stop`;
|
||||
- exposes one complete `start` and one `stop` lifecycle interface;
|
||||
- starts Modules in a fixed serial composition order without cross-Module
|
||||
started flags or staged activation;
|
||||
- installs initial ACL, proxy CIDR, and manual-peer inputs before startup;
|
||||
- serializes lifecycle operations with one instance-level operation lock;
|
||||
- owns cooperative cancellation and component shutdown order;
|
||||
- exposes `CorePacketPlane` as the narrow packet/route projection used by Host
|
||||
dataplane Adapters;
|
||||
- treats its normalized runtime store as authoritative after construction.
|
||||
|
||||
`CoreHostAdapters` contains the required Host, DNS, packet sink, and
|
||||
`CoreProcessRuntime`, plus optional protocol and platform capabilities. The
|
||||
bundle carries capabilities, not preconstructed portable managers.
|
||||
|
||||
The standard native factory owns one continuously driven Tokio executor per
|
||||
instance. `multi_thread` selects a multi-thread or current-thread runtime;
|
||||
`multi_thread_count` sets the worker count with the historical minimum of two.
|
||||
Construction, start/stop, and FFI data-plane tasks use that executor, independent
|
||||
of the process management runtime. Dropping its Host owner shuts down the
|
||||
executor without blocking a worker on its own termination. Compact and WASI
|
||||
Hosts retain their externally supplied execution model.
|
||||
|
||||
Each Module owns partial-start cleanup for its internal resources.
|
||||
`CoreInstance` has one outer cancellation and recovery path for the complete
|
||||
serial startup. `Running` therefore means the Host runtime and every enabled
|
||||
portable Module have started successfully; there is no separate post-Host
|
||||
activation state. Host packet tasks stop before PeerManager resources are
|
||||
cleared.
|
||||
|
||||
`InstanceManager<F>` is the canonical UUID-indexed instance collection for one
|
||||
Host composition. Its `InstanceFactory` constructs one complete record before
|
||||
the manager performs an atomic uniqueness check. The manager owns collection
|
||||
membership; it does not own startup order, persistence, daemon policy, cached
|
||||
errors, ABI handles, or RPC projections.
|
||||
|
||||
`management/` consumes the canonical manager and instances. It owns:
|
||||
|
||||
- stable UUID/name selection;
|
||||
- read-only instance and peer management RPC;
|
||||
- full process mutation and configuration transactions when enabled;
|
||||
- persistence and logger-control capability interfaces;
|
||||
- management listener/client lifecycle and JSON-RPC presentation.
|
||||
|
||||
There is one process-level management entry. Instances and the manager do not
|
||||
depend on management response projections.
|
||||
|
||||
## Process-scoped state
|
||||
|
||||
`CoreProcessRuntime` owns portable resources shared across instances in one
|
||||
process or instantiated module:
|
||||
|
||||
- the Ring Tunnel registry and namespace;
|
||||
- a reference-counted protected TCP-port registry.
|
||||
|
||||
The composition root creates and shares one runtime. Management listener ports
|
||||
are protected before bind and held by leases after the concrete port is known.
|
||||
Native and target adapters supply bound resources but do not implement a
|
||||
second protected-port registry.
|
||||
|
||||
Process-global capability objects may contain stateless or shared platform
|
||||
mechanisms. They must not contain instance-specific peer, route,
|
||||
configuration, or connectivity state.
|
||||
|
||||
### Native shared TUN ownership
|
||||
|
||||
The native composition owns `SharedVirtualNicRegistry`. Named desktop TUNs
|
||||
are shared within a network namespace; mobile instances attach to one supplied
|
||||
TUN descriptor. Members own address, route, and MTU claims, not the physical
|
||||
device. Bootstrap route cleanup runs only when that device is created. Mobile
|
||||
IPv6 claims are initialized before forwarding and follow public IPv6 updates.
|
||||
Mobile VPN configuration is projected from admitted members' native claims.
|
||||
Admission keeps unchanged attached members ahead of conflicting newcomers or
|
||||
reconfigured members. Rejected members are reported individually, while the
|
||||
remaining members stay attached. Each member's initial claims are applied as
|
||||
one batch before it can open or replace the shared descriptor.
|
||||
Mobile claims update dispatcher tables without configuring the OS interface;
|
||||
the platform VPN owns its addresses, routes, and MTU.
|
||||
If applying native claims fails, the shared NIC is invalidated and its
|
||||
dispatcher is stopped so every member can recreate the device. Restoring only
|
||||
the recorded claims would leave partially applied OS configuration in use.
|
||||
TUN replacement and I/O failures also invalidate the shared NIC. TUN writes
|
||||
have bounded waits so a stalled device cannot indefinitely block claim updates
|
||||
or member cleanup.
|
||||
|
||||
Desktop and mobile shared TUNs use the same lazily initialized native Tokio
|
||||
runtime, independent of every member's executor. It creates the device's async
|
||||
I/O registration, drives dispatchers, and completes member claim cleanup even
|
||||
after the member executor stops or the registration is dropped outside Tokio.
|
||||
The runtime has two workers on desktop to allow independent TUN dispatchers
|
||||
to run concurrently, and retains one worker on mobile. It owns no registry or
|
||||
member claims; those remain with each shared NIC. Cleanup remains asynchronous,
|
||||
and the last member's cleanup stops the dispatcher and releases the desktop
|
||||
device. A process-lifetime runtime does not extend the device's lifetime.
|
||||
Unnamed desktop TUNs and instance-local work retain their existing executors.
|
||||
|
||||
Each member connects through one bounded Ring Tunnel. The shared dispatcher
|
||||
polls member streams in rotation and writes directly to their sinks, with no
|
||||
per-member forwarding tasks or intermediate packet queues. Desktop and mobile
|
||||
use one dispatch loop; mobile descriptor replacement is a control message.
|
||||
The loop consumes Tokio's cooperative budget even when packets are dropped.
|
||||
|
||||
Only the member running the Magic DNS resolver claims its shared fake-IP
|
||||
route. The claim is removed before resolver shutdown and acquired by the next
|
||||
serving member after takeover. Client-only runners do not claim that route.
|
||||
|
||||
The dispatcher preserves desktop kernel-forwarded subnet sources. It corrects
|
||||
wrong-member local sources and, on mobile, synthetic sources. Native local
|
||||
flows and translated flows reserve member-scoped transport tuples so address
|
||||
translation cannot merge sockets using the same port and remote endpoint.
|
||||
Collisions remap the source port or ICMP Echo identifier; replies restore the
|
||||
original tuple. Unrelated route updates and member removals retain mappings.
|
||||
All Echo sequence numbers for one identifier share a reservation; translation
|
||||
preserves the sequence field in each request and reply.
|
||||
Removing an involved address or member releases its mappings. A full member
|
||||
queue drops that packet without blocking other members or control messages.
|
||||
|
||||
With one attached member, packets go directly to that member without destination
|
||||
matching or new source translations. Native flow reservations remain active so
|
||||
later members cannot reuse their tuples. Existing translations continue in both
|
||||
directions after other members leave. Unmodified IPv4 fragments and IPv6
|
||||
extension headers pass through the single-member path unchanged.
|
||||
|
||||
Reservations are bounded at 4096 flows. Activity in either direction refreshes
|
||||
them; table pressure reclaims reservations idle for at least 180 seconds. If
|
||||
all slots remain active, new tracked flows are dropped rather than evicting a
|
||||
live mapping. Desktop external-source forwarding bypasses this table. As with
|
||||
other idle-expiring NAT state, very late replies after tuple reuse cannot be
|
||||
distinguished from replies to the new flow.
|
||||
|
||||
Translation supports unfragmented IPv4 and ordinary IPv6 TCP, UDP, and ICMP
|
||||
Echo traffic. IPv4 fragment and IPv6 extension-header translation remain
|
||||
unsupported. ICMP errors quoting a translated flow are not reverse-translated;
|
||||
in particular, a smaller downstream MTU can prevent PMTU discovery for such
|
||||
flows. This limitation does not affect unmodified flows and is intentionally
|
||||
retained rather than extending the dispatcher into a full ICMP/fragment NAT.
|
||||
|
||||
## Runtime configuration authority
|
||||
|
||||
`TomlConfig` is the authoritative desired configuration used for management
|
||||
readback and patch transactions. Compact Hosts keep unsupported accepted values
|
||||
there so controllers observe the configuration they submitted.
|
||||
|
||||
The separately typed, normalized core runtime store is authoritative for live
|
||||
behavior:
|
||||
|
||||
- peer feature flags and routing policy;
|
||||
- listeners and initial peers;
|
||||
- ACL and whitelist inputs;
|
||||
- manual and VPN portal CIDRs;
|
||||
- gateway and connectivity settings;
|
||||
- runtime configuration patches.
|
||||
|
||||
Manual routing retains three distinct states: absent routes select automatic
|
||||
routing, a present empty list installs no remote subnet routes, and a present
|
||||
nonempty list selects explicit routes. `NetworkConfig.enable_manual_routes`
|
||||
preserves this distinction on readback. `InstanceConfigPatch` applies its
|
||||
optional `enable_manual_routes` together with route edits in one route
|
||||
sub-patch: true retains an empty manual list and false restores automatic
|
||||
routing. Omitting the mode preserves released RoutePatch behavior, including
|
||||
returning to automatic routing when the edited list becomes empty. This does
|
||||
not change the ordered partial-commit contract of a multi-field patch.
|
||||
|
||||
`HeartbeatRequest.support_manual_routes_patch` advertises this complete route
|
||||
contract, including typed empty-manual readback, on the WebClient Session.
|
||||
The Web controller uses the current authenticated heartbeat to choose route
|
||||
patches; observed instance configuration caches contain no capability state.
|
||||
Released clients omit the flag and still receive
|
||||
full configuration replacements for route changes. For their ambiguous empty
|
||||
readback, Web reads the existing `GetConfig.toml_config`, or, when absent, the
|
||||
instance-scoped `ShowNodeInfo` TOML used by 2.6.4. It reads only the instance ID
|
||||
and route field, requires a matching instance, and confirms the actual mode
|
||||
before recording application. Supporting clients use typed readback directly;
|
||||
compact clients with raw GetConfig need no Peer management RPC.
|
||||
|
||||
Host persistence is an effect following a successful core transaction. A Host
|
||||
Adapter must not call back into an instance to obtain a hidden configuration
|
||||
snapshot while core is applying an operation.
|
||||
|
||||
Non-serializable resources such as TUN descriptors, packet sinks, execution
|
||||
domains, and native protocol engines are construction context, not TOML
|
||||
fields.
|
||||
|
||||
## Logging
|
||||
|
||||
The main native runtime uses a small logger implemented in
|
||||
`easytier/src/common/log`:
|
||||
|
||||
- `log` records and `tracing` events share console and file sinks;
|
||||
- timestamps, compact formatting, optional terminal colours, `NO_COLOR`, and
|
||||
basic `RUST_LOG` target/level filters are implemented directly;
|
||||
- file rotation uses the existing EasyTier rolling appender;
|
||||
- management RPC can reload the file level;
|
||||
- an atomic maximum-level gate rejects disabled events before target matching
|
||||
or file-filter locking;
|
||||
- concurrent file-level reload serializes the filter and atomic-level update.
|
||||
|
||||
File logging and no-file logging are separate selected backends. The default
|
||||
tracing backend records events and deliberately ignores span trees. The
|
||||
optional `tracing` feature selects the tokio-console subscriber integration;
|
||||
only that diagnostic profile pulls the main crate's `tracing-subscriber` and
|
||||
`console-subscriber` dependencies.
|
||||
|
||||
Contrib applications and platform integrations may have independent logging
|
||||
requirements and are not implicitly wired to the native process logger.
|
||||
|
||||
## Feature model
|
||||
|
||||
Features represent coherent capabilities, not arbitrary source fragments.
|
||||
Important core feature relationships are:
|
||||
|
||||
- `management-rpc` enables generated management API types and read-only
|
||||
management services.
|
||||
- `management` adds configuration writes, full management composition, rich
|
||||
errors, and JSON-RPC.
|
||||
- `proxy-packet` enables portable packet parsing/proxy machinery and the
|
||||
required smoltcp packet features.
|
||||
- `proxy-smoltcp-stack` adds the async TCP/UDP smoltcp stack.
|
||||
- `dns-resolver` is the shared Hickory resolver leaf used by endpoint
|
||||
discovery and Magic DNS without coupling either capability to the other.
|
||||
- `endpoint-discovery` adds HTTPS endpoint discovery dependencies.
|
||||
- `magic-dns` enables its DNS server, management wire messages, and portable
|
||||
packet-query integration.
|
||||
- `tcp-hole-punch` enables the TCP hole-punch runtime.
|
||||
- `dhcp-ipv4`, `public-ipv6-provider`, `vpn-portal`,
|
||||
`wrapped-transport`, and `proxy-cidr-monitor` are independent gateway or
|
||||
platform-policy leaves.
|
||||
- `extended-services` is the compatibility aggregate for those leaves.
|
||||
- encryption and compression engines remain independently selectable.
|
||||
|
||||
The native crate maps product features to the core and protocol features it
|
||||
actually consumes. A protocol feature must not accidentally enable unrelated
|
||||
gateway or management capabilities.
|
||||
|
||||
Production feature and platform selection belongs at Module or Adapter
|
||||
boundaries rather than inside shared implementations. The logger demonstrates
|
||||
the intended pattern: file and tracing variants are complete backend modules
|
||||
with one stable interface, so shared event processing contains no feature
|
||||
branches.
|
||||
|
||||
## Module boundaries
|
||||
|
||||
The dependency directions in this document define the intended module
|
||||
boundaries. Changes that require a new upward edge must first define a stable
|
||||
lower-layer interface or explicitly revise this architecture.
|
||||
|
||||
Modules are `pub(crate)` by default. Each domain's `mod.rs` declares its
|
||||
outward surface. Public visibility is used for real cross-crate Host,
|
||||
configuration, management, packet-plane, or test-support interfaces.
|
||||
|
||||
## Architectural invariants
|
||||
|
||||
1. Portable EasyTier policy has one owner in `easytier-core`.
|
||||
2. Core does not perform real OS socket, DNS, TUN, route, filesystem
|
||||
configuration, process, or service-manager operations.
|
||||
3. Host-OS policy is runtime input; a WASI compile target is not Host policy.
|
||||
4. Every real socket and DNS operation crosses a Host capability seam.
|
||||
5. Core owns socket scheduling, backpressure, protocol state, and cancellation.
|
||||
6. Dial, accept, and hole-punch paths produce sockets before protocol upgrade.
|
||||
7. Peer admission consumes upgraded transports and does not create OS
|
||||
resources.
|
||||
8. Each instance owns its mutable peer, route, connectivity, gateway, and
|
||||
runtime configuration state.
|
||||
9. One Host composition has one canonical UUID-to-instance manager.
|
||||
10. Process-level runtimes do not capture instance state.
|
||||
11. `CoreInstance::new` is the sole normalized direct construction entry.
|
||||
12. The manager owns membership, not lifecycle or presentation.
|
||||
13. Management consumes the manager; the manager does not return management
|
||||
projections.
|
||||
14. Unknown protobuf fields in reflected route information survive forwarding
|
||||
and credential filtering.
|
||||
15. Feature selection is localized at cohesive Module/Adapter boundaries.
|
||||
16. The standard runtime rejects unsupported configured capabilities. Compact
|
||||
compatibility Hosts may preserve them as runtime no-ops, but never change
|
||||
wire protocol, advertise them, or silently fall back to an unsafe mode.
|
||||
|
||||
## Validation
|
||||
|
||||
Changes to these boundaries should run, at minimum:
|
||||
|
||||
```text
|
||||
cargo fmt --all -- --check
|
||||
cargo check -p easytier-core -p easytier-proto -p easytier --features full
|
||||
cargo test -p easytier-core --lib
|
||||
```
|
||||
|
||||
Feature work should add focused checks for the changed no-default, isolated,
|
||||
default, full, and cross-target profiles. Socket, TUN, namespace, protocol
|
||||
engine, and multi-node changes require the relevant Docker integration tests.
|
||||
WASI ABI or Adapter changes require a `wasm32-wasip1` build and target-side
|
||||
tests. These compiler-resolved profiles are the authority for feature and
|
||||
target boundaries.
|
||||
|
||||
CI path filters include `easytier-core`, `easytier-proto`, native, web, GUI
|
||||
Tauri, and contrib. The archived Rust test suite contains both `easytier` and
|
||||
`easytier-core`.
|
||||
|
||||
## Known limitations and debt
|
||||
|
||||
- Some production feature and platform gates still select fields or statements
|
||||
inside shared implementations. New code should prefer complete Module or
|
||||
Adapter variants, and existing cases should move only when their owning
|
||||
Module is changed.
|
||||
- Connectivity retains localized Adapter implementations that name
|
||||
`PeerManagerCore`; further decoupling requires an interface extraction, not
|
||||
a visibility-only move.
|
||||
- Native Linux namespace guards exist in paths that can cross async suspension.
|
||||
Because `setns` is thread-local, those operations should eventually be kept
|
||||
on one non-migrating execution context.
|
||||
- QUIC session retirement after failed or exhausted accepted sessions remains
|
||||
separate native-engine correctness work; it must preserve multiple
|
||||
connections sharing one QUIC endpoint/session.
|
||||
|
||||
These limitations are not reasons to add fallback owners or parallel state.
|
||||
Fixes should preserve the ownership rules above and address the responsible
|
||||
Module directly.
|
||||
@@ -0,0 +1,55 @@
|
||||
# Inspect credential users from the CLI
|
||||
|
||||
On a node that stores the issued credentials, list their currently authenticated
|
||||
direct peers:
|
||||
|
||||
```sh
|
||||
easytier-cli credential peers
|
||||
|
||||
# Select one network instance and return machine-readable output.
|
||||
easytier-cli --instance-name my-network --output json credential peers
|
||||
```
|
||||
|
||||
The table associates each credential ID with peer IDs, virtual IPv4/IPv6
|
||||
addresses, and hostnames. A reusable credential can have multiple peers;
|
||||
multiple tunnels to the same peer appear only once. Existing `credential list`
|
||||
output is unchanged.
|
||||
|
||||
JSON output has this shape for one instance:
|
||||
|
||||
```json
|
||||
{
|
||||
"credentials": [
|
||||
{
|
||||
"credential_id": "user-123",
|
||||
"peers": [
|
||||
{
|
||||
"peer_id": 42,
|
||||
"ipv4": "10.144.144.2",
|
||||
"ipv6": null,
|
||||
"hostname": "laptop"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Without an explicit instance selector, the command follows the other CLI query
|
||||
commands: it queries running instances, wrapping multiple results with their
|
||||
instance IDs and names.
|
||||
|
||||
## Scope
|
||||
|
||||
The association uses the remote public key of an authenticated live credential
|
||||
connection, matched against the credential's SHA-256 public-key fingerprint.
|
||||
It never uses a route advertisement alone as proof of credential identity.
|
||||
Credential secrets and connection keys are not printed.
|
||||
|
||||
This is a per-node snapshot, not a network-wide login history. An empty `peers`
|
||||
array (shown as `no direct peers` in the table) does not mean a credential is
|
||||
unused elsewhere: its users may only be connected to another node or reachable
|
||||
through a relay. Older cores without credential fingerprints cannot be matched.
|
||||
Missing route addresses are returned as `null` (shown as `-`); a peer may connect
|
||||
before its route or DHCP address is available. Addresses and connection state
|
||||
can change between the RPC snapshots.
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,584 @@
|
||||
# EasyTier Web Managed Config Incremental Sync Plan
|
||||
|
||||
## Status
|
||||
|
||||
- 状态:Implemented(核心协议、持久化与 Session 增量收敛)
|
||||
- 实施范围:EasyTier Web 的 HTTP 接收、校验、SQLite 持久化和 Session 运行态收敛
|
||||
- 上游依赖:后续由 Console 计算并发送 Patch
|
||||
- 兼容要求:保留现有 Full PUT
|
||||
|
||||
本文记录当前接收端方案。Session 合并已持久化 Patch 的 touched instance IDs,
|
||||
并在运行态收敛时读取这些实例的最新持久化状态。重启、通知丢失或无法安全判断
|
||||
实例 ownership 时沿用 Full reconcile。
|
||||
|
||||
## 1. 背景与结论
|
||||
|
||||
当前 `/validate-token` webhook 已经只交换 token、机器信息和 revision,不再
|
||||
携带完整 managed config 集合。剩余的大集合位于独立的配置发布路径:
|
||||
|
||||
```text
|
||||
PUT /api/internal/users/:user-id/machines/:machine-id/networks
|
||||
```
|
||||
|
||||
Console 每次发布都会向该路径发送完整 Exact Set。实例很多时,请求体、JSON
|
||||
解析、现有配置扫描和逐条 SQLite 写入都随实例总数增长。
|
||||
|
||||
第一阶段采用以下方案:
|
||||
|
||||
1. 保留 PUT,作为完整发布、首次同步和冲突恢复路径。
|
||||
2. 在同一路径增加 PATCH;普通变更只发送完整的单实例 upsert 和删除 ID。
|
||||
3. PATCH 使用 `expected_config_revision` 做 compare-and-swap(CAS)。
|
||||
4. Full/Patch 的配置变更与 revision 更新在一个 SQLite transaction 中提交。
|
||||
5. Patch 只查询和写入 touched instances,不扫描完整 Target。
|
||||
6. 写入成功后通知 Session 本次 expected、target 和 transaction 实际 touched
|
||||
instance IDs。
|
||||
7. Session 只合并 revision 连续的 touched IDs,并以 SQLite 当前状态为准增量
|
||||
收敛;可信 runtime base、通知链或 persisted target 无法证明连续时回退 Full。
|
||||
|
||||
普通变更的接收端成本由:
|
||||
|
||||
```text
|
||||
O(total instances)
|
||||
```
|
||||
|
||||
降为:
|
||||
|
||||
```text
|
||||
wire / JSON / persistence transaction / runtime config apply = O(changed instances)
|
||||
```
|
||||
|
||||
冷启动或 revision 冲突仍需要 `O(total)` 的 Full。这是没有可用基线时传递完整
|
||||
目标状态所必需的成本;如果 Full 超过安全的单请求上限,需另行设计 staged
|
||||
snapshot,而不是直接分页写入 live rows。
|
||||
|
||||
## 2. 目标与非目标
|
||||
|
||||
### 2.1 目标
|
||||
|
||||
1. 普通新增、更新和删除只传输、解析、查询并写入变化实例。
|
||||
2. Config rows 与 persisted revision 原子提交。
|
||||
3. Patch 可安全重试,并能确定性处理并发或乱序请求。
|
||||
4. 保持 user-owned 与 web-owned 配置的 ownership 规则。
|
||||
5. 保持 Full Exact Set 的删除和显式空集合语义。
|
||||
6. 为 Full 和 Patch 设置显式且可测试的容量限制。
|
||||
7. 先部署接收端,再允许 Console 使用 Patch。
|
||||
|
||||
### 2.2 非目标
|
||||
|
||||
1. 修改 `/validate-token` request/response。
|
||||
2. 在本阶段实现 Console 的 diff/cache 逻辑。
|
||||
3. 优化 Core heartbeat 中的完整运行实例上报。
|
||||
4. 实现 Full 分页、上传会话或持久化 delivery FSM。
|
||||
5. 让冷启动 Full 的成本低于 `O(total)`。
|
||||
|
||||
## 3. 必须保持的语义
|
||||
|
||||
### 3.1 Full Exact Set
|
||||
|
||||
Full 表示一个 `(user_id, machine_id)` 下全部期望的 web-owned configs:
|
||||
|
||||
- 请求中存在的实例应被创建或更新;
|
||||
- 已存在但请求中缺失的 web-owned 实例应被删除;
|
||||
- 空集合应删除该 Target 下全部 web-owned 实例;
|
||||
- user-owned 实例不能被覆盖或删除。
|
||||
|
||||
### 3.2 Patch
|
||||
|
||||
Patch 只描述从一个已知 revision 到另一个 revision 的变化:
|
||||
|
||||
- `upserts`:新增或变化实例的完整 config;
|
||||
- `delete_instance_ids`:从目标集合中删除的实例 ID;
|
||||
- `expected_config_revision`:receiver 必须已经处于的 base revision;
|
||||
- `config_revision`:提交完成后的 target revision。
|
||||
|
||||
Patch 不是独立的完整目标。当前 revision 与 expected revision 不一致时,必须
|
||||
返回冲突且不做任何写入。
|
||||
|
||||
### 3.3 Revision invariants
|
||||
|
||||
1. 一个 persisted revision 只对应与其一起提交的 web-owned projection。
|
||||
2. Config mutation 和 revision advancement 必须位于同一 transaction。
|
||||
3. Patch 只能应用在完全匹配的 expected revision 上。
|
||||
4. 当前 revision 已等于 target revision 时,返回幂等成功且不重复写入。
|
||||
5. Publisher 不得为不同目标状态复用同一个 target revision。
|
||||
6. 任何其他写路径只要改变 web-owned row,就必须在同一 transaction 中清除
|
||||
managed revision;否则未来 Patch 会基于错误的 base。
|
||||
7. Persisted revision 与 Session applied revision 保持为两个不同事实。HTTP
|
||||
成功只代表本地持久化完成,不代表 Core 已经应用。
|
||||
|
||||
## 4. HTTP contract
|
||||
|
||||
### 4.1 保留 Full PUT
|
||||
|
||||
路径不变:
|
||||
|
||||
```text
|
||||
PUT /api/internal/users/:user-id/machines/:machine-id/networks
|
||||
```
|
||||
|
||||
现有 JSON shape 保持兼容:
|
||||
|
||||
```json
|
||||
{
|
||||
"managed_network_configs": [
|
||||
{
|
||||
"instance_id": "11111111-1111-1111-1111-111111111111",
|
||||
"network_config": {}
|
||||
}
|
||||
],
|
||||
"config_revision": "target-revision",
|
||||
"expected_config_revision": "base-revision"
|
||||
}
|
||||
```
|
||||
|
||||
`expected_config_revision` 保持当前含义:
|
||||
|
||||
- 字段缺失:兼容旧调用者,不检查 base;
|
||||
- 空字符串:要求当前 persisted revision 不存在;
|
||||
- 非空字符串:要求当前 revision 与该值相等。
|
||||
|
||||
新 Console 必须发送 expected revision。省略 expected 的形式只用于旧版本兼容
|
||||
和明确的运维修复。
|
||||
|
||||
`config_revision` 的处理:
|
||||
|
||||
- 非空:配置与 target revision 原子提交;
|
||||
- 缺失:保留旧 Full 请求兼容,但清除已有 managed revision,因此该结果不能
|
||||
作为后续 Patch base;
|
||||
- 空字符串:拒绝为 400。
|
||||
|
||||
Revisioned Full 遇到 user-owned instance ID 冲突时整体失败。Legacy
|
||||
unrevisioned Full 保持当前兼容行为:跳过 user-owned row,且绝不覆盖它。
|
||||
|
||||
### 4.2 新增 Patch
|
||||
|
||||
同一资源增加:
|
||||
|
||||
```text
|
||||
PATCH /api/internal/users/:user-id/machines/:machine-id/networks
|
||||
```
|
||||
|
||||
请求格式:
|
||||
|
||||
```json
|
||||
{
|
||||
"upserts": [
|
||||
{
|
||||
"instance_id": "11111111-1111-1111-1111-111111111111",
|
||||
"network_config": {}
|
||||
}
|
||||
],
|
||||
"delete_instance_ids": [
|
||||
"22222222-2222-2222-2222-222222222222"
|
||||
],
|
||||
"config_revision": "target-revision",
|
||||
"expected_config_revision": "base-revision"
|
||||
}
|
||||
```
|
||||
|
||||
Patch contract:
|
||||
|
||||
1. 两个 revision 字段均必填、非空且不能相同。
|
||||
2. `upserts` 中的 instance ID 不得重复。
|
||||
3. `delete_instance_ids` 中的 ID 不得重复。
|
||||
4. 同一个 ID 不得同时出现在 upsert 和 delete 中。
|
||||
5. 每个 upsert 必须携带该实例的完整 `NetworkConfig`,不支持字段级 JSON
|
||||
Patch。
|
||||
6. `network_config` 内部的 instance ID 不受信任,receiver 使用 envelope 中的
|
||||
`instance_id` 进行归一化。
|
||||
7. 删除不存在的 ID 是幂等 no-op。
|
||||
8. Upsert 或 delete 碰到 user-owned row 时,整个 Patch 返回冲突且不写入。
|
||||
9. 不允许从“receiver revision 不存在”的未知状态直接 Patch;使用 Full 建立
|
||||
Exact Set 和首个 revision。
|
||||
10. 空 Patch 不能把 revision 改成另一个值;这通常表示 publisher revision
|
||||
计算错误,因此返回 400。
|
||||
|
||||
### 4.3 HTTP outcomes
|
||||
|
||||
| 条件 | HTTP | 语义 |
|
||||
| --- | ---: | --- |
|
||||
| Full/Patch 新提交成功 | 204 | Config 和 revision 已持久化 |
|
||||
| Target revision 已经存在 | 204 | 幂等成功,无 row mutation |
|
||||
| Expected revision 不匹配 | 409 | 零写入,调用者重新观察或发送 Full |
|
||||
| User-owned ownership 冲突 | 409 | 零写入,不能自动覆盖 |
|
||||
| 非法 ID、重复、交集或非法 config | 400 | 调用 contract 错误 |
|
||||
| 请求超过 byte limit | 413 | 未进入 reconciliation |
|
||||
| 条目数或单 config 超过限制 | 422 | 超出接收端容量 contract |
|
||||
| SQLite 错误 | 500 | Transaction rollback |
|
||||
|
||||
409 返回机器可读字段:revision 冲突为
|
||||
`code=managed_config_revision_conflict` 并在已知时带
|
||||
`current_config_revision`;ownership 冲突为
|
||||
`code=managed_config_ownership_conflict`。响应不得返回配置内容。日志不得记录
|
||||
token、secret 或完整 config JSON。
|
||||
|
||||
## 5. Receiver architecture
|
||||
|
||||
### 5.1 Module responsibilities
|
||||
|
||||
| Module | 本阶段职责 |
|
||||
| --- | --- |
|
||||
| Internal HTTP Adapter | 内部鉴权、body/count limit、DTO 解析、HTTP 状态映射 |
|
||||
| `ClientManager` | 解析 Target,调用 managed-config Interface,成功后通知 Session |
|
||||
| `client_manager::managed_config` | Full/Patch 规则、归一化、typed outcome |
|
||||
| `Db` Adapter | CAS、ownership fence、批量 mutation、revision transaction |
|
||||
| Session runtime reconciliation | 校验 applied/base/target fence,增量收敛 touched instances;断链时 Full |
|
||||
|
||||
HTTP Adapter 不实现 ownership、diff 或 transaction 逻辑。PUT 和 PATCH 共用
|
||||
managed-config Module,避免两套规则逐渐分叉。
|
||||
|
||||
### 5.2 Internal Interface
|
||||
|
||||
Module 接收两种 intent:
|
||||
|
||||
```text
|
||||
Full {
|
||||
desired_configs,
|
||||
target_revision: Option<Revision>,
|
||||
expected_revision: Any | Exact(Option<Revision>)
|
||||
}
|
||||
|
||||
Patch {
|
||||
upserts,
|
||||
delete_instance_ids,
|
||||
target_revision: Revision,
|
||||
expected_revision: Revision
|
||||
}
|
||||
```
|
||||
|
||||
返回 typed outcome:
|
||||
|
||||
```text
|
||||
Applied {
|
||||
previous_revision,
|
||||
target_revision
|
||||
}
|
||||
|
||||
AlreadyApplied {
|
||||
target_revision
|
||||
}
|
||||
|
||||
RevisionConflict {
|
||||
expected_revision,
|
||||
current_revision
|
||||
}
|
||||
|
||||
OwnershipConflict {
|
||||
instance_id
|
||||
}
|
||||
```
|
||||
|
||||
Validation error 与 database error 保持独立类型。HTTP handler 只负责将这些结果
|
||||
映射到 section 4.3 的状态码。
|
||||
|
||||
## 6. Receiver implementation
|
||||
|
||||
### 6.1 Validation and normalization
|
||||
|
||||
在打开 SQLite write transaction 之前完成:
|
||||
|
||||
- request byte/count/per-entry limit;
|
||||
- UUID、重复 ID 和 upsert/delete 交集校验;
|
||||
- config key 拼写归一化;
|
||||
- envelope instance ID 覆盖 nested identity;
|
||||
- `NetworkConfig` 反序列化。
|
||||
|
||||
这样非法大请求不会长时间占用 SQLite writer lock。Ownership 必须在 transaction
|
||||
内重新查询,因为 transaction 外的结果可能已过期。
|
||||
|
||||
当 request 带 target revision 时,可以先做一次 O(1) revision read;如果当前值
|
||||
已经等于 target,可直接返回 `AlreadyApplied`,避免完整 config 归一化。任何可能
|
||||
写入的请求仍必须在 transaction 内再次检查 revision。
|
||||
|
||||
### 6.2 Full transaction
|
||||
|
||||
在同一个 SQLite connection 上执行:
|
||||
|
||||
1. `BEGIN IMMEDIATE`。
|
||||
2. 读取 `(user_id, machine_id)` 当前 persisted revision。
|
||||
3. 若 supplied target 已经是 current,返回 `AlreadyApplied`。
|
||||
4. 检查 optional expected revision。
|
||||
5. 只读取现有 row 的 `(instance_id, source)`;不加载无关 config JSON。
|
||||
6. 执行 user-owned ownership fence。
|
||||
7. 批量 upsert 全部 desired web-owned rows。
|
||||
8. 计算并批量删除 `existing_web_ids - desired_ids`。
|
||||
9. 最后写入 supplied target revision;legacy unrevisioned Full 则删除旧 revision。
|
||||
10. Commit。
|
||||
|
||||
任一步骤失败都 rollback。Full 仍是 `O(total)`,但不会再逐条独立提交,也不会
|
||||
出现“部分 rows 已更新、revision 仍是旧值”的中间持久状态。
|
||||
|
||||
### 6.3 Patch transaction
|
||||
|
||||
在同一个 SQLite connection 上执行:
|
||||
|
||||
1. `BEGIN IMMEDIATE`。
|
||||
2. 读取 current revision。
|
||||
3. 如果 current 等于 target,返回 `AlreadyApplied`。
|
||||
4. 如果 current 不等于 expected,返回 `RevisionConflict`。
|
||||
5. 只查询 upsert/delete IDs 的 source。
|
||||
6. 任一 touched ID 属于 user 时,返回 `OwnershipConflict`。
|
||||
7. 批量 upsert changed configs。
|
||||
8. 批量删除 requested web-owned IDs。
|
||||
9. 最后写入 target revision。
|
||||
10. Commit。
|
||||
|
||||
Patch 禁止:
|
||||
|
||||
- list 全部 Target rows;
|
||||
- 重算完整 Target digest;
|
||||
- 根据 touched IDs 之外的数据做 stale-row scan。
|
||||
|
||||
因此其数据库工作量只随 `upserts + deletes` 增长。
|
||||
|
||||
### 6.4 Bounded batch SQL
|
||||
|
||||
批量操作不构造无限长 SQL。根据 SQLite bind-variable limit 选取固定 batch size,
|
||||
并在同一个 transaction 内分批执行:
|
||||
|
||||
- multi-row `INSERT ... ON CONFLICT DO UPDATE`;
|
||||
- 带 `source = web` 条件的 batch delete;
|
||||
- 只返回 instance ID/source 的 ownership query。
|
||||
|
||||
Patch statement 数量应为 `O(ceil(delta / batch_size))`;Full 为
|
||||
`O(ceil(total / batch_size))`。每个 accepted request 只有一个 transaction 和
|
||||
一次 revision 写入。
|
||||
|
||||
### 6.5 Alternate-write revision invalidation
|
||||
|
||||
现有其他路径可能 save、delete、disable 或改变 web-owned row。若这些路径修改
|
||||
rows 后仍保留旧 managed revision,Patch CAS 会把错误状态当作正确 base。
|
||||
|
||||
因此所有 config mutation Adapter 必须遵守:
|
||||
|
||||
1. 判断 mutation 是否改变 web-owned row;
|
||||
2. 在一个 transaction 中执行 mutation;
|
||||
3. 在 commit 前删除该 Target 的 managed revision。
|
||||
|
||||
Managed Full/Patch 在同一 transaction 内先完成 mutation,最后写入新的 target
|
||||
revision。只影响 user-owned rows 的操作不清除 managed revision。
|
||||
|
||||
本方案不在 `/validate-token` 读取 revision 时重算完整 digest,否则周期性验证会
|
||||
重新变成 `O(total)`。Revision 完整性由所有写入 Adapter 局部维护。
|
||||
|
||||
### 6.6 Locking, cancellation and notification
|
||||
|
||||
现有 per-target process-local lock 可以保留,用于减少同进程的重复工作,但它不
|
||||
承担正确性。正确性由 SQLite transaction 和 CAS 提供。
|
||||
|
||||
- Transaction 内不执行 Session RPC、网络请求或无关 async 工作。
|
||||
- HTTP future 在 commit 前取消时,transaction drop 必须 rollback。
|
||||
- Commit 后即使 response 或 notification 丢失,persisted state 仍然有效;调用者
|
||||
用同一 target retry 会得到幂等成功。
|
||||
- 只有带 target revision 的 `Applied` 才通知匹配的 live Session;
|
||||
`AlreadyApplied`、legacy unrevisioned Full、conflict 和失败不重复通知。
|
||||
- Notification 必须发生在 commit 之后。
|
||||
- Full notification 将 pending reconcile hint 提升为 Full,触发完整收敛。
|
||||
- Patch notification 携带 expected revision、target revision、upsert IDs 和本次
|
||||
transaction 实际接受删除的 web-owned IDs。请求删除但数据库原本不存在的 ID
|
||||
仍是 no-op,不能借机删除 Core 中同 ID 的 user-owned 实例。
|
||||
- Session 将尚未应用的 Patch touched IDs 合并为一个 Dirty set,并始终以 SQLite
|
||||
最新 revision 下的 rows 为准。它不重放历史 Patch,也不维护 Patch queue 或
|
||||
delivery FSM。只有 incoming expected 等于 pending target 的通知才能合并;Dirty
|
||||
hint 保留最早 expected 和最新 target。乱序、不连续或无法证明顺序的通知将 hint
|
||||
提升为 Full。多个连续 Patch 积压时,旧 round 由 runtime epoch 拦截,下一 round
|
||||
直接收敛到最新 target。
|
||||
- Session 分开记录对外报告的 applied revision 和内部可信的 runtime base。开始任何
|
||||
runtime side effect 前清除 applied;Patch round 的 side effects 完全包含在 Dirty
|
||||
set 中,因此失败或被新通知拦截时仍保留最早 runtime base,以便按最新持久化状态
|
||||
重试 Dirty set。Full round、direct mutation、授权失败或 Session ownership 中断会
|
||||
清除 runtime base。
|
||||
- 只有可信 runtime base 等于 Dirty 最早 expected,并且 SQLite persisted revision
|
||||
等于 Dirty 最新 target 时,才允许增量 round。重连后 runtime base 未知、通知
|
||||
丢失,或 SQLite 已经提交了更靠后的 revision 而通知尚未送达时都回退 Full,避免
|
||||
不完整的 Dirty set 把完整 target revision 误标为已应用。
|
||||
- 增量 round 逐个读取 Dirty set 中的最新 row。仍然存在且启用的 web-owned row
|
||||
使用其最新 config;已经删除的 row 进入 delete set;遇到 disabled 或非 web-owned
|
||||
row 时回退 Full,以保留 ownership 规则。完成前再次校验 persisted target
|
||||
revision;只有全部 touched instances 成功且 target 仍相同,才推进 applied
|
||||
revision。
|
||||
- 任何通过 EasyTier Web mutation route 直接 Run、Save、Delete 或切换实例状态的
|
||||
操作在执行前和结束后(包括部分 side effect 后返回错误)都清除 Session applied
|
||||
revision、可信 runtime base 与 pending hint,增加运行配置 cache epoch,并唤醒一次 Full
|
||||
reconcile。旧 round 只有 epoch 仍匹配时才能推进 applied revision;新一轮不得
|
||||
信任 mutation 前缓存的 runtime config。否则 runtime-only mutation 或 Core 成功、
|
||||
SQLite 失败的复合 mutation 可能在 persisted revision 不变时破坏 Patch base 的
|
||||
完整性。
|
||||
|
||||
## 7. Capacity contract
|
||||
|
||||
当前 route 没有显式 body limit,Axum `Json` 使用依赖版本的默认 2 MiB 限制。
|
||||
生产容量不应依赖框架隐式默认值。
|
||||
|
||||
本阶段定义并测试四个独立限制:
|
||||
|
||||
- decoded request 最大 bytes;
|
||||
- Full entries / Patch upserts 最大数量;
|
||||
- Patch deletes 最大数量;
|
||||
- 单个 `network_config` 最大 bytes。
|
||||
|
||||
限制只应用于 internal managed-config route,不提高其他 public route 的 limit。
|
||||
具体默认值不能拍脑袋确定:先采集 1k/10k representative configs 的 encoded
|
||||
size 和 peak memory,再选择有明确 headroom 的默认值及硬上限。
|
||||
|
||||
提高 Full limit 只是确保 fallback 覆盖已支持的生产规模,不是稳态优化。请求压缩
|
||||
同样只能降低 wire bytes,不能降低 JSON materialization 和 SQLite 工作量,因此
|
||||
不作为 Patch 的前置条件。
|
||||
|
||||
## 8. Failure and recovery
|
||||
|
||||
| Failure | Receiver state | Caller action |
|
||||
| --- | --- | --- |
|
||||
| Invalid payload | Unchanged | 修复请求,不重试相同 payload |
|
||||
| Capacity exceeded | Unchanged | 使用较小 Patch;Full 需检查支持规模 |
|
||||
| Revision conflict | Unchanged | 重新观察;有 base 时重算 Patch,否则 Full |
|
||||
| Ownership conflict | Unchanged | 解决 ownership,不能自动覆盖 |
|
||||
| SQLite error before commit | Rolled back | 从相同 observed revision 重试 |
|
||||
| Response lost after commit | Target committed | 同一 target retry,幂等成功 |
|
||||
| Process exits before revisioned Session notify | Target committed | 现有 revision reconciliation 恢复 |
|
||||
| Alternate web-row mutation | Revision atomically cleared | 下一次观察触发 Full 修复 |
|
||||
| Console cache loss | Receiver unchanged | Console 发布 Full |
|
||||
|
||||
Receiver 不保存 Patch delivery ledger。Publisher 根据自己的完整目标和 receiver
|
||||
当前 revision 重算 Patch 或选择 Full。
|
||||
|
||||
## 9. Rollout and rollback
|
||||
|
||||
### 9.1 Receiver-first rollout
|
||||
|
||||
1. 为现有 Full 行为增加 characterization tests。
|
||||
2. 将 Full rows/revision 改为一个 atomic transaction。
|
||||
3. 为 alternate web-row mutation 增加 revision invalidation。
|
||||
4. 增加 PATCH、typed conflict、capacity limits 和 metrics。
|
||||
5. 在 Console 仍只发送 PUT 时部署到全部 EasyTier Web 实例。
|
||||
6. 完成旧 Console PUT、新 Console PUT/PATCH contract 测试。
|
||||
7. 最后启用 Console Patch 发布。
|
||||
|
||||
Patch capability 不通过 `/validate-token` 协商。部署顺序就是 compatibility gate;
|
||||
这样不会把配置能力重新耦合回鉴权 Interface。
|
||||
|
||||
Console 遇到 409 可以 re-observe 后发送 Full。它不能把 404、401 或 malformed
|
||||
response 当作旧 receiver 并静默换一种 mutation contract;出现 404 表示接收端
|
||||
部署门禁未满足。
|
||||
|
||||
### 9.2 Rollback
|
||||
|
||||
- Console 尚未发送 Patch 时,EasyTier Web 可正常回滚。
|
||||
- Console 已发送 Patch 后,先回滚 Console,使调用恢复为 PUT,再回滚 Web。
|
||||
- PUT 在整个发布周期保持兼容。
|
||||
- Patch 和 Full 写入相同 rows/revision,不需要格式级数据迁移。
|
||||
|
||||
本方案不新增 persistent table。现有 Target/instance unique index 应覆盖 touched-ID
|
||||
查询;若实现时需要新 index,必须先用实际 SQLite query plan 证明。
|
||||
|
||||
## 10. Verification
|
||||
|
||||
### 10.1 Contract tests
|
||||
|
||||
- 现有 Full JSON 继续接受。
|
||||
- 空 Full 删除所有 web-owned rows,保留 user-owned rows。
|
||||
- Patch add/update/delete 与等价 Full 得到相同最终 projection。
|
||||
- Duplicate/overlap/invalid config 返回 400 且零写入。
|
||||
- Patch 缺少 revision 返回 400。
|
||||
- Revision conflict 返回 409 和 current revision,不返回 config。
|
||||
- Byte/count/per-entry limits 分别有确定性测试。
|
||||
|
||||
### 10.2 Transaction and ownership tests
|
||||
|
||||
- 在 upsert 后、delete 后、revision write 前注入错误,rows/revision 全部 rollback。
|
||||
- Revisioned Full/Patch 的 user-owned collision 整体 rollback。
|
||||
- 删除不存在的 ID 幂等成功。
|
||||
- 两个 target 从同一 base 并发时,一个成功、一个 409。
|
||||
- 相同 target retry 只有第一次写入,第二次为 no-op success。
|
||||
- Alternate save/delete/disable web row 与 revision invalidation 原子提交。
|
||||
- User-owned-only mutation 不清除 managed revision。
|
||||
- 数据库重连后,任一 persisted revision 都对应完整一致的 rows。
|
||||
|
||||
### 10.3 Scale tests
|
||||
|
||||
至少使用 1k 和 10k representative entries:
|
||||
|
||||
- 单实例 Patch 的 decoded bytes、row reads、writes 和 statement count 不随 Target
|
||||
总实例数增长;
|
||||
- Patch 不执行 list-all query;
|
||||
- Full 使用 bounded batches 和一个 transaction;
|
||||
- Revision read 保持 O(1);
|
||||
- 超限 Full 稳定返回 413/422,而不是耗尽进程内存;
|
||||
- 并发请求无 deadlock,且 CAS 结果确定。
|
||||
|
||||
Session 测试还必须验证:连续 Patch 的 Dirty IDs 会合并且保留最早 expected;未知
|
||||
或不匹配的 runtime base、乱序/不连续通知使用 Full;增量 round 读取最新 row;已经
|
||||
删除的 web-owned row 只删除对应 Dirty ID;Full notification 覆盖 Dirty hint;目标
|
||||
revision 已变化或 touched runtime apply 失败时不推进 applied revision;直接 runtime
|
||||
mutation 使 revision 与运行配置 cache 同时失效。运行态 Config Get/Patch/Run/Delete
|
||||
数量应随 touched instances 增长。为确认运行实例身份而进行的一次 list/meta RPC
|
||||
可以保留,它不发送或重写所有实例配置。
|
||||
|
||||
## 11. Observability
|
||||
|
||||
每个请求记录结构化字段,但不记录 config 内容:
|
||||
|
||||
- mode:`full` / `patch`;
|
||||
- user/machine scope;
|
||||
- request bytes;
|
||||
- desired/upsert/delete count;
|
||||
- normalization、target-lock wait、transaction duration;
|
||||
- SQL statement/batch count;
|
||||
- result:applied、already-applied、revision-conflict、ownership-conflict、
|
||||
invalid、oversized、database-error;
|
||||
- Session notification 是否发送。
|
||||
|
||||
Rollout acceptance:
|
||||
|
||||
- Console 启用后 Patch 占普通变更的绝大多数;
|
||||
- 单实例变化的 request size 与 SQLite cost 与单实例成比例;
|
||||
- conflict rate 可解释且稳定;
|
||||
- 支持规模内的 Full 没有 413/422;
|
||||
- validate-token latency 不随 Target 实例数增长。
|
||||
|
||||
## 12. 后续优化
|
||||
|
||||
### 12.1 Session runtime delta apply(已实现)
|
||||
|
||||
Patch commit outcome 已携带 expected、target 和 transaction 实际 touched IDs。
|
||||
Session 只合并 expected/target 连续的 Dirty IDs,并在每一轮从 SQLite 读取最新
|
||||
target revision 对应的当前 rows;因此正常积压只增加 Dirty set,不需要保留中间
|
||||
revision 的 Patch queue。可信 runtime base 必须等于 Dirty 最早 expected;Patch
|
||||
side effect 失败可保留该 base 重试,通知丢失、乱序、进程重启或新 Session 尚无
|
||||
runtime base 时回退 Full。Full
|
||||
notification、disabled row 或 ownership 无法证明时也回退 Full。
|
||||
|
||||
### 12.2 Chunked Full
|
||||
|
||||
不能把 Full Exact Set 直接分页写入 live rows:接收端无法在中间页判断哪些旧
|
||||
实例最终应删除,crash 也会暴露半套目标。
|
||||
|
||||
如果测量证明单请求 Full 无法覆盖必须支持的冷恢复规模,需要单独设计带
|
||||
snapshot ID、staging rows、expiry、finalize 和 atomic swap 的协议。在出现数据
|
||||
证明前不新增该状态机。
|
||||
|
||||
## 13. Implementation files and checklist
|
||||
|
||||
主要涉及:
|
||||
|
||||
- `easytier-web/src/restful/network.rs`
|
||||
- `easytier-web/src/client_manager/mod.rs`
|
||||
- `easytier-web/src/client_manager/managed_config.rs`
|
||||
- `easytier-web/src/db/mod.rs`
|
||||
- 对应 contract、database 和 managed-config tests
|
||||
|
||||
完成条件:
|
||||
|
||||
- [x] 现有 Full compatibility tests 固定。
|
||||
- [x] Full config rows 与 revision 原子提交。
|
||||
- [x] Alternate web-owned mutations 原子清除 revision。
|
||||
- [x] PATCH contract 和 typed 409 实现。
|
||||
- [x] Patch 只查询、写入 touched IDs。
|
||||
- [ ] Bulk SQL 遵守 tested bind-count bound。
|
||||
- [ ] Route byte/count/per-entry limits 有文档和测试。
|
||||
- [x] User-owned rows 不能被 Full/Patch 覆盖或删除。
|
||||
- [x] Empty Full 语义保持。
|
||||
- [x] Applied/AlreadyApplied/conflict 的通知行为符合设计。
|
||||
- [x] Session 在 revision 连续时只收敛 touched instances,断链时使用 Full。
|
||||
- [ ] 1k/10k scale 与 concurrent CAS tests 通过。
|
||||
- [ ] Receiver-first compatibility matrix 通过。
|
||||
@@ -0,0 +1,470 @@
|
||||
# KCP 控制报文可靠性与旧版兼容设计
|
||||
|
||||
日期:2026-09-14。
|
||||
|
||||
状态:已按本文实现并提交;当前实现、验收结果及仍未定位的异常见
|
||||
[实现与验证记录](kcp-control-reliability-validation-2026-09-14.md)。
|
||||
本文保留设计要求,具体通过范围以验证记录为准。
|
||||
|
||||
## 1. 背景与问题边界
|
||||
|
||||
此前已修复 TCP 代理任一方向 EOF 导致双向转发提前退出、KCP accept
|
||||
通知队列满后连接交接丢失、接收缓冲排空与提前 FIN 的处理,以及握手前
|
||||
心跳触发 RST 的竞态。具体版本及结果见
|
||||
[半关闭验证记录](tcp-proxy-half-close-validation-2026-09-14.md)。
|
||||
|
||||
仍有两个问题:
|
||||
|
||||
1. 已证实的握手最终 ACK 丢失:源端收到 SYNACK 后认为连接建立,目标端
|
||||
仍停留在 SynReceived;服务端先发 greeting 的业务因此超时。
|
||||
2. 一次未主动注入丢包的反向半关闭超时:发送端出现输出队列 Full,FIN
|
||||
已进入端点输出队列,但没有证据证明它何时到达对端。失败日志未记录
|
||||
客户端已收字节数,尚不能确定缺少的是数据、EOF,还是两者。后续
|
||||
TRACE 重跑通过,不足以消除或解释原失败。
|
||||
|
||||
KCP 数据传输自身的确认重传没有覆盖外层 SYN、SYNACK、握手确认和 FIN。
|
||||
这是连接状态机的局部协议设计问题。修复需要明确控制报文的重复处理、
|
||||
完成条件和状态寿命,无需重做 KCP 数据传输或 EasyTier 路由架构。
|
||||
|
||||
本设计解决已证实的握手恢复缺口,并为 FIN 增加可靠确认。同时继续调查
|
||||
第二项异常;不能将新增 FIN 重传直接当作该异常已经修复的证明。
|
||||
|
||||
## 2. 目标、范围与不变量
|
||||
|
||||
目标:
|
||||
|
||||
- 新旧节点双向正常互通,滚动升级不得要求所有节点同时升级。
|
||||
- 新节点之间,控制报文在重试预算内丢失、重复或乱序时能够恢复。
|
||||
- 连接只交接一次,应用数据不重复交付,EOF 不越过已接收数据。
|
||||
- 半关闭只关闭一个发送方向,另一方向仍可长期传输。
|
||||
- 无法恢复时有界结束并报告错误,不能用正常 EOF 掩盖失败。
|
||||
- 关闭记录、待发送控制报文和重试任务均有明确生命周期。
|
||||
|
||||
范围:kcp-sys 的报文定义、连接状态机、端点调度与清理、必要的流错误
|
||||
传播,以及 EasyTier 的依赖 pin、集成测试和验证记录。
|
||||
|
||||
不新增路由能力公告、PeerFeatureFlag 字段、配置开关或应用层请求重试;
|
||||
不修改 TCP flow-key 修复、QUIC 协议、KCP 数据分段和拥塞算法。保留现有
|
||||
connect API、调用方超时和业务测试的 5 秒 socket 超时。
|
||||
|
||||
协议不变量:
|
||||
|
||||
1. 模式由本连接握手确定,交付应用后不能切换。
|
||||
2. 握手状态变化和是否产生应答是两件事;可靠模式允许状态不变但应答。
|
||||
3. 已经进入应用交接路径的连接,重复控制包不能再次 accept。
|
||||
4. FIN_ACK 只确认对方 FIN,不表示自己也关闭发送方向。
|
||||
5. 只有正常收到对端 FIN 且排空接收数据,才能向应用报告正常 EOF。
|
||||
6. FIN 确认成功后,FIN 重试期限不能限制正常半关闭连接的寿命。
|
||||
7. 重复包不能延长握手、FIN 重试和最终关闭记录的固定截止时间。
|
||||
|
||||
## 3. 版本基线与兼容证据
|
||||
|
||||
| 用途 | kcp-sys revision |
|
||||
| --- | --- |
|
||||
| 旧协议基线 | `d7427c22d764deb1860a7d37acc446ed5033464c` |
|
||||
| 本设计实施基线,已含前述局部修复 | `b37ee660fb70bb6d816fb8bbc08b140e55e7218b` |
|
||||
|
||||
源码检查确认:
|
||||
|
||||
- 旧 header 含三个 u32、一个 flags 字节及一个 `rsv` 保留字节,总长
|
||||
14 字节;旧实现不读取 `rsv`。
|
||||
- 旧 SYNACK 使用新建且清零的 header,不回显 SYN 的 `rsv`。
|
||||
- 旧 FSM 收到重复 SYN 时可能构造 RST,但状态不变;外层
|
||||
`KcpConnectionState::handle_packet` 丢弃状态未变化时的输出,
|
||||
因此已检查的旧端点不会实际发送该 RST。
|
||||
- `rsv` 未被读取、状态不变时抑制输出的行为,在已检查历史
|
||||
`a5f4b4e`、`37f653c`、`0f2fea1`、`9ce5c08`、`0f0a055`、
|
||||
`71eff18`、`d7427c2` 中一致。
|
||||
|
||||
这些是源码与历史检查,不是上述每个 revision 的运行测试。正式兼容
|
||||
声明必须对应实际测试的 EasyTier 版本及其锁定依赖,不能扩展为所有
|
||||
未检查的历史版本或第三方修改版本都已验证。
|
||||
|
||||
实现基线源码:
|
||||
[报文定义](https://github.com/EasyTier/kcp-sys/blob/b37ee660fb70bb6d816fb8bbc08b140e55e7218b/src/packet_def.rs)、
|
||||
[状态机](https://github.com/EasyTier/kcp-sys/blob/b37ee660fb70bb6d816fb8bbc08b140e55e7218b/src/state.rs)、
|
||||
[端点](https://github.com/EasyTier/kcp-sys/blob/b37ee660fb70bb6d816fb8bbc08b140e55e7218b/src/endpoint.rs)。
|
||||
|
||||
## 4. 报文格式与连接内协商
|
||||
|
||||
### 4.1 字段定义
|
||||
|
||||
保持 14 字节 header、连接标识字段、已有 flags 和 KCP DATA payload
|
||||
布局不变。使用已有 `rsv` 字节:
|
||||
|
||||
| 值 | 定义 |
|
||||
| --- | --- |
|
||||
| `0` | 旧协议,后文称 legacy |
|
||||
| `1` | 本文可靠控制协议,后文称可靠模式 |
|
||||
|
||||
新增 FIN_ACK 使用 flags 当前未分配的最高位 `0x80`。它是独立报文,
|
||||
flags 必须恰好为 FIN_ACK,`rsv=1`,payload 为空。不能用 FIN|ACK
|
||||
表示关闭确认,因为旧端会把 FIN 位解释为对端关闭发送方向。
|
||||
|
||||
可靠模式报文约束:
|
||||
|
||||
| 报文 | flags | payload |
|
||||
| --- | --- | --- |
|
||||
| SYN | SYN | 原有连接元数据,重传保持完全相同 |
|
||||
| SYNACK | SYN\|ACK | 空 |
|
||||
| 最终握手确认 | ACK\|DATA | 空 |
|
||||
| 数据及 KCP 内部确认 | ACK\|DATA | 原有 KCP payload |
|
||||
| 关闭发送方向 | FIN | 空 |
|
||||
| 关闭确认 | FIN_ACK (`0x80`) | 空 |
|
||||
| 重置、心跳 | 原有 RST、PING、PING\|PONG | 沿用现有定义 |
|
||||
|
||||
可靠模式报文均带 `rsv=1`。legacy 的后续报文仍使用现有格式和 `rsv=0`;
|
||||
新源端最初提出协商的 SYN 是可能发给旧目标端的唯一保留字节扩展。
|
||||
|
||||
只有匹配连接标识、当前握手阶段和合法 flags/payload 的 SYNACK,
|
||||
才可以确认模式。不能通过 PING/PONG 确认:旧端的心跳应答会复制输入
|
||||
报文,可能保留它并不理解的 `rsv`。
|
||||
|
||||
### 4.2 协商步骤
|
||||
|
||||
1. 新源端发送 SYN,`rsv=1`,进入尚未确认模式的 SynSent。
|
||||
2. 新目标端收到 SYN:`rsv=1` 时回复 SYNACK `rsv=1`,记录可靠模式
|
||||
候选状态;`rsv=0` 时从头使用 legacy,回复 SYNACK `rsv=0`。
|
||||
3. 旧目标端忽略 `rsv`,自然回复 SYNACK `rsv=0`。新源端收到它后,
|
||||
在交付应用前确定 legacy,最终 ACK 及后续报文都使用旧模式。
|
||||
4. 新源端收到合法 SYNACK `rsv=1` 后确认可靠模式,发送最终 ACK
|
||||
`rsv=1`。目标端收到匹配模式的确认后完成握手并登记待 accept。
|
||||
5. 源端继续在收到合法 SYNACK 后完成 connect,不增加第四次握手或
|
||||
额外 RTT。最终 ACK 丢失后的恢复规则见第 5 节。
|
||||
|
||||
未识别的 SYN `rsv` 值不得被当作可靠能力,可按不支持扩展响应 legacy
|
||||
SYNACK。新源端只接受它实际支持的 SYNACK 模式 `0` 或 `1`;未知值
|
||||
报告协议错误,不启用增强行为。重复 SYN 不允许更改已有连接的模式或
|
||||
连接元数据。已协商可靠模式后,模式不匹配的控制包不得触发降级。
|
||||
|
||||
节点重启或降级后的新连接重新协商;不缓存按节点永久生效的协议模式。
|
||||
不承诺跨进程重启维持既有连接。
|
||||
|
||||
### 4.3 初始 SYN 与旧版兼容
|
||||
|
||||
首个 SYN 或 SYNACK 丢失时,源端尚不可能拿到协商确认。因此 SynSent
|
||||
期间允许在原截止时间内重传完全相同的 SYN,不以前置能力公告或已收到
|
||||
SYNACK 为条件。
|
||||
|
||||
在已检查的旧端点中,重复 SYN 不引出 RST,但也不会让旧目标端重发
|
||||
SYNACK。所以新源端→旧目标端仍保留旧目标端 SYNACK 丢失时的限制。
|
||||
这一行为必须由真实旧依赖运行测试确认,不能只测试重写的 legacy 模型。
|
||||
|
||||
新目标端对旧源端保持 legacy 行为,不主动开启 SYNACK 重传。
|
||||
legacy 分支继续保留“状态不变则抑制 FSM 输出”的现有行为;不得将
|
||||
可靠模式的应答修复无条件套用,否则会改变旧连接的 RST 行为。
|
||||
|
||||
## 5. 可靠握手与重复报文
|
||||
|
||||
源端在 SynSent 重传 SYN,目标端在可靠 SynReceived 重传 SYNACK。
|
||||
所有重传使用原连接 ID,不创建额外连接或重试业务请求。
|
||||
|
||||
| 当前状态 | 输入 | 动作 |
|
||||
| --- | --- | --- |
|
||||
| 无状态 | 合法 SYN | 保存模式与连接元数据,建立 SynReceived,回复 SYNACK |
|
||||
| SynReceived | 相同 SYN | 重新回复 SYNACK,不替换状态或延长截止时间 |
|
||||
| SynReceived | 匹配的 ACK\|DATA | 完成握手,登记待 accept 一次,停止 SYNACK 定时重传 |
|
||||
| SynSent | 合法 SYNACK | 确定模式,发送最终 ACK,完成 connect |
|
||||
| Established 或半关闭 | 重复 SYNACK | 回复最终 ACK,不重新创建流、不改变关闭状态 |
|
||||
| 已握手、尚未最终释放 | 相同 SYN | 可重发原 SYNACK,不重复 accept、不恢复已关闭方向 |
|
||||
| 已握手 | 重复最终 ACK | 幂等处理,不重复交接连接 |
|
||||
|
||||
ACK\|DATA 带有效 KCP payload 时仍按 KCP 处理。若它同时完成握手,
|
||||
不得为了修复握手而重复交付 payload;必须验证 accept 前到达数据时,
|
||||
现有 KCP 重传和接收路径最终完整交付字节。
|
||||
|
||||
最终 ACK 丢失的正常恢复:
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant S as 源端
|
||||
participant D as 目标端
|
||||
S->>D: SYN
|
||||
D->>S: SYNACK
|
||||
S--xD: 最终 ACK 丢失
|
||||
Note over S: connect 已返回
|
||||
Note over D: 保持 SynReceived
|
||||
D->>S: 重传 SYNACK
|
||||
S->>D: 重发最终 ACK
|
||||
Note over D: 登记待 accept 一次
|
||||
```
|
||||
|
||||
### 5.1 最终 ACK 丢失后立即 FIN
|
||||
|
||||
空流的发送缓冲本来就是空的,connect 返回后可以立即 shutdown;不能
|
||||
假设 FIN 一定晚于最终 ACK 到达。
|
||||
|
||||
可靠 SynReceived 收到匹配模式和连接标识的合法 FIN 时,将它视为
|
||||
隐含的握手确认,同时执行:
|
||||
|
||||
1. 完成握手并停止 SYNACK 重传。
|
||||
2. 登记待 accept 一次,保存对端发送方向已关闭。
|
||||
3. 回复 FIN_ACK;accept 创建流时继承对端关闭状态。
|
||||
4. 接收排空后报告 EOF,本端仍可回复数据。
|
||||
|
||||
不能先进入 Closed 再返回 RST,也不能要求应用先发送非空数据才能
|
||||
完成握手。该规则仅用于可靠模式。
|
||||
|
||||
## 6. FIN、确认与双向关闭
|
||||
|
||||
### 6.1 本端关闭发送方向
|
||||
|
||||
保留现有顺序:应用关闭发送队列,发送任务把排队数据交给 KCP,并等待
|
||||
`waitsnd == 0`,然后进入 FIN 待确认阶段。重试预算从这一阶段开始计算,
|
||||
不能从应用开始传输或尚未排空数据时开始计算。
|
||||
|
||||
FIN 发送入队成功不代表对端已收到。记录待确认状态、下一次重传时间和
|
||||
固定截止时间,直到收到有效 FIN_ACK 或连接明确失败。收到对端 FIN
|
||||
本身不等同于本端 FIN 已被确认。
|
||||
|
||||
保留现有 AsyncWrite shutdown 的本地关闭语义,不将其返回成功解释为
|
||||
对端已确认。关闭确认和最终回收由端点负责;后续超时通过仍存活的流和
|
||||
诊断状态表达,不能变成伪造的正常 EOF。
|
||||
|
||||
### 6.2 收到 FIN 或 FIN_ACK
|
||||
|
||||
- 收到 FIN:记录对端发送方向已关闭,并安排 FIN_ACK。接收任务仍需
|
||||
把已确认接收的数据交给应用,排空后才产生 EOF。
|
||||
- FIN_ACK 可以先于应用读完数据发送;它确认端点收到了 FIN,不确认
|
||||
应用已经消费数据。因此接收状态和缓冲生命周期不能随确认提前删除。
|
||||
- 重复 FIN:重复应答 FIN_ACK,不重复产生应用 EOF,不重置连接。
|
||||
- 收到 FIN_ACK:仅当本端确实已经发送 FIN,且模式、连接标识、flags
|
||||
和 payload 全部合法时,停止本端 FIN 重传。
|
||||
- 提前、重复或与当前阶段无关的 FIN_ACK 不推进状态、不关闭写方向。
|
||||
|
||||
发送和接收两个方向分别记录完成情况。现有 Established、LocalClosed、
|
||||
PeerClosed、Closed 不足以单独表达“本端 FIN 已发但未确认”,应在现有
|
||||
连接状态内补足该信息,不另建一套连接所有权或通用重试框架。
|
||||
|
||||
### 6.3 正常回收与短暂关闭记录
|
||||
|
||||
可靠模式最终回收分两步:
|
||||
|
||||
1. 本端 FIN 已确认、对端 FIN 已收到,并且接收任务排空后,释放 KCP
|
||||
数据对象、应用通道和数据任务;在原 state_map 保留最小关闭记录。
|
||||
2. 固定保留期结束后删除该记录。重复报文不延长保留期。
|
||||
|
||||
关闭记录仅保存原连接 ID、协议模式、正常关闭原因、回复重复 FIN 所需
|
||||
信息和过期时间。它继续回复 FIN_ACK;合法在途 PING/PONG 可以忽略,
|
||||
不得因现有 Closed 心跳路径而回复 RST。迟到 SYN/SYNACK 不得重新
|
||||
accept、创建数据对象或恢复连接;已经完成的数据包可以丢弃。
|
||||
|
||||
双方同时关闭时,即使只有一方收到 FIN_ACK,先完成的一方也要依靠该
|
||||
记录继续回答另一方的 FIN 重传。不能沿用“Closed 且 recv_done 就立即
|
||||
删除所有状态”的判断。
|
||||
|
||||
legacy 继续使用原有回收规则。可靠模式的短暂记录会使状态表在有限
|
||||
时间内多保留条目,容量验收必须单独计算,不能继续要求所有内部状态
|
||||
在原 15 秒观察点清零。
|
||||
|
||||
## 7. 定时、队列与失败路径
|
||||
|
||||
### 7.1 拟定默认参数
|
||||
|
||||
以下为实现初值,需由定点丢包与延迟测试验证后固定;调整须在验证记录
|
||||
说明,不能为了让测试通过而延长业务超时。
|
||||
|
||||
| 参数 | 初值 / 规则 |
|
||||
| --- | --- |
|
||||
| SYN、SYNACK、FIN 首次重传间隔 | 200 ms |
|
||||
| 后续重传 | 指数退避,单次间隔上限 2 s |
|
||||
| 源端握手期限 | 调用方现有 `connect(timeout_dur)`,不延长 |
|
||||
| 可靠目标端握手期限 | 首次收到 SYN 后 60 s,不由心跳或重复包续期 |
|
||||
| FIN 待确认期限 | 进入 FIN 阶段后 60 s |
|
||||
| 正常关闭记录保留期 | 65 s,覆盖 FIN 重试窗口并留调度余量 |
|
||||
|
||||
目标端和 FIN 的 60 秒预算是端点控制状态的资源边界,不替代应用更短的
|
||||
超时。网络永久不可达时,正确结果是可诊断的超时和最终清理。
|
||||
|
||||
关闭记录保留期必须不少于另一端允许的最大 FIN 重试窗口加余量,因此
|
||||
FIN 期限不能变成各端任意可调且互不知情的参数。若后续版本改变这些
|
||||
上界,需要重新审查版本间兼容性。
|
||||
|
||||
FIN 已确认后立即解除 FIN 重试期限;正常半关闭继续按既有连接存活规则
|
||||
处理。仅本端 FIN 未确认才受该期限限制,反向有数据或 PONG 也不能使
|
||||
未确认 FIN 无限续期。
|
||||
|
||||
### 7.2 端点调度与输出队列
|
||||
|
||||
使用单个端点控制调度任务和现有 state_map;不为每次重传创建任务,不
|
||||
扩容或改成无界队列。按最近的控制截止时间唤醒,新状态通过通知更新
|
||||
调度;没有待处理控制状态时,避免对全部空闲连接进行高频扫描。
|
||||
|
||||
状态记录是控制报文待处理事实的来源。输出队列暂满时保留该事实,稍后
|
||||
再尝试;不能像原 accept 通知一样丢失后就再无恢复机会。
|
||||
|
||||
- 用短临界区和非阻塞入队,禁止持有 state_map/conn_map 锁跨 await。
|
||||
- 每次实际入队前重新检查状态、连接标识和期限,防止陈旧重传在取消或
|
||||
回收后继续发送。回收不能与入队检查形成复活旧连接的竞态。
|
||||
- SYN/SYNACK/FIN 的重传受各自固定预算限制。重复请求所需的 ACK 或
|
||||
FIN_ACK 应答可合并为待发状态,不能累计无界报文列表。
|
||||
- 应答暂时无法入队时由调度器继续尝试;应答不会获得独立的无限寿命。
|
||||
合并应答记录从首次待发起最多保留 60 秒,重复请求不续期;正常关闭
|
||||
记录中的应答还受该记录更早的截止时间限制。入队成功即撤销待发标记。
|
||||
单纯的应答待发过期只清除该应答,不因此关闭仍合法存活的连接;请求
|
||||
发送方最终依靠自身握手或 FIN 重试期限判定成功或失败。
|
||||
- 队列满不能阻塞整个控制调度任务,也不能阻止它处理其他连接的截止
|
||||
时间。不得把“尝试入队成功”统计成“对端确认成功”。
|
||||
|
||||
### 7.3 取消、错误与连接 ID
|
||||
|
||||
- connect future 取消或超时:撤销该连接的重试与本地所有权;目标端
|
||||
未完成握手的状态按其固定期限退出,重复包不能延长。
|
||||
- 有效 RST、端点销毁或 FIN 重试耗尽:停止控制重试、唤醒等待任务,
|
||||
保留错误与正常 EOF 的区别。只发送必要的现有重置通知,不建立 RST
|
||||
确认和重试协议。
|
||||
- 清理失败连接不能重新产生待 accept;迟到的控制响应不能恢复已取消
|
||||
的 connect future。
|
||||
- ConnId 分配必须避开所有仍有效的状态,包括正常关闭记录。回绕不能
|
||||
复用仍在保留期内的 ID;会话标识变化后,旧报文不得作用于新会话。
|
||||
- 保留期之后无限迟到的包不在可靠恢复窗口内。不得通过永久保存关闭
|
||||
记录来试图覆盖无界的网络延迟。
|
||||
|
||||
## 8. 兼容性矩阵与降级
|
||||
|
||||
| 源端 | 目标端 | 预期模式 | 必须满足 |
|
||||
| --- | --- | --- | --- |
|
||||
| 旧 | 旧 | legacy | 对照基线,保留既有行为 |
|
||||
| 新 | 旧 | SYNACK `rsv=0` 确定 legacy | 正常互通,不发 FIN_ACK,不要求目标支持新确认 |
|
||||
| 旧 | 新 | 从 SYN `rsv=0` 确定 legacy | 正常互通,不主动开启新 SYNACK/FIN 重传语义 |
|
||||
| 新 | 新 | 本连接握手确认可靠模式 | 握手与关闭在重试预算内恢复 |
|
||||
|
||||
兼容的含义是旧节点仍可互通,且混合组合不因新代码发生行为退化。它不
|
||||
意味着旧目标端自动获得 accept 修复,或旧连接获得其未实现的 ACK/FIN
|
||||
恢复能力。必须把遗留失败与新增失败分别记录。
|
||||
|
||||
发布或回退影响后续新连接的协商。协议模式固定在连接内,禁止基于后续
|
||||
路由公告变化对活跃连接进行升级或降级。旧可执行程序重启后不承担继续
|
||||
解释原进程可靠模式连接的义务。
|
||||
|
||||
## 9. 实施拆分
|
||||
|
||||
实施使用独立协议修复分支,以 `b37ee660` 及 EasyTier 当前已验证修复
|
||||
为基线,不将协议改动混入原 TCP flow-key patch。
|
||||
|
||||
1. **先建立兼容测试夹具。** 使用实际旧依赖和当前基线端点,确认 `rsv`
|
||||
处理、旧 SYNACK、重复 SYN 和旧模式输出门控。明确失败基线。
|
||||
2. **实现协商及状态处理。** 增加模式、合法报文校验、可靠模式幂等应答,
|
||||
保留 legacy 路径。补上最终 ACK 丢失加立即 FIN 的交叉状态。
|
||||
3. **实现统一控制调度及可靠关闭。** 补足握手/FIN 期限、FIN_ACK、正常
|
||||
关闭记录、队列暂满、取消和错误清理。
|
||||
4. **完成依赖验证后接入 EasyTier。** 更新依赖 pin 与锁文件,运行真实
|
||||
代理路径、混合版本和跨平台验证,提交可追溯记录。
|
||||
|
||||
协商声明代表完整可靠控制协议,不能在只实现握手恢复、尚未实现 FIN_ACK
|
||||
时对外声明 `rsv=1`。中间提交可以用于审阅,但仅完整实现且验证通过的
|
||||
依赖版本可被 EasyTier 发布使用。
|
||||
|
||||
代码主要落点为 kcp-sys 的 `packet_def.rs`、`state.rs`、`endpoint.rs`,
|
||||
按实际错误传播需要调整 `stream.rs`;不预先拆出通用协议框架。EasyTier
|
||||
预计只需依赖更新、测试和记录,不需要修改 protobuf 或节点能力公告。
|
||||
|
||||
## 10. 验证与验收标准
|
||||
|
||||
### 10.1 确定性协议测试
|
||||
|
||||
在端点输入/输出之间设置测试用报文过滤器,按连接 ID、报文类型和次数
|
||||
精确丢弃、延迟或重复报文。可使用可控时钟测试期限,不依赖随机 netem
|
||||
才能触发边界,也不通过扩大生产超时使测试通过。
|
||||
|
||||
| 场景 | 验收断言 |
|
||||
| --- | --- |
|
||||
| 分别丢首个 SYN、SYNACK、最终 ACK | 新新组合恢复,同一连接只交接一次 |
|
||||
| 连续丢多次控制包后恢复链路 | 截止时间内恢复,重试次数与退避符合预期 |
|
||||
| 最终 ACK 丢失后空流立即 FIN | 目标完成握手、返回 EOF、仍能回复数据,无 RST |
|
||||
| 最终 ACK 丢失后首个 DATA 到达 | 正确完成握手,应用字节完整且不重复 |
|
||||
| 重复 SYN/SYNACK/ACK,含半关闭阶段 | 不重复创建连接,不恢复已经关闭的方向 |
|
||||
| 丢单向 FIN 或 FIN_ACK | 重传恢复;确认不关闭另一方向 |
|
||||
| 双方同时 FIN,单侧或双侧确认丢失 | 正确排空,正常关闭记录继续应答,最终回收 |
|
||||
| FIN 先于 accept 或 connect 返回 | 空请求、空响应均正确得到 EOF |
|
||||
| 接收缓冲尚未被应用读完 | FIN_ACK 不导致缓冲和数据任务提前释放 |
|
||||
| FIN 已确认后持续半关闭超过 60 s | 反向仍可传输,不受 FIN 期限误杀 |
|
||||
| 正常关闭记录收到重复 FIN、旧心跳 | 应答或忽略,无 RST,不延长固定保留期 |
|
||||
| SYN/FIN 永久丢失、控制输出队列长时间满 | 到期退出,无无限任务或状态残留 |
|
||||
| 队列临时满后恢复 | 待发事实不丢失,其他连接和清理继续运行 |
|
||||
| connect 取消、RST、端点销毁与重试并发 | 不复活旧状态,等待者正确结束,无伪 EOF |
|
||||
| ConnId 回绕、会话变化、迟到报文 | 不命中仍在保留期的旧 ID,不污染新会话 |
|
||||
|
||||
### 10.2 真实旧版兼容测试
|
||||
|
||||
测试夹具必须运行旧依赖的真实 endpoint,不能只在新实现上设置 legacy
|
||||
标志代替旧版本。首次验收覆盖 `d7427c2`、`b37ee660` 与新实现的组合,
|
||||
并记录 EasyTier 发布验证实际选用的旧二进制版本和哈希。
|
||||
|
||||
除四种组合的双向数据与关闭外,额外断言:
|
||||
|
||||
- 旧端收到 SYN `rsv=1` 仍按旧协议回复 SYNACK `rsv=0`。
|
||||
- 重复 SYN 不引出混合连接新增 RST;legacy 输出门控保持原有行为。
|
||||
- 混合组合捕获不到 FIN_ACK;旧端不必识别任何新控制语义。
|
||||
- PING/PONG、错误连接 ID、非法 flags 或未知 SYNACK 模式不能确认能力。
|
||||
- 新连接在节点升级、回退后重新选择正确模式,不沿用节点级缓存。
|
||||
- 旧版原有半关闭或丢包失败作为对照保留,不将它们写成新协议已通过。
|
||||
|
||||
### 10.3 EasyTier 流量与平台验证
|
||||
|
||||
Linux 需要 root 的测试在现有 `rust` 容器中运行。复用正常 target,权限
|
||||
问题通过修复所有权解决,不另开编译目录。
|
||||
|
||||
- 完整三节点组合及 ACL、配置更新、端口转发、断连测试。
|
||||
- TCP/KCP/QUIC × 内核/smoltcp 六种模式,检查未修改协议没有退化。
|
||||
- 每组合 300 次短连接、16 并发、5 秒 socket 超时;KCP 每栈追加三轮,
|
||||
与已有每版本 2,400 次 KCP 结果对照。
|
||||
- 空请求、256 KiB 请求后 EOF、1 MiB 响应,以及服务端先半关闭后客户端
|
||||
才发送 256 KiB 的反向场景,均逐字节核对。
|
||||
- `netem delay 10ms 3ms loss 1%` 下持续双连接与短连接;定点控制包丢失
|
||||
由协议测试证明,随机丢包实验用于验证整体行为。
|
||||
- 新旧两方向真实二进制互通;无丢包与丢包结果分别记录。
|
||||
- Linux、macOS、Windows 原生依赖测试及网关测试;格式与严格 Clippy。
|
||||
缺少组件、未运行的项目不得记录为通过。
|
||||
|
||||
### 10.4 未解释的反向超时
|
||||
|
||||
复现脚本必须记录超时时已收到的字节数、是否收到 EOF、源端口、连接 ID,
|
||||
并在双方记录 FIN/FIN_ACK 的入队、发送和接收时间。补足原失败缺少的
|
||||
证据,区分数据缺失、关闭通知缺失、队列延迟和状态处理错误。
|
||||
|
||||
恢复实际链路的 FIN 丢失,只能证明这一类注入故障已修复;仍需解释原
|
||||
异常或明确保留未定位项。不得用重跑通过覆盖原始失败。
|
||||
|
||||
### 10.5 资源与交付门槛
|
||||
|
||||
分别统计:应用代理连接、KCP 数据对象、半开握手、未确认 FIN、正常关闭
|
||||
记录、FD 和 RSS。保留期内的轻量记录属于设计成本,不能混同于活跃连接
|
||||
泄漏;其数量约受每秒关闭连接数乘以保留期约束,需实测内存成本。
|
||||
|
||||
静止并超过握手/FIN 期限及关闭记录保留期后,所有应回收状态必须消失。
|
||||
使用重复负载周期检查资源是否持续累积,并检查端点空闲及大量并发时
|
||||
控制调度的 CPU 成本。不能仅凭一次 FD 回到基线宣布不存在泄漏。
|
||||
|
||||
交付必须满足:确定性恢复用例通过;实际旧版兼容用例没有新增失败;
|
||||
错误路径有界清理;原有矩阵没有新退化;所有异常如实保留。完整最终
|
||||
代码 diff 由独立子代理审查,只处理高置信度真实缺陷。验证记录绑定
|
||||
提交、依赖 revision、二进制哈希、准确命令及原始日志。
|
||||
|
||||
## 11. 当前进度与证据
|
||||
|
||||
已完成:连接级版本协商、握手与 FIN 恢复、FIN_ACK、重复控制处理、
|
||||
正常关闭记录、队列饱和处理与取消清理。kcp-sys 最终提交为
|
||||
`3ef5c4161faf99940f3ed51efd43cef0cbc02b4f`,EasyTier 最终依赖接入为
|
||||
`ee02b8f7`。首次验收已测试真实旧实现 `d7427c2` 和 `b37ee660`。
|
||||
后续测试整理仅保留 `d7427c2` 长期基线;当前维护版本与依赖 pin 见
|
||||
[实现与验证记录](kcp-control-reliability-validation-2026-09-14.md)。
|
||||
|
||||
三平台原生依赖测试、Linux 完整矩阵及实际流量的本轮结果单独记录,
|
||||
不沿用上一轮局部修复的通过数。最终代码审查没有 blocker / major,
|
||||
一项首次 SYNACK 调度竞态 minor 按用户规则记录待办。
|
||||
|
||||
仍需保留的边界:原反向超时没有足够证据做最终归因;混合版本保留
|
||||
legacy 的控制恢复限制;有限负载和应用级资源采样不能代替生产规模
|
||||
长期内存、CPU 和容量验证。所有实测异常及未执行项见关联验证记录。
|
||||
|
||||
此前调查原始产物位于:
|
||||
|
||||
```text
|
||||
/data/project/proxy-close-validation-20260914/
|
||||
```
|
||||
|
||||
关键证据:`remaining-loss-handshake.md`、`loss-handshake-evidence.log`、
|
||||
`delivery-reverse-timeout.md`、`fin-send-path-old-new.txt`、
|
||||
`delivery-manifest.json`、`traffic-summary.md`。上述绝对路径是本机
|
||||
调查产物位置;仓库读者可通过关联验证记录了解结论与证据限制。
|
||||
@@ -0,0 +1,296 @@
|
||||
# KCP 控制报文可靠性实现与验证(2026-09-14)
|
||||
|
||||
本文记录
|
||||
[协议设计](kcp-control-reliability-design-2026-09-14.md)
|
||||
的实现与验证,独立于此前的 TCP flow-key 和半关闭局部修复。
|
||||
|
||||
## 兼容测试维护整理
|
||||
|
||||
当前维护版本为 kcp-sys `268533568d734ae89dc89603078da3ca522effe1`。
|
||||
保留 `d7427c22` 作为长期兼容基线,移除中间版本 `b37ee660` 的
|
||||
`kcp-sys-baseline` dev-dependency 与三项重复用例;单次使用的宏展开为
|
||||
普通测试函数。四项旧版兼容测试继续覆盖能力协商、双向数据、半关闭及
|
||||
重复 SYN;15 项 library 与 12 项协议回归不变。
|
||||
|
||||
整理后 Linux 共 31/31 测试通过,格式与严格 Clippy 通过。EasyTier
|
||||
同步依赖 pin 与锁文件,并通过 `cargo +1.95 check --locked -p easytier
|
||||
--features full`。此次只整理测试与测试依赖,协议源码未改变。
|
||||
历史三平台 34/34 和全部流量结果仍属于下述 `3ef5c416` 实现验证,
|
||||
没有重写为整理后版本的执行结果。中间基线作为调查证据保留在本文。
|
||||
本次日志位于 `/data/project/kcp-compat-cleanup-validation/`。
|
||||
|
||||
## 版本与实现
|
||||
|
||||
- kcp-sys 基线:`b37ee660fb70bb6d816fb8bbc08b140e55e7218b`。
|
||||
- kcp-sys 初版实现:`c84733d4479b40a299d51f4c5b8bb02ccacadc68`。
|
||||
- kcp-sys 最终实现:`3ef5c4161faf99940f3ed51efd43cef0cbc02b4f`,补齐
|
||||
可靠模式下未知连接 RST 输出队列饱和时的非阻塞处理。
|
||||
- EasyTier 基线:`851e7523`;接入提交:`4fedbdd1`、`ee02b8f7`,
|
||||
仅修改依赖 pin 和 Cargo.lock。
|
||||
- 最终真实流量二进制:`final-easytier-core`,SHA-256:
|
||||
`962f2480cb611ceb6cab293560d0dfb337218594cdc298a08f0e83801a2d17b8`。
|
||||
它在接入提交前构建,源码及依赖内容与该提交一致;识别产物以哈希和
|
||||
依赖 revision 为准,不单凭内嵌的 EasyTier git 版本字符串。
|
||||
|
||||
依赖已发布到 `EasyTier/kcp-sys` 的独立分支
|
||||
`fix/control-reliability-20260914`,远端引用核对为上述最终 revision。
|
||||
EasyTier 任务分支为 `fix/kcp-control-reliability`。
|
||||
|
||||
实现保留 14 字节 header,通过 SYN/SYNACK 的 `rsv` 协商可靠控制模式。
|
||||
收到旧 SYNACK 后固定使用 legacy,旧源端连接新目标端也从头使用 legacy。
|
||||
只有协商为可靠模式的连接使用独立 FIN_ACK 和新增恢复逻辑。
|
||||
|
||||
可靠模式在原连接 ID 上恢复 SYN、SYNACK 和 FIN;重复握手不重复交接。
|
||||
最终 ACK 丢失后的空 FIN 可以同时确认握手并报告对端半关闭,反向仍能
|
||||
发送数据。FIN_ACK 只确认收到 FIN,不关闭本端发送方向,也不提前释放
|
||||
尚未被应用读取的接收数据。
|
||||
|
||||
控制请求和待发送应答在原连接状态中保存,由端点统一调度。输出队列
|
||||
暂满不会丢失待发事实或延长固定期限;取消 connect 同步撤销本地状态。
|
||||
状态检查、控制入队和清理保持一致锁序,不持锁跨 await。
|
||||
|
||||
初始重传间隔 200 ms、指数退避至 2 s;源端使用调用方连接超时,目标端
|
||||
握手及 FIN 待确认期限为 60 s。正常双向关闭且接收排空后释放数据对象,
|
||||
原状态表保留 65 s 的关闭记录,继续应答重复 FIN,并避免迟到心跳引出
|
||||
RST。FIN 一旦确认,正常半关闭不受 FIN 重试期限限制。
|
||||
|
||||
## 自动化结果
|
||||
|
||||
| 验证 | 结果 |
|
||||
| --- | --- |
|
||||
| Linux kcp-sys | 15 library + 12 协议回归 + 7 真实旧依赖兼容,34/34 |
|
||||
| macOS kcp-sys | 相同 34/34,两个 example target 通过 |
|
||||
| Windows kcp-sys | 相同 34/34,两个 example target 通过 |
|
||||
| Linux 两个 example target、格式、严格 Clippy | 通过 |
|
||||
| macOS 格式、严格 Clippy | 通过 |
|
||||
| Windows 格式、Clippy | 所选 stable 缺少组件,未执行 |
|
||||
| Linux 原生网关测试 | 200/200 |
|
||||
| macOS、Windows 原生网关测试 | 各 200/200 |
|
||||
| EasyTier 完整三节点及补充集成测试 | 276/276,834.639 s |
|
||||
| EasyTier Linux 严格 Clippy | 通过 |
|
||||
|
||||
macOS 首次获取依赖遇到 GitHub TLS 错误,随后导入本机真实 Git 对象与
|
||||
checkout,在依赖 revision 不变的情况下离线测试;没有改成 path 依赖,
|
||||
也没有用新实现替换旧依赖。Windows 原始日志为 UTF-16LE,另保存 UTF-8
|
||||
副本。两平台复用原 target,没有另开编译目录绕过权限问题。
|
||||
|
||||
协议修改集中在 kcp-sys,EasyTier core 源码没有变化。原生网关验证检查
|
||||
与现有相同 core 源码的兼容行为,不能替代上面的真实新旧 KCP 端点测试。
|
||||
远端应用 manifest 仍锁定旧依赖 `d7427c2`,网关命令只选择 easytier-core;
|
||||
关键转发文件 `tcp_proxy_service.rs` 哈希与本地一致。因此不能将这组
|
||||
网关测试描述为完整原生应用已经接入最终新依赖。
|
||||
|
||||
## 失败到通过的对照
|
||||
|
||||
- 丢弃第一份最终握手 ACK:基线客户端 connect 返回,服务端 accept
|
||||
超过 5 s 仍未完成;实现后约 0.25 s 恢复,并成功发送服务端 greeting。
|
||||
- 新源端连接真实旧依赖:基线仅因 SYN 尚未声明协商能力而未达到新协议
|
||||
测试要求;实现后 SYN 提出 `rsv=1`、旧 SYNACK 回复 `rsv=0`,后续
|
||||
数据与关闭均走 legacy。该项验证协商功能,不把它描述为旧版互通 bug。
|
||||
- 原有 RST 单测曾在新连接上注入 `rsv=0` 的合成 RST,因可靠模式拒绝
|
||||
不匹配模式而超时;测试改为注入该连接实际协商版本的 RST 后通过。
|
||||
模式不匹配的报文不能用于证明正常 RST 错误传播失败。
|
||||
- 新增双向大缓冲夹具最初单次写入超过既有 KCP send 的分片限制,出现
|
||||
`Err(-2)`。调整为与原有测试一致的 16 KiB 分块,仍验证双向各
|
||||
200 KiB 总数据和原 5 s 期限;本补丁没有修改既有单次大写入限制。
|
||||
|
||||
- 最终补查发现初版 `c84733d` 对未知可靠 FIN 的 RST 仍使用阻塞发送。
|
||||
填满输出队列后,新 SYN 不能在 100 ms 内进入状态表;改为可靠模式
|
||||
的无状态应答使用 try_send 后通过。需要重试的有状态控制仍由状态表
|
||||
保管;legacy 发送路径不变。
|
||||
|
||||
原始失败日志保留,未使用扩大业务超时或重跑通过覆盖失败记录。
|
||||
|
||||
## 协议与生命周期覆盖
|
||||
|
||||
12 项公有 API 协议回归使用真实端点及按报文类型过滤的链路:
|
||||
|
||||
- 连续丢 SYN、连续丢 SYNACK、丢最终 ACK 后的服务端 greeting。
|
||||
- 所有空最终 ACK 均丢失,空 FIN 直接完成握手,EOF 后仍可回复。
|
||||
- 单向 FIN 或 FIN_ACK 丢失,另一方向仍可传输。
|
||||
- 双向 200 KiB 缓冲、双方首个关闭确认丢失、延后读取与排空。
|
||||
- 半关闭后重复 SYN/SYNACK/ACK;握手 ACK 丢失时 DATA 乱序、重复。
|
||||
- FIN 确认后推进 61 s,再恢复实际时钟,仍可反向传输。
|
||||
- PONG 保留 `rsv=1`、非法 SYNACK flags、非空 SYNACK 均不能确认模式。
|
||||
- 未知 SYNACK version 返回 `InvalidProtocolVersion`,不启用新模式。
|
||||
|
||||
15 项 library 测试包含原有 10 项以及五项生命周期与队列测试:取消 connect
|
||||
立即释放状态;重复 SYN 与满输出队列不延长半开期限;未确认 FIN 超时
|
||||
唤醒读端并释放状态;正常关闭记录应答迟到包、不引出 RST、不被延长,
|
||||
保留期间跳过相同 ConnId,期满后删除旧记录而不影响新连接;满输出
|
||||
队列下未知可靠 FIN 不能阻止后续 SYN 进入握手。
|
||||
|
||||
可控时钟只用于测试期限,不修改生产时间常量。数据测试仍通过实际 KCP
|
||||
发送、接收及 AsyncRead/AsyncWrite 路径。
|
||||
|
||||
## 真实旧依赖兼容
|
||||
|
||||
dev-dependency 固定并实际运行 `d7427c2`、`b37ee660` 两个历史实现,
|
||||
没有用新代码上的 legacy 开关模拟旧端。
|
||||
|
||||
7 项测试覆盖旧 SYN 处理探针和两个基线的新旧双向连接、256 KiB 双向
|
||||
数据、半关闭、重复 SYN。报文捕获断言:新源初始 SYN 可为 `rsv=1`,
|
||||
其余混合连接报文均为 `rsv=0`;没有 FIN_ACK、新增 RST 或重复 accept。
|
||||
|
||||
`d7427c2` 原有正常关闭被报告为 BrokenPipe 的行为仍在对照中保留。
|
||||
兼容意味着旧节点仍可互通,不意味着它自动获得新协议的恢复能力。
|
||||
|
||||
## 实际代理流量
|
||||
|
||||
实验使用独立 namespace,底层 UDP,
|
||||
覆盖 TCP/KCP/QUIC 与内核/smoltcp 六种模式。固定每轮 300 次短连接、
|
||||
16 并发、5 s socket 超时,逐字节校验大小请求、纯空请求及反向半关闭。
|
||||
|
||||
验证分为两个阶段;不将初版流量统计冒充最终版本结果。
|
||||
|
||||
### 初版 c84733d
|
||||
|
||||
二进制 `protocol-easytier-core` 的 SHA-256 为
|
||||
`f41ff98762ed1ce8691d8f83b8c9e47acc4548c76432a17cd2daba36e2cc7b80`。
|
||||
|
||||
- 六模式主矩阵:1,800 次短连接、12 次大小请求半关闭、6 次反向
|
||||
半关闭、192 次纯空请求,全部通过。
|
||||
- KCP 每栈追加三轮,合并主矩阵共 2,400 次 KCP 短连接,全部通过。
|
||||
- `netem delay 10ms 3ms loss 1%` 六模式:1,800 次短连接、12 次大小
|
||||
半关闭、6 次反向、192 次纯空及每组合 15 s 双连接持续流量,全部通过。
|
||||
- 新旧双方向各六模式:3,600 次短连接、24 次大小半关闭、384 次纯空
|
||||
和每组合 3 s 持续流量通过;**反向半关闭为 11/12,存在一次失败**。
|
||||
|
||||
旧端为依赖 b37 的 `delivery-easytier-core`,SHA-256 为
|
||||
`4635810ac9a536258f9b7ff606e5e1a5eb0243cb0f187825ba6f721d92dfc4fe`。
|
||||
失败发生在新 source → 旧 destination 的 KCP/smoltcp,连接
|
||||
`conv=3560055726`、源端口 `40748`:客户端已经收到完整 1 MiB,随后
|
||||
等待 EOF 超过 5 s;因此未进入后发 256 KiB 阶段。旧目标端在
|
||||
10:11:08.953 进入 LocalClosed,直到客户端超时才见客户端方向关闭。
|
||||
DEBUG 不能确定旧端 FIN 后续的发送、到达或处理点,不能直接定性为
|
||||
FIN 丢失,也不能把混合版本测试写成全部通过。
|
||||
|
||||
另一次有界 KCP/kernel 丢包 TRACE 测试中,300 次短连接及全部半关闭
|
||||
通过。实际捕获的控制事件全部使用 version 1:连接 `1468864199` 的
|
||||
重复 SYNACK 相隔约 200 ms,目标随后收到 ACK,业务完成;连接
|
||||
`1468864161` 的重复 FIN 后收到 FIN_ACK。它们证明实际链路使用了
|
||||
新控制恢复路径;单轮成功不代表任意丢包条件下都能成功。
|
||||
|
||||
### 最终 3ef5c416
|
||||
|
||||
- 六模式主矩阵全部通过:1,800 次短连接、12 次大小半关闭、6 次反向
|
||||
半关闭、192 次纯空请求。
|
||||
- 六模式随机丢包全部通过:相同流量规模,另每组合 15 s 双连接持续
|
||||
逐字节回显。原 5 s socket 超时不变,没有添加应用重试。
|
||||
- 混合版本无丢包,两连接方向 × 两个 KCP 栈全部通过:1,200 次短连接、
|
||||
8 次大小半关闭、4 次反向及 128 次纯空请求。该轮通过不覆盖初版
|
||||
混合实验中的 EOF 超时记录。
|
||||
- KCP 每栈追加三轮全部通过;合并主矩阵为 **2,400/2,400** 次 KCP
|
||||
短连接;追加轮次的 12 次大小、6 次反向和 192 次纯空也全部通过。
|
||||
混合丢包仍走 legacy,下表为单轮**失败数**,不重跑取最好结果。
|
||||
每组合为 300 短连接、2 次大小半关闭、1 次反向、32 次纯空和 3 s 持续流量。
|
||||
|
||||
| 连接方向/栈 | 短连接失败 | 大小失败 | 反向失败 | 纯空失败 | 持续失败 |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| b37 → 最终新 / kernel | 6 | 0 | 0 | 16 | 0 |
|
||||
| b37 → 最终新 / smoltcp | 2 | 0 | 0 | 13 | 0 |
|
||||
| 最终新 → b37 / kernel | 7 | 1 | 0 | 16 | 0 |
|
||||
| 最终新 → b37 / smoltcp | 1 | 0 | 0 | 14 | 0 |
|
||||
|
||||
唯一大小用例失败停在 greeting 阶段,5 s 超时;纯空失败为 response
|
||||
阶段收到空 EOF。为判断兼容回退,另跑一次相同条件、相同业务参数的
|
||||
b37 → b37 旧旧对照:kernel 短连接失败 2/300、纯空失败 12/32;
|
||||
smoltcp 分别为 0/300、16/32。两栈的大小、反向与持续项目均通过。
|
||||
|
||||
旧旧 TRACE 确认了一条失败链:kernel 的 `conv=3046346801`、空请求
|
||||
`id=3`、源端口 `35958`,目标在 10:30:44.112 先收到 FIN,直接
|
||||
Closed 并发送 RST;10:30:44.115 才收到最终 ACK|DATA。源在 .113
|
||||
收到 RST,客户端约 37 ms 后得到零字节 EOF。`src/state.rs` 的
|
||||
SynReceived + FIN → Closed/RST 路径本次没有修改;可靠模式单独支持
|
||||
FIN 完成握手,legacy 按已批准设计保留原行为。
|
||||
|
||||
这条时序证明上述失效路径在旧旧连接也存在,不能由随机样本的失败率
|
||||
断言所有混合版本失败均已归因或已经排除一切回退。混合实验原 DEBUG
|
||||
不足以逐包归因全部失败。兼容保证旧节点可按原协议互通;控制恢复能力
|
||||
需要两端都协商为 version 1,混合部署仍不能获得这一保证。
|
||||
|
||||
最终丢包轮次直接启用 KCP TRACE:kernel 捕获 5 个成功连接收到重复
|
||||
SYNACK、4 个收到重复 FIN;smoltcp 分别为 2 个、5 个。捕获的控制
|
||||
报文均使用 version 1。详见 `final-control-recovery-*-summary.json`
|
||||
及对应 `evidence.log`;这份证据直接绑定最终二进制。
|
||||
|
||||
资源观察区分应用代理条目与内部 KCP 状态。CLI 不直接暴露依赖内部
|
||||
关闭记录,不能用 CLI 条目清零证明内部记录已经删除;内部期限由
|
||||
生命周期测试验证。实际流量保存起始、结束、15 s、80 s 的 FD/RSS 与
|
||||
应用代理条目。65 s 关闭记录属于设计成本,不能沿用旧的 15 s 内部
|
||||
状态必须清零的断言。
|
||||
|
||||
最终每栈三轮连续负载的资源结果如下。a 为 source,b 为 destination。
|
||||
该追加轮次的 FD 全程未增长;应用 proxy 条目在 15 s 与 80 s 均为零。
|
||||
|
||||
| 栈/节点 | FD 前后 | RSS 前 → 80 s(KiB) | 负载 CPU 秒 / 墙钟秒 | 静置 15–80 s 单核 CPU |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| kernel/a | 15 → 15 | 46,944 → 50,792 | 2.20 / 2.697 | 0.29% |
|
||||
| kernel/b | 15 → 15 | 46,580 → 49,344 | 1.12 / 2.694 | 0.31% |
|
||||
| smoltcp/a | 14 → 14 | 46,784 → 50,352 | 4.39 / 5.378 | 0.49% |
|
||||
| smoltcp/b | 14 → 14 | 46,024 → 49,060 | 2.32 / 5.368 | 0.48% |
|
||||
|
||||
CPU 来自 `/proc/PID/stat` 的 user/system ticks 与单调时钟差,包含
|
||||
路由、心跳、代理、日志等全部进程工作。产物为 debug 构建,表中负载
|
||||
CPU 也不是纯协议开销或吞吐基准。RSS 未回到起点,无法由这些采样区分
|
||||
分配器缓存与其他长期对象,也不能推算每条关闭记录的精确内存成本。
|
||||
内部对象期限由单测验证,生产规模的逐对象内存与长期容量验证仍未完成。
|
||||
主矩阵 kernel/b 在 15 s 采样曾由 15 个 FD 暂升为 16,80 s 恢复为 15;
|
||||
该瞬时变化同样保留在原始报告,未作为持续增长处理。
|
||||
原始记录为 `final-kcp-repeat-kcp-*-resources.json`,换算另存
|
||||
`final-resource-summary.json`。
|
||||
|
||||
## 审查、复现与限制
|
||||
|
||||
初轮独立子代理只读审查 `b37ee660..c84733d`,没有高置信度缺陷发现。
|
||||
最终由新子代理审查完整 `b37ee660..3ef5c416` 及 EasyTier 最终 pin,
|
||||
未发现 blocker / major;重点检查旧版输出规则、握手与关闭交叉状态、
|
||||
队列、取消清理和锁序。
|
||||
|
||||
最终审查记录一项 **minor / high confidence** 待办:
|
||||
`kcp-sys/src/endpoint.rs:922` 新 SYN 路径先 notify 再插入连接状态,
|
||||
多线程时可能先消费通知并漏过首次 SYNACK 调度。正常源端约 200 ms 后
|
||||
重发 SYN 即可恢复;若后续 SYN 未到达,则等待约 10 s 周期扫描。状态
|
||||
不会丢失,也不会永久阻塞,但特别短的 connect 期限可能超时。按用户
|
||||
minor 默认记录的规则保留;后续最小改动是将通知放到状态插入之后。
|
||||
|
||||
主要命令:
|
||||
|
||||
```sh
|
||||
# kcp-sys 工作树
|
||||
cargo test --all-targets
|
||||
cargo fmt --all -- --check
|
||||
cargo clippy --all-targets -- -D warnings
|
||||
|
||||
# EasyTier,Linux root 集成测试在 rust 容器内执行
|
||||
cargo +1.95 test --locked -p easytier-core \
|
||||
--features proxy-smoltcp-stack --lib gateway::
|
||||
cargo +1.95 nextest run --locked -p easytier --features full --lib \
|
||||
-E 'test(subnet_proxy_three_node_test) | test(subnet_proxy_half_close_test) | test(acl_rule_test_inbound) | test(acl_rule_test_subnet_proxy) | test(proxy_three_node_disconnect_test) | test(config_patch_test) | test(port_forward_with_inbound_default_drop_acl_test)' \
|
||||
--test-threads 1 --no-fail-fast
|
||||
cargo +1.95 clippy --locked -p easytier --features full \
|
||||
--lib --tests -- -D warnings
|
||||
```
|
||||
|
||||
本机原始日志、脚本、JSON 与二进制:
|
||||
|
||||
```text
|
||||
/data/project/kcp-control-validation-20260914/
|
||||
```
|
||||
|
||||
主要证据为 `final-ack-red.log`、`final-ack-green.log`、
|
||||
`final-dependency-tests.log`、`final-dependency-clippy.log`、
|
||||
`unknown-close-full-output-red.log`、`legacy-final.log`、
|
||||
`negotiation-lifetime-regressions.log`、`final-native-*-tests.log`、
|
||||
`linux-gateway.log`、`integration-final.log`、`app-final-clippy.log`。
|
||||
`integration-protocol.log` 是初版的中断轮次,切换最终实现后重新执行完整
|
||||
矩阵,不计入最终通过数。
|
||||
流量的准确 argv、产物 hash 和逐连接结果另存该目录。完整流量汇总为
|
||||
`traffic-summary.md`,混合丢包对照为 `mixed-loss-analysis.md`,
|
||||
旧旧逐包证据为 `legacy-fin-before-ack-evidence.log` 与
|
||||
`legacy-fin-before-ack-client.json`。测试环境清理记录为 `cleanup.json`。
|
||||
|
||||
此前单次反向超时的具体丢包点仍不能由旧 DEBUG 日志倒推出。定点 FIN
|
||||
丢失测试证明该类失效现在能够恢复,不能因此改写原事故的根因结论。
|
||||
Windows/macOS 原生 TUN 端到端、生产规模长期负载、吞吐与容量上限仍
|
||||
不是这些有限测试能够证明的事项。
|
||||
@@ -0,0 +1,65 @@
|
||||
# HarmonyOS HAR delivery
|
||||
|
||||
The `ohos` workflow builds the Core HAR on pushes, pull requests, tags, and
|
||||
manual runs. Every successful run retains a short-lived HAR artifact, while
|
||||
publication to the private OHPM registry is deliberately restricted:
|
||||
|
||||
- A push to `main` publishes only when the pushed SHA is the merge commit of a
|
||||
pull request targeting `main` and the push is not forced.
|
||||
- A manual run on `main` publishes by default.
|
||||
- A manual run on another branch publishes only when its `publish` input is
|
||||
enabled.
|
||||
- Direct pushes, pull requests, tags, and ordinary non-main branch builds do
|
||||
not publish.
|
||||
|
||||
## Package identity
|
||||
|
||||
All branches publish the same private package name, `easytier-ohrs`. The
|
||||
source branch is encoded in the package version instead of the package name:
|
||||
|
||||
```text
|
||||
<core-version>-<branch-id>-<commits-since-tag>-<run-number>-<run-attempt>-g<short-sha>
|
||||
```
|
||||
|
||||
`branch-id` is a lowercase, OHPM-safe form of the source branch. Publishing a
|
||||
new version advances the registry's `latest` version. After publication, Core
|
||||
sends the `core-har-published` repository dispatch to the ArkTS and Pro
|
||||
repositories. The payload contains only `core_repository`, `core_ref`, and
|
||||
`package_name`.
|
||||
|
||||
## App install sequence
|
||||
|
||||
ArkTS and Pro use the same three OHPM commands:
|
||||
|
||||
```bash
|
||||
ohpm uninstall "$CORE_HAR_PACKAGE"
|
||||
ohpm install "$CORE_HAR_PACKAGE@latest" \
|
||||
--registry "$CORE_HAR_REGISTRY"
|
||||
ohpm install
|
||||
```
|
||||
|
||||
The App workflow then reads the installed version from:
|
||||
|
||||
```text
|
||||
oh_modules/<package_name>/oh-package.json5
|
||||
```
|
||||
|
||||
The existing `oh-package-lock.json5` and `oh_modules` directory are not
|
||||
manually deleted. Because the package name remains `easytier-ohrs`, downstream
|
||||
source imports do not need to be rewritten.
|
||||
|
||||
## Secrets
|
||||
|
||||
Core requires:
|
||||
|
||||
- `CODEARTS_PRIVATE_OHPM`: publish-capable OHPM configuration.
|
||||
- `DOWNSTREAM_DISPATCH_TOKEN`: permission to dispatch both App repositories.
|
||||
|
||||
ArkTS and Pro require:
|
||||
|
||||
- `CODEARTS_PRIVATE_OHPM_READ`: read-only private OHPM authentication.
|
||||
- `SIGNING_REPOSITORY_TOKEN`: read access to the corresponding private signing
|
||||
repository.
|
||||
|
||||
Signing and AppGallery Connect credentials remain downstream application
|
||||
concerns and are not passed through the Core dispatch payload.
|
||||
@@ -0,0 +1,176 @@
|
||||
# QUIC TCP Proxy 内存对比(2026-07-27)
|
||||
|
||||
## 结论
|
||||
|
||||
在相同的双节点 network namespace 环境中,当前分支相对 2.6.4:
|
||||
|
||||
- 空闲且未建立 TCP proxy 连接时,两端合计 USS 从 15.95 MiB
|
||||
降至 11.66 MiB,下降 26.9%;
|
||||
- 66 条空闲 TCP proxy 连接时,两端合计 USS 从 19.45 MiB
|
||||
降至 13.85 MiB,下降 28.8%;
|
||||
- 固定 1 Gbit/s 的单流 TCP proxy 传输中,两端平均 USS 从
|
||||
20.69 MiB 降至 14.75 MiB,下降 28.7%,同步峰值从
|
||||
21.50 MiB 降至 15.02 MiB;
|
||||
- 从 0 增长到 66 条空闲连接推算,每条连接在两个 core 上合计
|
||||
增加约 33.9 KiB USS;2.6.4 为 54.2 KiB,下降 37.4%;
|
||||
- 当前分支的匿名内存下降约 40% 至 44%,说明堆和连接缓冲区开销
|
||||
确实降低。
|
||||
|
||||
当前分支的 RSS 比 2.6.4 高约 5% 至 11%,但这部分差异没有出现在
|
||||
Anonymous 中,主要体现为非匿名或共享驻留页。PSS 在高连接数及
|
||||
固定吞吐场景基本持平,USS 和 Anonymous 则显著更低。因此不能
|
||||
只根据 RSS 判断发生了内存回退。
|
||||
|
||||
## 测试对象
|
||||
|
||||
| 版本 | 标识 | 二进制 |
|
||||
|---|---|---|
|
||||
| 当前分支 | commit `9e2ed33aeb37`,版本 `2.6.4-9e2ed33a` | `target/x86_64-unknown-linux-musl/release/easytier-core` |
|
||||
| 2.6.4 | 版本 `2.6.4-8428a89d` | `/data/tickets/easytier/easytier-linux-x86_64/easytier-core` |
|
||||
|
||||
当前分支使用以下命令重新构建,确保被测二进制准确对应 HEAD:
|
||||
|
||||
```console
|
||||
cargo build --release \
|
||||
--target x86_64-unknown-linux-musl \
|
||||
-p easytier \
|
||||
--features jemalloc \
|
||||
--bin easytier-core \
|
||||
--bin easytier-cli
|
||||
```
|
||||
|
||||
两个二进制均为 stripped static PIE。当前分支明确使用 musl 和
|
||||
jemalloc。
|
||||
|
||||
## 测试拓扑
|
||||
|
||||
- 两个 `easytier-core` 分别运行在独立的 network namespace;
|
||||
- namespace 通过 Linux bridge 和 veth 连接;
|
||||
- underlay 地址为 `10.251.89.10/24` 和 `10.251.89.11/24`;
|
||||
- EasyTier 虚拟地址为 `10.144.144.1/24` 和
|
||||
`10.144.144.2/24`;
|
||||
- 两个节点之间使用 UDP listener 建立 EasyTier peer 连接;
|
||||
- 源节点启用 `--enable-quic-proxy true`;
|
||||
- 两个节点均保留默认 QUIC input;
|
||||
- TCP client 从 `10.144.144.1` 访问绑定在
|
||||
`10.144.144.2` 上的 server;
|
||||
- `tcp_proxy_connect` 指标的 `protocol` 标签确认为 `QUIC`;
|
||||
- 66 条连接场景通过两端各 132 个 established TCP socket 条目
|
||||
确认当前连接数。
|
||||
|
||||
## 采样口径
|
||||
|
||||
数据读取自 `/proc/<pid>/smaps_rollup`:
|
||||
|
||||
- RSS:进程映射的全部驻留页,包含共享代码页;
|
||||
- PSS:共享页按共享进程数量分摊后的驻留内存;
|
||||
- USS:`Private_Clean + Private_Dirty`,表示进程独占内存;
|
||||
- Anonymous:匿名页,主要反映堆、栈和运行时缓冲区。
|
||||
|
||||
空闲场景每隔 2 秒采样一次,共 5 次,表格记录均值。固定吞吐场景
|
||||
持续 20 秒,每隔 2 秒采样一次,共 8 次,同时记录均值和峰值。
|
||||
所有容量单位均为 MiB。
|
||||
|
||||
## 空闲连接结果
|
||||
|
||||
以下数据均为两个 EasyTier core 的合计值:
|
||||
|
||||
| 当前连接数 | 当前 RSS | 2.6.4 RSS | 当前 PSS | 2.6.4 PSS | 当前 USS | 2.6.4 USS | USS 变化 | 当前 Anonymous | 2.6.4 Anonymous |
|
||||
|---:|---:|---:|---:|---:|---:|---:|---:|---:|---:|
|
||||
| 0 | 40.42 | 36.33 | 26.03 | 24.50 | 11.66 | 15.95 | -26.9% | 8.89 | 15.62 |
|
||||
| 1 | 41.52 | 38.46 | 26.49 | 25.98 | 11.46 | 16.82 | -31.9% | 9.08 | 16.23 |
|
||||
| 10 | 41.63 | 38.79 | 26.59 | 26.32 | 11.56 | 17.15 | -32.6% | 9.19 | 16.55 |
|
||||
| 66 | 43.68 | 41.11 | 28.76 | 28.63 | 13.85 | 19.45 | -28.8% | 11.22 | 18.79 |
|
||||
|
||||
0 条和 1 条连接之间的小幅反向波动属于分配器回收和采样时序噪声,
|
||||
不能解释为连接产生负开销。使用 0 到 66 条连接的跨度估算单位
|
||||
连接成本更稳定。
|
||||
|
||||
### 分节点 USS
|
||||
|
||||
| 当前连接数 | 当前源端 | 当前目的端 | 2.6.4 源端 | 2.6.4 目的端 |
|
||||
|---:|---:|---:|---:|---:|
|
||||
| 0 | 5.45 | 6.21 | 8.07 | 7.88 |
|
||||
| 1 | 5.43 | 6.03 | 8.68 | 8.14 |
|
||||
| 10 | 5.49 | 6.07 | 8.90 | 8.25 |
|
||||
| 66 | 6.60 | 7.25 | 10.19 | 9.25 |
|
||||
|
||||
### 单位连接增量
|
||||
|
||||
以 0 到 66 条连接的 USS 增量计算:
|
||||
|
||||
| 版本 | 两端 USS 增量 | 每连接两端合计 | 每连接单端平均 |
|
||||
|---|---:|---:|---:|
|
||||
| 当前分支 | 2.19 MiB | 33.9 KiB | 17.0 KiB |
|
||||
| 2.6.4 | 3.50 MiB | 54.2 KiB | 27.1 KiB |
|
||||
|
||||
当前分支的每连接独占内存增量下降约 37.4%。
|
||||
|
||||
## 固定 1 Gbit/s 活跃流量
|
||||
|
||||
为排除两个版本最大吞吐不同造成的缓冲区差异,使用
|
||||
`iperf3 -b 1G -P 1 -t 20` 将两个版本都限制为 1 Gbit/s。
|
||||
两次测试均实际完成 2.33 GiB 传输,接收端报告 1000 Mbit/s。
|
||||
|
||||
### 平均值
|
||||
|
||||
| 版本 | 节点 | RSS | PSS | USS | Anonymous |
|
||||
|---|---|---:|---:|---:|---:|
|
||||
| 当前分支 | 源端 | 22.89 | 15.41 | 7.94 | 6.88 |
|
||||
| 当前分支 | 目的端 | 21.77 | 14.29 | 6.82 | 5.27 |
|
||||
| 当前分支 | 两端合计 | 44.67 | 29.70 | 14.75 | 12.14 |
|
||||
| 2.6.4 | 源端 | 22.59 | 16.34 | 11.70 | 11.35 |
|
||||
| 2.6.4 | 目的端 | 19.95 | 13.67 | 8.99 | 8.77 |
|
||||
| 2.6.4 | 两端合计 | 42.53 | 30.01 | 20.69 | 20.13 |
|
||||
|
||||
### 对比
|
||||
|
||||
| 指标 | 当前分支 | 2.6.4 | 变化 |
|
||||
|---|---:|---:|---:|
|
||||
| 两端平均 RSS | 44.67 | 42.53 | +5.0% |
|
||||
| 两端平均 PSS | 29.70 | 30.01 | -1.0% |
|
||||
| 两端平均 USS | 14.75 | 20.69 | -28.7% |
|
||||
| 两端平均 Anonymous | 12.14 | 20.13 | -39.7% |
|
||||
| 两端同步峰值 USS | 15.02 | 21.50 | -30.2% |
|
||||
|
||||
## 分节点原始统计
|
||||
|
||||
下表保留各场景所有样本计算出的均值;`max_uss` 是该节点采样期间
|
||||
的最大 USS。
|
||||
|
||||
| 版本 | 场景 | 节点 | 样本数 | mean_rss | mean_pss | mean_uss | mean_anon | max_uss |
|
||||
|---|---|---|---:|---:|---:|---:|---:|---:|
|
||||
| 当前 | 0 连接 | 源端 | 5 | 19.830 | 12.636 | 5.451 | 4.314 | 5.582 |
|
||||
| 当前 | 0 连接 | 目的端 | 5 | 20.587 | 13.393 | 6.208 | 4.579 | 6.320 |
|
||||
| 当前 | 1 连接 | 源端 | 5 | 20.463 | 12.944 | 5.432 | 4.401 | 5.465 |
|
||||
| 当前 | 1 连接 | 目的端 | 5 | 21.061 | 13.541 | 6.030 | 4.682 | 6.051 |
|
||||
| 当前 | 10 连接 | 源端 | 5 | 20.522 | 13.002 | 5.491 | 4.459 | 5.496 |
|
||||
| 当前 | 10 连接 | 目的端 | 5 | 21.105 | 13.585 | 6.073 | 4.726 | 6.086 |
|
||||
| 当前 | 66 连接 | 源端 | 5 | 21.513 | 14.050 | 6.595 | 5.498 | 6.672 |
|
||||
| 当前 | 66 连接 | 目的端 | 5 | 22.169 | 14.706 | 7.251 | 5.723 | 7.375 |
|
||||
| 当前 | 1 Gbit/s | 源端 | 8 | 22.893 | 15.410 | 7.936 | 6.877 | 8.188 |
|
||||
| 当前 | 1 Gbit/s | 目的端 | 8 | 21.773 | 14.291 | 6.816 | 5.266 | 6.832 |
|
||||
| 2.6.4 | 0 连接 | 源端 | 5 | 18.278 | 12.355 | 8.071 | 7.876 | 8.328 |
|
||||
| 2.6.4 | 0 连接 | 目的端 | 5 | 18.048 | 12.144 | 7.880 | 7.747 | 8.203 |
|
||||
| 2.6.4 | 1 连接 | 源端 | 5 | 19.535 | 13.279 | 8.676 | 8.262 | 8.727 |
|
||||
| 2.6.4 | 1 连接 | 目的端 | 5 | 18.920 | 12.705 | 8.143 | 7.971 | 8.191 |
|
||||
| 2.6.4 | 10 连接 | 源端 | 5 | 19.762 | 13.506 | 8.902 | 8.473 | 8.910 |
|
||||
| 2.6.4 | 10 连接 | 目的端 | 5 | 19.027 | 12.812 | 8.250 | 8.078 | 8.297 |
|
||||
| 2.6.4 | 66 连接 | 源端 | 5 | 21.069 | 14.805 | 10.194 | 9.702 | 10.320 |
|
||||
| 2.6.4 | 66 连接 | 目的端 | 5 | 20.045 | 13.822 | 9.252 | 9.088 | 9.293 |
|
||||
| 2.6.4 | 1 Gbit/s | 源端 | 8 | 22.588 | 16.343 | 11.697 | 11.354 | 12.258 |
|
||||
| 2.6.4 | 1 Gbit/s | 目的端 | 8 | 19.946 | 13.670 | 8.993 | 8.774 | 9.277 |
|
||||
|
||||
## 解释和限制
|
||||
|
||||
1. 以固定 1 Gbit/s 场景为例,当前分支 RSS 增加 5.0%,但
|
||||
Anonymous 下降 39.7%,PSS 下降 1.0%。这说明差异主要体现
|
||||
在非匿名或共享驻留页;本次没有保存逐 VMA 数据,因此不进一步
|
||||
将它归因到某一个具体映射。
|
||||
2. 两个相同版本进程运行在同一宿主机时会共享可执行文件代码页,
|
||||
所以 PSS 比 RSS 更适合估算该测试拓扑的宿主机总成本,USS 和
|
||||
Anonymous 更适合判断 EasyTier 私有堆及缓冲区的变化。
|
||||
3. 这是一轮受控 A/B 测试,而不是长期统计分布。数值可用于确认
|
||||
差异方向和量级;若作为发布门禁,应固定机器负载并增加多轮重复。
|
||||
4. 本文只比较 QUIC TCP proxy 内存,不使用未限速吞吐结果推断性能,
|
||||
避免吞吐差异污染内存结论。
|
||||
@@ -0,0 +1,84 @@
|
||||
# Host socket protection
|
||||
|
||||
VPN bypass is a socket-creation requirement, not an operation on a socket that
|
||||
core has already connected. Core and WASI guests never need an OS file descriptor.
|
||||
|
||||
## Portable requests
|
||||
|
||||
`TcpBindOptions::need_protect` and `UdpBindOptions::need_protect` travel through the
|
||||
existing connect/bind operations. A host requiring VPN bypass must acknowledge
|
||||
successful protection before connect, bind/listen, or publishing the socket.
|
||||
Failure or cancellation fails creation and discards the owned socket; emitting
|
||||
an event alone is not acknowledgement. Platforms without VPN bypass can treat
|
||||
the requirement as a no-op.
|
||||
|
||||
- TCP connect constructors request protection, including egress for proxies.
|
||||
- TCP transport/hole-punch listeners request protection. Hosts retain this flag
|
||||
on the listener and protect accepted children before handing them to core.
|
||||
- Local ProxyNat/SOCKS/port-forward/port-lease listeners do not request it.
|
||||
- UDP transport, candidates, listeners, NAT egress and STUN request protection;
|
||||
HolePunchControl, local SOCKS/port-forward and port-lease sockets do not.
|
||||
- Low-level TCP/UDP bind options default to protection, including deserialization
|
||||
of options without `need_protect`. The named local/TUN-facing constructors set
|
||||
`false` explicitly. The UDP default keeps its historical purpose label for
|
||||
socket setup; only the named `hole_punch_control()` constructor opts out.
|
||||
- `with_bind()` replaces the **entire** bind object. A local listener's replacement
|
||||
must retain its opt-out rather than inherit the protected default. A native
|
||||
adapter must honor explicit `false`, not silently change it based on `purpose`.
|
||||
- DNS and source-route queries are already host-owned operations. A bypass-enabled
|
||||
host must protect their underlying sockets before querying/probing, including
|
||||
DNS TCP fallback, rather than letting system DNS silently bypass this contract.
|
||||
|
||||
## TUN-facing ingress and port forwarding
|
||||
|
||||
The extra `force_smoltcp` wildcard port-forward ingress listener has been removed.
|
||||
Normal TUN-backed ingress is delivered to the existing unprotected native listener;
|
||||
its accepted sockets remain unprotected so overlay replies can return through TUN.
|
||||
The physical/underlay egress socket is protected independently. A separate
|
||||
DataPlane listener must not mask broken host/TUN routing in this path.
|
||||
|
||||
This does not remove the existing public DataPlane listener APIs, the generic
|
||||
no-TUN smoltcp TCP proxy, or `force_smoltcp` itself. Those have other uses. Whether
|
||||
Android subnet proxy works without forced smoltcp needs actual platform regression
|
||||
testing; socket-creation unit tests alone do not establish that result.
|
||||
|
||||
## Native integration and HarmonyOS
|
||||
|
||||
The native `easytier` adapter implements the creation requirement using an async
|
||||
`NativeSocketProtector` callback. This is a native implementation detail, not a
|
||||
new portable or WASI ABI. It takes only the native handle; the creation options
|
||||
select policy, without a second purpose enum. Namespace switching is confined to synchronous socket
|
||||
creation and never held across the callback's await.
|
||||
|
||||
The existing native `bind` builder is async and shared by TCP/UDP creation. Its
|
||||
legacy direct-call default remains unprotected; portable factories explicitly
|
||||
pass their core bind options (default protected). Callers must await `.call()`.
|
||||
TCP listeners reuse TCP socket creation then listen, instead of duplicating the
|
||||
setup. Existing legacy WebSocket direct-call policy is unchanged.
|
||||
|
||||
The HarmonyOS broker wakes the already-pending request consumer with `Notify`
|
||||
(no polling timer). It keeps a duplicate FD alive until ArkTS completes
|
||||
`VpnConnection.protect(fd)` and returns its ACK. The waiting creation future is
|
||||
woken immediately by the oneshot acknowledgement. Failure stays fail-closed;
|
||||
shutdown retains dispatched FDs for late ACKs to prevent FD reuse races.
|
||||
The ArkTS request shape is unchanged; its diagnostic `purpose` string is now
|
||||
the generic `"socket"`. Neither ACK routing nor protection policy uses that label.
|
||||
|
||||
This guarantees ordering, not a wall-clock real-time bound: OS/ArkTS scheduling
|
||||
can still delay protection. Such a delay keeps the socket unconnected; it must
|
||||
never allow the first SYN/query to race ahead of protection.
|
||||
|
||||
## WASI option format
|
||||
|
||||
The `easytier_host` import names and function signatures are unchanged. TCP
|
||||
connect, TCP listen and UDP bind use option document **version 3** (previously 2):
|
||||
one `u8` boolean `need_protect` is inserted immediately after the existing purpose
|
||||
byte and before the optional bind-device field. All other field encodings and
|
||||
purpose values are unchanged. The document version lets older hosts reject
|
||||
unsupported options instead of silently ignoring protection. DNS, environment,
|
||||
instance-config and data-plane layouts/versions are unchanged.
|
||||
|
||||
An embedding host must update its versioned option decoder and honor the flag
|
||||
inside its existing creation implementation. The external host implementation
|
||||
is not in this repository: building the guest proves propagation/compatibility
|
||||
of imports, not that every external host has implemented platform protection.
|
||||
@@ -0,0 +1,137 @@
|
||||
# TCP proxy flow-key 验证记录(2026-09-13)
|
||||
|
||||
本次验证没有发现只在修复版本出现的行为退化。同源端口、不同目标的
|
||||
并发连接在六种代理模式下均由父提交的超时变为成功。验证中仍有半关闭
|
||||
失败和 KCP 突发短连接超时,父提交也存在这些现象,不能将结果描述为
|
||||
所有场景均无异常。
|
||||
|
||||
## 版本与范围
|
||||
|
||||
- 修复版本:`eb83655958be932d6de34e090dc361b6f3ba3393`。
|
||||
- 父提交:`e0bdb516b6dc8a654940efbe12960dbfa846f424`。
|
||||
- 本次提交仅新增测试与记录,生产代码保持上述修复版本的内容。
|
||||
- Linux 测试在现有 `rust` 容器内运行,Rust 1.93.1。
|
||||
- macOS arm64、Windows x64 原生测试使用 Rust 1.95.0。
|
||||
|
||||
父提交使用独立 worktree,但复用当前 worktree 的 `target`。第一次父提交
|
||||
网关测试意外复用了修复版本的构建缓存,结果已排除。清理相应 package
|
||||
的构建产物后重新编译,确认父提交网关测试为 189 项,修复版本原有
|
||||
192 项。真实流量实验还通过 CLI 核对两端实际运行的版本号。
|
||||
|
||||
## 自动化验证
|
||||
|
||||
| 验证 | 结果 |
|
||||
| --- | --- |
|
||||
| 修复版本完整 `subnet_proxy_three_node_test` 矩阵 | 256/256,714.800 秒 |
|
||||
| ACL、端口转发 ACL、配置更新、代理断连 | 父提交与修复版本各 14/14 |
|
||||
| 父提交 Linux 网关测试 | 189/189 |
|
||||
| 新增测试后的 Linux 网关测试 | 198/198 |
|
||||
| 新增测试后的 macOS 原生网关测试 | 198/198 |
|
||||
| 新增测试后的 Windows 原生网关测试 | 198/198 |
|
||||
|
||||
完整三节点矩阵覆盖 TUN/no-TUN、普通/公共中继、源端 KCP/QUIC 开关、
|
||||
目标端 KCP/QUIC 开关及对应输入禁用组合。每个组合检查映射子网地址、
|
||||
真实子网地址和节点虚拟地址上的 ICMP、TCP、UDP。
|
||||
|
||||
命令(Linux 需在容器内运行):
|
||||
|
||||
```sh
|
||||
cargo nextest run -p easytier --features full --lib \
|
||||
subnet_proxy_three_node_test --test-threads 1 --no-fail-fast
|
||||
|
||||
cargo test -p easytier-core --features proxy-smoltcp-stack --lib gateway::
|
||||
|
||||
cargo fmt --all -- --check
|
||||
|
||||
cargo clippy -p easytier-core \
|
||||
--features proxy-smoltcp-stack,ring-crypto --lib --tests -- -D warnings
|
||||
```
|
||||
|
||||
格式检查和上述严格 Clippy 检查均通过。仅启用 `proxy-smoltcp-stack`
|
||||
时,严格 Clippy 被未修改的 `tunnel/encrypt/mod.rs` 中
|
||||
`assert_interoperable` 未使用警告阻断;增加 `ring-crypto` 会启用调用
|
||||
该函数的现有后端互操作测试。本次没有修改或屏蔽该警告。
|
||||
|
||||
14 项补充集成测试来自以下测试组,使用各版本独立保存的测试二进制,
|
||||
逐项 `--exact` 执行,避免不同进程同时操作相同的测试 network namespace:
|
||||
|
||||
- `acl_rule_test_inbound`:4 项。
|
||||
- `acl_rule_test_subnet_proxy`:4 项。
|
||||
- `port_forward_with_inbound_default_drop_acl_test`:3 项。
|
||||
- `config_patch_test`:1 项。
|
||||
- `proxy_three_node_disconnect_test`:2 项。
|
||||
|
||||
三节点矩阵与流量实验在新增测试前完成;新增测试不改变生产代码,随后
|
||||
在三平台重新执行完整网关测试。macOS、Windows 的结果不包含原生 TUN
|
||||
端到端验证。
|
||||
|
||||
## 新增回归测试
|
||||
|
||||
测试位于 `easytier-core/src/gateway/proxy/tcp_proxy_engine.rs`:
|
||||
|
||||
1. SYN 在 accept 前后重传,均保留转换端口,且不会重复 accept。
|
||||
2. 旧连接处于 ClosingSrc、ClosingDst、Closed 时,被同一流的新连接
|
||||
替换;旧连接清理不会删除新映射,反向地址、端口和校验和仍正确。
|
||||
3. 过期 SYN 同时释放两个索引,已 accept 的连接不受 SYN 超时清理影响。
|
||||
4. 转换端口计数器回绕后跳过零、已占用端口和监听端口。
|
||||
5. 实际填满同一源 IP 的 65,534 个转换端口后,新 SYN 被丢弃;其他源
|
||||
IP 仍可建立映射,释放一个条目后该端口可复用,clear 后也可重新分配。
|
||||
6. 八个线程同时处理同一流,仅建立一个映射并成功 accept 一次。
|
||||
|
||||
端口池测试验证实际容量和恢复行为,没有将 debug 构建的耗时用作生产
|
||||
性能阈值,也没有通过延长超时或添加重试改变被测行为。
|
||||
|
||||
## 真实流量对照
|
||||
|
||||
使用独立的 `flow_val_a`、`flow_val_b` namespace,物理链路为一对 veth。
|
||||
虚拟地址为 `10.251.92.1/24` 和 `10.251.92.2/24`;目标端将
|
||||
`192.0.2.0/24` 映射到 `198.18.0.0/24`。两个 TCP 服务监听真实地址
|
||||
`.10`、`.11` 的 23456 端口,返回服务地址标记,并逐字节回显、校验数据。
|
||||
底层隧道使用 UDP,两个节点均使用四线程运行时。
|
||||
|
||||
每个版本分别验证普通 TCP、KCP、QUIC 与内核/smoltcp 的六种组合。
|
||||
|
||||
| 场景 | 父提交 | 修复版本 |
|
||||
| --- | --- | --- |
|
||||
| 源 IP/端口相同、两个不同目标的并发连接,各进行 30 轮回显 | 6/6 超时 | 6/6 通过 |
|
||||
| 同一四元组 RST 后重连,30 次,间隔 20ms | 6/6 通过 | 6/6 通过 |
|
||||
| 普通 TCP、QUIC 的短连接,各 300 次、16 并发 | 各组合 300/300 | 各组合 300/300 |
|
||||
| KCP 内核模式短连接,300 次、16 并发 | 4 次超时 | 4 次超时 |
|
||||
| KCP smoltcp 模式短连接,300 次、16 并发 | 12 次超时 | 11 次超时 |
|
||||
| 关闭客户端写端后等待服务端响应 | 6/6 无响应数据 | 6/6 无响应数据 |
|
||||
|
||||
各组合完成流量后等待 15 秒。修复版本两端的代理条目均回收为零,FD
|
||||
数量回到接近起始水平(采样期间 RPC 连接带来约一个 FD 的差异)。这只能
|
||||
证明本次有限负载下的回收行为,不能证明长期内存占用没有增长。
|
||||
|
||||
KCP 的超时阈值为 5 秒,尚未定位这些突发短连接超时的根因;两版都有
|
||||
失败不等于已证明每次失败属于同一根因。半关闭实验的服务端在 EOF 后
|
||||
返回响应;未修改的 `copy_bidirectional_no_shutdown` 在任一方向结束
|
||||
后即退出转发,与两版都丢失该响应的现象一致。本次不修复这两类问题。
|
||||
|
||||
## 丢包与混合版本
|
||||
|
||||
- 在客户端 veth 出方向施加 `netem delay 10ms 3ms loss 1%`。
|
||||
- 两版各六种模式,每个组合保持两个连接持续回显至少 15 秒,所有数据
|
||||
均通过逐字节校验;共 12/12 通过。
|
||||
- 无 netem 时,旧源端/新目标端、新源端/旧目标端,各覆盖六种模式。
|
||||
两个连接持续回显至少 2 秒,共 12/12 通过。
|
||||
- 混合版本验证使用不同客户端源端口,验证正常互通;它不意味着仍运行
|
||||
旧代理引擎的一端也获得了同源端口冲突修复。
|
||||
|
||||
## 证据与限制
|
||||
|
||||
本机原始日志、流量脚本、各阶段 JSON、二进制与 SHA-256 清单保存在:
|
||||
|
||||
```text
|
||||
/data/project/tcp-flow-validation-20260913/
|
||||
```
|
||||
|
||||
主要记录为 `manifest.json`、`current-matrix-summary.log`、
|
||||
`*-extra.json`、`traffic-matrix.json`、`loss-interop.json` 和
|
||||
`current-final*gateway.log`。矩阵记录是最终摘要,不是完整逐项日志。
|
||||
实验进程、独立 namespace 和 netem 均已清理。
|
||||
|
||||
未覆盖 Windows/macOS 原生 TUN 路径、生产防火墙/conntrack 规则兼容性、
|
||||
长期运行、吞吐回归基准,以及接近容量极限时的真实内核连接负载。
|
||||
端口耗尽已在引擎级验证,但不能替代上述生产容量测试。
|
||||
@@ -0,0 +1,178 @@
|
||||
# TCP 代理半关闭与 KCP 短连接修复验证(2026-09-14)
|
||||
|
||||
本次修复上一轮验证发现的半关闭失败、无丢包 KCP 突发连接交接丢失,
|
||||
以及跨平台验证暴露的握手前心跳竞态。这些是转发与连接生命周期的局部
|
||||
逻辑问题;没有改变 KCP 报文格式,也没有增加重试或放宽业务超时。
|
||||
丢包网络下另有握手最终 ACK 丢失的问题,本次未修复,见下文。
|
||||
|
||||
## 版本与根因
|
||||
|
||||
- EasyTier 基线:`24572b49`;生产修复:`03fa375e`、`fd6e2a3e`。
|
||||
- kcp-sys 基线:`d7427c22d764deb1860a7d37acc446ed5033464c`。
|
||||
- 最终依赖:`b37ee660fb70bb6d816fb8bbc08b140e55e7218b`,
|
||||
`Cargo.toml` 和 `Cargo.lock` 均锁定此 Git revision。
|
||||
已发布到 kcp-sys 的 `fix/accept-half-close-20260914` 专用分支,
|
||||
便于其他机器获取锁定的依赖;未合并主分支。
|
||||
- 旧版真实流量对照:`eb836559`,已包含 flow-key 修复,尚无本次修复。
|
||||
- 最终流量二进制:`delivery-easytier-core`,SHA-256:
|
||||
`4635810ac9a536258f9b7ff606e5e1a5eb0243cb0f187825ba6f721d92dfc4fe`。
|
||||
构建内容与最终生产代码一致;构建发生在依赖 pin 提交前,内嵌版本号
|
||||
不用于区分本次产物,以 SHA-256 和依赖 revision 为准。
|
||||
|
||||
修复内容:
|
||||
|
||||
1. 原转发函数在任一方向 EOF 后退出,取消另一方向,服务端在请求 EOF
|
||||
后返回的响应因此丢失。改用 Tokio 双向拷贝传播写端关闭,继续转发
|
||||
反向数据,直到双向完成或发生错误。
|
||||
2. KCP 容量为 4 的 accept 通知队列满时,已建立连接失去交接机会。
|
||||
在连接状态中保留待 accept 标记,队列为空时领取待交接连接;保留
|
||||
原有有界通知队列和串行领取,防止重复交接。
|
||||
3. KCP 将正常关闭当作读错误,并可能在 FIN 后丢弃已经确认接收、尚未
|
||||
交给应用的数据。现在先排空接收数据再返回 EOF,RST 和端点销毁仍
|
||||
返回错误;双向关闭清理等待接收任务完成。EOF 判断和过期清理的
|
||||
状态读取顺序也一并修正,避免并发数据到达或心跳更新被旧判断覆盖。
|
||||
4. FIN 可以先于 accept 或 connect 返回到达。创建流时继承该关闭状态,
|
||||
使纯空请求、纯空响应也能得到 EOF。半关闭连接继续响应心跳。
|
||||
5. Windows 原生测试捕获到 PING 先于 SYN 到达,对端因连接未知返回
|
||||
RST;旧依赖也复现相同时序。周期心跳现在只覆盖已建立和半关闭状态。
|
||||
|
||||
## 回归覆盖与自动化结果
|
||||
|
||||
| 验证 | 结果 |
|
||||
| --- | --- |
|
||||
| Linux、macOS arm64、Windows x64 原生网关测试 | 各 200/200 |
|
||||
| 最终 kcp-sys 三平台原生测试 | 各 10/10,两个 example target 通过 |
|
||||
| 最终完整三节点矩阵及补充集成测试 | 276/276,848.628 秒 |
|
||||
| EasyTier 格式检查、Linux 严格 Clippy | 通过 |
|
||||
| kcp-sys Linux/macOS 格式检查、严格 Clippy | 通过 |
|
||||
|
||||
网关新增两个测试,分别从两端先关闭写方向;使用空请求、32 KiB 请求
|
||||
及 64 KiB 响应,接收方等到 EOF 才返回响应。旧转发函数两项均在 5 秒
|
||||
超时,修复后通过。原生网关测试不依赖应用层 KCP,心跳修复没有改变
|
||||
这些测试的生产代码。
|
||||
|
||||
新增三节点半关闭测试覆盖 TCP/KCP/QUIC × 内核/smoltcp 六种模式,
|
||||
各检查两个关闭方向、空请求和 64 KiB 请求、128 KiB 响应。完整集成
|
||||
集合共 276 项:原有三节点矩阵 256 项、新增 6 项、ACL/配置更新/
|
||||
端口转发/断连 14 项,均在已有 `rust` 容器中串行运行。
|
||||
|
||||
依赖回归覆盖:32 次握手全部完成后才 accept;200 KiB 缓冲数据后的
|
||||
EOF/响应;双向关闭后跨越清理周期再读数据;半关闭心跳;RST/端点销毁
|
||||
读错误;FIN 先于 accept、FIN 先于 connect 返回;各握手阶段的心跳
|
||||
筛选。通知饱和测试在旧实现第五次 accept 超时;提前 FIN 测试在修复
|
||||
前等待 EOF 超时。排空测试使用多工作线程运行时,并做过额外重复验证。
|
||||
|
||||
主要命令(Linux 容器内,Rust 1.95):
|
||||
|
||||
```sh
|
||||
cargo +1.95 test --locked -p easytier-core \
|
||||
--features proxy-smoltcp-stack --lib gateway::
|
||||
cargo +1.95 nextest run --locked -p easytier --features full --lib \
|
||||
-E 'test(subnet_proxy_three_node_test) | test(subnet_proxy_half_close_test) | test(acl_rule_test_inbound) | test(acl_rule_test_subnet_proxy) | test(proxy_three_node_disconnect_test) | test(config_patch_test) | test(port_forward_with_inbound_default_drop_acl_test)' \
|
||||
--test-threads 1 --no-fail-fast
|
||||
cargo fmt --all -- --check
|
||||
cargo +1.95 clippy --locked -p easytier --features full \
|
||||
--lib --tests -- -D warnings
|
||||
```
|
||||
|
||||
网关严格 Clippy 另以 `proxy-smoltcp-stack,ring-crypto` 通过;原因见上一轮
|
||||
记录中的既有未使用警告说明。Windows 所选 stable 工具链缺少格式检查
|
||||
组件,未将 Windows 格式或 Clippy 计为通过。
|
||||
|
||||
## 最终二进制真实流量
|
||||
|
||||
普通 TCP、KCP、QUIC 与内核/smoltcp 六种组合,底层 UDP;每组合 300 次
|
||||
短连接、16 并发、5 秒 socket 超时,检查 greeting 和逐字节回显。
|
||||
|
||||
- 六模式主矩阵:1,800/1,800 次短连接通过。
|
||||
- 请求 EOF 后返回响应:小/大请求共 12/12,通过;大请求 256 KiB,
|
||||
响应 1 MiB + 4 字节。
|
||||
- 服务端先关闭写端,客户端读到 EOF 后再发 256 KiB:6/6,通过,
|
||||
服务端实际校验接收数据。
|
||||
- connect 后立即关闭写端,不发送模式字节、不等待 greeting:每模式
|
||||
32 次、16 并发,共 192/192 次纯空请求通过。
|
||||
- KCP 每栈额外三轮,与主矩阵合计 2,400/2,400 次短连接通过。追加
|
||||
测试的大小半关闭及 192 次纯空请求通过,6 次反向半关闭中 1 次超时,
|
||||
因此不能将所有追加半关闭计为通过,具体证据见下文。
|
||||
- 最终版本在 `delay 10ms 3ms loss 1%` 下,六模式各两个连接持续
|
||||
15 秒逐字节回显、12 次大小半关闭及 6 次反向半关闭通过。KCP 短连接
|
||||
仍有内核模式 5/300、smoltcp 模式 8/300 次 greeting 超时,其他
|
||||
四种模式零失败;没有为每次超时单独抓包证明原因相同。
|
||||
- 新旧两方向 × 六模式,共 12 组合正常持续回显通过。旧源端→新目标端
|
||||
1,800 次短连接全通过;新源端→旧目标端 KCP 内核模式 12/300、
|
||||
smoltcp 模式 9/300 次超时,其他四种模式零失败。
|
||||
|
||||
旧版同样的无丢包 KCP 2,400 次短连接有 78 次 greeting 超时;旧版六种
|
||||
模式在请求 EOF 后均没有响应,且出现过 QUIC 请求数据被截断。
|
||||
|
||||
最终版本 KCP 主矩阵后,两种栈源端各 335 条 Closed 代理记录均在
|
||||
15 秒观察点清零;目标端记录为零。两端 FD 数保持内核模式 15、smoltcp
|
||||
模式 14。RSS 较起始保留约 3.2–4.4 MiB 增长,单次有限负载不能证明
|
||||
或排除长期内存泄漏,也不能将代理条目清零等同于所有内部对象回收。
|
||||
|
||||
## 仍然存在的丢包握手问题与升级限制
|
||||
|
||||
在源端施加 `netem delay 10ms 3ms loss 1%` 后,中间修复版本
|
||||
(SHA-256 `2e78ecf…`)KCP 仍有 3/600 次 greeting 超时。额外有界
|
||||
TRACE 重跑捕获 3 个连接:源端收到 SYN|ACK,发出空 ACK|DATA 后即
|
||||
返回已建立;目标端未收到最终 ACK,直到 5 秒后的 FIN 才返回 RST。
|
||||
这与 accept 通知饱和不同,目标端尚未完成握手。
|
||||
|
||||
该控制报文可靠性问题需要单独设计握手恢复、重传及过期清理的一致性,
|
||||
不能仅延长超时或重试应用请求。本次没有声称消除了所有 KCP 超时。
|
||||
具体连接号和双方日志行见 `remaining-loss-handshake.md`、
|
||||
`loss-handshake-evidence.log`。
|
||||
|
||||
混合版本正常回显通过并不意味着旧端获得修复。旧 KCP 目标端仍保留
|
||||
accept 缺陷;KCP 的完整半关闭能力需要两端升级。没有修改报文格式。
|
||||
|
||||
追加无 netem 的 KCP/kernel 反向半关闭中,连接 `3969305677` 在等
|
||||
服务端数据和 EOF 时超时;服务端没有收到客户端原定在 EOF 后发送的
|
||||
256 KiB 数据。发送端同一连接出现 780 次数据输出队列 Full,随后在
|
||||
08:36:47.247 UTC 发送缓冲排空,FIN 成功进入输出队列;接收端直到
|
||||
5 秒业务超时都未报告对端关闭,08:37:01.030 才报告关闭或重置信号。
|
||||
该 debug 日志行也可能由 RST 触发,不能据此认定迟到的是 FIN,更不能
|
||||
将数据队列 Full 直接解释为 FIN 被该队列丢弃。现有 FIN 发送路径没有
|
||||
确认重传;这一单次失败的具体丢包点尚未证实,保留为未解决的验证异常。
|
||||
同场景有界 TRACE 重跑 3 轮,900 次短连接、3 次反向半关闭均通过,
|
||||
抓包确认成功轮次收到 FIN 并完成反向数据,未复现原失败;不能以重跑
|
||||
通过覆盖原失败。旧依赖与最终依赖的发送排空后入队 FIN 路径相同,
|
||||
对照保存在 `fin-send-path-old-new.txt`。
|
||||
|
||||
异常轮次的 15 秒资源观察点仍有 1 条 Closed 代理记录,无 Connecting/
|
||||
Connected,FD 已回到起始值;因此仅主矩阵可以报告该观察点全部清零。
|
||||
定向 TRACE 三轮的 1,005 条 Closed 记录在 15 秒观察点全部清零。
|
||||
|
||||
## 验证中的异常与证据
|
||||
|
||||
首轮未完成的集成运行中,端口转发 ACL case 3 曾在连接本地监听端口时
|
||||
出现一次 ConnectionRefused;随后完整一轮 276 项通过,同项也通过。
|
||||
旧版单次及额外十次均通过,因此没有证据把这次失败断言为旧版必现问题,
|
||||
也未修改无关的端口转发代码。新增半关闭测试的早期夹具曾只等待路由
|
||||
公告而未等待实际 TUN 路由可用,已补足就绪检查,并用有界 try_join
|
||||
及时报告连接错误;这不计入生产代码的 red/green 对照。
|
||||
|
||||
Windows 测试早期表现为挂起,原因是测试 join 在 connect 失败后仍等待
|
||||
accept。限定握手等待后暴露出上述 PING/RST/SYN 竞态,旧依赖和修复前
|
||||
分支均有 TRACE 证据;最终原生测试通过。完整 diff 和后续心跳增量均
|
||||
经过独立子代理审查,没有 high-confidence 的新缺陷发现。
|
||||
|
||||
原始脚本、日志、逐连接 JSON、二进制和哈希保存在本机:
|
||||
|
||||
```text
|
||||
/data/project/proxy-close-validation-20260914/
|
||||
```
|
||||
|
||||
主要文件:`delivery-manifest.json`、`delivery-integration.log`、`delivery-clippy.log`、
|
||||
`final-core-gateway.log`、`macos-core.log`、`windows-core.log`、
|
||||
`mac-kcp-heartbeat-tests.log`、`final-tests.log`(Windows KCP)、
|
||||
`delivery-summary.json`、`delivery-kcp-repeat-summary.json`、
|
||||
`delivery-*-resources.json`、`traffic-summary.md`、`traffic-binaries.json`、
|
||||
`delivery-commands.json`、`artifacts-manifest.json`、`delivery-reverse-timeout.md`、
|
||||
`relay-red.log`、`windows-kcp-trace-fail.txt`、
|
||||
`windows-kcp-baseline-startup.txt`。Windows 部分原始日志为 UTF-16LE。
|
||||
Linux kcp-sys 测试、格式、Clippy 及依赖测试 red/green 对照为子代理
|
||||
执行结果,未单独落盘原始命令日志;最终 10 项测试耗时 11.03 秒。
|
||||
|
||||
未覆盖 Windows/macOS 原生 TUN 端到端路径、生产规模长时间运行、吞吐
|
||||
基准及容量极限负载。流量实验仅使用并清理自己的 namespace 和进程。
|
||||
@@ -2,15 +2,17 @@
|
||||
name = "easytier-android-jni"
|
||||
version = "0.1.0"
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
|
||||
[lib]
|
||||
crate-type = ["cdylib"]
|
||||
|
||||
[dependencies]
|
||||
jni = "0.21"
|
||||
once_cell = "1.18.0"
|
||||
log = "0.4"
|
||||
android_logger = "0.13"
|
||||
serde = { version = "1.0.220", features = ["derive"] }
|
||||
serde_json = "1.0"
|
||||
easytier = { path = "../../easytier" }
|
||||
# JNI 0.22 changes native entry points to EnvUnowned and Env.
|
||||
jni = "0.21.1"
|
||||
once_cell.workspace = true
|
||||
log.workspace = true
|
||||
android_logger = "0.15"
|
||||
serde_json.workspace = true
|
||||
easytier = { workspace = true, default-features = true }
|
||||
easytier-ffi.workspace = true
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
- 📱 原生 Android JNI 支持
|
||||
- 🔧 支持多种 Android 架构 (arm64-v8a, armeabi-v7a, x86, x86_64)
|
||||
- 🛡️ 类型安全的 Java 接口
|
||||
- 🔌 支持通过 JSON 调用已暴露的 EasyTier RPC 查询/管理接口
|
||||
- 📝 详细的错误处理和日志记录
|
||||
|
||||
## 支持的架构
|
||||
@@ -176,6 +177,20 @@ public class EasyTierManager {
|
||||
}
|
||||
```
|
||||
|
||||
### 通用 JSON RPC
|
||||
|
||||
`EasyTierJNI.callJsonRpc(serviceName, methodName, domainName, payloadJson)` 可以调用已暴露的
|
||||
EasyTier RPC 服务,payload 和返回值均为 protobuf JSON。该接口不支持
|
||||
`api.manage.WebClientService`;实例启动、保留、删除、信息收集仍使用专用 JNI API。
|
||||
|
||||
```java
|
||||
String response = EasyTierJNI.callJsonRpc(
|
||||
"api.logger.LoggerRpcService",
|
||||
"get_logger_config",
|
||||
"{}"
|
||||
);
|
||||
```
|
||||
|
||||
### VPN 服务集成
|
||||
|
||||
如果您要在 Android VPN 服务中使用:
|
||||
@@ -264,4 +279,4 @@ public class EasyTierVpnService extends VpnService {
|
||||
|
||||
- [EasyTier 主项目](https://github.com/EasyTier/EasyTier)
|
||||
- [Android NDK 文档](https://developer.android.com/ndk)
|
||||
- [Rust JNI 文档](https://docs.rs/jni/)
|
||||
- [Rust JNI 文档](https://docs.rs/jni/)
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
use std::{env, path::PathBuf};
|
||||
|
||||
fn main() {
|
||||
let target_os = env::var("CARGO_CFG_TARGET_OS").unwrap_or_default();
|
||||
if !matches!(target_os.as_str(), "android" | "linux") {
|
||||
return;
|
||||
}
|
||||
|
||||
let manifest_dir = PathBuf::from(env::var("CARGO_MANIFEST_DIR").unwrap());
|
||||
let exports = manifest_dir.join("exports.map");
|
||||
println!("cargo:rerun-if-changed={}", exports.display());
|
||||
println!(
|
||||
"cargo:rustc-cdylib-link-arg=-Wl,--version-script={}",
|
||||
exports.display()
|
||||
);
|
||||
println!("cargo:rustc-cdylib-link-arg=-Wl,--exclude-libs,ALL");
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
global:
|
||||
Java_com_easytier_jni_EasyTierJNI_*;
|
||||
local:
|
||||
*;
|
||||
};
|
||||
@@ -1,8 +1,11 @@
|
||||
package com.easytier.jni
|
||||
|
||||
/** EasyTier JNI 接口类 提供 Android 应用调用 EasyTier 网络功能的接口 */
|
||||
object EasyTierJNI {
|
||||
fun interface ConfigServerEventCallback {
|
||||
fun onEvent(eventJson: String)
|
||||
}
|
||||
|
||||
/** EasyTier JNI 接口类 提供 Android 应用调用 EasyTier 核心网络功能的接口 */
|
||||
object EasyTierJNI {
|
||||
init {
|
||||
// 加载本地库
|
||||
System.loadLibrary("easytier_android_jni")
|
||||
@@ -33,6 +36,35 @@ object EasyTierJNI {
|
||||
*/
|
||||
@JvmStatic external fun runNetworkInstance(config: String): Int
|
||||
|
||||
/**
|
||||
* 启动配置服务器客户端
|
||||
* @param url 配置服务器 URL
|
||||
* @param hostname 主机名,传入 null 使用系统主机名
|
||||
* @param machineId 稳定机器 ID,由调用方负责持久化
|
||||
* @param secureMode 是否启用 secure mode
|
||||
* @param callback 远程配置应用/删除事件回调
|
||||
* @return 0 表示成功,-1 表示失败
|
||||
* @throws RuntimeException 当客户端启动失败时抛出异常
|
||||
*/
|
||||
@JvmStatic
|
||||
external fun startConfigServerClient(
|
||||
url: String,
|
||||
hostname: String?,
|
||||
machineId: String,
|
||||
secureMode: Boolean,
|
||||
callback: ConfigServerEventCallback?
|
||||
): Int
|
||||
|
||||
/**
|
||||
* 停止配置服务器客户端
|
||||
* @return 0 表示成功,-1 表示失败
|
||||
* @throws RuntimeException 当客户端停止失败时抛出异常
|
||||
*/
|
||||
@JvmStatic external fun stopConfigServerClient(): Int
|
||||
|
||||
/** 查询配置服务器客户端是否已连接 */
|
||||
@JvmStatic external fun isConfigServerClientConnected(): Boolean
|
||||
|
||||
/**
|
||||
* 保留指定的网络实例,停止其他实例
|
||||
* @param instanceNames 要保留的实例名称数组,传入 null 或空数组将停止所有实例
|
||||
@@ -41,14 +73,59 @@ object EasyTierJNI {
|
||||
*/
|
||||
@JvmStatic external fun retainNetworkInstance(instanceNames: Array<String>?): Int
|
||||
|
||||
/**
|
||||
* 停止指定的网络实例,其他实例不受影响
|
||||
* @param instanceName 要停止的实例名称,不存在时为 no-op
|
||||
* @return 0 表示成功,-1 表示失败
|
||||
* @throws RuntimeException 当操作失败时抛出异常
|
||||
*/
|
||||
@JvmStatic external fun deleteNetworkInstance(instanceName: String): Int
|
||||
|
||||
/**
|
||||
* 收集网络信息
|
||||
* @param maxLength 最大返回条目数
|
||||
* @return 包含网络信息的字符串数组,每个元素格式为 "key=value"
|
||||
* @return 包含网络信息的 JSON 字符串
|
||||
* @throws RuntimeException 当操作失败时抛出异常
|
||||
*/
|
||||
@JvmStatic external fun collectNetworkInfos(maxLength: Int): String?
|
||||
|
||||
/**
|
||||
* 列出当前运行的实例名称和实例 ID。
|
||||
* @param maxLength 最大返回条目数
|
||||
* @return JSON 对象,key 为 instance name,value 为 instance id
|
||||
* @throws RuntimeException 当操作失败时抛出异常
|
||||
*/
|
||||
@JvmStatic external fun listInstances(maxLength: Int): String?
|
||||
|
||||
/**
|
||||
* 调用暴露的 EasyTier RPC 方法,输入和输出均为 protobuf JSON 字符串。
|
||||
*
|
||||
* 不支持 api.manage.WebClientService;实例启动、保留、删除、信息收集请继续使用专用 JNI API。
|
||||
* payloadJson 需要包含目标 RPC 所需的 instance selector。
|
||||
*
|
||||
* @param serviceName RPC 服务名,例如 api.instance.PeerManageRpcService
|
||||
* @param methodName RPC 方法名,支持 snake_case 或 proto 方法名
|
||||
* @param domainName 仅 TcpProxyRpcService 使用;传 null 或空字符串默认 tcp
|
||||
* @param payloadJson protobuf JSON 请求体
|
||||
* @return protobuf JSON 响应体
|
||||
* @throws RuntimeException 当 RPC 调用失败时抛出异常
|
||||
*/
|
||||
@JvmStatic
|
||||
external fun callJsonRpc(
|
||||
serviceName: String,
|
||||
methodName: String,
|
||||
domainName: String?,
|
||||
payloadJson: String
|
||||
): String?
|
||||
|
||||
/**
|
||||
* 调用不需要 domainName 的 EasyTier RPC 方法。
|
||||
*/
|
||||
@JvmStatic
|
||||
fun callJsonRpc(serviceName: String, methodName: String, payloadJson: String): String? {
|
||||
return callJsonRpc(serviceName, methodName, null, payloadJson)
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取最后的错误消息
|
||||
* @return 错误消息字符串,如果没有错误则返回 null
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
use std::{
|
||||
ffi::{CStr, c_char, c_void},
|
||||
sync::{Arc, Mutex, MutexGuard},
|
||||
};
|
||||
|
||||
use easytier_ffi::ConfigServerEventCallback;
|
||||
use jni::JNIEnv;
|
||||
use jni::objects::{GlobalRef, JObject, JValue};
|
||||
use once_cell::sync::Lazy;
|
||||
|
||||
use crate::error;
|
||||
|
||||
pub(crate) struct JniConfigServerCallback {
|
||||
java_vm: jni::JavaVM,
|
||||
callback: GlobalRef,
|
||||
}
|
||||
|
||||
static CONFIG_SERVER_CALLBACK: Lazy<Mutex<Option<Arc<JniConfigServerCallback>>>> =
|
||||
Lazy::new(|| Mutex::new(None));
|
||||
|
||||
pub(crate) fn lock_callback_storage()
|
||||
-> Result<MutexGuard<'static, Option<Arc<JniConfigServerCallback>>>, String> {
|
||||
CONFIG_SERVER_CALLBACK
|
||||
.lock()
|
||||
.map_err(|e| format!("Failed to lock config server callback: {}", e))
|
||||
}
|
||||
|
||||
pub(crate) fn new_callback(
|
||||
env: &mut JNIEnv,
|
||||
callback: &JObject,
|
||||
) -> Result<Arc<JniConfigServerCallback>, String> {
|
||||
let java_vm = env
|
||||
.get_java_vm()
|
||||
.map_err(|e| format!("Failed to get JavaVM: {:?}", e))?;
|
||||
let callback = env
|
||||
.new_global_ref(callback)
|
||||
.map_err(|e| format!("Failed to create callback global ref: {:?}", e))?;
|
||||
Ok(Arc::new(JniConfigServerCallback { java_vm, callback }))
|
||||
}
|
||||
|
||||
pub(crate) fn callback_fn(
|
||||
callback: &Option<Arc<JniConfigServerCallback>>,
|
||||
) -> ConfigServerEventCallback {
|
||||
callback
|
||||
.as_ref()
|
||||
.map(|_| config_server_event_callback as unsafe extern "C" fn(*const c_char, *mut c_void))
|
||||
}
|
||||
|
||||
pub(crate) fn user_data(callback: &Option<Arc<JniConfigServerCallback>>) -> *mut c_void {
|
||||
callback
|
||||
.as_ref()
|
||||
.map(|callback| Arc::as_ptr(callback) as *mut c_void)
|
||||
.unwrap_or(std::ptr::null_mut())
|
||||
}
|
||||
|
||||
impl JniConfigServerCallback {
|
||||
fn clear_pending_exception(
|
||||
env: &mut JNIEnv,
|
||||
context: &str,
|
||||
error: &dyn std::fmt::Debug,
|
||||
) -> String {
|
||||
match env.exception_check() {
|
||||
Ok(true) => {
|
||||
if let Err(clear_err) = env.exception_clear() {
|
||||
return format!(
|
||||
"{}: {:?}; failed to clear pending Java exception: {:?}",
|
||||
context, error, clear_err
|
||||
);
|
||||
}
|
||||
}
|
||||
Ok(false) => {}
|
||||
Err(check_err) => {
|
||||
return format!(
|
||||
"{}: {:?}; failed to check pending Java exception: {:?}",
|
||||
context, error, check_err
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
format!("{}: {:?}", context, error)
|
||||
}
|
||||
|
||||
fn on_event(&self, event_json: *const c_char) -> Result<(), String> {
|
||||
let event_json = unsafe { CStr::from_ptr(event_json) }
|
||||
.to_str()
|
||||
.map_err(|e| format!("Invalid config server event JSON: {:?}", e))?;
|
||||
let mut env = self
|
||||
.java_vm
|
||||
.attach_current_thread()
|
||||
.map_err(|e| format!("Failed to attach callback thread: {:?}", e))?;
|
||||
let event_json = env.new_string(event_json).map_err(|e| {
|
||||
Self::clear_pending_exception(&mut env, "Failed to create event string", &e)
|
||||
})?;
|
||||
|
||||
if let Err(e) = env.call_method(
|
||||
self.callback.as_obj(),
|
||||
"onEvent",
|
||||
"(Ljava/lang/String;)V",
|
||||
&[JValue::from(&event_json)],
|
||||
) {
|
||||
return Err(Self::clear_pending_exception(
|
||||
&mut env,
|
||||
"Failed to call config server callback",
|
||||
&e,
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
unsafe extern "C" fn config_server_event_callback(
|
||||
event_json: *const c_char,
|
||||
user_data: *mut c_void,
|
||||
) {
|
||||
if event_json.is_null() || user_data.is_null() {
|
||||
return;
|
||||
}
|
||||
|
||||
let callback = unsafe { &*(user_data as *const JniConfigServerCallback) };
|
||||
|
||||
if let Err(error) = callback.on_event(event_json) {
|
||||
error::set_callback_error(error);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
use std::ptr;
|
||||
|
||||
use easytier_ffi::{
|
||||
in_config_server_callback, is_config_server_client_connected, start_config_server_client,
|
||||
stop_config_server_client,
|
||||
};
|
||||
use jni::JNIEnv;
|
||||
use jni::objects::{JClass, JObject, JString};
|
||||
use jni::sys::{JNI_FALSE, JNI_TRUE, jboolean, jint};
|
||||
|
||||
use crate::{
|
||||
callback, error,
|
||||
strings::{jstring_to_cstring, optional_jstring_to_cstring},
|
||||
};
|
||||
|
||||
pub(crate) fn start_config_server_client_jni(
|
||||
env: &mut JNIEnv,
|
||||
config_server_url: JString,
|
||||
hostname: JString,
|
||||
machine_id: JString,
|
||||
secure_mode: jboolean,
|
||||
callback_obj: JObject,
|
||||
) -> jint {
|
||||
if in_config_server_callback() {
|
||||
error::throw_exception(
|
||||
env,
|
||||
"Cannot start config server client from config server callback",
|
||||
);
|
||||
return -1;
|
||||
}
|
||||
|
||||
let config_server_url = match jstring_to_cstring(env, &config_server_url) {
|
||||
Ok(cstr) => cstr,
|
||||
Err(e) => {
|
||||
error::throw_exception(env, &format!("Invalid config server URL: {}", e));
|
||||
return -1;
|
||||
}
|
||||
};
|
||||
let hostname = match optional_jstring_to_cstring(env, &hostname) {
|
||||
Ok(cstr) => cstr,
|
||||
Err(e) => {
|
||||
error::throw_exception(env, &format!("Invalid hostname: {}", e));
|
||||
return -1;
|
||||
}
|
||||
};
|
||||
let machine_id = match jstring_to_cstring(env, &machine_id) {
|
||||
Ok(cstr) => cstr,
|
||||
Err(e) => {
|
||||
error::throw_exception(env, &format!("Invalid machine ID: {}", e));
|
||||
return -1;
|
||||
}
|
||||
};
|
||||
|
||||
let callback_ref = if callback_obj.is_null() {
|
||||
None
|
||||
} else {
|
||||
match callback::new_callback(env, &callback_obj) {
|
||||
Ok(state) => Some(state),
|
||||
Err(e) => {
|
||||
error::throw_exception(env, &e);
|
||||
return -1;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let mut callback_guard = match callback::lock_callback_storage() {
|
||||
Ok(guard) => guard,
|
||||
Err(e) => {
|
||||
error::throw_exception(env, &e);
|
||||
return -1;
|
||||
}
|
||||
};
|
||||
if callback_guard.is_none() {
|
||||
error::clear_callback_error();
|
||||
}
|
||||
|
||||
let callback_fn = callback::callback_fn(&callback_ref);
|
||||
let user_data = callback::user_data(&callback_ref);
|
||||
let result = unsafe {
|
||||
start_config_server_client(
|
||||
config_server_url.as_ptr(),
|
||||
hostname
|
||||
.as_ref()
|
||||
.map(|value| value.as_ptr())
|
||||
.unwrap_or(ptr::null()),
|
||||
machine_id.as_ptr(),
|
||||
secure_mode == JNI_TRUE,
|
||||
callback_fn,
|
||||
user_data,
|
||||
)
|
||||
};
|
||||
if result != 0 {
|
||||
if let Some(error_msg) = error::get_last_error() {
|
||||
error::throw_exception(env, &error_msg);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
*callback_guard = callback_ref;
|
||||
result
|
||||
}
|
||||
|
||||
pub(crate) fn stop_config_server_client_jni(mut env: JNIEnv, _class: JClass) -> jint {
|
||||
if in_config_server_callback() {
|
||||
let result = stop_config_server_client();
|
||||
if result != 0
|
||||
&& let Some(error_msg) = error::get_last_error()
|
||||
{
|
||||
error::throw_exception(&mut env, &error_msg);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
let mut callback_guard = match callback::lock_callback_storage() {
|
||||
Ok(guard) => guard,
|
||||
Err(e) => {
|
||||
error::throw_exception(&mut env, &e);
|
||||
return -1;
|
||||
}
|
||||
};
|
||||
|
||||
let result = stop_config_server_client();
|
||||
if result != 0 {
|
||||
if let Some(error_msg) = error::get_last_error() {
|
||||
error::throw_exception(&mut env, &error_msg);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
*callback_guard = None;
|
||||
result
|
||||
}
|
||||
|
||||
pub(crate) fn is_config_server_client_connected_jni(_env: JNIEnv, _class: JClass) -> jboolean {
|
||||
if is_config_server_client_connected() != 0 {
|
||||
JNI_TRUE
|
||||
} else {
|
||||
JNI_FALSE
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
use std::{
|
||||
ffi::{CStr, c_char},
|
||||
ptr,
|
||||
sync::Mutex,
|
||||
};
|
||||
|
||||
use easytier_ffi::{free_string, get_error_msg};
|
||||
use jni::JNIEnv;
|
||||
use jni::objects::JClass;
|
||||
use jni::sys::jstring;
|
||||
use once_cell::sync::Lazy;
|
||||
|
||||
static JNI_CALLBACK_ERROR: Lazy<Mutex<Option<String>>> = Lazy::new(|| Mutex::new(None));
|
||||
|
||||
pub(crate) fn set_callback_error(error: String) {
|
||||
log::error!("{}", error);
|
||||
if let Ok(mut guard) = JNI_CALLBACK_ERROR.lock() {
|
||||
*guard = Some(error);
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn clear_callback_error() {
|
||||
if let Ok(mut guard) = JNI_CALLBACK_ERROR.lock() {
|
||||
*guard = None;
|
||||
}
|
||||
}
|
||||
|
||||
fn take_callback_error() -> Option<String> {
|
||||
JNI_CALLBACK_ERROR
|
||||
.lock()
|
||||
.ok()
|
||||
.and_then(|mut guard| guard.take())
|
||||
}
|
||||
|
||||
fn get_ffi_last_error() -> Option<String> {
|
||||
unsafe {
|
||||
let mut error_ptr: *const c_char = ptr::null();
|
||||
get_error_msg(&mut error_ptr);
|
||||
if error_ptr.is_null() {
|
||||
None
|
||||
} else {
|
||||
let error_cstr = CStr::from_ptr(error_ptr);
|
||||
let error_str = error_cstr.to_string_lossy().into_owned();
|
||||
free_string(error_ptr);
|
||||
Some(error_str)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn get_last_error() -> Option<String> {
|
||||
match (get_ffi_last_error(), take_callback_error()) {
|
||||
(Some(ffi_error), Some(callback_error)) => Some(format!(
|
||||
"{}; config server callback error: {}",
|
||||
ffi_error, callback_error
|
||||
)),
|
||||
(Some(ffi_error), None) => Some(ffi_error),
|
||||
(None, Some(callback_error)) => Some(callback_error),
|
||||
(None, None) => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn throw_exception(env: &mut JNIEnv, message: &str) {
|
||||
let _ = env.throw_new("java/lang/RuntimeException", message);
|
||||
}
|
||||
|
||||
pub(crate) fn get_last_error_jni(env: JNIEnv, _class: JClass) -> jstring {
|
||||
match get_last_error() {
|
||||
Some(error) => match env.new_string(&error) {
|
||||
Ok(jstr) => jstr.into_raw(),
|
||||
Err(_) => ptr::null_mut(),
|
||||
},
|
||||
None => ptr::null_mut(),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
use std::{
|
||||
ffi::{CStr, c_char},
|
||||
ptr,
|
||||
};
|
||||
|
||||
use easytier_ffi::{call_json_rpc, free_string};
|
||||
use jni::JNIEnv;
|
||||
use jni::objects::{JClass, JString};
|
||||
use jni::sys::jstring;
|
||||
|
||||
use crate::{
|
||||
error::{get_last_error, throw_exception},
|
||||
strings::{jstring_to_cstring, optional_jstring_to_cstring},
|
||||
};
|
||||
|
||||
pub(crate) fn call_json_rpc_jni(
|
||||
mut env: JNIEnv,
|
||||
_class: JClass,
|
||||
service_name: JString,
|
||||
method_name: JString,
|
||||
domain_name: JString,
|
||||
payload_json: JString,
|
||||
) -> jstring {
|
||||
let service_name_cstr = match jstring_to_cstring(&mut env, &service_name) {
|
||||
Ok(cstr) => cstr,
|
||||
Err(e) => {
|
||||
throw_exception(&mut env, &format!("Invalid service name: {}", e));
|
||||
return ptr::null_mut();
|
||||
}
|
||||
};
|
||||
let method_name_cstr = match jstring_to_cstring(&mut env, &method_name) {
|
||||
Ok(cstr) => cstr,
|
||||
Err(e) => {
|
||||
throw_exception(&mut env, &format!("Invalid method name: {}", e));
|
||||
return ptr::null_mut();
|
||||
}
|
||||
};
|
||||
let domain_name_cstr = match optional_jstring_to_cstring(&mut env, &domain_name) {
|
||||
Ok(cstr) => cstr,
|
||||
Err(e) => {
|
||||
throw_exception(&mut env, &format!("Invalid domain name: {}", e));
|
||||
return ptr::null_mut();
|
||||
}
|
||||
};
|
||||
let payload_json_cstr = match jstring_to_cstring(&mut env, &payload_json) {
|
||||
Ok(cstr) => cstr,
|
||||
Err(e) => {
|
||||
throw_exception(&mut env, &format!("Invalid payload JSON: {}", e));
|
||||
return ptr::null_mut();
|
||||
}
|
||||
};
|
||||
|
||||
let domain_name_ptr = domain_name_cstr
|
||||
.as_ref()
|
||||
.map_or(ptr::null(), |cstr| cstr.as_ptr());
|
||||
let mut response_ptr: *const c_char = ptr::null();
|
||||
let result = unsafe {
|
||||
call_json_rpc(
|
||||
service_name_cstr.as_ptr(),
|
||||
method_name_cstr.as_ptr(),
|
||||
domain_name_ptr,
|
||||
payload_json_cstr.as_ptr(),
|
||||
&mut response_ptr,
|
||||
)
|
||||
};
|
||||
|
||||
if result != 0 {
|
||||
if let Some(error) = get_last_error() {
|
||||
throw_exception(&mut env, &error);
|
||||
}
|
||||
return ptr::null_mut();
|
||||
}
|
||||
|
||||
if response_ptr.is_null() {
|
||||
throw_exception(&mut env, "JSON RPC returned a null response");
|
||||
return ptr::null_mut();
|
||||
}
|
||||
|
||||
let response = unsafe { CStr::from_ptr(response_ptr) }
|
||||
.to_string_lossy()
|
||||
.into_owned();
|
||||
free_string(response_ptr);
|
||||
|
||||
match env.new_string(&response) {
|
||||
Ok(jstr) => jstr.into_raw(),
|
||||
Err(_) => {
|
||||
throw_exception(&mut env, "Failed to create JSON RPC response string");
|
||||
ptr::null_mut()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,319 +1,271 @@
|
||||
use easytier::proto::api::manage::{NetworkInstanceRunningInfo, NetworkInstanceRunningInfoMap};
|
||||
//! JNI facade for Android callers of EasyTier.
|
||||
//!
|
||||
//! This file intentionally lists every Java-visible native method exported by
|
||||
//! `libeasytier_android_jni.so`. The implementation details live in sibling
|
||||
//! modules so this facade stays readable as an API map.
|
||||
//!
|
||||
//! Network management APIs:
|
||||
//! - `setTunFd(instanceName, fd)`: attach an Android TUN fd to an instance.
|
||||
//! - `parseConfig(config)`: validate TOML config text.
|
||||
//! - `runNetworkInstance(config)`: start a local network instance.
|
||||
//! - `retainNetworkInstance(instanceNames)`: retain named instances and stop the rest.
|
||||
//! - `deleteNetworkInstance(instanceName)`: stop exactly one named instance.
|
||||
//! - `listInstances()`: return running instance names and IDs as JSON.
|
||||
//! - `collectNetworkInfos()`: return running instance info as a JSON string.
|
||||
//! - `callJsonRpc(...)`: call an exposed EasyTier RPC service with JSON payload.
|
||||
//!
|
||||
//! Config server client APIs:
|
||||
//! - `startConfigServerClient(url, hostname, machineId, secureMode, callback)`:
|
||||
//! start the managed remote config client.
|
||||
//! - `stopConfigServerClient()`: stop the managed client and release its Java callback.
|
||||
//! - `isConfigServerClientConnected()`: return whether the managed client is connected.
|
||||
//!
|
||||
//! Error API:
|
||||
//! - `getLastError()`: return the latest FFI/JNI error string for the calling thread.
|
||||
//!
|
||||
mod callback;
|
||||
mod config_server_api;
|
||||
mod error;
|
||||
mod json_rpc_api;
|
||||
mod logger;
|
||||
mod network_api;
|
||||
mod strings;
|
||||
|
||||
use jni::JNIEnv;
|
||||
use jni::objects::{JClass, JObjectArray, JString};
|
||||
use jni::sys::{jint, jstring};
|
||||
use once_cell::sync::Lazy;
|
||||
use std::ffi::{CStr, CString};
|
||||
use std::ptr;
|
||||
use jni::objects::{JClass, JObject, JObjectArray, JString};
|
||||
use jni::sys::{jboolean, jint, jstring};
|
||||
|
||||
// 定义 KeyValuePair 结构体
|
||||
#[repr(C)]
|
||||
#[derive(Clone, Copy)]
|
||||
pub struct KeyValuePair {
|
||||
pub key: *const std::ffi::c_char,
|
||||
pub value: *const std::ffi::c_char,
|
||||
}
|
||||
|
||||
// 声明外部 C 函数
|
||||
unsafe extern "C" {
|
||||
fn set_tun_fd(inst_name: *const std::ffi::c_char, fd: std::ffi::c_int) -> std::ffi::c_int;
|
||||
fn get_error_msg(out: *mut *const std::ffi::c_char);
|
||||
fn free_string(s: *const std::ffi::c_char);
|
||||
fn parse_config(cfg_str: *const std::ffi::c_char) -> std::ffi::c_int;
|
||||
fn run_network_instance(cfg_str: *const std::ffi::c_char) -> std::ffi::c_int;
|
||||
fn retain_network_instance(
|
||||
inst_names: *const *const std::ffi::c_char,
|
||||
length: usize,
|
||||
) -> std::ffi::c_int;
|
||||
fn collect_network_infos(infos: *mut KeyValuePair, max_length: usize) -> std::ffi::c_int;
|
||||
}
|
||||
|
||||
// 初始化 Android 日志
|
||||
static LOGGER_INIT: Lazy<()> = Lazy::new(|| {
|
||||
android_logger::init_once(
|
||||
android_logger::Config::default()
|
||||
.with_max_level(log::LevelFilter::Debug)
|
||||
.with_tag("EasyTier-JNI"),
|
||||
);
|
||||
});
|
||||
|
||||
// 辅助函数:从 Java String 转换为 CString
|
||||
fn jstring_to_cstring(env: &mut JNIEnv, jstr: &JString) -> Result<CString, String> {
|
||||
let java_str = env
|
||||
.get_string(jstr)
|
||||
.map_err(|e| format!("Failed to get string: {:?}", e))?;
|
||||
let rust_str = java_str.to_str().map_err(|_| "Invalid UTF-8".to_string())?;
|
||||
CString::new(rust_str).map_err(|_| "String contains null byte".to_string())
|
||||
}
|
||||
|
||||
// 辅助函数:获取错误消息
|
||||
fn get_last_error() -> Option<String> {
|
||||
unsafe {
|
||||
let mut error_ptr: *const std::ffi::c_char = ptr::null();
|
||||
get_error_msg(&mut error_ptr);
|
||||
if error_ptr.is_null() {
|
||||
None
|
||||
} else {
|
||||
let error_cstr = CStr::from_ptr(error_ptr);
|
||||
let error_str = error_cstr.to_string_lossy().into_owned();
|
||||
free_string(error_ptr);
|
||||
Some(error_str)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 辅助函数:抛出 Java 异常
|
||||
fn throw_exception(env: &mut JNIEnv, message: &str) {
|
||||
let _ = env.throw_new("java/lang/RuntimeException", message);
|
||||
}
|
||||
|
||||
/// 设置 TUN 文件描述符
|
||||
/// Attach a TUN file descriptor to an EasyTier network instance.
|
||||
///
|
||||
/// Java signature:
|
||||
/// `EasyTierJNI.setTunFd(instanceName: String, fd: Int): Int`
|
||||
///
|
||||
/// `instanceName` must name an instance known to the shared FFI instance cache.
|
||||
/// The `fd` must be a valid Android TUN file descriptor. On failure this
|
||||
/// returns `-1` and throws `RuntimeException` with the FFI error message when
|
||||
/// one is available.
|
||||
#[unsafe(no_mangle)]
|
||||
pub extern "system" fn Java_com_easytier_jni_EasyTierJNI_setTunFd(
|
||||
mut env: JNIEnv,
|
||||
_class: JClass,
|
||||
env: JNIEnv,
|
||||
class: JClass,
|
||||
inst_name: JString,
|
||||
fd: jint,
|
||||
) -> jint {
|
||||
Lazy::force(&LOGGER_INIT);
|
||||
|
||||
let inst_name_cstr = match jstring_to_cstring(&mut env, &inst_name) {
|
||||
Ok(cstr) => cstr,
|
||||
Err(e) => {
|
||||
throw_exception(&mut env, &format!("Invalid instance name: {}", e));
|
||||
return -1;
|
||||
}
|
||||
};
|
||||
|
||||
unsafe {
|
||||
let result = set_tun_fd(inst_name_cstr.as_ptr(), fd);
|
||||
if result != 0
|
||||
&& let Some(error) = get_last_error()
|
||||
{
|
||||
throw_exception(&mut env, &error);
|
||||
}
|
||||
result
|
||||
}
|
||||
logger::init();
|
||||
network_api::set_tun_fd_jni(env, class, inst_name, fd)
|
||||
}
|
||||
|
||||
/// 解析配置
|
||||
/// Validate a TOML network config string.
|
||||
///
|
||||
/// Java signature:
|
||||
/// `EasyTierJNI.parseConfig(config: String): Int`
|
||||
///
|
||||
/// This only validates the config text; it does not start or mutate any
|
||||
/// instance. On failure this returns `-1` and throws `RuntimeException`.
|
||||
#[unsafe(no_mangle)]
|
||||
pub extern "system" fn Java_com_easytier_jni_EasyTierJNI_parseConfig(
|
||||
mut env: JNIEnv,
|
||||
_class: JClass,
|
||||
env: JNIEnv,
|
||||
class: JClass,
|
||||
config: JString,
|
||||
) -> jint {
|
||||
Lazy::force(&LOGGER_INIT);
|
||||
|
||||
let config_cstr = match jstring_to_cstring(&mut env, &config) {
|
||||
Ok(cstr) => cstr,
|
||||
Err(e) => {
|
||||
throw_exception(&mut env, &format!("Invalid config string: {}", e));
|
||||
return -1;
|
||||
}
|
||||
};
|
||||
|
||||
unsafe {
|
||||
let result = parse_config(config_cstr.as_ptr());
|
||||
if result != 0
|
||||
&& let Some(error) = get_last_error()
|
||||
{
|
||||
throw_exception(&mut env, &error);
|
||||
}
|
||||
result
|
||||
}
|
||||
logger::init();
|
||||
network_api::parse_config_jni(env, class, config)
|
||||
}
|
||||
|
||||
/// 运行网络实例
|
||||
/// Start one local EasyTier network instance from TOML config text.
|
||||
///
|
||||
/// Java signature:
|
||||
/// `EasyTierJNI.runNetworkInstance(config: String): Int`
|
||||
///
|
||||
/// The instance name in the config must be unique in the FFI instance cache.
|
||||
/// On failure this returns `-1` and throws `RuntimeException`.
|
||||
#[unsafe(no_mangle)]
|
||||
pub extern "system" fn Java_com_easytier_jni_EasyTierJNI_runNetworkInstance(
|
||||
mut env: JNIEnv,
|
||||
_class: JClass,
|
||||
env: JNIEnv,
|
||||
class: JClass,
|
||||
config: JString,
|
||||
) -> jint {
|
||||
Lazy::force(&LOGGER_INIT);
|
||||
|
||||
let config_cstr = match jstring_to_cstring(&mut env, &config) {
|
||||
Ok(cstr) => cstr,
|
||||
Err(e) => {
|
||||
throw_exception(&mut env, &format!("Invalid config string: {}", e));
|
||||
return -1;
|
||||
}
|
||||
};
|
||||
|
||||
unsafe {
|
||||
let result = run_network_instance(config_cstr.as_ptr());
|
||||
if result != 0
|
||||
&& let Some(error) = get_last_error()
|
||||
{
|
||||
throw_exception(&mut env, &error);
|
||||
}
|
||||
result
|
||||
}
|
||||
logger::init();
|
||||
network_api::run_network_instance_jni(env, class, config)
|
||||
}
|
||||
|
||||
/// 保持网络实例
|
||||
/// Retain the named network instances and stop all other instances.
|
||||
///
|
||||
/// Java signature:
|
||||
/// `EasyTierJNI.retainNetworkInstance(instanceNames: Array<String>?): Int`
|
||||
///
|
||||
/// Passing `null` or an empty array stops all instances. Null elements inside a
|
||||
/// non-empty array are invalid. On failure this returns `-1` and throws
|
||||
/// `RuntimeException`.
|
||||
#[unsafe(no_mangle)]
|
||||
pub extern "system" fn Java_com_easytier_jni_EasyTierJNI_retainNetworkInstance(
|
||||
mut env: JNIEnv,
|
||||
_class: JClass,
|
||||
env: JNIEnv,
|
||||
class: JClass,
|
||||
instance_names: JObjectArray,
|
||||
) -> jint {
|
||||
Lazy::force(&LOGGER_INIT);
|
||||
|
||||
// 处理 null 数组的情况
|
||||
if instance_names.is_null() {
|
||||
unsafe {
|
||||
let result = retain_network_instance(ptr::null(), 0);
|
||||
if result != 0
|
||||
&& let Some(error) = get_last_error()
|
||||
{
|
||||
throw_exception(&mut env, &error);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
// 获取数组长度
|
||||
let array_length = match env.get_array_length(&instance_names) {
|
||||
Ok(len) => len as usize,
|
||||
Err(e) => {
|
||||
throw_exception(&mut env, &format!("Failed to get array length: {:?}", e));
|
||||
return -1;
|
||||
}
|
||||
};
|
||||
|
||||
// 如果数组为空,停止所有实例
|
||||
if array_length == 0 {
|
||||
unsafe {
|
||||
let result = retain_network_instance(ptr::null(), 0);
|
||||
if result != 0
|
||||
&& let Some(error) = get_last_error()
|
||||
{
|
||||
throw_exception(&mut env, &error);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
// 转换 Java 字符串数组为 C 字符串数组
|
||||
let mut c_strings = Vec::with_capacity(array_length);
|
||||
let mut c_string_ptrs = Vec::with_capacity(array_length);
|
||||
|
||||
for i in 0..array_length {
|
||||
let java_string = match env.get_object_array_element(&instance_names, i as i32) {
|
||||
Ok(obj) => obj,
|
||||
Err(e) => {
|
||||
throw_exception(
|
||||
&mut env,
|
||||
&format!("Failed to get array element {}: {:?}", i, e),
|
||||
);
|
||||
return -1;
|
||||
}
|
||||
};
|
||||
|
||||
if java_string.is_null() {
|
||||
continue; // 跳过 null 元素
|
||||
}
|
||||
|
||||
let jstring = JString::from(java_string);
|
||||
let c_string = match jstring_to_cstring(&mut env, &jstring) {
|
||||
Ok(cstr) => cstr,
|
||||
Err(e) => {
|
||||
throw_exception(
|
||||
&mut env,
|
||||
&format!("Invalid instance name at index {}: {}", i, e),
|
||||
);
|
||||
return -1;
|
||||
}
|
||||
};
|
||||
|
||||
c_string_ptrs.push(c_string.as_ptr());
|
||||
c_strings.push(c_string); // 保持 CString 的所有权
|
||||
}
|
||||
|
||||
unsafe {
|
||||
let result = retain_network_instance(c_string_ptrs.as_ptr(), c_string_ptrs.len());
|
||||
if result != 0
|
||||
&& let Some(error) = get_last_error()
|
||||
{
|
||||
throw_exception(&mut env, &error);
|
||||
}
|
||||
result
|
||||
}
|
||||
logger::init();
|
||||
network_api::retain_network_instance_jni(env, class, instance_names)
|
||||
}
|
||||
|
||||
/// 收集网络信息
|
||||
/// Stop exactly one named network instance without affecting other instances.
|
||||
///
|
||||
/// Java signature:
|
||||
/// `EasyTierJNI.deleteNetworkInstance(instanceName: String): Int`
|
||||
///
|
||||
/// An unknown name is a no-op. On failure this returns `-1` and throws
|
||||
/// `RuntimeException`.
|
||||
#[unsafe(no_mangle)]
|
||||
pub extern "system" fn Java_com_easytier_jni_EasyTierJNI_deleteNetworkInstance(
|
||||
env: JNIEnv,
|
||||
class: JClass,
|
||||
instance_name: JString,
|
||||
) -> jint {
|
||||
logger::init();
|
||||
network_api::delete_network_instance_jni(env, class, instance_name)
|
||||
}
|
||||
|
||||
/// Collect running network instance information.
|
||||
///
|
||||
/// Java signature:
|
||||
/// `EasyTierJNI.collectNetworkInfos(maxLength: Int): String?`
|
||||
///
|
||||
/// Returns a JSON string containing `NetworkInstanceRunningInfoMap`, or null if
|
||||
/// collection fails. `maxLength` limits how many FFI entries are collected. On
|
||||
/// failure this throws `RuntimeException` when an error message is available.
|
||||
#[unsafe(no_mangle)]
|
||||
pub extern "system" fn Java_com_easytier_jni_EasyTierJNI_collectNetworkInfos(
|
||||
mut env: JNIEnv,
|
||||
_class: JClass,
|
||||
env: JNIEnv,
|
||||
class: JClass,
|
||||
max_length: jint,
|
||||
) -> jstring {
|
||||
Lazy::force(&LOGGER_INIT);
|
||||
|
||||
const MAX_INFOS: usize = 100;
|
||||
let mut infos = vec![
|
||||
KeyValuePair {
|
||||
key: ptr::null(),
|
||||
value: ptr::null(),
|
||||
};
|
||||
MAX_INFOS
|
||||
];
|
||||
|
||||
unsafe {
|
||||
let count = collect_network_infos(infos.as_mut_ptr(), MAX_INFOS);
|
||||
if count < 0 {
|
||||
if let Some(error) = get_last_error() {
|
||||
throw_exception(&mut env, &error);
|
||||
}
|
||||
return ptr::null_mut();
|
||||
}
|
||||
|
||||
let mut ret = NetworkInstanceRunningInfoMap::default();
|
||||
|
||||
// 使用 serde_json 构建 JSON
|
||||
for info in infos.iter().take(count as usize) {
|
||||
let key_ptr = info.key;
|
||||
let val_ptr = info.value;
|
||||
if key_ptr.is_null() || val_ptr.is_null() {
|
||||
break;
|
||||
}
|
||||
|
||||
let key = CStr::from_ptr(key_ptr).to_string_lossy();
|
||||
let val = CStr::from_ptr(val_ptr).to_string_lossy();
|
||||
let value = match serde_json::from_str::<NetworkInstanceRunningInfo>(val.as_ref()) {
|
||||
Ok(v) => v,
|
||||
Err(_) => {
|
||||
throw_exception(&mut env, "Failed to parse JSON");
|
||||
continue;
|
||||
}
|
||||
};
|
||||
ret.map.insert(key.to_string(), value);
|
||||
}
|
||||
|
||||
let json_str = serde_json::to_string(&ret).unwrap_or_else(|_| "{}".to_string());
|
||||
|
||||
match env.new_string(&json_str) {
|
||||
Ok(jstr) => jstr.into_raw(),
|
||||
Err(_) => {
|
||||
throw_exception(&mut env, "Failed to create JSON string");
|
||||
ptr::null_mut()
|
||||
}
|
||||
}
|
||||
}
|
||||
logger::init();
|
||||
network_api::collect_network_infos_jni(env, class, max_length)
|
||||
}
|
||||
|
||||
/// 获取最后的错误信息
|
||||
/// List running network instance names and IDs.
|
||||
///
|
||||
/// Java signature:
|
||||
/// `EasyTierJNI.listInstances(maxLength: Int): String?`
|
||||
///
|
||||
/// Returns a JSON object whose keys are instance names and whose values are
|
||||
/// instance ID strings. On failure this returns null and throws
|
||||
/// `RuntimeException`.
|
||||
#[unsafe(no_mangle)]
|
||||
pub extern "system" fn Java_com_easytier_jni_EasyTierJNI_listInstances(
|
||||
env: JNIEnv,
|
||||
class: JClass,
|
||||
max_length: jint,
|
||||
) -> jstring {
|
||||
logger::init();
|
||||
network_api::list_instances_jni(env, class, max_length)
|
||||
}
|
||||
|
||||
/// Call an exposed EasyTier RPC method using protobuf JSON.
|
||||
///
|
||||
/// Java signature:
|
||||
/// `EasyTierJNI.callJsonRpc(serviceName, methodName, domainName, payloadJson): String?`
|
||||
///
|
||||
/// Instance lifecycle management RPCs are intentionally not exposed here. Use
|
||||
/// the dedicated EasyTierJNI instance APIs for start/retain/delete/collect.
|
||||
/// `payloadJson` must include any `instance` selector required by the target
|
||||
/// RPC. On failure this returns null and throws `RuntimeException`.
|
||||
#[unsafe(no_mangle)]
|
||||
pub extern "system" fn Java_com_easytier_jni_EasyTierJNI_callJsonRpc(
|
||||
env: JNIEnv,
|
||||
class: JClass,
|
||||
service_name: JString,
|
||||
method_name: JString,
|
||||
domain_name: JString,
|
||||
payload_json: JString,
|
||||
) -> jstring {
|
||||
logger::init();
|
||||
json_rpc_api::call_json_rpc_jni(
|
||||
env,
|
||||
class,
|
||||
service_name,
|
||||
method_name,
|
||||
domain_name,
|
||||
payload_json,
|
||||
)
|
||||
}
|
||||
|
||||
/// Return the latest FFI/JNI error string for the calling thread.
|
||||
///
|
||||
/// Java signature:
|
||||
/// `EasyTierJNI.getLastError(): String?`
|
||||
///
|
||||
/// This combines the FFI thread-local error with any pending config-server Java
|
||||
/// callback error. It returns null when no error is available.
|
||||
#[unsafe(no_mangle)]
|
||||
pub extern "system" fn Java_com_easytier_jni_EasyTierJNI_getLastError(
|
||||
env: JNIEnv,
|
||||
_class: JClass,
|
||||
class: JClass,
|
||||
) -> jstring {
|
||||
match get_last_error() {
|
||||
Some(error) => match env.new_string(&error) {
|
||||
Ok(jstr) => jstr.into_raw(),
|
||||
Err(_) => ptr::null_mut(),
|
||||
},
|
||||
None => ptr::null_mut(),
|
||||
}
|
||||
error::get_last_error_jni(env, class)
|
||||
}
|
||||
|
||||
/// Start the managed config-server client.
|
||||
///
|
||||
/// Java signature:
|
||||
/// `EasyTierJNI.startConfigServerClient(url, hostname, machineId, secureMode, callback): Int`
|
||||
///
|
||||
/// JNI only converts Java values and keeps the Java callback alive. The FFI
|
||||
/// layer owns singleton lifecycle, config-server/data-plane mutual exclusion,
|
||||
/// remote instance tracking, and callback event timing. If `callback` is
|
||||
/// non-null, each remote apply/delete event is delivered to
|
||||
/// `ConfigServerEventCallback.onEvent(eventJson)`.
|
||||
///
|
||||
/// On failure this returns `-1` and throws `RuntimeException`.
|
||||
#[unsafe(no_mangle)]
|
||||
pub extern "system" fn Java_com_easytier_jni_EasyTierJNI_startConfigServerClient(
|
||||
mut env: JNIEnv,
|
||||
_class: JClass,
|
||||
config_server_url: JString,
|
||||
hostname: JString,
|
||||
machine_id: JString,
|
||||
secure_mode: jboolean,
|
||||
callback: JObject,
|
||||
) -> jint {
|
||||
logger::init();
|
||||
config_server_api::start_config_server_client_jni(
|
||||
&mut env,
|
||||
config_server_url,
|
||||
hostname,
|
||||
machine_id,
|
||||
secure_mode,
|
||||
callback,
|
||||
)
|
||||
}
|
||||
|
||||
/// Stop the managed config-server client.
|
||||
///
|
||||
/// Java signature:
|
||||
/// `EasyTierJNI.stopConfigServerClient(): Int`
|
||||
///
|
||||
/// The FFI layer performs the actual stop and managed instance cleanup. JNI
|
||||
/// releases the Java callback reference after FFI stop succeeds. On failure
|
||||
/// this returns `-1` and throws `RuntimeException`.
|
||||
#[unsafe(no_mangle)]
|
||||
pub extern "system" fn Java_com_easytier_jni_EasyTierJNI_stopConfigServerClient(
|
||||
env: JNIEnv,
|
||||
class: JClass,
|
||||
) -> jint {
|
||||
logger::init();
|
||||
config_server_api::stop_config_server_client_jni(env, class)
|
||||
}
|
||||
|
||||
/// Report whether the managed config-server client is connected.
|
||||
///
|
||||
/// Java signature:
|
||||
/// `EasyTierJNI.isConfigServerClientConnected(): Boolean`
|
||||
///
|
||||
/// Returns `JNI_TRUE` only when the FFI config-server client exists and reports
|
||||
/// connected.
|
||||
#[unsafe(no_mangle)]
|
||||
pub extern "system" fn Java_com_easytier_jni_EasyTierJNI_isConfigServerClientConnected(
|
||||
env: JNIEnv,
|
||||
class: JClass,
|
||||
) -> jboolean {
|
||||
logger::init();
|
||||
config_server_api::is_config_server_client_connected_jni(env, class)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
use once_cell::sync::Lazy;
|
||||
|
||||
static LOGGER_INIT: Lazy<()> = Lazy::new(|| {
|
||||
android_logger::init_once(
|
||||
android_logger::Config::default()
|
||||
.with_max_level(log::LevelFilter::Debug)
|
||||
.with_tag("EasyTier-JNI"),
|
||||
);
|
||||
});
|
||||
|
||||
pub(crate) fn init() {
|
||||
Lazy::force(&LOGGER_INIT);
|
||||
}
|
||||
@@ -0,0 +1,285 @@
|
||||
use std::{ffi::CStr, ptr};
|
||||
|
||||
use easytier::proto::api::manage::{NetworkInstanceRunningInfo, NetworkInstanceRunningInfoMap};
|
||||
use easytier_ffi::{
|
||||
KeyValuePair, collect_network_infos, delete_network_instance, free_string, list_instance,
|
||||
parse_config, retain_network_instance, run_network_instance, set_tun_fd,
|
||||
};
|
||||
use jni::JNIEnv;
|
||||
use jni::objects::{JClass, JObjectArray, JString};
|
||||
use jni::sys::{jint, jstring};
|
||||
|
||||
use crate::{
|
||||
error::{get_last_error, throw_exception},
|
||||
strings::jstring_to_cstring,
|
||||
};
|
||||
|
||||
pub(crate) fn set_tun_fd_jni(
|
||||
mut env: JNIEnv,
|
||||
_class: JClass,
|
||||
inst_name: JString,
|
||||
fd: jint,
|
||||
) -> jint {
|
||||
let inst_name_cstr = match jstring_to_cstring(&mut env, &inst_name) {
|
||||
Ok(cstr) => cstr,
|
||||
Err(e) => {
|
||||
throw_exception(&mut env, &format!("Invalid instance name: {}", e));
|
||||
return -1;
|
||||
}
|
||||
};
|
||||
unsafe {
|
||||
let result = set_tun_fd(inst_name_cstr.as_ptr(), fd);
|
||||
if result != 0
|
||||
&& let Some(error) = get_last_error()
|
||||
{
|
||||
throw_exception(&mut env, &error);
|
||||
}
|
||||
result
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn parse_config_jni(mut env: JNIEnv, _class: JClass, config: JString) -> jint {
|
||||
let config_cstr = match jstring_to_cstring(&mut env, &config) {
|
||||
Ok(cstr) => cstr,
|
||||
Err(e) => {
|
||||
throw_exception(&mut env, &format!("Invalid config string: {}", e));
|
||||
return -1;
|
||||
}
|
||||
};
|
||||
unsafe {
|
||||
let result = parse_config(config_cstr.as_ptr());
|
||||
if result != 0
|
||||
&& let Some(error) = get_last_error()
|
||||
{
|
||||
throw_exception(&mut env, &error);
|
||||
}
|
||||
result
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn run_network_instance_jni(mut env: JNIEnv, _class: JClass, config: JString) -> jint {
|
||||
let config_cstr = match jstring_to_cstring(&mut env, &config) {
|
||||
Ok(cstr) => cstr,
|
||||
Err(e) => {
|
||||
throw_exception(&mut env, &format!("Invalid config string: {}", e));
|
||||
return -1;
|
||||
}
|
||||
};
|
||||
unsafe {
|
||||
let result = run_network_instance(config_cstr.as_ptr());
|
||||
if result != 0
|
||||
&& let Some(error) = get_last_error()
|
||||
{
|
||||
throw_exception(&mut env, &error);
|
||||
}
|
||||
result
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn delete_network_instance_jni(
|
||||
mut env: JNIEnv,
|
||||
_class: JClass,
|
||||
instance_name: JString,
|
||||
) -> jint {
|
||||
let instance_name = match jstring_to_cstring(&mut env, &instance_name) {
|
||||
Ok(name) => name,
|
||||
Err(error) => {
|
||||
throw_exception(&mut env, &format!("Invalid instance name: {error}"));
|
||||
return -1;
|
||||
}
|
||||
};
|
||||
let instance_names = [instance_name.as_ptr()];
|
||||
unsafe {
|
||||
let result = delete_network_instance(instance_names.as_ptr(), instance_names.len());
|
||||
if result != 0
|
||||
&& let Some(error) = get_last_error()
|
||||
{
|
||||
throw_exception(&mut env, &error);
|
||||
}
|
||||
result
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn retain_network_instance_jni(
|
||||
mut env: JNIEnv,
|
||||
_class: JClass,
|
||||
instance_names: JObjectArray,
|
||||
) -> jint {
|
||||
if instance_names.is_null() {
|
||||
return retain_all(&mut env);
|
||||
}
|
||||
|
||||
let array_length = match env.get_array_length(&instance_names) {
|
||||
Ok(len) => len as usize,
|
||||
Err(e) => {
|
||||
throw_exception(&mut env, &format!("Failed to get array length: {:?}", e));
|
||||
return -1;
|
||||
}
|
||||
};
|
||||
|
||||
if array_length == 0 {
|
||||
return retain_all(&mut env);
|
||||
}
|
||||
|
||||
let mut c_strings = Vec::with_capacity(array_length);
|
||||
let mut c_string_ptrs = Vec::with_capacity(array_length);
|
||||
|
||||
for i in 0..array_length {
|
||||
let java_string = match env.get_object_array_element(&instance_names, i as i32) {
|
||||
Ok(obj) => obj,
|
||||
Err(e) => {
|
||||
throw_exception(
|
||||
&mut env,
|
||||
&format!("Failed to get array element {}: {:?}", i, e),
|
||||
);
|
||||
return -1;
|
||||
}
|
||||
};
|
||||
|
||||
if java_string.is_null() {
|
||||
throw_exception(
|
||||
&mut env,
|
||||
&format!("Invalid instance name at index {}: null", i),
|
||||
);
|
||||
return -1;
|
||||
}
|
||||
|
||||
let jstring = JString::from(java_string);
|
||||
let c_string = match jstring_to_cstring(&mut env, &jstring) {
|
||||
Ok(cstr) => cstr,
|
||||
Err(e) => {
|
||||
throw_exception(
|
||||
&mut env,
|
||||
&format!("Invalid instance name at index {}: {}", i, e),
|
||||
);
|
||||
return -1;
|
||||
}
|
||||
};
|
||||
|
||||
c_string_ptrs.push(c_string.as_ptr());
|
||||
c_strings.push(c_string);
|
||||
}
|
||||
|
||||
unsafe {
|
||||
let result = retain_network_instance(c_string_ptrs.as_ptr(), c_string_ptrs.len());
|
||||
if result != 0
|
||||
&& let Some(error) = get_last_error()
|
||||
{
|
||||
throw_exception(&mut env, &error);
|
||||
}
|
||||
result
|
||||
}
|
||||
}
|
||||
|
||||
fn retain_all(env: &mut JNIEnv) -> jint {
|
||||
unsafe {
|
||||
let result = retain_network_instance(ptr::null(), 0);
|
||||
if result != 0
|
||||
&& let Some(error) = get_last_error()
|
||||
{
|
||||
throw_exception(env, &error);
|
||||
}
|
||||
result
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn collect_network_infos_jni(
|
||||
mut env: JNIEnv,
|
||||
_class: JClass,
|
||||
max_length: jint,
|
||||
) -> jstring {
|
||||
let max_length = max_length.max(0) as usize;
|
||||
let mut infos = vec![
|
||||
KeyValuePair {
|
||||
key: ptr::null(),
|
||||
value: ptr::null(),
|
||||
};
|
||||
max_length
|
||||
];
|
||||
|
||||
unsafe {
|
||||
let count = collect_network_infos(infos.as_mut_ptr(), max_length);
|
||||
if count < 0 {
|
||||
if let Some(error) = get_last_error() {
|
||||
throw_exception(&mut env, &error);
|
||||
}
|
||||
return ptr::null_mut();
|
||||
}
|
||||
|
||||
let mut ret = NetworkInstanceRunningInfoMap::default();
|
||||
for info in infos.iter().take(count as usize) {
|
||||
let key_ptr = info.key;
|
||||
let val_ptr = info.value;
|
||||
if key_ptr.is_null() || val_ptr.is_null() {
|
||||
break;
|
||||
}
|
||||
|
||||
let key = CStr::from_ptr(key_ptr).to_string_lossy().into_owned();
|
||||
let val = CStr::from_ptr(val_ptr).to_string_lossy().into_owned();
|
||||
free_string(key_ptr);
|
||||
free_string(val_ptr);
|
||||
let value = match serde_json::from_str::<NetworkInstanceRunningInfo>(&val) {
|
||||
Ok(v) => v,
|
||||
Err(_) => {
|
||||
throw_exception(&mut env, "Failed to parse JSON");
|
||||
continue;
|
||||
}
|
||||
};
|
||||
ret.map.insert(key, value);
|
||||
}
|
||||
|
||||
let json_str = serde_json::to_string(&ret).unwrap_or_else(|_| "{}".to_string());
|
||||
match env.new_string(&json_str) {
|
||||
Ok(jstr) => jstr.into_raw(),
|
||||
Err(_) => {
|
||||
throw_exception(&mut env, "Failed to create JSON string");
|
||||
ptr::null_mut()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn list_instances_jni(mut env: JNIEnv, _class: JClass, max_length: jint) -> jstring {
|
||||
let max_length = max_length.max(0) as usize;
|
||||
let mut infos = vec![
|
||||
KeyValuePair {
|
||||
key: ptr::null(),
|
||||
value: ptr::null(),
|
||||
};
|
||||
max_length
|
||||
];
|
||||
|
||||
unsafe {
|
||||
let count = list_instance(infos.as_mut_ptr(), max_length);
|
||||
if count < 0 {
|
||||
if let Some(error) = get_last_error() {
|
||||
throw_exception(&mut env, &error);
|
||||
}
|
||||
return ptr::null_mut();
|
||||
}
|
||||
|
||||
let mut ret = serde_json::Map::new();
|
||||
for info in infos.iter().take(count as usize) {
|
||||
let key_ptr = info.key;
|
||||
let val_ptr = info.value;
|
||||
if key_ptr.is_null() || val_ptr.is_null() {
|
||||
break;
|
||||
}
|
||||
|
||||
let key = CStr::from_ptr(key_ptr).to_string_lossy().into_owned();
|
||||
let val = CStr::from_ptr(val_ptr).to_string_lossy().into_owned();
|
||||
free_string(key_ptr);
|
||||
free_string(val_ptr);
|
||||
ret.insert(key, serde_json::Value::String(val));
|
||||
}
|
||||
|
||||
let json_str = serde_json::Value::Object(ret).to_string();
|
||||
match env.new_string(&json_str) {
|
||||
Ok(jstr) => jstr.into_raw(),
|
||||
Err(_) => {
|
||||
throw_exception(&mut env, "Failed to create instance list JSON string");
|
||||
ptr::null_mut()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
use std::ffi::CString;
|
||||
|
||||
use jni::JNIEnv;
|
||||
use jni::objects::JString;
|
||||
|
||||
pub(crate) fn jstring_to_cstring(env: &mut JNIEnv, jstr: &JString) -> Result<CString, String> {
|
||||
let java_str = env
|
||||
.get_string(jstr)
|
||||
.map_err(|e| format!("Failed to get string: {:?}", e))?;
|
||||
let rust_str = java_str.to_str().map_err(|_| "Invalid UTF-8".to_string())?;
|
||||
CString::new(rust_str).map_err(|_| "String contains null byte".to_string())
|
||||
}
|
||||
|
||||
pub(crate) fn optional_jstring_to_cstring(
|
||||
env: &mut JNIEnv,
|
||||
jstr: &JString,
|
||||
) -> Result<Option<CString>, String> {
|
||||
if jstr.is_null() {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
jstring_to_cstring(env, jstr).map(Some)
|
||||
}
|
||||
@@ -2,16 +2,39 @@
|
||||
name = "easytier-ffi"
|
||||
version = "0.1.0"
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
|
||||
[lib]
|
||||
crate-type = ["cdylib"]
|
||||
crate-type = ["cdylib", "rlib"]
|
||||
|
||||
[features]
|
||||
default = ["c-abi", "ffi-dataplane"]
|
||||
c-abi = []
|
||||
ffi-dataplane = [
|
||||
"easytier/ffi-dataplane",
|
||||
"easytier-core/proxy-smoltcp-stack",
|
||||
]
|
||||
macos-ne = ["easytier/macos-ne"]
|
||||
|
||||
[dependencies]
|
||||
easytier = { path = "../../easytier" }
|
||||
easytier = { workspace = true, default-features = true, features = ["tracing-log"] }
|
||||
easytier-core = { workspace = true, default-features = true }
|
||||
|
||||
once_cell = "1.18.0"
|
||||
dashmap = "6.0"
|
||||
once_cell.workspace = true
|
||||
tokio = { workspace = true, default-features = true, features = ["rt-multi-thread", "io-util", "time", "sync", "macros"] }
|
||||
async-trait.workspace = true
|
||||
log.workspace = true
|
||||
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
uuid = "1.17.0"
|
||||
serde_json.workspace = true
|
||||
uuid.workspace = true
|
||||
|
||||
[build-dependencies]
|
||||
thunk-rs = { workspace = true, features = [
|
||||
"win7",
|
||||
] }
|
||||
|
||||
[package.metadata.cargo-machete]
|
||||
ignored = [
|
||||
# Windows-only build.rs setup calls thunk::thunk().
|
||||
"thunk-rs",
|
||||
]
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
# Native data-plane ABI v3
|
||||
|
||||
The native data-plane ABI is a thin adapter over the instance-owned
|
||||
`DataPlaneSession`. It does not own sockets, operation state, completion
|
||||
queues, routing policy, or timeouts.
|
||||
|
||||
## Conventions
|
||||
|
||||
- Every immediate call returns `0` on success or a negative
|
||||
`DataPlaneErrorKind` value on failure.
|
||||
- `data_plane_completion_wait` returns `1` when a completion is ready, `0` on
|
||||
timeout or session close, and a negative error value on failure.
|
||||
- `data_plane_completion_drain` returns a non-negative descriptor count or a
|
||||
negative error value.
|
||||
- Handle zero is invalid.
|
||||
- `timeout_ms == UINT64_MAX` means no deadline.
|
||||
- TCP connect/bind/accept and UDP bind timeouts start when submission is
|
||||
accepted.
|
||||
- TCP streams and UDP sockets have persistent read and write deadlines.
|
||||
`data_plane_resource_deadline_set` replaces the selected directions'
|
||||
deadlines immediately, including for active operations. An expired deadline
|
||||
remains expired until it is replaced or cleared with `UINT64_MAX`.
|
||||
- Deadline direction `1` selects reads, `2` selects writes, and `3` selects
|
||||
both.
|
||||
- Request and write bytes are copied before a submit call returns.
|
||||
- Socket-address fields use native-endian integers. Address bytes are in
|
||||
network order. ABI v3 accepts IPv4 only.
|
||||
|
||||
`DataPlaneSocketAddr` is:
|
||||
|
||||
```c
|
||||
typedef struct {
|
||||
uint16_t family; /* 4 */
|
||||
uint16_t port;
|
||||
uint8_t address[16]; /* IPv4 uses the first four bytes */
|
||||
} DataPlaneSocketAddr;
|
||||
```
|
||||
|
||||
`DataPlaneCompletion` is:
|
||||
|
||||
```c
|
||||
typedef struct {
|
||||
uint64_t operation_id;
|
||||
uint16_t operation_kind;
|
||||
uint16_t status; /* 0 or DataPlaneErrorKind */
|
||||
} DataPlaneCompletion;
|
||||
```
|
||||
|
||||
## Lifecycle
|
||||
|
||||
One native session may be open for an EasyTier instance at a time:
|
||||
|
||||
```text
|
||||
data_plane_session_open
|
||||
-> set resource deadlines
|
||||
-> submit operations
|
||||
-> completion_wait
|
||||
-> completion_drain
|
||||
-> typed result_take
|
||||
-> resource_close / operation_free
|
||||
data_plane_session_close
|
||||
```
|
||||
|
||||
Closing a native session cancels and discards its outstanding operations and
|
||||
resources and wakes a thread blocked in `data_plane_completion_wait`.
|
||||
|
||||
The resource and operation IDs returned by the ABI belong to that session.
|
||||
They must always be passed together with the same session handle.
|
||||
|
||||
## Completion and result ownership
|
||||
|
||||
Submission returns an operation ID immediately. Completion descriptors carry
|
||||
only the operation ID, operation kind, and terminal status. Draining a
|
||||
descriptor makes its typed result available but does not consume it.
|
||||
|
||||
`data_plane_result_size` reports the TCP-read or UDP-receive payload size.
|
||||
Typed result-take functions consume the result exactly once. If a supplied
|
||||
buffer is too small, they return `-BufferTooSmall` and leave the result
|
||||
available for a later call.
|
||||
|
||||
Call `data_plane_operation_free` when a drained result is intentionally
|
||||
abandoned. Call `data_plane_resource_close` for TCP streams, listeners, and
|
||||
UDP sockets.
|
||||
|
||||
## Operation kinds
|
||||
|
||||
| Value | Operation |
|
||||
| ---: | --- |
|
||||
| 1 | TCP connect |
|
||||
| 2 | TCP bind |
|
||||
| 3 | TCP accept |
|
||||
| 4 | TCP read |
|
||||
| 5 | TCP write |
|
||||
| 6 | UDP bind |
|
||||
| 7 | UDP receive |
|
||||
| 8 | UDP send |
|
||||
|
||||
The exported function families are:
|
||||
|
||||
- `data_plane_tcp_*_submit`
|
||||
- `data_plane_udp_*_submit`
|
||||
- `data_plane_resource_deadline_set`
|
||||
- `data_plane_completion_wait`
|
||||
- `data_plane_completion_drain`
|
||||
- `data_plane_*_result_take`
|
||||
- `data_plane_operation_cancel`
|
||||
- `data_plane_operation_free`
|
||||
- `data_plane_resource_close`
|
||||
@@ -0,0 +1,8 @@
|
||||
fn main() {
|
||||
let target_os = std::env::var("CARGO_CFG_TARGET_OS").unwrap_or_default();
|
||||
let target_arch = std::env::var("CARGO_CFG_TARGET_ARCH").unwrap_or_default();
|
||||
|
||||
if target_os == "windows" && (target_arch == "x86" || target_arch == "x86_64") {
|
||||
thunk::thunk();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <stdbool.h>
|
||||
#include <unistd.h> // for sleep
|
||||
|
||||
// FFI struct and function declarations
|
||||
typedef struct {
|
||||
const char* key;
|
||||
const char* value;
|
||||
} KeyValuePair;
|
||||
|
||||
typedef void (*config_server_event_callback)(
|
||||
const char* event_json,
|
||||
void* user_data
|
||||
);
|
||||
|
||||
extern int parse_config(const char* cfg_str);
|
||||
extern int run_network_instance(const char* cfg_str);
|
||||
extern void get_error_msg(const char** out);
|
||||
extern void free_string(const char* s);
|
||||
extern int collect_network_infos(KeyValuePair* infos, size_t max_length);
|
||||
extern int start_config_server_client(
|
||||
const char* config_server_url,
|
||||
const char* hostname,
|
||||
const char* machine_id,
|
||||
bool secure_mode,
|
||||
config_server_event_callback callback,
|
||||
void* user_data
|
||||
);
|
||||
extern int stop_config_server_client(void);
|
||||
extern int is_config_server_client_connected(void);
|
||||
|
||||
static void on_config_server_event(const char* event_json, void* user_data) {
|
||||
(void)user_data;
|
||||
printf("config server event: %s\n", event_json);
|
||||
}
|
||||
|
||||
int main() {
|
||||
const char* config = "inst_name = \"test\"\nnetwork = \"test_network\"\n";
|
||||
int ret;
|
||||
|
||||
// 调用 parse_config
|
||||
ret = parse_config(config);
|
||||
if (ret != 0) {
|
||||
const char* err = NULL;
|
||||
get_error_msg(&err);
|
||||
if (err) {
|
||||
printf("parse_config error: %s\n", err);
|
||||
free_string(err);
|
||||
}
|
||||
return 1;
|
||||
}
|
||||
printf("parse_config success\n");
|
||||
|
||||
// 调用 run_network_instance
|
||||
ret = run_network_instance(config);
|
||||
if (ret != 0) {
|
||||
const char* err = NULL;
|
||||
get_error_msg(&err);
|
||||
if (err) {
|
||||
printf("run_network_instance error: %s\n", err);
|
||||
free_string(err);
|
||||
}
|
||||
return 1;
|
||||
}
|
||||
printf("run_network_instance success\n");
|
||||
|
||||
// 周期性调用 collect_network_infos 并打印
|
||||
const size_t max_infos = 8;
|
||||
KeyValuePair* infos = (KeyValuePair*)malloc(sizeof(KeyValuePair) * max_infos);
|
||||
if (!infos) {
|
||||
fprintf(stderr, "malloc failed\n");
|
||||
return 1;
|
||||
}
|
||||
|
||||
for (int i = 0; i < 5; ++i) { // 循环5次作为示例
|
||||
memset(infos, 0, sizeof(KeyValuePair) * max_infos);
|
||||
int count = collect_network_infos(infos, max_infos);
|
||||
if (count < 0) {
|
||||
const char* err = NULL;
|
||||
get_error_msg(&err);
|
||||
if (err) {
|
||||
printf("collect_network_infos error: %s\n", err);
|
||||
free_string(err);
|
||||
}
|
||||
break;
|
||||
}
|
||||
printf("collect_network_infos: %d instance(s)\n", count);
|
||||
for (int j = 0; j < count; ++j) {
|
||||
printf(" [%d] key: %s\n value: %s\n", j, infos[j].key, infos[j].value);
|
||||
free_string(infos[j].key);
|
||||
free_string(infos[j].value);
|
||||
}
|
||||
sleep(1);
|
||||
}
|
||||
free(infos);
|
||||
|
||||
return 0;
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
github.com/go-webgpu/goffi v0.4.1 h1:2hQH5XXloxTyTtIleYv+Rajlwzp6UOETURhSZ5+zJxU=
|
||||
github.com/go-webgpu/goffi v0.4.1/go.mod h1:wfoxNsJkU+5RFbV1kNN1kunhc1lFHuJKK3zpgx08/uM=
|
||||
@@ -0,0 +1,482 @@
|
||||
use std::{
|
||||
cell::Cell,
|
||||
collections::HashSet,
|
||||
ffi::{CString, c_char, c_int, c_void},
|
||||
sync::{
|
||||
Arc, Mutex,
|
||||
atomic::{AtomicBool, Ordering},
|
||||
},
|
||||
};
|
||||
|
||||
use easytier::{
|
||||
common::{
|
||||
MachineIdOptions,
|
||||
config::{ConfigLoader as _, TomlConfigLoader},
|
||||
},
|
||||
web_client::{WebClient, WebClientHooks, parse_config_server_endpoint, run_web_client},
|
||||
};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::{
|
||||
data_plane::remove_data_plane_sessions_by_instance_ids,
|
||||
error::set_error_msg,
|
||||
state::{ffi_context, resolve_instance_id_by_name},
|
||||
strings::{c_str_to_string, optional_c_str_to_string},
|
||||
types::ConfigServerEventCallback,
|
||||
};
|
||||
|
||||
thread_local! {
|
||||
static IN_CONFIG_SERVER_CALLBACK: Cell<bool> = const { Cell::new(false) };
|
||||
}
|
||||
|
||||
static CONFIG_SERVER_CLIENT: once_cell::sync::Lazy<Mutex<Option<ManagedConfigServerClient>>> =
|
||||
once_cell::sync::Lazy::new(|| Mutex::new(None));
|
||||
static CONFIG_SERVER_CLIENT_ACTIVE: once_cell::sync::Lazy<AtomicBool> =
|
||||
once_cell::sync::Lazy::new(|| AtomicBool::new(false));
|
||||
static CONFIG_SERVER_CLIENT_STOPPING: once_cell::sync::Lazy<AtomicBool> =
|
||||
once_cell::sync::Lazy::new(|| AtomicBool::new(false));
|
||||
static LAST_CONFIG_SERVER_CALLBACK_ERROR: once_cell::sync::Lazy<Mutex<Option<String>>> =
|
||||
once_cell::sync::Lazy::new(|| Mutex::new(None));
|
||||
|
||||
pub(crate) struct ConfigServerCallbackScope;
|
||||
|
||||
impl ConfigServerCallbackScope {
|
||||
pub(crate) fn enter() -> Self {
|
||||
IN_CONFIG_SERVER_CALLBACK.with(|in_callback| in_callback.set(true));
|
||||
Self
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for ConfigServerCallbackScope {
|
||||
fn drop(&mut self) {
|
||||
IN_CONFIG_SERVER_CALLBACK.with(|in_callback| in_callback.set(false));
|
||||
}
|
||||
}
|
||||
|
||||
pub fn in_config_server_callback() -> bool {
|
||||
IN_CONFIG_SERVER_CALLBACK.with(Cell::get)
|
||||
}
|
||||
|
||||
fn config_server_machine_id_options(machine_id: String) -> MachineIdOptions {
|
||||
MachineIdOptions {
|
||||
explicit_machine_id: Some(machine_id),
|
||||
state_dir: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn validate_config_server_client_options(
|
||||
config_server_url_s: &str,
|
||||
machine_id: &str,
|
||||
) -> Result<(), String> {
|
||||
if machine_id.trim().is_empty() {
|
||||
return Err("machine_id is empty".to_string());
|
||||
}
|
||||
|
||||
parse_config_server_endpoint(config_server_url_s)
|
||||
.map(|_| ())
|
||||
.map_err(|error| error.to_string())
|
||||
}
|
||||
|
||||
struct ManagedConfigServerClient {
|
||||
client: WebClient,
|
||||
hooks: Arc<ManagedConfigServerClientHooks>,
|
||||
}
|
||||
|
||||
pub(crate) struct ManagedConfigServerClientHooks {
|
||||
pub(crate) instance_ids: Mutex<HashSet<Uuid>>,
|
||||
callback_delivery: Mutex<()>,
|
||||
stopping: AtomicBool,
|
||||
callback: ConfigServerEventCallback,
|
||||
user_data: usize,
|
||||
}
|
||||
|
||||
impl ManagedConfigServerClientHooks {
|
||||
pub(crate) fn new(callback: ConfigServerEventCallback, user_data: *mut c_void) -> Self {
|
||||
Self {
|
||||
instance_ids: Mutex::new(HashSet::new()),
|
||||
callback_delivery: Mutex::new(()),
|
||||
stopping: AtomicBool::new(false),
|
||||
callback,
|
||||
user_data: user_data as usize,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn tracked_instance_ids(&self) -> Vec<Uuid> {
|
||||
self.instance_ids
|
||||
.lock()
|
||||
.map(|guard| guard.iter().copied().collect())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
fn remove_tracked_instance_ids(&self, ids: &[Uuid]) -> Result<Vec<Uuid>, String> {
|
||||
let mut guard = self.instance_ids.lock().map_err(|err| err.to_string())?;
|
||||
Ok(ids
|
||||
.iter()
|
||||
.filter_map(|id| guard.remove(id).then_some(*id))
|
||||
.collect())
|
||||
}
|
||||
|
||||
fn validate_instance_name(&self, inst_name: &str, inst_id: Uuid) -> Result<(), String> {
|
||||
if let Some(existing_id) =
|
||||
resolve_instance_id_by_name(inst_name).map_err(|error| error.to_string())?
|
||||
&& existing_id != inst_id
|
||||
{
|
||||
return Err(format!("instance name {} already exists", inst_name));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn start_stopping(&self) -> Vec<Uuid> {
|
||||
let _delivery_guard = if in_config_server_callback() {
|
||||
None
|
||||
} else {
|
||||
self.callback_delivery.lock().ok()
|
||||
};
|
||||
let mut guard = match self.instance_ids.lock() {
|
||||
Ok(guard) => guard,
|
||||
Err(_) => return Vec::new(),
|
||||
};
|
||||
self.stopping.store(true, Ordering::Release);
|
||||
guard.drain().collect()
|
||||
}
|
||||
|
||||
pub(crate) fn note_callback_error(&self, error: String) {
|
||||
log::warn!("config server event callback failed: {}", error);
|
||||
if let Ok(mut guard) = LAST_CONFIG_SERVER_CALLBACK_ERROR.lock() {
|
||||
*guard = Some(error);
|
||||
}
|
||||
}
|
||||
|
||||
fn emit_event_with_delivery_locked(
|
||||
&self,
|
||||
event: &str,
|
||||
instance_id: Uuid,
|
||||
) -> Result<(), String> {
|
||||
if self.stopping.load(Ordering::Acquire) {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let Some(callback) = self.callback else {
|
||||
return Ok(());
|
||||
};
|
||||
let instance_name = ffi_context()
|
||||
.manager
|
||||
.instance(instance_id)
|
||||
.map(|instance| instance.instance_name().to_owned())
|
||||
.unwrap_or_default();
|
||||
let network_name = ffi_context()
|
||||
.manager
|
||||
.config(instance_id)
|
||||
.map(|config| config.get_network_identity().network_name)
|
||||
.unwrap_or_default();
|
||||
let event_json = serde_json::json!({
|
||||
"event": event,
|
||||
"success": true,
|
||||
"instance_id": instance_id.to_string(),
|
||||
"instance_name": instance_name,
|
||||
"network_name": network_name,
|
||||
"error": null,
|
||||
})
|
||||
.to_string();
|
||||
let event_json = CString::new(event_json).map_err(|err| err.to_string())?;
|
||||
let _callback_scope = ConfigServerCallbackScope::enter();
|
||||
unsafe {
|
||||
callback(event_json.as_ptr(), self.user_data as *mut c_void);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn emit_event(&self, event: &str, instance_id: Uuid) -> Result<(), String> {
|
||||
let _delivery_guard = self
|
||||
.callback_delivery
|
||||
.lock()
|
||||
.map_err(|err| err.to_string())?;
|
||||
self.emit_event_with_delivery_locked(event, instance_id)
|
||||
}
|
||||
|
||||
fn wait_for_callback_delivery(&self) {
|
||||
if in_config_server_callback() {
|
||||
return;
|
||||
}
|
||||
|
||||
if let Ok(guard) = self.callback_delivery.lock() {
|
||||
drop(guard);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl WebClientHooks for ManagedConfigServerClientHooks {
|
||||
fn manages_remote_config_instances(&self) -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
async fn pre_run_network_instance(&self, cfg: &TomlConfigLoader) -> Result<(), String> {
|
||||
if self.stopping.load(Ordering::Acquire) {
|
||||
return Err("config server client is stopping".to_string());
|
||||
}
|
||||
|
||||
let inst_name = cfg.get_inst_name();
|
||||
let inst_id = cfg.get_id();
|
||||
|
||||
self.validate_instance_name(&inst_name, inst_id)
|
||||
}
|
||||
|
||||
async fn post_run_network_instance(&self, id: &Uuid) -> Result<(), String> {
|
||||
let _delivery_guard = self
|
||||
.callback_delivery
|
||||
.lock()
|
||||
.map_err(|err| err.to_string())?;
|
||||
if self.stopping.load(Ordering::Acquire) {
|
||||
return Err("config server client is stopping".to_string());
|
||||
}
|
||||
let Some(inst_name) = ffi_context()
|
||||
.manager
|
||||
.instance(*id)
|
||||
.map(|instance| instance.instance_name().to_owned())
|
||||
else {
|
||||
return Err(format!("instance {} not found after start", id));
|
||||
};
|
||||
|
||||
self.instance_ids
|
||||
.lock()
|
||||
.map_err(|err| err.to_string())?
|
||||
.insert(*id);
|
||||
if let Err(error) = self.validate_instance_name(&inst_name, *id) {
|
||||
self.remove_tracked_instance_ids(&[*id])?;
|
||||
return Err(error);
|
||||
}
|
||||
|
||||
remove_data_plane_sessions_by_instance_ids(&[*id]);
|
||||
|
||||
if let Err(err) = self.emit_event_with_delivery_locked("run_network_instance", *id) {
|
||||
self.note_callback_error(err);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn post_remove_network_instances(&self, ids: &[Uuid]) -> Result<(), String> {
|
||||
let removed_ids = self.remove_tracked_instance_ids(ids)?;
|
||||
remove_data_plane_sessions_by_instance_ids(&removed_ids);
|
||||
|
||||
for id in removed_ids {
|
||||
if let Err(err) = self.emit_event("delete_network_instance", id) {
|
||||
self.note_callback_error(err);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn remove_config_server_tracked_instance_ids(ids: &[Uuid]) {
|
||||
if ids.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
if let Ok(guard) = CONFIG_SERVER_CLIENT.lock()
|
||||
&& let Some(managed) = guard.as_ref()
|
||||
&& let Err(err) = managed.hooks.remove_tracked_instance_ids(ids)
|
||||
{
|
||||
log::warn!("failed to remove config server tracked ids: {}", err);
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn wait_for_config_server_delivery() {
|
||||
let hooks = CONFIG_SERVER_CLIENT
|
||||
.lock()
|
||||
.ok()
|
||||
.and_then(|guard| guard.as_ref().map(|managed| managed.hooks.clone()));
|
||||
if let Some(hooks) = hooks {
|
||||
hooks.wait_for_callback_delivery();
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn last_callback_error() -> Option<String> {
|
||||
LAST_CONFIG_SERVER_CALLBACK_ERROR
|
||||
.lock()
|
||||
.ok()
|
||||
.and_then(|guard| guard.clone())
|
||||
}
|
||||
|
||||
pub(crate) fn clear_last_callback_error() {
|
||||
if let Ok(mut guard) = LAST_CONFIG_SERVER_CALLBACK_ERROR.lock() {
|
||||
*guard = None;
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "ffi-dataplane")]
|
||||
pub(crate) fn is_config_server_active_or_stopping() -> bool {
|
||||
CONFIG_SERVER_CLIENT_ACTIVE.load(Ordering::Acquire)
|
||||
|| CONFIG_SERVER_CLIENT_STOPPING.load(Ordering::Acquire)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn set_active_for_test(active: bool) {
|
||||
CONFIG_SERVER_CLIENT_ACTIVE.store(active, Ordering::Release);
|
||||
}
|
||||
|
||||
/// # Safety
|
||||
/// Start the config server client.
|
||||
///
|
||||
/// `config_server_url` must be a valid null-terminated UTF-8 string.
|
||||
/// `hostname` may be null; if non-null it must be a valid null-terminated UTF-8 string.
|
||||
/// `machine_id` must be a valid null-terminated UTF-8 string.
|
||||
/// `event_json` passed to `callback` is valid only during that callback invocation.
|
||||
pub(crate) unsafe fn start_config_server_client(
|
||||
config_server_url: *const c_char,
|
||||
hostname: *const c_char,
|
||||
machine_id: *const c_char,
|
||||
secure_mode: bool,
|
||||
callback: ConfigServerEventCallback,
|
||||
user_data: *mut c_void,
|
||||
) -> c_int {
|
||||
if in_config_server_callback() {
|
||||
set_error_msg("cannot start config server client from config server callback");
|
||||
return -1;
|
||||
}
|
||||
|
||||
let config_server_url = match unsafe { c_str_to_string(config_server_url, "config_server_url") }
|
||||
{
|
||||
Ok(value) => value,
|
||||
Err(err) => {
|
||||
set_error_msg(&err);
|
||||
return -1;
|
||||
}
|
||||
};
|
||||
let hostname = match unsafe { optional_c_str_to_string(hostname, "hostname") } {
|
||||
Ok(value) => value,
|
||||
Err(err) => {
|
||||
set_error_msg(&err);
|
||||
return -1;
|
||||
}
|
||||
};
|
||||
let machine_id = match unsafe { c_str_to_string(machine_id, "machine_id") } {
|
||||
Err(err) => {
|
||||
set_error_msg(&err);
|
||||
return -1;
|
||||
}
|
||||
Ok(value) => value,
|
||||
};
|
||||
if let Err(err) = validate_config_server_client_options(&config_server_url, &machine_id) {
|
||||
set_error_msg(&err);
|
||||
return -1;
|
||||
}
|
||||
|
||||
let mut guard = match CONFIG_SERVER_CLIENT.lock() {
|
||||
Ok(guard) => guard,
|
||||
Err(err) => {
|
||||
set_error_msg(&format!("failed to lock config server client: {}", err));
|
||||
return -1;
|
||||
}
|
||||
};
|
||||
|
||||
if guard.is_some() {
|
||||
set_error_msg("config server client already exists");
|
||||
return -1;
|
||||
}
|
||||
if CONFIG_SERVER_CLIENT_STOPPING.load(Ordering::Acquire) {
|
||||
set_error_msg("config server client is stopping");
|
||||
return -1;
|
||||
}
|
||||
clear_last_callback_error();
|
||||
|
||||
#[cfg(feature = "ffi-dataplane")]
|
||||
let data_plane_usage_guard = match crate::data_plane::lock_for_config_server_start() {
|
||||
Ok(guard) => guard,
|
||||
Err(err) => {
|
||||
set_error_msg(&err);
|
||||
return -1;
|
||||
}
|
||||
};
|
||||
|
||||
CONFIG_SERVER_CLIENT_ACTIVE.store(true, Ordering::Release);
|
||||
#[cfg(feature = "ffi-dataplane")]
|
||||
drop(data_plane_usage_guard);
|
||||
|
||||
let hooks = Arc::new(ManagedConfigServerClientHooks::new(callback, user_data));
|
||||
let client = match ffi_context().runtime.block_on(run_web_client(
|
||||
&config_server_url,
|
||||
config_server_machine_id_options(machine_id),
|
||||
hostname,
|
||||
secure_mode,
|
||||
ffi_context().manager.clone(),
|
||||
Some(hooks.clone()),
|
||||
)) {
|
||||
Ok(client) => client,
|
||||
Err(err) => {
|
||||
CONFIG_SERVER_CLIENT_ACTIVE.store(false, Ordering::Release);
|
||||
set_error_msg(&format!("failed to start config server client: {}", err));
|
||||
return -1;
|
||||
}
|
||||
};
|
||||
|
||||
*guard = Some(ManagedConfigServerClient { client, hooks });
|
||||
0
|
||||
}
|
||||
|
||||
pub(crate) fn stop_config_server_client() -> c_int {
|
||||
if in_config_server_callback() {
|
||||
set_error_msg("cannot stop config server client from config server callback");
|
||||
return -1;
|
||||
}
|
||||
|
||||
let guard = match CONFIG_SERVER_CLIENT.lock() {
|
||||
Ok(guard) => guard,
|
||||
Err(err) => {
|
||||
set_error_msg(&format!("failed to lock config server client: {}", err));
|
||||
return -1;
|
||||
}
|
||||
};
|
||||
|
||||
let Some(managed) = guard.as_ref() else {
|
||||
CONFIG_SERVER_CLIENT_ACTIVE.store(false, Ordering::Release);
|
||||
return 0;
|
||||
};
|
||||
if CONFIG_SERVER_CLIENT_STOPPING.swap(true, Ordering::AcqRel) {
|
||||
set_error_msg("config server client is stopping");
|
||||
return -1;
|
||||
}
|
||||
let hooks = managed.hooks.clone();
|
||||
// Keep the client discoverable until the canonical transaction drains its
|
||||
// tracking. Earlier removals must still retire IDs from these same hooks.
|
||||
drop(guard);
|
||||
|
||||
let delete_result = ffi_context().runtime.block_on(
|
||||
ffi_context()
|
||||
.process_management
|
||||
.delete_owned_network_instances_selected_by(|| hooks.start_stopping()),
|
||||
);
|
||||
let managed = match CONFIG_SERVER_CLIENT.lock() {
|
||||
Ok(mut guard) => guard.take(),
|
||||
Err(err) => {
|
||||
hooks.wait_for_callback_delivery();
|
||||
CONFIG_SERVER_CLIENT_ACTIVE.store(false, Ordering::Release);
|
||||
set_error_msg(&format!("failed to lock config server client: {err}"));
|
||||
return -1;
|
||||
}
|
||||
};
|
||||
drop(managed);
|
||||
hooks.wait_for_callback_delivery();
|
||||
CONFIG_SERVER_CLIENT_ACTIVE.store(false, Ordering::Release);
|
||||
CONFIG_SERVER_CLIENT_STOPPING.store(false, Ordering::Release);
|
||||
|
||||
if let Err(err) = delete_result {
|
||||
set_error_msg(&format!(
|
||||
"failed to delete config server instances: {}",
|
||||
err
|
||||
));
|
||||
return -1;
|
||||
}
|
||||
0
|
||||
}
|
||||
|
||||
pub(crate) fn is_config_server_client_connected() -> c_int {
|
||||
CONFIG_SERVER_CLIENT
|
||||
.lock()
|
||||
.ok()
|
||||
.and_then(|guard| guard.as_ref().map(|managed| managed.client.is_connected()))
|
||||
.map(i32::from)
|
||||
.unwrap_or(0)
|
||||
}
|
||||
@@ -0,0 +1,685 @@
|
||||
use std::{
|
||||
ffi::{c_char, c_int, c_uchar},
|
||||
net::{IpAddr, Ipv4Addr, SocketAddr},
|
||||
ptr,
|
||||
};
|
||||
|
||||
use easytier_core::gateway::DataPlaneErrorKind;
|
||||
|
||||
use super::session::{self, NativeDataPlaneError, NativeDataPlaneResult};
|
||||
use crate::{
|
||||
error::set_error_msg,
|
||||
strings::c_str_to_string,
|
||||
types::{DataPlaneCompletion, DataPlaneSocketAddr},
|
||||
};
|
||||
|
||||
pub const DATA_PLANE_DEADLINE_READ: u32 = 1 << 0;
|
||||
pub const DATA_PLANE_DEADLINE_WRITE: u32 = 1 << 1;
|
||||
|
||||
fn failure(error: NativeDataPlaneError) -> c_int {
|
||||
set_error_msg(&error.message);
|
||||
-(error.kind as c_int)
|
||||
}
|
||||
|
||||
fn status(result: NativeDataPlaneResult<()>) -> c_int {
|
||||
match result {
|
||||
Ok(()) => 0,
|
||||
Err(error) => failure(error),
|
||||
}
|
||||
}
|
||||
|
||||
fn invalid(message: impl Into<String>) -> NativeDataPlaneError {
|
||||
NativeDataPlaneError {
|
||||
kind: DataPlaneErrorKind::Io,
|
||||
message: message.into(),
|
||||
}
|
||||
}
|
||||
|
||||
fn socket_addr(address: DataPlaneSocketAddr) -> NativeDataPlaneResult<SocketAddr> {
|
||||
let ip = match address.family {
|
||||
4 => IpAddr::V4(Ipv4Addr::new(
|
||||
address.address[0],
|
||||
address.address[1],
|
||||
address.address[2],
|
||||
address.address[3],
|
||||
)),
|
||||
6 => {
|
||||
return Err(NativeDataPlaneError {
|
||||
kind: DataPlaneErrorKind::AddressFamilyUnsupported,
|
||||
message: "IPv6 is not supported by data-plane ABI v3".to_string(),
|
||||
});
|
||||
}
|
||||
family => {
|
||||
return Err(NativeDataPlaneError {
|
||||
kind: DataPlaneErrorKind::AddressFamilyUnsupported,
|
||||
message: format!("unsupported address family {family}"),
|
||||
});
|
||||
}
|
||||
};
|
||||
Ok(SocketAddr::new(ip, address.port))
|
||||
}
|
||||
|
||||
fn ffi_socket_addr(address: SocketAddr) -> DataPlaneSocketAddr {
|
||||
match address.ip() {
|
||||
IpAddr::V4(ip) => {
|
||||
let mut bytes = [0; 16];
|
||||
bytes[..4].copy_from_slice(&ip.octets());
|
||||
DataPlaneSocketAddr {
|
||||
family: 4,
|
||||
port: address.port(),
|
||||
address: bytes,
|
||||
}
|
||||
}
|
||||
IpAddr::V6(ip) => DataPlaneSocketAddr {
|
||||
family: 6,
|
||||
port: address.port(),
|
||||
address: ip.octets(),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
unsafe fn copy_input(ptr: *const c_uchar, len: u32) -> NativeDataPlaneResult<Vec<u8>> {
|
||||
if len == 0 {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
if ptr.is_null() {
|
||||
return Err(invalid("input buffer is null"));
|
||||
}
|
||||
Ok(unsafe { std::slice::from_raw_parts(ptr, len as usize) }.to_vec())
|
||||
}
|
||||
|
||||
unsafe fn output_slice<'a>(ptr: *mut c_uchar, len: u32) -> NativeDataPlaneResult<&'a mut [u8]> {
|
||||
if len == 0 {
|
||||
return Ok(&mut []);
|
||||
}
|
||||
if ptr.is_null() {
|
||||
return Err(invalid("output buffer is null"));
|
||||
}
|
||||
Ok(unsafe { std::slice::from_raw_parts_mut(ptr, len as usize) })
|
||||
}
|
||||
|
||||
fn write_operation(
|
||||
out_operation: *mut u64,
|
||||
submit: impl FnOnce() -> NativeDataPlaneResult<u64>,
|
||||
) -> c_int {
|
||||
if out_operation.is_null() {
|
||||
return failure(invalid("out_operation is null"));
|
||||
}
|
||||
match submit() {
|
||||
Ok(operation) => {
|
||||
unsafe {
|
||||
*out_operation = operation;
|
||||
}
|
||||
0
|
||||
}
|
||||
Err(error) => failure(error),
|
||||
}
|
||||
}
|
||||
|
||||
/// # Safety
|
||||
///
|
||||
/// If non-null, `inst_name` must point to a valid NUL-terminated string.
|
||||
/// `out_session` must be null or point to writable, properly aligned storage
|
||||
/// for one `u64`.
|
||||
#[cfg_attr(feature = "c-abi", unsafe(no_mangle))]
|
||||
pub unsafe extern "C" fn data_plane_session_open(
|
||||
inst_name: *const c_char,
|
||||
out_session: *mut u64,
|
||||
) -> c_int {
|
||||
if out_session.is_null() {
|
||||
return failure(invalid("out_session is null"));
|
||||
}
|
||||
unsafe {
|
||||
*out_session = 0;
|
||||
}
|
||||
let inst_name = match unsafe { c_str_to_string(inst_name, "inst_name") } {
|
||||
Ok(inst_name) => inst_name,
|
||||
Err(error) => return failure(invalid(error)),
|
||||
};
|
||||
match session::open(&inst_name) {
|
||||
Ok(handle) => {
|
||||
unsafe {
|
||||
*out_session = handle;
|
||||
}
|
||||
0
|
||||
}
|
||||
Err(error) => failure(error),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "c-abi", unsafe(no_mangle))]
|
||||
pub extern "C" fn data_plane_session_close(session: u64) -> c_int {
|
||||
status(super::session::close(session))
|
||||
}
|
||||
|
||||
/// # Safety
|
||||
///
|
||||
/// `out_operation` must be null or point to writable, properly aligned
|
||||
/// storage for one `u64`.
|
||||
#[cfg_attr(feature = "c-abi", unsafe(no_mangle))]
|
||||
pub unsafe extern "C" fn data_plane_tcp_connect_submit(
|
||||
session: u64,
|
||||
peer_addr: DataPlaneSocketAddr,
|
||||
timeout_ms: u64,
|
||||
out_operation: *mut u64,
|
||||
) -> c_int {
|
||||
let peer_addr = match socket_addr(peer_addr) {
|
||||
Ok(address) => address,
|
||||
Err(error) => return failure(error),
|
||||
};
|
||||
write_operation(out_operation, || {
|
||||
super::session::submit_tcp_connect(session, peer_addr, timeout_ms)
|
||||
})
|
||||
}
|
||||
|
||||
/// # Safety
|
||||
///
|
||||
/// `out_operation` must be null or point to writable, properly aligned
|
||||
/// storage for one `u64`.
|
||||
#[cfg_attr(feature = "c-abi", unsafe(no_mangle))]
|
||||
pub unsafe extern "C" fn data_plane_tcp_bind_submit(
|
||||
session: u64,
|
||||
local_port: u16,
|
||||
timeout_ms: u64,
|
||||
out_operation: *mut u64,
|
||||
) -> c_int {
|
||||
write_operation(out_operation, || {
|
||||
super::session::submit_tcp_bind(session, local_port, timeout_ms)
|
||||
})
|
||||
}
|
||||
|
||||
/// # Safety
|
||||
///
|
||||
/// `out_operation` must be null or point to writable, properly aligned
|
||||
/// storage for one `u64`.
|
||||
#[cfg_attr(feature = "c-abi", unsafe(no_mangle))]
|
||||
pub unsafe extern "C" fn data_plane_tcp_accept_submit(
|
||||
session: u64,
|
||||
listener: u64,
|
||||
timeout_ms: u64,
|
||||
out_operation: *mut u64,
|
||||
) -> c_int {
|
||||
write_operation(out_operation, || {
|
||||
super::session::submit_tcp_accept(session, listener, timeout_ms)
|
||||
})
|
||||
}
|
||||
|
||||
/// # Safety
|
||||
///
|
||||
/// `out_operation` must be null or point to writable, properly aligned
|
||||
/// storage for one `u64`.
|
||||
#[cfg_attr(feature = "c-abi", unsafe(no_mangle))]
|
||||
pub unsafe extern "C" fn data_plane_tcp_read_submit(
|
||||
session: u64,
|
||||
stream: u64,
|
||||
max_len: u32,
|
||||
out_operation: *mut u64,
|
||||
) -> c_int {
|
||||
write_operation(out_operation, || {
|
||||
super::session::submit_tcp_read(session, stream, max_len)
|
||||
})
|
||||
}
|
||||
|
||||
/// # Safety
|
||||
///
|
||||
/// When `len` is nonzero, `data` must point to `len` readable bytes.
|
||||
/// `out_operation` must be null or point to writable, properly aligned
|
||||
/// storage for one `u64`.
|
||||
#[cfg_attr(feature = "c-abi", unsafe(no_mangle))]
|
||||
pub unsafe extern "C" fn data_plane_tcp_write_submit(
|
||||
session: u64,
|
||||
stream: u64,
|
||||
data: *const c_uchar,
|
||||
len: u32,
|
||||
out_operation: *mut u64,
|
||||
) -> c_int {
|
||||
let data = match unsafe { copy_input(data, len) } {
|
||||
Ok(data) => data,
|
||||
Err(error) => return failure(error),
|
||||
};
|
||||
write_operation(out_operation, || {
|
||||
super::session::submit_tcp_write(session, stream, data)
|
||||
})
|
||||
}
|
||||
|
||||
/// # Safety
|
||||
///
|
||||
/// `out_operation` must be null or point to writable, properly aligned
|
||||
/// storage for one `u64`.
|
||||
#[cfg_attr(feature = "c-abi", unsafe(no_mangle))]
|
||||
pub unsafe extern "C" fn data_plane_udp_bind_submit(
|
||||
session: u64,
|
||||
local_port: u16,
|
||||
timeout_ms: u64,
|
||||
out_operation: *mut u64,
|
||||
) -> c_int {
|
||||
write_operation(out_operation, || {
|
||||
super::session::submit_udp_bind(session, local_port, timeout_ms)
|
||||
})
|
||||
}
|
||||
|
||||
/// # Safety
|
||||
///
|
||||
/// `out_operation` must be null or point to writable, properly aligned
|
||||
/// storage for one `u64`.
|
||||
#[cfg_attr(feature = "c-abi", unsafe(no_mangle))]
|
||||
pub unsafe extern "C" fn data_plane_udp_receive_submit(
|
||||
session: u64,
|
||||
socket: u64,
|
||||
max_len: u32,
|
||||
out_operation: *mut u64,
|
||||
) -> c_int {
|
||||
write_operation(out_operation, || {
|
||||
super::session::submit_udp_receive(session, socket, max_len)
|
||||
})
|
||||
}
|
||||
|
||||
/// # Safety
|
||||
///
|
||||
/// When `len` is nonzero, `data` must point to `len` readable bytes.
|
||||
/// `out_operation` must be null or point to writable, properly aligned
|
||||
/// storage for one `u64`.
|
||||
#[cfg_attr(feature = "c-abi", unsafe(no_mangle))]
|
||||
pub unsafe extern "C" fn data_plane_udp_send_submit(
|
||||
session: u64,
|
||||
socket: u64,
|
||||
peer_addr: DataPlaneSocketAddr,
|
||||
data: *const c_uchar,
|
||||
len: u32,
|
||||
out_operation: *mut u64,
|
||||
) -> c_int {
|
||||
let peer_addr = match socket_addr(peer_addr) {
|
||||
Ok(address) => address,
|
||||
Err(error) => return failure(error),
|
||||
};
|
||||
let data = match unsafe { copy_input(data, len) } {
|
||||
Ok(data) => data,
|
||||
Err(error) => return failure(error),
|
||||
};
|
||||
write_operation(out_operation, || {
|
||||
super::session::submit_udp_send(session, socket, peer_addr, data)
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "c-abi", unsafe(no_mangle))]
|
||||
pub extern "C" fn data_plane_resource_deadline_set(
|
||||
session: u64,
|
||||
resource: u64,
|
||||
direction: u32,
|
||||
timeout_ms: u64,
|
||||
) -> c_int {
|
||||
let read = direction & DATA_PLANE_DEADLINE_READ != 0;
|
||||
let write = direction & DATA_PLANE_DEADLINE_WRITE != 0;
|
||||
if direction == 0 || direction & !(DATA_PLANE_DEADLINE_READ | DATA_PLANE_DEADLINE_WRITE) != 0 {
|
||||
return failure(invalid(format!("invalid deadline direction {direction}")));
|
||||
}
|
||||
status(super::session::set_resource_deadline(
|
||||
session, resource, read, write, timeout_ms,
|
||||
))
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "c-abi", unsafe(no_mangle))]
|
||||
pub extern "C" fn data_plane_operation_cancel(session: u64, operation: u64) -> c_int {
|
||||
status(super::session::cancel_operation(session, operation))
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "c-abi", unsafe(no_mangle))]
|
||||
pub extern "C" fn data_plane_operation_free(session: u64, operation: u64) -> c_int {
|
||||
status(super::session::free_operation(session, operation))
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "c-abi", unsafe(no_mangle))]
|
||||
pub extern "C" fn data_plane_resource_close(session: u64, resource: u64) -> c_int {
|
||||
status(super::session::close_resource(session, resource))
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "c-abi", unsafe(no_mangle))]
|
||||
pub extern "C" fn data_plane_completion_wait(session: u64, timeout_ms: u64) -> c_int {
|
||||
match super::session::completion_wait(session, timeout_ms) {
|
||||
Ok(true) => 1,
|
||||
Ok(false) => 0,
|
||||
Err(error) => failure(error),
|
||||
}
|
||||
}
|
||||
|
||||
/// # Safety
|
||||
///
|
||||
/// When `capacity` is nonzero, `completions` must point to writable, properly
|
||||
/// aligned storage for `capacity` consecutive [`DataPlaneCompletion`] values.
|
||||
#[cfg_attr(feature = "c-abi", unsafe(no_mangle))]
|
||||
pub unsafe extern "C" fn data_plane_completion_drain(
|
||||
session: u64,
|
||||
completions: *mut DataPlaneCompletion,
|
||||
capacity: u32,
|
||||
) -> c_int {
|
||||
if capacity != 0 && completions.is_null() {
|
||||
return failure(invalid("completions is null"));
|
||||
}
|
||||
let drained = match super::session::drain_completions(session, capacity as usize) {
|
||||
Ok(drained) => drained,
|
||||
Err(error) => return failure(error),
|
||||
};
|
||||
for (index, completion) in drained.iter().enumerate() {
|
||||
unsafe {
|
||||
ptr::write(
|
||||
completions.add(index),
|
||||
DataPlaneCompletion {
|
||||
operation_id: completion.operation_id.get(),
|
||||
operation_kind: completion.kind as u16,
|
||||
status: completion.status.code(),
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
drained.len() as c_int
|
||||
}
|
||||
|
||||
/// # Safety
|
||||
///
|
||||
/// `out_size` must be null or point to writable, properly aligned storage for
|
||||
/// one `u32`.
|
||||
#[cfg_attr(feature = "c-abi", unsafe(no_mangle))]
|
||||
pub unsafe extern "C" fn data_plane_result_size(
|
||||
session: u64,
|
||||
operation: u64,
|
||||
out_size: *mut u32,
|
||||
) -> c_int {
|
||||
if out_size.is_null() {
|
||||
return failure(invalid("out_size is null"));
|
||||
}
|
||||
match super::session::result_size(session, operation) {
|
||||
Ok(size) => match u32::try_from(size) {
|
||||
Ok(size) => {
|
||||
unsafe {
|
||||
*out_size = size;
|
||||
}
|
||||
0
|
||||
}
|
||||
Err(_) => failure(invalid("data-plane result size exceeds u32")),
|
||||
},
|
||||
Err(error) => failure(error),
|
||||
}
|
||||
}
|
||||
|
||||
/// # Safety
|
||||
///
|
||||
/// Each output pointer must be null or point to writable, properly aligned
|
||||
/// storage for its pointee type. Non-null output locations must not overlap.
|
||||
#[cfg_attr(feature = "c-abi", unsafe(no_mangle))]
|
||||
pub unsafe extern "C" fn data_plane_tcp_connect_result_take(
|
||||
session: u64,
|
||||
operation: u64,
|
||||
out_stream: *mut u64,
|
||||
out_local_addr: *mut DataPlaneSocketAddr,
|
||||
out_peer_addr: *mut DataPlaneSocketAddr,
|
||||
) -> c_int {
|
||||
if out_stream.is_null() || out_local_addr.is_null() || out_peer_addr.is_null() {
|
||||
return failure(invalid("TCP connect result output pointer is null"));
|
||||
}
|
||||
match super::session::take_tcp_connect(session, operation) {
|
||||
Ok(result) => {
|
||||
unsafe {
|
||||
*out_stream = result.stream;
|
||||
*out_local_addr = ffi_socket_addr(result.local_addr);
|
||||
*out_peer_addr = ffi_socket_addr(result.peer_addr);
|
||||
}
|
||||
0
|
||||
}
|
||||
Err(error) => failure(error),
|
||||
}
|
||||
}
|
||||
|
||||
/// # Safety
|
||||
///
|
||||
/// Each output pointer must be null or point to writable, properly aligned
|
||||
/// storage for its pointee type. Non-null output locations must not overlap.
|
||||
#[cfg_attr(feature = "c-abi", unsafe(no_mangle))]
|
||||
pub unsafe extern "C" fn data_plane_tcp_bind_result_take(
|
||||
session: u64,
|
||||
operation: u64,
|
||||
out_listener: *mut u64,
|
||||
out_local_addr: *mut DataPlaneSocketAddr,
|
||||
) -> c_int {
|
||||
if out_listener.is_null() || out_local_addr.is_null() {
|
||||
return failure(invalid("TCP bind result output pointer is null"));
|
||||
}
|
||||
match super::session::take_tcp_bind(session, operation) {
|
||||
Ok(result) => {
|
||||
unsafe {
|
||||
*out_listener = result.listener;
|
||||
*out_local_addr = ffi_socket_addr(result.local_addr);
|
||||
}
|
||||
0
|
||||
}
|
||||
Err(error) => failure(error),
|
||||
}
|
||||
}
|
||||
|
||||
/// # Safety
|
||||
///
|
||||
/// Each output pointer must be null or point to writable, properly aligned
|
||||
/// storage for its pointee type. Non-null output locations must not overlap.
|
||||
#[cfg_attr(feature = "c-abi", unsafe(no_mangle))]
|
||||
pub unsafe extern "C" fn data_plane_tcp_accept_result_take(
|
||||
session: u64,
|
||||
operation: u64,
|
||||
out_stream: *mut u64,
|
||||
out_local_addr: *mut DataPlaneSocketAddr,
|
||||
out_peer_addr: *mut DataPlaneSocketAddr,
|
||||
) -> c_int {
|
||||
if out_stream.is_null() || out_local_addr.is_null() || out_peer_addr.is_null() {
|
||||
return failure(invalid("TCP accept result output pointer is null"));
|
||||
}
|
||||
match super::session::take_tcp_accept(session, operation) {
|
||||
Ok(result) => {
|
||||
unsafe {
|
||||
*out_stream = result.stream;
|
||||
*out_local_addr = ffi_socket_addr(result.local_addr);
|
||||
*out_peer_addr = ffi_socket_addr(result.peer_addr);
|
||||
}
|
||||
0
|
||||
}
|
||||
Err(error) => failure(error),
|
||||
}
|
||||
}
|
||||
|
||||
/// # Safety
|
||||
///
|
||||
/// When `capacity` is nonzero, `data` must point to `capacity` writable bytes.
|
||||
/// Each scalar output pointer must be null or point to writable, properly
|
||||
/// aligned storage for its pointee type. Non-null output ranges must not
|
||||
/// overlap.
|
||||
#[cfg_attr(feature = "c-abi", unsafe(no_mangle))]
|
||||
pub unsafe extern "C" fn data_plane_tcp_read_result_take(
|
||||
session: u64,
|
||||
operation: u64,
|
||||
data: *mut c_uchar,
|
||||
capacity: u32,
|
||||
out_len: *mut u32,
|
||||
out_eof: *mut bool,
|
||||
) -> c_int {
|
||||
if out_len.is_null() || out_eof.is_null() {
|
||||
return failure(invalid("TCP read result output pointer is null"));
|
||||
}
|
||||
let data = match unsafe { output_slice(data, capacity) } {
|
||||
Ok(data) => data,
|
||||
Err(error) => return failure(error),
|
||||
};
|
||||
match super::session::take_tcp_read(session, operation, data) {
|
||||
Ok(result) => {
|
||||
unsafe {
|
||||
*out_len = result.len as u32;
|
||||
*out_eof = result.eof;
|
||||
}
|
||||
0
|
||||
}
|
||||
Err(error) => failure(error),
|
||||
}
|
||||
}
|
||||
|
||||
/// # Safety
|
||||
///
|
||||
/// `out_len` must be null or point to writable, properly aligned storage for
|
||||
/// one `u32`.
|
||||
#[cfg_attr(feature = "c-abi", unsafe(no_mangle))]
|
||||
pub unsafe extern "C" fn data_plane_tcp_write_result_take(
|
||||
session: u64,
|
||||
operation: u64,
|
||||
out_len: *mut u32,
|
||||
) -> c_int {
|
||||
if out_len.is_null() {
|
||||
return failure(invalid("out_len is null"));
|
||||
}
|
||||
match super::session::take_tcp_write(session, operation) {
|
||||
Ok(len) => match u32::try_from(len) {
|
||||
Ok(len) => {
|
||||
unsafe {
|
||||
*out_len = len;
|
||||
}
|
||||
0
|
||||
}
|
||||
Err(_) => failure(invalid("TCP write result exceeds u32")),
|
||||
},
|
||||
Err(error) => failure(error),
|
||||
}
|
||||
}
|
||||
|
||||
/// # Safety
|
||||
///
|
||||
/// Each output pointer must be null or point to writable, properly aligned
|
||||
/// storage for its pointee type. Non-null output locations must not overlap.
|
||||
#[cfg_attr(feature = "c-abi", unsafe(no_mangle))]
|
||||
pub unsafe extern "C" fn data_plane_udp_bind_result_take(
|
||||
session: u64,
|
||||
operation: u64,
|
||||
out_socket: *mut u64,
|
||||
out_local_addr: *mut DataPlaneSocketAddr,
|
||||
) -> c_int {
|
||||
if out_socket.is_null() || out_local_addr.is_null() {
|
||||
return failure(invalid("UDP bind result output pointer is null"));
|
||||
}
|
||||
match super::session::take_udp_bind(session, operation) {
|
||||
Ok(result) => {
|
||||
unsafe {
|
||||
*out_socket = result.socket;
|
||||
*out_local_addr = ffi_socket_addr(result.local_addr);
|
||||
}
|
||||
0
|
||||
}
|
||||
Err(error) => failure(error),
|
||||
}
|
||||
}
|
||||
|
||||
/// # Safety
|
||||
///
|
||||
/// When `capacity` is nonzero, `data` must point to `capacity` writable bytes.
|
||||
/// Each scalar output pointer must be null or point to writable, properly
|
||||
/// aligned storage for its pointee type. Non-null output ranges must not
|
||||
/// overlap.
|
||||
#[cfg_attr(feature = "c-abi", unsafe(no_mangle))]
|
||||
pub unsafe extern "C" fn data_plane_udp_receive_result_take(
|
||||
session: u64,
|
||||
operation: u64,
|
||||
data: *mut c_uchar,
|
||||
capacity: u32,
|
||||
out_len: *mut u32,
|
||||
out_peer_addr: *mut DataPlaneSocketAddr,
|
||||
out_truncated: *mut bool,
|
||||
) -> c_int {
|
||||
if out_len.is_null() || out_peer_addr.is_null() || out_truncated.is_null() {
|
||||
return failure(invalid("UDP receive result output pointer is null"));
|
||||
}
|
||||
let data = match unsafe { output_slice(data, capacity) } {
|
||||
Ok(data) => data,
|
||||
Err(error) => return failure(error),
|
||||
};
|
||||
match super::session::take_udp_receive(session, operation, data) {
|
||||
Ok(result) => {
|
||||
unsafe {
|
||||
*out_len = result.len as u32;
|
||||
*out_peer_addr = ffi_socket_addr(result.peer_addr);
|
||||
*out_truncated = result.truncated;
|
||||
}
|
||||
0
|
||||
}
|
||||
Err(error) => failure(error),
|
||||
}
|
||||
}
|
||||
|
||||
/// # Safety
|
||||
///
|
||||
/// `out_len` must be null or point to writable, properly aligned storage for
|
||||
/// one `u32`.
|
||||
#[cfg_attr(feature = "c-abi", unsafe(no_mangle))]
|
||||
pub unsafe extern "C" fn data_plane_udp_send_result_take(
|
||||
session: u64,
|
||||
operation: u64,
|
||||
out_len: *mut u32,
|
||||
) -> c_int {
|
||||
if out_len.is_null() {
|
||||
return failure(invalid("out_len is null"));
|
||||
}
|
||||
match super::session::take_udp_send(session, operation) {
|
||||
Ok(len) => match u32::try_from(len) {
|
||||
Ok(len) => {
|
||||
unsafe {
|
||||
*out_len = len;
|
||||
}
|
||||
0
|
||||
}
|
||||
Err(_) => failure(invalid("UDP send result exceeds u32")),
|
||||
},
|
||||
Err(error) => failure(error),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn socket_address_round_trip() {
|
||||
let address = "127.0.0.1:1234".parse::<SocketAddr>().unwrap();
|
||||
assert_eq!(socket_addr(ffi_socket_addr(address)).unwrap(), address);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ipv6_is_rejected_by_v3() {
|
||||
let error = socket_addr(ffi_socket_addr(
|
||||
"[2001:db8::1]:4321".parse::<SocketAddr>().unwrap(),
|
||||
))
|
||||
.unwrap_err();
|
||||
assert_eq!(error.kind, DataPlaneErrorKind::AddressFamilyUnsupported);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_address_family_is_stable() {
|
||||
let error = socket_addr(DataPlaneSocketAddr {
|
||||
family: 9,
|
||||
..Default::default()
|
||||
})
|
||||
.unwrap_err();
|
||||
assert_eq!(error.kind, DataPlaneErrorKind::AddressFamilyUnsupported);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_deadline_direction_is_rejected_before_session_lookup() {
|
||||
let invalid = -(DataPlaneErrorKind::Io as c_int);
|
||||
assert_eq!(data_plane_resource_deadline_set(u64::MAX, 1, 0, 0), invalid);
|
||||
assert_eq!(data_plane_resource_deadline_set(u64::MAX, 1, 4, 0), invalid);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn null_operation_output_does_not_submit() {
|
||||
let submitted = std::cell::Cell::new(false);
|
||||
|
||||
assert_eq!(
|
||||
write_operation(std::ptr::null_mut(), || {
|
||||
submitted.set(true);
|
||||
Ok(1)
|
||||
}),
|
||||
-(DataPlaneErrorKind::Io as c_int)
|
||||
);
|
||||
assert!(!submitted.get());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
//! Native C ABI adapter for the instance-scoped data-plane operation broker.
|
||||
|
||||
#[cfg(feature = "ffi-dataplane")]
|
||||
mod abi;
|
||||
#[cfg(feature = "ffi-dataplane")]
|
||||
mod session;
|
||||
|
||||
#[cfg(feature = "ffi-dataplane")]
|
||||
pub use abi::*;
|
||||
#[cfg(feature = "ffi-dataplane")]
|
||||
pub(crate) use session::{
|
||||
lock_for_config_server_start, remove_data_plane_sessions_by_instance_ids,
|
||||
};
|
||||
|
||||
#[cfg(not(feature = "ffi-dataplane"))]
|
||||
pub(crate) fn remove_data_plane_sessions_by_instance_ids(_ids: &[uuid::Uuid]) {}
|
||||
@@ -0,0 +1,646 @@
|
||||
use std::{
|
||||
collections::HashMap,
|
||||
net::SocketAddr,
|
||||
sync::{
|
||||
Arc, Mutex, RwLock,
|
||||
atomic::{AtomicBool, AtomicU64, Ordering},
|
||||
},
|
||||
time::Duration,
|
||||
};
|
||||
|
||||
use easytier::instance::host::NativeInstanceHost;
|
||||
use easytier_core::gateway::{
|
||||
DataPlaneCompletionDescriptor, DataPlaneError, DataPlaneErrorKind, DataPlaneOperationId,
|
||||
DataPlaneOperationKind, DataPlaneOperationResult, DataPlaneResourceId, DataPlaneSession,
|
||||
};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::{
|
||||
config_server::{in_config_server_callback, is_config_server_active_or_stopping},
|
||||
state::{ffi_context, resolve_instance_id_by_name},
|
||||
};
|
||||
|
||||
type CoreDataPlaneSession = DataPlaneSession<NativeInstanceHost>;
|
||||
|
||||
static NEXT_SESSION_HANDLE: AtomicU64 = AtomicU64::new(1);
|
||||
static SESSIONS: once_cell::sync::Lazy<Mutex<HashMap<u64, Arc<NativeDataPlaneSession>>>> =
|
||||
once_cell::sync::Lazy::new(|| Mutex::new(HashMap::new()));
|
||||
static DATA_PLANE_USAGE_LOCK: once_cell::sync::Lazy<RwLock<()>> =
|
||||
once_cell::sync::Lazy::new(|| RwLock::new(()));
|
||||
|
||||
#[derive(Debug)]
|
||||
pub(super) struct NativeDataPlaneError {
|
||||
pub(super) kind: DataPlaneErrorKind,
|
||||
pub(super) message: String,
|
||||
}
|
||||
|
||||
impl NativeDataPlaneError {
|
||||
fn new(kind: DataPlaneErrorKind, message: impl Into<String>) -> Self {
|
||||
Self {
|
||||
kind,
|
||||
message: message.into(),
|
||||
}
|
||||
}
|
||||
|
||||
fn invalid(message: impl Into<String>) -> Self {
|
||||
Self::new(DataPlaneErrorKind::Io, message)
|
||||
}
|
||||
|
||||
fn closed(message: impl Into<String>) -> Self {
|
||||
Self::new(DataPlaneErrorKind::HandleClosed, message)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<DataPlaneError> for NativeDataPlaneError {
|
||||
fn from(error: DataPlaneError) -> Self {
|
||||
Self::new(error.kind(), error.message())
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) type NativeDataPlaneResult<T> = Result<T, NativeDataPlaneError>;
|
||||
|
||||
pub(super) struct TcpConnectResult {
|
||||
pub(super) stream: u64,
|
||||
pub(super) local_addr: SocketAddr,
|
||||
pub(super) peer_addr: SocketAddr,
|
||||
}
|
||||
|
||||
pub(super) struct TcpBindResult {
|
||||
pub(super) listener: u64,
|
||||
pub(super) local_addr: SocketAddr,
|
||||
}
|
||||
|
||||
pub(super) struct TcpAcceptResult {
|
||||
pub(super) stream: u64,
|
||||
pub(super) local_addr: SocketAddr,
|
||||
pub(super) peer_addr: SocketAddr,
|
||||
}
|
||||
|
||||
pub(super) struct TcpReadResult {
|
||||
pub(super) len: usize,
|
||||
pub(super) eof: bool,
|
||||
}
|
||||
|
||||
pub(super) struct UdpBindResult {
|
||||
pub(super) socket: u64,
|
||||
pub(super) local_addr: SocketAddr,
|
||||
}
|
||||
|
||||
pub(super) struct UdpReceiveResult {
|
||||
pub(super) len: usize,
|
||||
pub(super) peer_addr: SocketAddr,
|
||||
pub(super) truncated: bool,
|
||||
}
|
||||
|
||||
struct NativeDataPlaneSession {
|
||||
instance_id: Uuid,
|
||||
runtime: tokio::runtime::Handle,
|
||||
core: Arc<CoreDataPlaneSession>,
|
||||
submit_gate: Mutex<()>,
|
||||
closed: AtomicBool,
|
||||
}
|
||||
|
||||
impl NativeDataPlaneSession {
|
||||
fn close(&self) {
|
||||
let _gate = self
|
||||
.submit_gate
|
||||
.lock()
|
||||
.unwrap_or_else(|error| error.into_inner());
|
||||
if self.closed.swap(true, Ordering::AcqRel) {
|
||||
return;
|
||||
}
|
||||
self.core.discard_all();
|
||||
}
|
||||
|
||||
fn call<T>(
|
||||
&self,
|
||||
call: impl FnOnce(&Arc<CoreDataPlaneSession>) -> Result<T, DataPlaneError>,
|
||||
) -> NativeDataPlaneResult<T> {
|
||||
let _gate = self
|
||||
.submit_gate
|
||||
.lock()
|
||||
.map_err(|error| NativeDataPlaneError::invalid(error.to_string()))?;
|
||||
if self.closed.load(Ordering::Acquire) {
|
||||
return Err(NativeDataPlaneError::closed(
|
||||
"native data-plane session is closed",
|
||||
));
|
||||
}
|
||||
let _runtime = self.runtime.enter();
|
||||
call(&self.core).map_err(Into::into)
|
||||
}
|
||||
|
||||
fn submit(
|
||||
&self,
|
||||
submit: impl FnOnce(&Arc<CoreDataPlaneSession>) -> Result<DataPlaneOperationId, DataPlaneError>,
|
||||
) -> NativeDataPlaneResult<u64> {
|
||||
self.call(submit).map(DataPlaneOperationId::get)
|
||||
}
|
||||
}
|
||||
|
||||
fn sessions()
|
||||
-> NativeDataPlaneResult<std::sync::MutexGuard<'static, HashMap<u64, Arc<NativeDataPlaneSession>>>>
|
||||
{
|
||||
SESSIONS
|
||||
.lock()
|
||||
.map_err(|error| NativeDataPlaneError::invalid(error.to_string()))
|
||||
}
|
||||
|
||||
fn get_session(handle: u64) -> NativeDataPlaneResult<Arc<NativeDataPlaneSession>> {
|
||||
if handle == 0 {
|
||||
return Err(NativeDataPlaneError::closed(
|
||||
"native data-plane session handle is invalid",
|
||||
));
|
||||
}
|
||||
let session = sessions()?
|
||||
.get(&handle)
|
||||
.cloned()
|
||||
.ok_or_else(|| NativeDataPlaneError::closed("native data-plane session is closed"))?;
|
||||
if session.closed.load(Ordering::Acquire) {
|
||||
return Err(NativeDataPlaneError::closed(
|
||||
"native data-plane session is closed",
|
||||
));
|
||||
}
|
||||
Ok(session)
|
||||
}
|
||||
|
||||
fn next_session_handle(
|
||||
sessions: &HashMap<u64, Arc<NativeDataPlaneSession>>,
|
||||
) -> NativeDataPlaneResult<u64> {
|
||||
for _ in 0..sessions.len().saturating_add(2) {
|
||||
let handle = NEXT_SESSION_HANDLE.fetch_add(1, Ordering::Relaxed);
|
||||
if handle != 0 && !sessions.contains_key(&handle) {
|
||||
return Ok(handle);
|
||||
}
|
||||
}
|
||||
Err(NativeDataPlaneError::new(
|
||||
DataPlaneErrorKind::ResourceLimit,
|
||||
"native data-plane session handle space is exhausted",
|
||||
))
|
||||
}
|
||||
|
||||
fn reject_data_plane_use() -> NativeDataPlaneResult<()> {
|
||||
if in_config_server_callback() {
|
||||
Err(NativeDataPlaneError::invalid(
|
||||
"cannot use data plane from config server callback",
|
||||
))
|
||||
} else if is_config_server_active_or_stopping() {
|
||||
Err(NativeDataPlaneError::invalid(
|
||||
"cannot use data plane while config server client is active",
|
||||
))
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn open(inst_name: &str) -> NativeDataPlaneResult<u64> {
|
||||
reject_data_plane_use()?;
|
||||
let _usage = DATA_PLANE_USAGE_LOCK
|
||||
.read()
|
||||
.map_err(|error| NativeDataPlaneError::invalid(error.to_string()))?;
|
||||
reject_data_plane_use()?;
|
||||
|
||||
let instance_id = resolve_instance_id_by_name(inst_name)
|
||||
.map_err(NativeDataPlaneError::invalid)?
|
||||
.ok_or_else(|| NativeDataPlaneError::closed("instance not found"))?;
|
||||
let manager = &ffi_context().manager;
|
||||
let core = manager.data_plane_session(&instance_id).ok_or_else(|| {
|
||||
NativeDataPlaneError::closed("instance data-plane session is unavailable")
|
||||
})?;
|
||||
let runtime = manager
|
||||
.data_plane_runtime_handle(&instance_id)
|
||||
.ok_or_else(|| NativeDataPlaneError::closed("instance runtime is unavailable"))?;
|
||||
|
||||
let mut sessions = sessions()?;
|
||||
if sessions
|
||||
.values()
|
||||
.any(|session| session.instance_id == instance_id)
|
||||
{
|
||||
return Err(NativeDataPlaneError::new(
|
||||
DataPlaneErrorKind::ResourceLimit,
|
||||
"instance already has an open native data-plane session",
|
||||
));
|
||||
}
|
||||
let handle = next_session_handle(&sessions)?;
|
||||
sessions.insert(
|
||||
handle,
|
||||
Arc::new(NativeDataPlaneSession {
|
||||
instance_id,
|
||||
runtime,
|
||||
core,
|
||||
submit_gate: Mutex::new(()),
|
||||
closed: AtomicBool::new(false),
|
||||
}),
|
||||
);
|
||||
Ok(handle)
|
||||
}
|
||||
|
||||
pub(super) fn close(handle: u64) -> NativeDataPlaneResult<()> {
|
||||
let _usage = DATA_PLANE_USAGE_LOCK
|
||||
.read()
|
||||
.map_err(|error| NativeDataPlaneError::invalid(error.to_string()))?;
|
||||
let mut sessions = sessions()?;
|
||||
let session = sessions
|
||||
.remove(&handle)
|
||||
.ok_or_else(|| NativeDataPlaneError::closed("native data-plane session is closed"))?;
|
||||
// Keep the registry locked until the shared core namespace is empty. An
|
||||
// open for the same instance must not publish a replacement session before
|
||||
// this old wrapper finishes discarding its operations and resources.
|
||||
session.close();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn timeout(timeout_ms: u64) -> Option<Duration> {
|
||||
(timeout_ms != u64::MAX).then(|| Duration::from_millis(timeout_ms))
|
||||
}
|
||||
|
||||
fn operation_id(raw: u64) -> NativeDataPlaneResult<DataPlaneOperationId> {
|
||||
DataPlaneOperationId::from_raw(raw)
|
||||
.ok_or_else(|| NativeDataPlaneError::closed("data-plane operation handle is invalid"))
|
||||
}
|
||||
|
||||
fn resource_id(raw: u64) -> NativeDataPlaneResult<DataPlaneResourceId> {
|
||||
DataPlaneResourceId::from_raw(raw)
|
||||
.ok_or_else(|| NativeDataPlaneError::closed("data-plane resource handle is invalid"))
|
||||
}
|
||||
|
||||
pub(super) fn submit_tcp_connect(
|
||||
session: u64,
|
||||
peer_addr: SocketAddr,
|
||||
timeout_ms: u64,
|
||||
) -> NativeDataPlaneResult<u64> {
|
||||
get_session(session)?.submit(|core| core.submit_tcp_connect(peer_addr, timeout(timeout_ms)))
|
||||
}
|
||||
|
||||
pub(super) fn submit_tcp_bind(
|
||||
session: u64,
|
||||
local_port: u16,
|
||||
timeout_ms: u64,
|
||||
) -> NativeDataPlaneResult<u64> {
|
||||
get_session(session)?.submit(|core| core.submit_tcp_bind(local_port, timeout(timeout_ms)))
|
||||
}
|
||||
|
||||
pub(super) fn submit_tcp_accept(
|
||||
session: u64,
|
||||
listener: u64,
|
||||
timeout_ms: u64,
|
||||
) -> NativeDataPlaneResult<u64> {
|
||||
let listener = resource_id(listener)?;
|
||||
get_session(session)?.submit(|core| core.submit_tcp_accept(listener, timeout(timeout_ms)))
|
||||
}
|
||||
|
||||
pub(super) fn submit_tcp_read(
|
||||
session: u64,
|
||||
stream: u64,
|
||||
max_len: u32,
|
||||
) -> NativeDataPlaneResult<u64> {
|
||||
let stream = resource_id(stream)?;
|
||||
get_session(session)?.submit(|core| core.submit_tcp_read(stream, max_len as usize))
|
||||
}
|
||||
|
||||
pub(super) fn submit_tcp_write(
|
||||
session: u64,
|
||||
stream: u64,
|
||||
data: Vec<u8>,
|
||||
) -> NativeDataPlaneResult<u64> {
|
||||
let stream = resource_id(stream)?;
|
||||
get_session(session)?.submit(|core| core.submit_tcp_write(stream, data))
|
||||
}
|
||||
|
||||
pub(super) fn submit_udp_bind(
|
||||
session: u64,
|
||||
local_port: u16,
|
||||
timeout_ms: u64,
|
||||
) -> NativeDataPlaneResult<u64> {
|
||||
get_session(session)?.submit(|core| core.submit_udp_bind(local_port, timeout(timeout_ms)))
|
||||
}
|
||||
|
||||
pub(super) fn submit_udp_receive(
|
||||
session: u64,
|
||||
socket: u64,
|
||||
max_len: u32,
|
||||
) -> NativeDataPlaneResult<u64> {
|
||||
let socket = resource_id(socket)?;
|
||||
get_session(session)?.submit(|core| core.submit_udp_receive(socket, max_len as usize))
|
||||
}
|
||||
|
||||
pub(super) fn submit_udp_send(
|
||||
session: u64,
|
||||
socket: u64,
|
||||
peer_addr: SocketAddr,
|
||||
data: Vec<u8>,
|
||||
) -> NativeDataPlaneResult<u64> {
|
||||
let socket = resource_id(socket)?;
|
||||
get_session(session)?.submit(|core| core.submit_udp_send(socket, peer_addr, data))
|
||||
}
|
||||
|
||||
pub(super) fn set_resource_deadline(
|
||||
session: u64,
|
||||
resource: u64,
|
||||
read: bool,
|
||||
write: bool,
|
||||
timeout_ms: u64,
|
||||
) -> NativeDataPlaneResult<()> {
|
||||
let resource = resource_id(resource)?;
|
||||
get_session(session)?
|
||||
.call(|core| core.set_resource_deadline(resource, read, write, timeout(timeout_ms)))
|
||||
}
|
||||
|
||||
pub(super) fn cancel_operation(session: u64, operation: u64) -> NativeDataPlaneResult<()> {
|
||||
let operation = operation_id(operation)?;
|
||||
get_session(session)?.core.cancel_operation(operation);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(super) fn free_operation(session: u64, operation: u64) -> NativeDataPlaneResult<()> {
|
||||
let operation = operation_id(operation)?;
|
||||
get_session(session)?.core.free_operation(operation);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(super) fn close_resource(session: u64, resource: u64) -> NativeDataPlaneResult<()> {
|
||||
let resource = resource_id(resource)?;
|
||||
get_session(session)?.core.close_resource(resource);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(super) fn completion_wait(session: u64, timeout_ms: u64) -> NativeDataPlaneResult<bool> {
|
||||
let session = get_session(session)?;
|
||||
let ready = session.core.completion_wait(timeout(timeout_ms));
|
||||
Ok(ready && !session.closed.load(Ordering::Acquire))
|
||||
}
|
||||
|
||||
pub(super) fn drain_completions(
|
||||
session: u64,
|
||||
max_count: usize,
|
||||
) -> NativeDataPlaneResult<Vec<DataPlaneCompletionDescriptor>> {
|
||||
Ok(get_session(session)?.core.drain_completions(max_count))
|
||||
}
|
||||
|
||||
pub(super) fn result_size(session: u64, operation: u64) -> NativeDataPlaneResult<usize> {
|
||||
let operation = operation_id(operation)?;
|
||||
get_session(session)?
|
||||
.core
|
||||
.result_payload_bytes(operation)
|
||||
.map_err(Into::into)
|
||||
}
|
||||
|
||||
fn take_result<T>(
|
||||
session: u64,
|
||||
operation: u64,
|
||||
expected: DataPlaneOperationKind,
|
||||
take: impl FnOnce(&DataPlaneOperationResult) -> Option<T>,
|
||||
) -> NativeDataPlaneResult<T> {
|
||||
let operation = operation_id(operation)?;
|
||||
let session = get_session(session)?;
|
||||
let actual = session.core.operation_kind(operation)?;
|
||||
if actual != expected {
|
||||
return Err(NativeDataPlaneError::invalid(format!(
|
||||
"operation kind mismatch: expected {expected:?}, got {actual:?}"
|
||||
)));
|
||||
}
|
||||
let result = session.core.take_result_with(operation, |outcome| {
|
||||
Some(match outcome {
|
||||
Ok(result) => take(result).ok_or_else(|| {
|
||||
NativeDataPlaneError::invalid("data-plane result variant does not match operation")
|
||||
}),
|
||||
Err(kind) => Err(NativeDataPlaneError::new(
|
||||
*kind,
|
||||
format!("data-plane operation failed with {kind:?}"),
|
||||
)),
|
||||
})
|
||||
})?;
|
||||
result
|
||||
.ok_or_else(|| NativeDataPlaneError::invalid("data-plane result could not be consumed"))?
|
||||
}
|
||||
|
||||
pub(super) fn take_tcp_connect(
|
||||
session: u64,
|
||||
operation: u64,
|
||||
) -> NativeDataPlaneResult<TcpConnectResult> {
|
||||
take_result(
|
||||
session,
|
||||
operation,
|
||||
DataPlaneOperationKind::TcpConnect,
|
||||
|result| match result {
|
||||
DataPlaneOperationResult::TcpConnected {
|
||||
stream,
|
||||
local_addr,
|
||||
peer_addr,
|
||||
} => Some(TcpConnectResult {
|
||||
stream: stream.get(),
|
||||
local_addr: *local_addr,
|
||||
peer_addr: *peer_addr,
|
||||
}),
|
||||
_ => None,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
pub(super) fn take_tcp_bind(session: u64, operation: u64) -> NativeDataPlaneResult<TcpBindResult> {
|
||||
take_result(
|
||||
session,
|
||||
operation,
|
||||
DataPlaneOperationKind::TcpBind,
|
||||
|result| match result {
|
||||
DataPlaneOperationResult::TcpBound {
|
||||
listener,
|
||||
local_addr,
|
||||
} => Some(TcpBindResult {
|
||||
listener: listener.get(),
|
||||
local_addr: *local_addr,
|
||||
}),
|
||||
_ => None,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
pub(super) fn take_tcp_accept(
|
||||
session: u64,
|
||||
operation: u64,
|
||||
) -> NativeDataPlaneResult<TcpAcceptResult> {
|
||||
take_result(
|
||||
session,
|
||||
operation,
|
||||
DataPlaneOperationKind::TcpAccept,
|
||||
|result| match result {
|
||||
DataPlaneOperationResult::TcpAccepted {
|
||||
stream,
|
||||
local_addr,
|
||||
peer_addr,
|
||||
} => Some(TcpAcceptResult {
|
||||
stream: stream.get(),
|
||||
local_addr: *local_addr,
|
||||
peer_addr: *peer_addr,
|
||||
}),
|
||||
_ => None,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
pub(super) fn take_tcp_read(
|
||||
session: u64,
|
||||
operation: u64,
|
||||
output: &mut [u8],
|
||||
) -> NativeDataPlaneResult<TcpReadResult> {
|
||||
let required = result_size(session, operation)?;
|
||||
if output.len() < required {
|
||||
return Err(NativeDataPlaneError::new(
|
||||
DataPlaneErrorKind::BufferTooSmall,
|
||||
format!(
|
||||
"TCP read result requires {required} bytes, buffer has {}",
|
||||
output.len()
|
||||
),
|
||||
));
|
||||
}
|
||||
take_result(
|
||||
session,
|
||||
operation,
|
||||
DataPlaneOperationKind::TcpRead,
|
||||
|result| match result {
|
||||
DataPlaneOperationResult::TcpRead { data, eof } => {
|
||||
output[..data.len()].copy_from_slice(data);
|
||||
Some(TcpReadResult {
|
||||
len: data.len(),
|
||||
eof: *eof,
|
||||
})
|
||||
}
|
||||
_ => None,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
pub(super) fn take_tcp_write(session: u64, operation: u64) -> NativeDataPlaneResult<usize> {
|
||||
take_result(
|
||||
session,
|
||||
operation,
|
||||
DataPlaneOperationKind::TcpWrite,
|
||||
|result| match result {
|
||||
DataPlaneOperationResult::TcpWritten { len } => Some(*len),
|
||||
_ => None,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
pub(super) fn take_udp_bind(session: u64, operation: u64) -> NativeDataPlaneResult<UdpBindResult> {
|
||||
take_result(
|
||||
session,
|
||||
operation,
|
||||
DataPlaneOperationKind::UdpBind,
|
||||
|result| match result {
|
||||
DataPlaneOperationResult::UdpBound { socket, local_addr } => Some(UdpBindResult {
|
||||
socket: socket.get(),
|
||||
local_addr: *local_addr,
|
||||
}),
|
||||
_ => None,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
pub(super) fn take_udp_receive(
|
||||
session: u64,
|
||||
operation: u64,
|
||||
output: &mut [u8],
|
||||
) -> NativeDataPlaneResult<UdpReceiveResult> {
|
||||
let required = result_size(session, operation)?;
|
||||
if output.len() < required {
|
||||
return Err(NativeDataPlaneError::new(
|
||||
DataPlaneErrorKind::BufferTooSmall,
|
||||
format!(
|
||||
"UDP receive result requires {required} bytes, buffer has {}",
|
||||
output.len()
|
||||
),
|
||||
));
|
||||
}
|
||||
take_result(
|
||||
session,
|
||||
operation,
|
||||
DataPlaneOperationKind::UdpReceive,
|
||||
|result| match result {
|
||||
DataPlaneOperationResult::UdpReceived {
|
||||
data,
|
||||
peer_addr,
|
||||
truncated,
|
||||
} => {
|
||||
output[..data.len()].copy_from_slice(data);
|
||||
Some(UdpReceiveResult {
|
||||
len: data.len(),
|
||||
peer_addr: *peer_addr,
|
||||
truncated: *truncated,
|
||||
})
|
||||
}
|
||||
_ => None,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
pub(super) fn take_udp_send(session: u64, operation: u64) -> NativeDataPlaneResult<usize> {
|
||||
take_result(
|
||||
session,
|
||||
operation,
|
||||
DataPlaneOperationKind::UdpSend,
|
||||
|result| match result {
|
||||
DataPlaneOperationResult::UdpSent { len } => Some(*len),
|
||||
_ => None,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) fn remove_data_plane_sessions_by_instance_ids(ids: &[Uuid]) {
|
||||
if ids.is_empty() {
|
||||
return;
|
||||
}
|
||||
let _usage = DATA_PLANE_USAGE_LOCK
|
||||
.write()
|
||||
.unwrap_or_else(|error| error.into_inner());
|
||||
let removed = {
|
||||
let mut sessions = SESSIONS.lock().unwrap_or_else(|error| error.into_inner());
|
||||
let handles = sessions
|
||||
.iter()
|
||||
.filter_map(|(handle, session)| ids.contains(&session.instance_id).then_some(*handle))
|
||||
.collect::<Vec<_>>();
|
||||
handles
|
||||
.into_iter()
|
||||
.filter_map(|handle| sessions.remove(&handle))
|
||||
.collect::<Vec<_>>()
|
||||
};
|
||||
for session in removed {
|
||||
session.close();
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn lock_for_config_server_start()
|
||||
-> Result<std::sync::RwLockWriteGuard<'static, ()>, String> {
|
||||
let guard = DATA_PLANE_USAGE_LOCK
|
||||
.write()
|
||||
.map_err(|error| format!("failed to lock data plane usage: {error}"))?;
|
||||
if !SESSIONS
|
||||
.lock()
|
||||
.map_err(|error| format!("failed to lock data-plane sessions: {error}"))?
|
||||
.is_empty()
|
||||
{
|
||||
return Err("cannot start config server client while data plane is in use".to_string());
|
||||
}
|
||||
Ok(guard)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::{sync::mpsc, time::Duration};
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn config_server_start_waits_for_session_open_or_close() {
|
||||
let read_guard = DATA_PLANE_USAGE_LOCK.read().unwrap();
|
||||
let (done_tx, done_rx) = mpsc::channel();
|
||||
let waiter = std::thread::spawn(move || {
|
||||
let _write_guard = lock_for_config_server_start().unwrap();
|
||||
done_tx.send(()).unwrap();
|
||||
});
|
||||
|
||||
assert!(done_rx.recv_timeout(Duration::from_millis(100)).is_err());
|
||||
drop(read_guard);
|
||||
done_rx.recv_timeout(Duration::from_secs(5)).unwrap();
|
||||
waiter.join().unwrap();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
use std::{
|
||||
cell::RefCell,
|
||||
ffi::{CString, c_char},
|
||||
};
|
||||
|
||||
thread_local! {
|
||||
// # Thread Safety
|
||||
// set_error_msg and get_error_msg must be called on the same thread to
|
||||
// get correct error. And since `Handle::block_on` polls the top-level
|
||||
// future on the calling thread, set_error_msg always runs on the same
|
||||
// thread as the corresponding get_error_msg.
|
||||
static ERROR_MSG: RefCell<Vec<u8>> = const { RefCell::new(Vec::new()) };
|
||||
}
|
||||
|
||||
pub(crate) fn set_error_msg(msg: &str) {
|
||||
ERROR_MSG.with(|cell| {
|
||||
let mut buf = cell.borrow_mut();
|
||||
buf.clear();
|
||||
buf.extend_from_slice(msg.as_bytes());
|
||||
});
|
||||
}
|
||||
|
||||
fn thread_local_error_msg() -> Option<String> {
|
||||
ERROR_MSG.with(|cell| {
|
||||
let buf = cell.borrow();
|
||||
if buf.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(String::from_utf8_lossy(&buf).into_owned())
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) unsafe fn get_error_msg(out: *mut *const c_char) {
|
||||
let msg = match (
|
||||
thread_local_error_msg(),
|
||||
crate::config_server::last_callback_error(),
|
||||
) {
|
||||
(Some(error), Some(callback_error)) => Some(format!(
|
||||
"{}; config server callback error: {}",
|
||||
error, callback_error
|
||||
)),
|
||||
(Some(error), None) => Some(error),
|
||||
(None, Some(callback_error)) => {
|
||||
Some(format!("config server callback error: {}", callback_error))
|
||||
}
|
||||
(None, None) => None,
|
||||
};
|
||||
let cstr = msg.and_then(|msg| CString::new(msg).ok());
|
||||
unsafe {
|
||||
*out = match cstr {
|
||||
Some(s) => s.into_raw() as *const c_char,
|
||||
None => std::ptr::null(),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn free_string(s: *const c_char) {
|
||||
if s.is_null() {
|
||||
return;
|
||||
}
|
||||
unsafe {
|
||||
let _ = CString::from_raw(s as *mut c_char);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,310 @@
|
||||
use std::ffi::{CString, c_char, c_int};
|
||||
|
||||
use easytier::common::config::{ConfigFileControl, TomlConfigLoader};
|
||||
|
||||
use crate::{
|
||||
config_server::{in_config_server_callback, wait_for_config_server_delivery},
|
||||
error::set_error_msg,
|
||||
state::{ffi_context, resolve_instance_id_by_name},
|
||||
types::KeyValuePair,
|
||||
};
|
||||
|
||||
/// # Safety
|
||||
/// Set the tun fd
|
||||
pub(crate) unsafe fn set_tun_fd(inst_name: *const c_char, fd: c_int) -> c_int {
|
||||
let inst_name = unsafe {
|
||||
assert!(!inst_name.is_null());
|
||||
std::ffi::CStr::from_ptr(inst_name)
|
||||
.to_string_lossy()
|
||||
.into_owned()
|
||||
};
|
||||
let inst_id = match resolve_instance_id_by_name(&inst_name) {
|
||||
Ok(Some(instance_id)) => instance_id,
|
||||
Ok(None) => {
|
||||
set_error_msg(&format!("instance not found: {inst_name}"));
|
||||
return -1;
|
||||
}
|
||||
Err(error) => {
|
||||
set_error_msg(&error.to_string());
|
||||
return -1;
|
||||
}
|
||||
};
|
||||
|
||||
match ffi_context().manager.attach_tun_fd(inst_id, fd) {
|
||||
Ok(_) => 0,
|
||||
Err(e) => {
|
||||
set_error_msg(&format!("failed to set tun fd: {}", e));
|
||||
-1
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// # Safety
|
||||
/// Parse the config
|
||||
pub(crate) unsafe fn parse_config(cfg_str: *const std::ffi::c_char) -> std::ffi::c_int {
|
||||
let cfg_str = unsafe {
|
||||
assert!(!cfg_str.is_null());
|
||||
std::ffi::CStr::from_ptr(cfg_str)
|
||||
.to_string_lossy()
|
||||
.into_owned()
|
||||
};
|
||||
|
||||
if let Err(e) = TomlConfigLoader::new_from_str(&cfg_str) {
|
||||
set_error_msg(&format!("failed to parse config: {:?}", e));
|
||||
return -1;
|
||||
}
|
||||
|
||||
0
|
||||
}
|
||||
|
||||
/// # Safety
|
||||
/// Run the network instance
|
||||
pub(crate) unsafe fn run_network_instance(cfg_str: *const std::ffi::c_char) -> std::ffi::c_int {
|
||||
if in_config_server_callback() {
|
||||
set_error_msg("cannot run network instance from config server callback");
|
||||
return -1;
|
||||
}
|
||||
|
||||
let cfg_str = unsafe {
|
||||
assert!(!cfg_str.is_null());
|
||||
std::ffi::CStr::from_ptr(cfg_str)
|
||||
.to_string_lossy()
|
||||
.into_owned()
|
||||
};
|
||||
let cfg = match TomlConfigLoader::new_from_str(&cfg_str) {
|
||||
Ok(cfg) => cfg,
|
||||
Err(e) => {
|
||||
set_error_msg(&format!("failed to parse config: {}", e));
|
||||
return -1;
|
||||
}
|
||||
};
|
||||
|
||||
wait_for_config_server_delivery();
|
||||
if let Err(e) = ffi_context().runtime.block_on(
|
||||
ffi_context()
|
||||
.process_management
|
||||
.run_owned_network_instance(cfg, ConfigFileControl::STATIC_CONFIG),
|
||||
) {
|
||||
set_error_msg(&format!("failed to start instance: {}", e));
|
||||
return -1;
|
||||
}
|
||||
|
||||
0
|
||||
}
|
||||
|
||||
unsafe fn parse_instance_names(
|
||||
inst_names: *const *const c_char,
|
||||
length: usize,
|
||||
) -> Option<Vec<String>> {
|
||||
if length == 0 {
|
||||
return Some(Vec::new());
|
||||
}
|
||||
if inst_names.is_null() {
|
||||
set_error_msg("inst_names is null");
|
||||
return None;
|
||||
}
|
||||
|
||||
let names = unsafe { std::slice::from_raw_parts(inst_names, length) };
|
||||
let mut parsed = Vec::with_capacity(length);
|
||||
for (index, &name) in names.iter().enumerate() {
|
||||
if name.is_null() {
|
||||
set_error_msg(&format!("inst_names[{}] is null", index));
|
||||
return None;
|
||||
}
|
||||
parsed.push(
|
||||
unsafe { std::ffi::CStr::from_ptr(name) }
|
||||
.to_string_lossy()
|
||||
.into_owned(),
|
||||
);
|
||||
}
|
||||
Some(parsed)
|
||||
}
|
||||
|
||||
/// # Safety
|
||||
/// Retain the network instance
|
||||
pub(crate) unsafe fn retain_network_instance(
|
||||
inst_names: *const *const std::ffi::c_char,
|
||||
length: usize,
|
||||
) -> std::ffi::c_int {
|
||||
if in_config_server_callback() {
|
||||
set_error_msg("cannot retain network instances from config server callback");
|
||||
return -1;
|
||||
}
|
||||
|
||||
wait_for_config_server_delivery();
|
||||
let retained_names = if length == 0 {
|
||||
Vec::new()
|
||||
} else {
|
||||
let Some(inst_names) = (unsafe { parse_instance_names(inst_names, length) }) else {
|
||||
return -1;
|
||||
};
|
||||
inst_names
|
||||
};
|
||||
|
||||
if let Err(error) = ffi_context().runtime.block_on(
|
||||
ffi_context()
|
||||
.process_management
|
||||
.retain_owned_network_instances_by_name(retained_names),
|
||||
) {
|
||||
set_error_msg(&format!("failed to retain instances: {error}"));
|
||||
return -1;
|
||||
}
|
||||
|
||||
0
|
||||
}
|
||||
|
||||
/// # Safety
|
||||
/// Delete named network instances.
|
||||
pub(crate) unsafe fn delete_network_instance(
|
||||
inst_names: *const *const std::ffi::c_char,
|
||||
length: usize,
|
||||
) -> std::ffi::c_int {
|
||||
if in_config_server_callback() {
|
||||
set_error_msg("cannot delete network instances from config server callback");
|
||||
return -1;
|
||||
}
|
||||
|
||||
wait_for_config_server_delivery();
|
||||
if length == 0 {
|
||||
return 0;
|
||||
}
|
||||
|
||||
let Some(inst_names) = (unsafe { parse_instance_names(inst_names, length) }) else {
|
||||
return -1;
|
||||
};
|
||||
|
||||
if let Err(error) = ffi_context().runtime.block_on(
|
||||
ffi_context()
|
||||
.process_management
|
||||
.delete_owned_network_instances_by_name(inst_names),
|
||||
) {
|
||||
set_error_msg(&format!("failed to delete instances: {error}"));
|
||||
return -1;
|
||||
}
|
||||
|
||||
0
|
||||
}
|
||||
|
||||
/// # Safety
|
||||
/// Collect the network infos
|
||||
pub(crate) unsafe fn collect_network_infos(
|
||||
infos: *mut KeyValuePair,
|
||||
max_length: usize,
|
||||
) -> std::ffi::c_int {
|
||||
if in_config_server_callback() {
|
||||
set_error_msg("cannot collect network infos from config server callback");
|
||||
return -1;
|
||||
}
|
||||
|
||||
if max_length == 0 {
|
||||
return 0;
|
||||
}
|
||||
|
||||
let infos = unsafe {
|
||||
assert!(!infos.is_null());
|
||||
std::slice::from_raw_parts_mut(infos, max_length)
|
||||
};
|
||||
|
||||
let collected_infos = match ffi_context().manager.collect_network_infos_sync() {
|
||||
Ok(infos) => infos,
|
||||
Err(e) => {
|
||||
set_error_msg(&format!("failed to collect network infos: {}", e));
|
||||
return -1;
|
||||
}
|
||||
};
|
||||
|
||||
let mut index = 0;
|
||||
for (instance_id, value) in collected_infos.iter() {
|
||||
if index >= max_length {
|
||||
break;
|
||||
}
|
||||
let Some(key) = ffi_context()
|
||||
.manager
|
||||
.instance(*instance_id)
|
||||
.map(|instance| instance.instance_name().to_owned())
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
// convert value to json string
|
||||
let value = match serde_json::to_string(&value) {
|
||||
Ok(value) => value,
|
||||
Err(e) => {
|
||||
set_error_msg(&format!("failed to serialize instance info: {}", e));
|
||||
return -1;
|
||||
}
|
||||
};
|
||||
|
||||
infos[index] = KeyValuePair {
|
||||
key: std::ffi::CString::new(key).unwrap().into_raw(),
|
||||
value: std::ffi::CString::new(value).unwrap().into_raw(),
|
||||
};
|
||||
index += 1;
|
||||
}
|
||||
|
||||
index as std::ffi::c_int
|
||||
}
|
||||
|
||||
/// # Safety
|
||||
/// List the instance names and IDs known by the FFI instance manager.
|
||||
pub(crate) unsafe fn list_instance(infos: *mut KeyValuePair, max_length: usize) -> std::ffi::c_int {
|
||||
if in_config_server_callback() {
|
||||
set_error_msg("cannot list instances from config server callback");
|
||||
return -1;
|
||||
}
|
||||
|
||||
if max_length == 0 {
|
||||
return 0;
|
||||
}
|
||||
|
||||
if infos.is_null() {
|
||||
set_error_msg("infos is null");
|
||||
return -1;
|
||||
}
|
||||
|
||||
let infos = unsafe { std::slice::from_raw_parts_mut(infos, max_length) };
|
||||
let mut instances = ffi_context()
|
||||
.manager
|
||||
.instance_ids()
|
||||
.into_iter()
|
||||
.filter_map(|id| {
|
||||
ffi_context()
|
||||
.manager
|
||||
.instance(id)
|
||||
.map(|instance| (instance.instance_name().to_owned(), id))
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
instances.sort_by(|(left_name, left_id), (right_name, right_id)| {
|
||||
left_name
|
||||
.cmp(right_name)
|
||||
.then_with(|| left_id.to_string().cmp(&right_id.to_string()))
|
||||
});
|
||||
|
||||
let encoded_instances = match instances
|
||||
.into_iter()
|
||||
.take(max_length)
|
||||
.map(|(name, id)| {
|
||||
let key = CString::new(name)
|
||||
.map_err(|err| format!("failed to encode instance name: {}", err))?;
|
||||
let value = CString::new(id.to_string())
|
||||
.map_err(|err| format!("failed to encode instance id: {}", err))?;
|
||||
Ok((key, value))
|
||||
})
|
||||
.collect::<Result<Vec<_>, String>>()
|
||||
{
|
||||
Ok(value) => value,
|
||||
Err(err) => {
|
||||
set_error_msg(&err);
|
||||
return -1;
|
||||
}
|
||||
};
|
||||
|
||||
let count = encoded_instances.len();
|
||||
for (index, (key, value)) in encoded_instances.into_iter().enumerate() {
|
||||
infos[index] = KeyValuePair {
|
||||
key: key.into_raw(),
|
||||
value: value.into_raw(),
|
||||
};
|
||||
}
|
||||
|
||||
count as std::ffi::c_int
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
use std::{
|
||||
ffi::{CString, c_char, c_int},
|
||||
sync::Arc,
|
||||
};
|
||||
|
||||
use crate::{
|
||||
config_server::in_config_server_callback,
|
||||
error::set_error_msg,
|
||||
state::ffi_context,
|
||||
strings::{c_str_to_string, optional_c_str_to_string},
|
||||
};
|
||||
|
||||
/// # Safety
|
||||
/// See `crate::call_json_rpc`.
|
||||
pub(crate) unsafe fn call_json_rpc(
|
||||
service_name: *const c_char,
|
||||
method_name: *const c_char,
|
||||
domain_name: *const c_char,
|
||||
payload_json: *const c_char,
|
||||
out_response_json: *mut *const c_char,
|
||||
) -> c_int {
|
||||
if out_response_json.is_null() {
|
||||
set_error_msg("out_response_json is null");
|
||||
return -1;
|
||||
}
|
||||
unsafe {
|
||||
*out_response_json = std::ptr::null();
|
||||
}
|
||||
|
||||
if in_config_server_callback() {
|
||||
set_error_msg("cannot call JSON RPC from config server callback");
|
||||
return -1;
|
||||
}
|
||||
|
||||
let service_name = match unsafe { c_str_to_string(service_name, "service_name") } {
|
||||
Ok(value) => value,
|
||||
Err(err) => {
|
||||
set_error_msg(&err);
|
||||
return -1;
|
||||
}
|
||||
};
|
||||
let method_name = match unsafe { c_str_to_string(method_name, "method_name") } {
|
||||
Ok(value) => value,
|
||||
Err(err) => {
|
||||
set_error_msg(&err);
|
||||
return -1;
|
||||
}
|
||||
};
|
||||
let domain_name = match unsafe { optional_c_str_to_string(domain_name, "domain_name") } {
|
||||
Ok(value) => value,
|
||||
Err(err) => {
|
||||
set_error_msg(&err);
|
||||
return -1;
|
||||
}
|
||||
};
|
||||
let payload_json = match unsafe { c_str_to_string(payload_json, "payload_json") } {
|
||||
Ok(value) => value,
|
||||
Err(err) => {
|
||||
set_error_msg(&err);
|
||||
return -1;
|
||||
}
|
||||
};
|
||||
let payload = match serde_json::from_str::<serde_json::Value>(&payload_json) {
|
||||
Ok(value) => value,
|
||||
Err(err) => {
|
||||
set_error_msg(&format!("failed to parse payload_json: {}", err));
|
||||
return -1;
|
||||
}
|
||||
};
|
||||
|
||||
let response =
|
||||
match ffi_context()
|
||||
.runtime
|
||||
.block_on(easytier_core::management::call_management_json_rpc(
|
||||
&ffi_context().manager,
|
||||
Arc::new(easytier::rpc_service::logger::NativeLoggerControl),
|
||||
&service_name,
|
||||
&method_name,
|
||||
domain_name.as_deref(),
|
||||
payload,
|
||||
)) {
|
||||
Ok(value) => value,
|
||||
Err(err) => {
|
||||
set_error_msg(&format!("RPC Error: {}", err));
|
||||
return -1;
|
||||
}
|
||||
};
|
||||
let response_json = match serde_json::to_string(&response) {
|
||||
Ok(value) => value,
|
||||
Err(err) => {
|
||||
set_error_msg(&format!("failed to serialize RPC response: {}", err));
|
||||
return -1;
|
||||
}
|
||||
};
|
||||
let response_json = match CString::new(response_json) {
|
||||
Ok(value) => value,
|
||||
Err(err) => {
|
||||
set_error_msg(&format!("failed to allocate RPC response: {}", err));
|
||||
return -1;
|
||||
}
|
||||
};
|
||||
|
||||
unsafe {
|
||||
*out_response_json = response_json.into_raw();
|
||||
}
|
||||
0
|
||||
}
|
||||
@@ -1,267 +1,358 @@
|
||||
use std::sync::Mutex;
|
||||
//! C ABI facade for EasyTier.
|
||||
//!
|
||||
//! The exported API is intentionally kept in this file so C users and JNI
|
||||
//! bindings can see the full callable surface without reading the internal
|
||||
//! implementation modules.
|
||||
//!
|
||||
//! Network management APIs:
|
||||
//! - `parse_config`: validate a TOML network config string.
|
||||
//! - `run_network_instance`: start one local network instance from TOML.
|
||||
//! - `retain_network_instance`: keep named instances and stop all others.
|
||||
//! - `delete_network_instance`: stop named local network instances.
|
||||
//! - `list_instance`: list running instance names and IDs.
|
||||
//! - `collect_network_infos`: collect running instance info as key/value pairs.
|
||||
//! - `set_tun_fd`: attach a TUN file descriptor to a named instance.
|
||||
//! - `call_json_rpc`: call an exposed EasyTier RPC service with JSON payload.
|
||||
//!
|
||||
//! Config server client APIs:
|
||||
//! - `start_config_server_client`: start the managed remote config client.
|
||||
//! - `stop_config_server_client`: stop the remote config client and its managed instances.
|
||||
//! - `is_config_server_client_connected`: report whether the client is connected.
|
||||
//!
|
||||
//! Data plane APIs, enabled by the `ffi-dataplane` feature:
|
||||
//! - `data_plane_session_open` / `data_plane_session_close`: own one instance session.
|
||||
//! - `data_plane_*_submit`: submit non-blocking TCP and UDP operations.
|
||||
//! - `data_plane_completion_wait` / `data_plane_completion_drain`: await completions.
|
||||
//! - `data_plane_*_result_take`: consume typed operation results.
|
||||
//! - `data_plane_operation_cancel` / `data_plane_operation_free`: control operations.
|
||||
//! - `data_plane_resource_close`: close streams, listeners, and UDP sockets.
|
||||
//!
|
||||
//! Shared FFI helper APIs:
|
||||
//! - `get_error_msg`: copy the last FFI or config-server callback error message.
|
||||
//! - `free_string`: release strings allocated by this library.
|
||||
|
||||
use dashmap::DashMap;
|
||||
use easytier::{
|
||||
common::config::{ConfigFileControl, ConfigLoader as _, TomlConfigLoader},
|
||||
instance_manager::NetworkInstanceManager,
|
||||
mod config_server;
|
||||
mod data_plane;
|
||||
mod error;
|
||||
mod instance_api;
|
||||
mod json_rpc;
|
||||
mod state;
|
||||
mod strings;
|
||||
mod types;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
||||
pub use config_server::{in_config_server_callback, validate_config_server_client_options};
|
||||
pub use types::{
|
||||
ConfigServerEventCallback, DataPlaneCompletion, DataPlaneSocketAddr, KeyValuePair,
|
||||
};
|
||||
|
||||
static INSTANCE_NAME_ID_MAP: once_cell::sync::Lazy<DashMap<String, uuid::Uuid>> =
|
||||
once_cell::sync::Lazy::new(DashMap::new);
|
||||
static INSTANCE_MANAGER: once_cell::sync::Lazy<NetworkInstanceManager> =
|
||||
once_cell::sync::Lazy::new(NetworkInstanceManager::new);
|
||||
use std::ffi::{c_char, c_int, c_void};
|
||||
|
||||
static ERROR_MSG: once_cell::sync::Lazy<Mutex<Vec<u8>>> =
|
||||
once_cell::sync::Lazy::new(|| Mutex::new(Vec::new()));
|
||||
|
||||
#[repr(C)]
|
||||
pub struct KeyValuePair {
|
||||
pub key: *const std::ffi::c_char,
|
||||
pub value: *const std::ffi::c_char,
|
||||
}
|
||||
|
||||
fn set_error_msg(msg: &str) {
|
||||
let bytes = msg.as_bytes();
|
||||
let mut msg_buf = ERROR_MSG.lock().unwrap();
|
||||
let len = bytes.len();
|
||||
msg_buf.resize(len, 0);
|
||||
msg_buf[..len].copy_from_slice(bytes);
|
||||
}
|
||||
// ===== Network Management API =====
|
||||
|
||||
/// Validate a TOML network config string.
|
||||
///
|
||||
/// This only parses and validates the config. It does not start an instance and
|
||||
/// does not change global FFI state.
|
||||
///
|
||||
/// # Safety
|
||||
/// Set the tun fd
|
||||
#[unsafe(no_mangle)]
|
||||
pub unsafe extern "C" fn set_tun_fd(
|
||||
inst_name: *const std::ffi::c_char,
|
||||
fd: std::ffi::c_int,
|
||||
) -> std::ffi::c_int {
|
||||
let inst_name = unsafe {
|
||||
assert!(!inst_name.is_null());
|
||||
std::ffi::CStr::from_ptr(inst_name)
|
||||
.to_string_lossy()
|
||||
.into_owned()
|
||||
};
|
||||
if !INSTANCE_NAME_ID_MAP.contains_key(&inst_name) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
let inst_id = *INSTANCE_NAME_ID_MAP
|
||||
.get(&inst_name)
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.value();
|
||||
|
||||
match INSTANCE_MANAGER.set_tun_fd(&inst_id, fd) {
|
||||
Ok(_) => 0,
|
||||
Err(_) => -1,
|
||||
}
|
||||
/// `cfg_str` must be a non-null pointer to a null-terminated UTF-8 string.
|
||||
///
|
||||
/// # Return
|
||||
/// Returns `0` if the config parses successfully, or `-1` on failure. On
|
||||
/// failure, call `get_error_msg` on the same thread to retrieve details.
|
||||
#[cfg_attr(feature = "c-abi", unsafe(no_mangle))]
|
||||
pub unsafe extern "C" fn parse_config(cfg_str: *const c_char) -> c_int {
|
||||
unsafe { instance_api::parse_config(cfg_str) }
|
||||
}
|
||||
|
||||
/// Start one local EasyTier network instance from a TOML config string.
|
||||
///
|
||||
/// The config's `inst_name` must be unique among instances started through this
|
||||
/// FFI layer. This API is mutually exclusive with config-server callback
|
||||
/// execution and will fail if called from a config-server event callback.
|
||||
///
|
||||
/// # Safety
|
||||
/// Get the last error message
|
||||
#[unsafe(no_mangle)]
|
||||
pub unsafe extern "C" fn get_error_msg(out: *mut *const std::ffi::c_char) {
|
||||
let msg_buf = ERROR_MSG.lock().unwrap();
|
||||
if msg_buf.is_empty() {
|
||||
unsafe {
|
||||
*out = std::ptr::null();
|
||||
}
|
||||
return;
|
||||
}
|
||||
let cstr = std::ffi::CString::new(&msg_buf[..]).unwrap();
|
||||
unsafe {
|
||||
*out = cstr.into_raw();
|
||||
}
|
||||
}
|
||||
|
||||
#[unsafe(no_mangle)]
|
||||
pub extern "C" fn free_string(s: *const std::ffi::c_char) {
|
||||
if s.is_null() {
|
||||
return;
|
||||
}
|
||||
unsafe {
|
||||
let _ = std::ffi::CString::from_raw(s as *mut std::ffi::c_char);
|
||||
}
|
||||
/// `cfg_str` must be a non-null pointer to a null-terminated UTF-8 string.
|
||||
///
|
||||
/// # Return
|
||||
/// Returns `0` after the instance is started and registered in the FFI name
|
||||
/// cache, or `-1` on failure. On failure, call `get_error_msg` on the same
|
||||
/// thread to retrieve details.
|
||||
#[cfg_attr(feature = "c-abi", unsafe(no_mangle))]
|
||||
pub unsafe extern "C" fn run_network_instance(cfg_str: *const c_char) -> c_int {
|
||||
unsafe { instance_api::run_network_instance(cfg_str) }
|
||||
}
|
||||
|
||||
/// Keep the named network instances and stop all other instances.
|
||||
///
|
||||
/// Passing `length == 0` stops all instances. When `length > 0`, `inst_names`
|
||||
/// must point to an array of `length` non-null C strings. Instances that are not
|
||||
/// retained are removed from the FFI name cache and any related data-plane
|
||||
/// handles are closed.
|
||||
///
|
||||
/// This API fails if called from a config-server event callback.
|
||||
///
|
||||
/// # Safety
|
||||
/// Parse the config
|
||||
#[unsafe(no_mangle)]
|
||||
pub unsafe extern "C" fn parse_config(cfg_str: *const std::ffi::c_char) -> std::ffi::c_int {
|
||||
let cfg_str = unsafe {
|
||||
assert!(!cfg_str.is_null());
|
||||
std::ffi::CStr::from_ptr(cfg_str)
|
||||
.to_string_lossy()
|
||||
.into_owned()
|
||||
};
|
||||
|
||||
if let Err(e) = TomlConfigLoader::new_from_str(&cfg_str) {
|
||||
set_error_msg(&format!("failed to parse config: {:?}", e));
|
||||
return -1;
|
||||
}
|
||||
|
||||
0
|
||||
}
|
||||
|
||||
/// # Safety
|
||||
/// Run the network instance
|
||||
#[unsafe(no_mangle)]
|
||||
pub unsafe extern "C" fn run_network_instance(cfg_str: *const std::ffi::c_char) -> std::ffi::c_int {
|
||||
let cfg_str = unsafe {
|
||||
assert!(!cfg_str.is_null());
|
||||
std::ffi::CStr::from_ptr(cfg_str)
|
||||
.to_string_lossy()
|
||||
.into_owned()
|
||||
};
|
||||
let cfg = match TomlConfigLoader::new_from_str(&cfg_str) {
|
||||
Ok(cfg) => cfg,
|
||||
Err(e) => {
|
||||
set_error_msg(&format!("failed to parse config: {}", e));
|
||||
return -1;
|
||||
}
|
||||
};
|
||||
|
||||
let inst_name = cfg.get_inst_name();
|
||||
|
||||
if INSTANCE_NAME_ID_MAP.contains_key(&inst_name) {
|
||||
set_error_msg("instance already exists");
|
||||
return -1;
|
||||
}
|
||||
|
||||
let instance_id =
|
||||
match INSTANCE_MANAGER.run_network_instance(cfg, false, ConfigFileControl::STATIC_CONFIG) {
|
||||
Ok(id) => id,
|
||||
Err(e) => {
|
||||
set_error_msg(&format!("failed to start instance: {}", e));
|
||||
return -1;
|
||||
}
|
||||
};
|
||||
|
||||
INSTANCE_NAME_ID_MAP.insert(inst_name, instance_id);
|
||||
|
||||
0
|
||||
}
|
||||
|
||||
/// # Safety
|
||||
/// Retain the network instance
|
||||
#[unsafe(no_mangle)]
|
||||
/// If `length > 0`, `inst_names` must be a non-null pointer to an array of
|
||||
/// `length` non-null pointers to null-terminated UTF-8 strings.
|
||||
///
|
||||
/// # Return
|
||||
/// Returns `0` on success, or `-1` on failure. On failure, call
|
||||
/// `get_error_msg` on the same thread to retrieve details.
|
||||
#[cfg_attr(feature = "c-abi", unsafe(no_mangle))]
|
||||
pub unsafe extern "C" fn retain_network_instance(
|
||||
inst_names: *const *const std::ffi::c_char,
|
||||
inst_names: *const *const c_char,
|
||||
length: usize,
|
||||
) -> std::ffi::c_int {
|
||||
if length == 0 {
|
||||
if let Err(e) = INSTANCE_MANAGER.retain_network_instance(Vec::new()) {
|
||||
set_error_msg(&format!("failed to retain instances: {}", e));
|
||||
return -1;
|
||||
}
|
||||
INSTANCE_NAME_ID_MAP.clear();
|
||||
return 0;
|
||||
}
|
||||
|
||||
let inst_names = unsafe {
|
||||
assert!(!inst_names.is_null());
|
||||
std::slice::from_raw_parts(inst_names, length)
|
||||
.iter()
|
||||
.map(|&name| {
|
||||
assert!(!name.is_null());
|
||||
std::ffi::CStr::from_ptr(name)
|
||||
.to_string_lossy()
|
||||
.into_owned()
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
};
|
||||
|
||||
let inst_ids: Vec<uuid::Uuid> = inst_names
|
||||
.iter()
|
||||
.filter_map(|name| INSTANCE_NAME_ID_MAP.get(name).map(|id| *id))
|
||||
.collect();
|
||||
|
||||
if let Err(e) = INSTANCE_MANAGER.retain_network_instance(inst_ids) {
|
||||
set_error_msg(&format!("failed to retain instances: {}", e));
|
||||
return -1;
|
||||
}
|
||||
|
||||
INSTANCE_NAME_ID_MAP.retain(|k, _| inst_names.contains(k));
|
||||
|
||||
0
|
||||
) -> c_int {
|
||||
unsafe { instance_api::retain_network_instance(inst_names, length) }
|
||||
}
|
||||
|
||||
/// Stop the named network instances.
|
||||
///
|
||||
/// Passing `length == 0` is a no-op. When `length > 0`, `inst_names` must point
|
||||
/// to an array of `length` non-null C strings. Unknown names are ignored.
|
||||
/// Removed instances are also removed from the FFI name cache and any related
|
||||
/// data-plane handles are closed.
|
||||
///
|
||||
/// This API fails if called from a config-server event callback.
|
||||
///
|
||||
/// # Safety
|
||||
/// Collect the network infos
|
||||
#[unsafe(no_mangle)]
|
||||
/// If `length > 0`, `inst_names` must be a non-null pointer to an array of
|
||||
/// `length` non-null pointers to null-terminated UTF-8 strings.
|
||||
///
|
||||
/// # Return
|
||||
/// Returns `0` on success, or `-1` on failure. On failure, call
|
||||
/// `get_error_msg` on the same thread to retrieve details.
|
||||
#[cfg_attr(feature = "c-abi", unsafe(no_mangle))]
|
||||
pub unsafe extern "C" fn delete_network_instance(
|
||||
inst_names: *const *const c_char,
|
||||
length: usize,
|
||||
) -> c_int {
|
||||
unsafe { instance_api::delete_network_instance(inst_names, length) }
|
||||
}
|
||||
|
||||
/// List running network instance names and IDs.
|
||||
///
|
||||
/// Writes up to `max_length` entries into `infos`. Each returned key is the
|
||||
/// instance name and each returned value is the instance ID string. Returned
|
||||
/// key/value strings are allocated by this library and must be released with
|
||||
/// `free_string`.
|
||||
///
|
||||
/// This API fails if called from a config-server event callback.
|
||||
///
|
||||
/// # Safety
|
||||
/// If `max_length > 0`, `infos` must be a non-null pointer to writable storage
|
||||
/// for at least `max_length` `KeyValuePair` values.
|
||||
///
|
||||
/// # Return
|
||||
/// Returns the number of entries written, or `-1` on failure. On failure, call
|
||||
/// `get_error_msg` on the same thread to retrieve details.
|
||||
#[cfg_attr(feature = "c-abi", unsafe(no_mangle))]
|
||||
pub unsafe extern "C" fn list_instance(infos: *mut KeyValuePair, max_length: usize) -> c_int {
|
||||
unsafe { instance_api::list_instance(infos, max_length) }
|
||||
}
|
||||
|
||||
/// Collect running network instance information.
|
||||
///
|
||||
/// Writes up to `max_length` entries into `infos`. Each returned key is the
|
||||
/// instance name and each returned value is a JSON string containing that
|
||||
/// instance's running information. Returned key/value strings are allocated by
|
||||
/// this library and must be released with `free_string`.
|
||||
///
|
||||
/// This API fails if called from a config-server event callback.
|
||||
///
|
||||
/// # Safety
|
||||
/// If `max_length > 0`, `infos` must be a non-null pointer to writable storage
|
||||
/// for at least `max_length` `KeyValuePair` values.
|
||||
///
|
||||
/// # Return
|
||||
/// Returns the number of entries written, or `-1` on failure. On failure, call
|
||||
/// `get_error_msg` on the same thread to retrieve details.
|
||||
#[cfg_attr(feature = "c-abi", unsafe(no_mangle))]
|
||||
pub unsafe extern "C" fn collect_network_infos(
|
||||
infos: *mut KeyValuePair,
|
||||
max_length: usize,
|
||||
) -> std::ffi::c_int {
|
||||
if max_length == 0 {
|
||||
return 0;
|
||||
}
|
||||
|
||||
let infos = unsafe {
|
||||
assert!(!infos.is_null());
|
||||
std::slice::from_raw_parts_mut(infos, max_length)
|
||||
};
|
||||
|
||||
let collected_infos = match INSTANCE_MANAGER.collect_network_infos_sync() {
|
||||
Ok(infos) => infos,
|
||||
Err(e) => {
|
||||
set_error_msg(&format!("failed to collect network infos: {}", e));
|
||||
return -1;
|
||||
}
|
||||
};
|
||||
|
||||
let mut index = 0;
|
||||
for (instance_id, value) in collected_infos.iter() {
|
||||
if index >= max_length {
|
||||
break;
|
||||
}
|
||||
let Some(key) = INSTANCE_MANAGER.get_instance_name(instance_id) else {
|
||||
continue;
|
||||
};
|
||||
// convert value to json string
|
||||
let value = match serde_json::to_string(&value) {
|
||||
Ok(value) => value,
|
||||
Err(e) => {
|
||||
set_error_msg(&format!("failed to serialize instance info: {}", e));
|
||||
return -1;
|
||||
}
|
||||
};
|
||||
|
||||
infos[index] = KeyValuePair {
|
||||
key: std::ffi::CString::new(key).unwrap().into_raw(),
|
||||
value: std::ffi::CString::new(value).unwrap().into_raw(),
|
||||
};
|
||||
index += 1;
|
||||
}
|
||||
|
||||
index as std::ffi::c_int
|
||||
) -> c_int {
|
||||
unsafe { instance_api::collect_network_infos(infos, max_length) }
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
/// Attach a TUN file descriptor to a named network instance.
|
||||
///
|
||||
/// The instance must already have been registered in the FFI name cache by
|
||||
/// `run_network_instance` or by a managed config-server remote start event.
|
||||
///
|
||||
/// # Safety
|
||||
/// `inst_name` must be a non-null pointer to a null-terminated UTF-8 string.
|
||||
/// `fd` must be a valid TUN file descriptor owned by the caller.
|
||||
///
|
||||
/// # Return
|
||||
/// Returns `0` if the descriptor is accepted by the instance, or `-1` on
|
||||
/// failure.
|
||||
#[cfg_attr(feature = "c-abi", unsafe(no_mangle))]
|
||||
pub unsafe extern "C" fn set_tun_fd(inst_name: *const c_char, fd: c_int) -> c_int {
|
||||
unsafe { instance_api::set_tun_fd(inst_name, fd) }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_config() {
|
||||
let cfg_str = r#"
|
||||
inst_name = "test"
|
||||
network = "test_network"
|
||||
"#;
|
||||
let cstr = std::ffi::CString::new(cfg_str).unwrap();
|
||||
unsafe {
|
||||
assert_eq!(parse_config(cstr.as_ptr()), 0);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_run_network_instance() {
|
||||
let cfg_str = r#"
|
||||
inst_name = "test"
|
||||
network = "test_network"
|
||||
"#;
|
||||
let cstr = std::ffi::CString::new(cfg_str).unwrap();
|
||||
unsafe {
|
||||
assert_eq!(run_network_instance(cstr.as_ptr()), 0);
|
||||
}
|
||||
/// Call an exposed EasyTier RPC method using protobuf JSON.
|
||||
///
|
||||
/// This generic bridge intentionally excludes instance lifecycle management
|
||||
/// RPCs. Use the dedicated FFI APIs for starting, retaining, deleting, and
|
||||
/// collecting instances. `payload_json` must contain the protobuf JSON request,
|
||||
/// including any `instance` selector required by the target RPC.
|
||||
///
|
||||
/// `domain_name` may be null or empty. It is only used by
|
||||
/// `api.instance.TcpProxyRpcService`; null or empty defaults to `tcp`, and the
|
||||
/// only accepted explicit values are `tcp`, `kcp_src`, `kcp_dst`, `quic_src`,
|
||||
/// and `quic_dst`.
|
||||
///
|
||||
/// On success, writes a newly allocated JSON response string to
|
||||
/// `out_response_json`. The caller must release it with `free_string`.
|
||||
///
|
||||
/// This API fails if called from a config-server event callback.
|
||||
///
|
||||
/// # Safety
|
||||
/// `service_name`, `method_name`, `payload_json`, and `out_response_json` must
|
||||
/// be non-null. String pointers must point to null-terminated UTF-8 strings.
|
||||
/// `domain_name` may be null.
|
||||
///
|
||||
/// # Return
|
||||
/// Returns `0` on success, or `-1` on failure. On failure, call
|
||||
/// `get_error_msg` on the same thread to retrieve details.
|
||||
#[cfg_attr(feature = "c-abi", unsafe(no_mangle))]
|
||||
pub unsafe extern "C" fn call_json_rpc(
|
||||
service_name: *const c_char,
|
||||
method_name: *const c_char,
|
||||
domain_name: *const c_char,
|
||||
payload_json: *const c_char,
|
||||
out_response_json: *mut *const c_char,
|
||||
) -> c_int {
|
||||
unsafe {
|
||||
json_rpc::call_json_rpc(
|
||||
service_name,
|
||||
method_name,
|
||||
domain_name,
|
||||
payload_json,
|
||||
out_response_json,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// ===== Config Server Client API =====
|
||||
|
||||
/// Start the managed config-server client.
|
||||
///
|
||||
/// The client reuses EasyTier's web-client path and applies remote config
|
||||
/// changes through the shared `NativeInstanceManager`. Successful remote run
|
||||
/// and delete operations are delivered to `callback` as JSON event strings, one
|
||||
/// callback per affected instance. The event string is valid only for the
|
||||
/// duration of the callback; callers must copy it if they need to keep it.
|
||||
///
|
||||
/// The config-server client is mutually exclusive with the FFI data plane. If a
|
||||
/// data-plane handle exists or is being created, this function returns `-1`.
|
||||
///
|
||||
/// # Safety
|
||||
/// `config_server_url` and `machine_id` must be non-null pointers to
|
||||
/// null-terminated UTF-8 strings. `hostname` may be null; when non-null it must
|
||||
/// also point to a null-terminated UTF-8 string. `user_data` is passed back to
|
||||
/// `callback` unchanged and must remain valid for the callback's expectations.
|
||||
///
|
||||
/// # Return
|
||||
/// Returns `0` after the client starts successfully, or `-1` on failure. On
|
||||
/// failure, call `get_error_msg` on the same thread to retrieve details.
|
||||
#[cfg_attr(feature = "c-abi", unsafe(no_mangle))]
|
||||
pub unsafe extern "C" fn start_config_server_client(
|
||||
config_server_url: *const c_char,
|
||||
hostname: *const c_char,
|
||||
machine_id: *const c_char,
|
||||
secure_mode: bool,
|
||||
callback: ConfigServerEventCallback,
|
||||
user_data: *mut c_void,
|
||||
) -> c_int {
|
||||
unsafe {
|
||||
config_server::start_config_server_client(
|
||||
config_server_url,
|
||||
hostname,
|
||||
machine_id,
|
||||
secure_mode,
|
||||
callback,
|
||||
user_data,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// Stop the managed config-server client.
|
||||
///
|
||||
/// This stops the client, removes instances tracked as remote config-server
|
||||
/// instances, waits for in-flight callback delivery when safe to do so, and
|
||||
/// releases the config-server/data-plane mutual exclusion state.
|
||||
///
|
||||
/// # Return
|
||||
/// Returns `0` if no client exists or if the active client is stopped
|
||||
/// successfully. Returns `-1` on failure. On failure, call `get_error_msg` on
|
||||
/// the same thread to retrieve details.
|
||||
#[cfg_attr(feature = "c-abi", unsafe(no_mangle))]
|
||||
pub extern "C" fn stop_config_server_client() -> c_int {
|
||||
config_server::stop_config_server_client()
|
||||
}
|
||||
|
||||
/// Report whether the managed config-server client is currently connected.
|
||||
///
|
||||
/// # Return
|
||||
/// Returns `1` when a client exists and reports connected, otherwise returns
|
||||
/// `0`.
|
||||
#[cfg_attr(feature = "c-abi", unsafe(no_mangle))]
|
||||
pub extern "C" fn is_config_server_client_connected() -> c_int {
|
||||
config_server::is_config_server_client_connected()
|
||||
}
|
||||
|
||||
// ===== Data Plane API =====
|
||||
|
||||
#[cfg(feature = "ffi-dataplane")]
|
||||
pub use data_plane::{
|
||||
DATA_PLANE_DEADLINE_READ, DATA_PLANE_DEADLINE_WRITE, data_plane_completion_drain,
|
||||
data_plane_completion_wait, data_plane_operation_cancel, data_plane_operation_free,
|
||||
data_plane_resource_close, data_plane_resource_deadline_set, data_plane_result_size,
|
||||
data_plane_session_close, data_plane_session_open, data_plane_tcp_accept_result_take,
|
||||
data_plane_tcp_accept_submit, data_plane_tcp_bind_result_take, data_plane_tcp_bind_submit,
|
||||
data_plane_tcp_connect_result_take, data_plane_tcp_connect_submit,
|
||||
data_plane_tcp_read_result_take, data_plane_tcp_read_submit, data_plane_tcp_write_result_take,
|
||||
data_plane_tcp_write_submit, data_plane_udp_bind_result_take, data_plane_udp_bind_submit,
|
||||
data_plane_udp_receive_result_take, data_plane_udp_receive_submit,
|
||||
data_plane_udp_send_result_take, data_plane_udp_send_submit,
|
||||
};
|
||||
|
||||
// ===== Shared FFI Helper API =====
|
||||
|
||||
/// Return the last FFI error message.
|
||||
///
|
||||
/// API failures are stored in a thread-local buffer, so call this on the same
|
||||
/// thread that received a negative status or another documented failure
|
||||
/// sentinel. Config-server
|
||||
/// callback delivery failures may happen on a runtime thread; those are stored
|
||||
/// globally and are included here so direct FFI callers can still retrieve the
|
||||
/// last callback error. If there is no error message, this writes a null pointer
|
||||
/// to `out`.
|
||||
///
|
||||
/// The returned string is allocated by this library and must be released with
|
||||
/// `free_string`.
|
||||
///
|
||||
/// # Safety
|
||||
/// `out` must be a non-null pointer to writable storage for one C string
|
||||
/// pointer.
|
||||
#[cfg_attr(feature = "c-abi", unsafe(no_mangle))]
|
||||
pub unsafe extern "C" fn get_error_msg(out: *mut *const c_char) {
|
||||
unsafe { error::get_error_msg(out) }
|
||||
}
|
||||
|
||||
/// Release a C string allocated by this library.
|
||||
///
|
||||
/// Use this for strings returned through `get_error_msg`,
|
||||
/// `collect_network_infos`, and data-plane address output parameters. Passing a
|
||||
/// null pointer is allowed and has no effect.
|
||||
#[cfg_attr(feature = "c-abi", unsafe(no_mangle))]
|
||||
pub extern "C" fn free_string(s: *const c_char) {
|
||||
error::free_string(s)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use easytier::instance::factory::{
|
||||
NativeInstanceManager, NativeProcessManagement, native_instance_manager_with_runtime,
|
||||
native_process_management,
|
||||
};
|
||||
use tokio::runtime::{Builder, Runtime};
|
||||
|
||||
struct FfiOwnedInstanceHooks;
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl easytier_core::management::InstanceMutationHooks for FfiOwnedInstanceHooks {
|
||||
async fn post_remove_network_instances(
|
||||
&self,
|
||||
instance_ids: &[uuid::Uuid],
|
||||
) -> Result<(), String> {
|
||||
crate::config_server::remove_config_server_tracked_instance_ids(instance_ids);
|
||||
crate::data_plane::remove_data_plane_sessions_by_instance_ids(instance_ids);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) struct FfiContext {
|
||||
pub(crate) runtime: Runtime,
|
||||
pub(crate) manager: Arc<NativeInstanceManager>,
|
||||
pub(crate) process_management: NativeProcessManagement,
|
||||
}
|
||||
|
||||
impl FfiContext {
|
||||
fn new() -> Self {
|
||||
let runtime = Builder::new_multi_thread()
|
||||
.enable_all()
|
||||
.build()
|
||||
.expect("tokio runtime for easytier-ffi");
|
||||
let manager = Arc::new(native_instance_manager_with_runtime(
|
||||
runtime.handle().clone(),
|
||||
));
|
||||
let process_management =
|
||||
native_process_management(manager.clone(), Arc::new(FfiOwnedInstanceHooks));
|
||||
Self {
|
||||
runtime,
|
||||
manager,
|
||||
process_management,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static FFI_CONTEXT: once_cell::sync::Lazy<FfiContext> = once_cell::sync::Lazy::new(FfiContext::new);
|
||||
|
||||
pub(crate) fn ffi_context() -> &'static FfiContext {
|
||||
&FFI_CONTEXT
|
||||
}
|
||||
|
||||
pub(crate) fn resolve_instance_id_by_name(inst_name: &str) -> Result<Option<uuid::Uuid>, String> {
|
||||
easytier_core::management::resolve_optional_instance_by_name(
|
||||
ffi_context().manager.as_ref(),
|
||||
inst_name,
|
||||
)
|
||||
.map(|instance| instance.map(|instance| instance.instance_id()))
|
||||
.map_err(|error| error.to_string())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn find_instance_id_by_name(inst_name: &str) -> Option<uuid::Uuid> {
|
||||
resolve_instance_id_by_name(inst_name).ok().flatten()
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
use std::ffi::{CStr, c_char};
|
||||
|
||||
pub(crate) unsafe fn c_str_to_string(ptr: *const c_char, name: &str) -> Result<String, String> {
|
||||
if ptr.is_null() {
|
||||
return Err(format!("{} is null", name));
|
||||
}
|
||||
|
||||
unsafe { CStr::from_ptr(ptr) }
|
||||
.to_str()
|
||||
.map(|value| value.to_string())
|
||||
.map_err(|err| format!("{} is not valid UTF-8: {}", name, err))
|
||||
}
|
||||
|
||||
pub(crate) unsafe fn optional_c_str_to_string(
|
||||
ptr: *const c_char,
|
||||
name: &str,
|
||||
) -> Result<Option<String>, String> {
|
||||
if ptr.is_null() {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
unsafe { c_str_to_string(ptr, name) }.map(Some)
|
||||
}
|
||||
@@ -0,0 +1,746 @@
|
||||
use crate::{
|
||||
config_server::{
|
||||
ConfigServerCallbackScope, ManagedConfigServerClientHooks, set_active_for_test,
|
||||
},
|
||||
state::{ffi_context, find_instance_id_by_name},
|
||||
*,
|
||||
};
|
||||
use easytier::{
|
||||
common::config::{ConfigFileControl, ConfigLoader as _, TomlConfigLoader},
|
||||
web_client::WebClientHooks,
|
||||
};
|
||||
use serde_json::Value;
|
||||
use std::{
|
||||
collections::HashSet,
|
||||
ffi::{CStr, CString, c_char, c_int, c_void},
|
||||
sync::{Mutex, mpsc},
|
||||
time::Duration,
|
||||
};
|
||||
use uuid::Uuid;
|
||||
|
||||
#[test]
|
||||
fn test_parse_config() {
|
||||
let cfg_str = r#"
|
||||
inst_name = "test"
|
||||
network = "test_network"
|
||||
"#;
|
||||
let cstr = std::ffi::CString::new(cfg_str).unwrap();
|
||||
unsafe {
|
||||
assert_eq!(parse_config(cstr.as_ptr()), 0);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_run_network_instance() {
|
||||
let cfg_str = r#"
|
||||
inst_name = "test"
|
||||
network = "test_network"
|
||||
"#;
|
||||
let cstr = std::ffi::CString::new(cfg_str).unwrap();
|
||||
unsafe {
|
||||
assert_eq!(run_network_instance(cstr.as_ptr()), 0);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn get_error_msg_returns_config_server_callback_error() {
|
||||
let hooks = ManagedConfigServerClientHooks::new(None, std::ptr::null_mut());
|
||||
let callback_error = format!("callback delivery failed {}", Uuid::new_v4());
|
||||
crate::config_server::clear_last_callback_error();
|
||||
hooks.note_callback_error(callback_error.clone());
|
||||
|
||||
unsafe {
|
||||
let mut error_ptr: *const c_char = std::ptr::null();
|
||||
get_error_msg(&mut error_ptr);
|
||||
assert!(!error_ptr.is_null());
|
||||
let error_msg = CStr::from_ptr(error_ptr).to_string_lossy().into_owned();
|
||||
free_string(error_ptr);
|
||||
assert!(error_msg.contains(&callback_error));
|
||||
}
|
||||
|
||||
crate::config_server::clear_last_callback_error();
|
||||
}
|
||||
|
||||
unsafe extern "C" fn record_config_server_event(event_json: *const c_char, user_data: *mut c_void) {
|
||||
let events = unsafe { &*(user_data as *const Mutex<Vec<String>>) };
|
||||
events.lock().unwrap().push(
|
||||
unsafe { CStr::from_ptr(event_json) }
|
||||
.to_string_lossy()
|
||||
.into_owned(),
|
||||
);
|
||||
}
|
||||
|
||||
fn take_last_error() -> Option<String> {
|
||||
unsafe {
|
||||
let mut error_ptr: *const c_char = std::ptr::null();
|
||||
get_error_msg(&mut error_ptr);
|
||||
if error_ptr.is_null() {
|
||||
None
|
||||
} else {
|
||||
let error = CStr::from_ptr(error_ptr).to_string_lossy().into_owned();
|
||||
free_string(error_ptr);
|
||||
Some(error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn free_key_value_pairs(infos: &[KeyValuePair]) {
|
||||
for info in infos {
|
||||
free_string(info.key);
|
||||
free_string(info.value);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn list_instance_returns_instance_names_and_ids() {
|
||||
let instance_id = Uuid::new_v4();
|
||||
let instance_name = format!("list-instance-{}", instance_id);
|
||||
let cfg = TomlConfigLoader::default();
|
||||
cfg.set_id(instance_id);
|
||||
cfg.set_inst_name(instance_name.clone());
|
||||
ffi_context()
|
||||
.manager
|
||||
.run_network_instance(cfg, ConfigFileControl::STATIC_CONFIG)
|
||||
.unwrap();
|
||||
|
||||
let mut infos = vec![
|
||||
KeyValuePair {
|
||||
key: std::ptr::null(),
|
||||
value: std::ptr::null(),
|
||||
};
|
||||
16
|
||||
];
|
||||
let count = unsafe { list_instance(infos.as_mut_ptr(), infos.len()) };
|
||||
assert!(count > 0);
|
||||
|
||||
let mut found = false;
|
||||
for info in infos.iter().take(count as usize) {
|
||||
let key = unsafe { CStr::from_ptr(info.key) }.to_string_lossy();
|
||||
let value = unsafe { CStr::from_ptr(info.value) }.to_string_lossy();
|
||||
if key == instance_name {
|
||||
assert_eq!(value, instance_id.to_string());
|
||||
found = true;
|
||||
}
|
||||
}
|
||||
|
||||
free_key_value_pairs(&infos[..count as usize]);
|
||||
ffi_context()
|
||||
.runtime
|
||||
.block_on(
|
||||
ffi_context()
|
||||
.manager
|
||||
.delete_network_instances([instance_id]),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(found);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn list_instance_allows_zero_length() {
|
||||
assert_eq!(unsafe { list_instance(std::ptr::null_mut(), 0) }, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn list_instance_rejects_null_output_pointer() {
|
||||
assert_eq!(unsafe { list_instance(std::ptr::null_mut(), 1) }, -1);
|
||||
assert!(take_last_error().unwrap().contains("infos is null"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn call_json_rpc_returns_logger_response() {
|
||||
let service = CString::new("api.logger.LoggerRpcService").unwrap();
|
||||
let method = CString::new("get_logger_config").unwrap();
|
||||
let payload = CString::new("{}").unwrap();
|
||||
let mut response_ptr: *const c_char = std::ptr::null();
|
||||
|
||||
assert_eq!(
|
||||
unsafe {
|
||||
call_json_rpc(
|
||||
service.as_ptr(),
|
||||
method.as_ptr(),
|
||||
std::ptr::null(),
|
||||
payload.as_ptr(),
|
||||
&mut response_ptr,
|
||||
)
|
||||
},
|
||||
0
|
||||
);
|
||||
assert!(!response_ptr.is_null());
|
||||
let response = unsafe { CStr::from_ptr(response_ptr) }
|
||||
.to_string_lossy()
|
||||
.into_owned();
|
||||
free_string(response_ptr);
|
||||
let response: Value = serde_json::from_str(&response).unwrap();
|
||||
assert!(response.get("level").is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn call_json_rpc_rejects_instance_management_service() {
|
||||
let service = CString::new("api.manage.WebClientService").unwrap();
|
||||
let method = CString::new("list_network_instance").unwrap();
|
||||
let payload = CString::new("{}").unwrap();
|
||||
let mut response_ptr: *const c_char = std::ptr::null();
|
||||
|
||||
assert_eq!(
|
||||
unsafe {
|
||||
call_json_rpc(
|
||||
service.as_ptr(),
|
||||
method.as_ptr(),
|
||||
std::ptr::null(),
|
||||
payload.as_ptr(),
|
||||
&mut response_ptr,
|
||||
)
|
||||
},
|
||||
-1
|
||||
);
|
||||
assert!(response_ptr.is_null());
|
||||
assert!(take_last_error().unwrap().contains("not exposed"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn call_json_rpc_rejects_malformed_payload_json() {
|
||||
let service = CString::new("api.logger.LoggerRpcService").unwrap();
|
||||
let method = CString::new("get_logger_config").unwrap();
|
||||
let payload = CString::new("{").unwrap();
|
||||
let mut response_ptr: *const c_char = std::ptr::null();
|
||||
|
||||
assert_eq!(
|
||||
unsafe {
|
||||
call_json_rpc(
|
||||
service.as_ptr(),
|
||||
method.as_ptr(),
|
||||
std::ptr::null(),
|
||||
payload.as_ptr(),
|
||||
&mut response_ptr,
|
||||
)
|
||||
},
|
||||
-1
|
||||
);
|
||||
assert!(response_ptr.is_null());
|
||||
assert!(
|
||||
take_last_error()
|
||||
.unwrap()
|
||||
.contains("failed to parse payload_json")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn call_json_rpc_rejects_null_output_pointer() {
|
||||
let service = CString::new("api.logger.LoggerRpcService").unwrap();
|
||||
let method = CString::new("get_logger_config").unwrap();
|
||||
let payload = CString::new("{}").unwrap();
|
||||
|
||||
assert_eq!(
|
||||
unsafe {
|
||||
call_json_rpc(
|
||||
service.as_ptr(),
|
||||
method.as_ptr(),
|
||||
std::ptr::null(),
|
||||
payload.as_ptr(),
|
||||
std::ptr::null_mut(),
|
||||
)
|
||||
},
|
||||
-1
|
||||
);
|
||||
assert!(
|
||||
take_last_error()
|
||||
.unwrap()
|
||||
.contains("out_response_json is null")
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn config_server_hooks_emit_run_event() {
|
||||
let events: Mutex<Vec<String>> = Mutex::new(Vec::new());
|
||||
let hooks = ManagedConfigServerClientHooks::new(
|
||||
Some(record_config_server_event),
|
||||
&events as *const _ as *mut c_void,
|
||||
);
|
||||
let instance_id = Uuid::new_v4();
|
||||
let cfg = TomlConfigLoader::default();
|
||||
cfg.set_id(instance_id);
|
||||
let inst_name = format!("test-{}", instance_id);
|
||||
cfg.set_inst_name(inst_name.clone());
|
||||
hooks.pre_run_network_instance(&cfg).await.unwrap();
|
||||
ffi_context()
|
||||
.manager
|
||||
.run_network_instance(cfg, ConfigFileControl::STATIC_CONFIG)
|
||||
.unwrap();
|
||||
|
||||
hooks.post_run_network_instance(&instance_id).await.unwrap();
|
||||
|
||||
let duplicate_cfg = TomlConfigLoader::default();
|
||||
duplicate_cfg.set_inst_name(inst_name);
|
||||
duplicate_cfg.set_id(Uuid::new_v4());
|
||||
assert!(
|
||||
hooks
|
||||
.pre_run_network_instance(&duplicate_cfg)
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
|
||||
assert_eq!(hooks.tracked_instance_ids(), vec![instance_id]);
|
||||
let events = events.lock().unwrap().clone();
|
||||
assert_eq!(events.len(), 1);
|
||||
let event: Value = serde_json::from_str(&events[0]).unwrap();
|
||||
assert_eq!(event["event"], "run_network_instance");
|
||||
assert_eq!(event["success"], true);
|
||||
assert_eq!(event["instance_id"], instance_id.to_string());
|
||||
assert!(event["error"].is_null());
|
||||
ffi_context()
|
||||
.manager
|
||||
.delete_network_instances([instance_id])
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn config_server_hooks_emit_delete_events_for_tracked_instances() {
|
||||
let events: Mutex<Vec<String>> = Mutex::new(Vec::new());
|
||||
let hooks = ManagedConfigServerClientHooks::new(
|
||||
Some(record_config_server_event),
|
||||
&events as *const _ as *mut c_void,
|
||||
);
|
||||
let instance_id_1 = Uuid::new_v4();
|
||||
let instance_id_2 = Uuid::new_v4();
|
||||
let unknown_instance_id = Uuid::new_v4();
|
||||
for id in [instance_id_1, instance_id_2] {
|
||||
let cfg = TomlConfigLoader::default();
|
||||
cfg.set_id(id);
|
||||
cfg.set_inst_name(format!("test-{}", id));
|
||||
hooks.pre_run_network_instance(&cfg).await.unwrap();
|
||||
ffi_context()
|
||||
.manager
|
||||
.run_network_instance(cfg, ConfigFileControl::STATIC_CONFIG)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
hooks
|
||||
.post_run_network_instance(&instance_id_1)
|
||||
.await
|
||||
.unwrap();
|
||||
hooks
|
||||
.post_run_network_instance(&instance_id_2)
|
||||
.await
|
||||
.unwrap();
|
||||
events.lock().unwrap().clear();
|
||||
|
||||
hooks
|
||||
.post_remove_network_instances(&[instance_id_1, unknown_instance_id, instance_id_2])
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert!(hooks.tracked_instance_ids().is_empty());
|
||||
let events = events.lock().unwrap().clone();
|
||||
assert_eq!(events.len(), 2);
|
||||
let event_ids = events
|
||||
.iter()
|
||||
.map(|event| {
|
||||
let event: Value = serde_json::from_str(event).unwrap();
|
||||
assert_eq!(event["event"], "delete_network_instance");
|
||||
assert_eq!(event["success"], true);
|
||||
assert!(event["error"].is_null());
|
||||
event["instance_id"].as_str().unwrap().to_string()
|
||||
})
|
||||
.collect::<HashSet<_>>();
|
||||
assert_eq!(
|
||||
event_ids,
|
||||
HashSet::from([instance_id_1.to_string(), instance_id_2.to_string()])
|
||||
);
|
||||
ffi_context()
|
||||
.manager
|
||||
.delete_network_instances([instance_id_1, instance_id_2])
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn config_server_hooks_ignore_untracked_instance_without_event() {
|
||||
let events: Mutex<Vec<String>> = Mutex::new(Vec::new());
|
||||
let hooks = ManagedConfigServerClientHooks::new(
|
||||
Some(record_config_server_event),
|
||||
&events as *const _ as *mut c_void,
|
||||
);
|
||||
let local_id = Uuid::new_v4();
|
||||
|
||||
hooks
|
||||
.post_remove_network_instances(&[local_id])
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert!(events.lock().unwrap().is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn config_server_hooks_reject_duplicate_instance_name() {
|
||||
let hooks = ManagedConfigServerClientHooks::new(None, std::ptr::null_mut());
|
||||
let inst_name = format!("test-{}", Uuid::new_v4());
|
||||
let existing_id = Uuid::new_v4();
|
||||
let new_id = Uuid::new_v4();
|
||||
let existing_cfg = TomlConfigLoader::default();
|
||||
existing_cfg.set_inst_name(inst_name.clone());
|
||||
existing_cfg.set_id(existing_id);
|
||||
ffi_context()
|
||||
.manager
|
||||
.run_network_instance(existing_cfg, ConfigFileControl::STATIC_CONFIG)
|
||||
.unwrap();
|
||||
|
||||
let cfg = TomlConfigLoader::default();
|
||||
cfg.set_inst_name(inst_name.clone());
|
||||
cfg.set_id(new_id);
|
||||
|
||||
assert!(hooks.pre_run_network_instance(&cfg).await.is_err());
|
||||
assert_eq!(find_instance_id_by_name(&inst_name), Some(existing_id));
|
||||
ffi_context()
|
||||
.manager
|
||||
.delete_network_instances([existing_id])
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn config_server_hooks_remove_overwritten_id_before_duplicate_name_error() {
|
||||
let events: Mutex<Vec<String>> = Mutex::new(Vec::new());
|
||||
let hooks = ManagedConfigServerClientHooks::new(
|
||||
Some(record_config_server_event),
|
||||
&events as *const _ as *mut c_void,
|
||||
);
|
||||
let old_name = format!("old-{}", Uuid::new_v4());
|
||||
let duplicate_name = format!("duplicate-{}", Uuid::new_v4());
|
||||
let overwritten_id = Uuid::new_v4();
|
||||
let duplicate_id = Uuid::new_v4();
|
||||
hooks.instance_ids.lock().unwrap().insert(overwritten_id);
|
||||
for (id, name) in [
|
||||
(overwritten_id, old_name.clone()),
|
||||
(duplicate_id, duplicate_name.clone()),
|
||||
] {
|
||||
let cfg = TomlConfigLoader::default();
|
||||
cfg.set_id(id);
|
||||
cfg.set_inst_name(name);
|
||||
ffi_context()
|
||||
.manager
|
||||
.run_network_instance(cfg, ConfigFileControl::STATIC_CONFIG)
|
||||
.unwrap();
|
||||
}
|
||||
ffi_context()
|
||||
.manager
|
||||
.delete_network_instances([overwritten_id])
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
hooks
|
||||
.post_remove_network_instances(&[overwritten_id])
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let cfg = TomlConfigLoader::default();
|
||||
cfg.set_inst_name(duplicate_name.clone());
|
||||
cfg.set_id(overwritten_id);
|
||||
|
||||
assert!(hooks.pre_run_network_instance(&cfg).await.is_err());
|
||||
assert!(hooks.tracked_instance_ids().is_empty());
|
||||
assert!(find_instance_id_by_name(&old_name).is_none());
|
||||
assert_eq!(
|
||||
find_instance_id_by_name(&duplicate_name),
|
||||
Some(duplicate_id)
|
||||
);
|
||||
assert_eq!(events.lock().unwrap().len(), 1);
|
||||
ffi_context()
|
||||
.manager
|
||||
.delete_network_instances([duplicate_id])
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn config_server_hooks_remove_tracked_state_before_overwrite_retry() {
|
||||
let hooks = ManagedConfigServerClientHooks::new(None, std::ptr::null_mut());
|
||||
let inst_name = format!("test-{}", Uuid::new_v4());
|
||||
let instance_id = Uuid::new_v4();
|
||||
hooks.instance_ids.lock().unwrap().insert(instance_id);
|
||||
|
||||
let cfg = TomlConfigLoader::default();
|
||||
cfg.set_inst_name(inst_name.clone());
|
||||
cfg.set_id(instance_id);
|
||||
ffi_context()
|
||||
.manager
|
||||
.run_network_instance(cfg.clone(), ConfigFileControl::STATIC_CONFIG)
|
||||
.unwrap();
|
||||
ffi_context()
|
||||
.manager
|
||||
.delete_network_instances([instance_id])
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
hooks
|
||||
.post_remove_network_instances(&[instance_id])
|
||||
.await
|
||||
.unwrap();
|
||||
hooks.pre_run_network_instance(&cfg).await.unwrap();
|
||||
|
||||
assert!(hooks.tracked_instance_ids().is_empty());
|
||||
assert!(find_instance_id_by_name(&inst_name).is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn config_server_hooks_reject_post_run_after_external_delete() {
|
||||
let hooks = ManagedConfigServerClientHooks::new(None, std::ptr::null_mut());
|
||||
let instance_id = Uuid::new_v4();
|
||||
let cfg = TomlConfigLoader::default();
|
||||
cfg.set_id(instance_id);
|
||||
cfg.set_inst_name(format!("test-{}", instance_id));
|
||||
hooks.pre_run_network_instance(&cfg).await.unwrap();
|
||||
ffi_context()
|
||||
.manager
|
||||
.run_network_instance(cfg, ConfigFileControl::STATIC_CONFIG)
|
||||
.unwrap();
|
||||
ffi_context()
|
||||
.manager
|
||||
.delete_network_instances([instance_id])
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert!(hooks.post_run_network_instance(&instance_id).await.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn find_instance_id_by_name_resolves_uncommitted_manager_instance_name() {
|
||||
let instance_id = Uuid::new_v4();
|
||||
let inst_name = format!("test-{}", instance_id);
|
||||
let cfg = TomlConfigLoader::default();
|
||||
cfg.set_id(instance_id);
|
||||
cfg.set_inst_name(inst_name.clone());
|
||||
ffi_context()
|
||||
.manager
|
||||
.run_network_instance(cfg, ConfigFileControl::STATIC_CONFIG)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(find_instance_id_by_name(&inst_name), Some(instance_id));
|
||||
ffi_context()
|
||||
.runtime
|
||||
.block_on(
|
||||
ffi_context()
|
||||
.manager
|
||||
.delete_network_instances([instance_id]),
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn delete_network_instance_removes_only_named_instances() {
|
||||
let keep_id = Uuid::new_v4();
|
||||
let delete_id = Uuid::new_v4();
|
||||
let keep_name = format!("keep-{}", keep_id);
|
||||
let delete_name = format!("delete-{}", delete_id);
|
||||
|
||||
for (id, name) in [
|
||||
(keep_id, keep_name.clone()),
|
||||
(delete_id, delete_name.clone()),
|
||||
] {
|
||||
let cfg = TomlConfigLoader::default();
|
||||
cfg.set_id(id);
|
||||
cfg.set_inst_name(name.clone());
|
||||
ffi_context()
|
||||
.manager
|
||||
.run_network_instance(cfg, ConfigFileControl::STATIC_CONFIG)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
let delete_name = CString::new(delete_name.clone()).unwrap();
|
||||
let inst_names = [delete_name.as_ptr()];
|
||||
assert_eq!(
|
||||
unsafe { delete_network_instance(inst_names.as_ptr(), inst_names.len()) },
|
||||
0
|
||||
);
|
||||
|
||||
assert_eq!(find_instance_id_by_name(&keep_name), Some(keep_id));
|
||||
assert!(find_instance_id_by_name(delete_name.to_str().unwrap()).is_none());
|
||||
|
||||
ffi_context()
|
||||
.runtime
|
||||
.block_on(ffi_context().manager.delete_network_instances([keep_id]))
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn retain_and_delete_network_instance_reject_invalid_name_pointers() {
|
||||
assert_eq!(unsafe { retain_network_instance(std::ptr::null(), 1) }, -1);
|
||||
assert_eq!(unsafe { delete_network_instance(std::ptr::null(), 1) }, -1);
|
||||
|
||||
let inst_names = [std::ptr::null()];
|
||||
assert_eq!(
|
||||
unsafe { retain_network_instance(inst_names.as_ptr(), inst_names.len()) },
|
||||
-1
|
||||
);
|
||||
assert_eq!(
|
||||
unsafe { delete_network_instance(inst_names.as_ptr(), inst_names.len()) },
|
||||
-1
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ffi_process_management_uses_manager_mutation_lock() {
|
||||
let manager_guard = ffi_context().manager.mutation_lock().blocking_lock_owned();
|
||||
let (done_tx, done_rx) = mpsc::channel();
|
||||
let waiter = std::thread::spawn(move || {
|
||||
ffi_context()
|
||||
.runtime
|
||||
.block_on(
|
||||
ffi_context()
|
||||
.process_management
|
||||
.delete_owned_network_instances(Vec::new()),
|
||||
)
|
||||
.unwrap();
|
||||
done_tx.send(()).unwrap();
|
||||
});
|
||||
|
||||
assert!(done_rx.recv_timeout(Duration::from_millis(100)).is_err());
|
||||
drop(manager_guard);
|
||||
done_rx.recv_timeout(Duration::from_secs(5)).unwrap();
|
||||
waiter.join().unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn config_server_hooks_reject_late_runs_for_core_rollback() {
|
||||
let events: Mutex<Vec<String>> = Mutex::new(Vec::new());
|
||||
let hooks = ManagedConfigServerClientHooks::new(
|
||||
Some(record_config_server_event),
|
||||
&events as *const _ as *mut c_void,
|
||||
);
|
||||
hooks.start_stopping();
|
||||
|
||||
assert!(
|
||||
hooks
|
||||
.post_run_network_instance(&Uuid::new_v4())
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
|
||||
assert!(hooks.tracked_instance_ids().is_empty());
|
||||
assert!(events.lock().unwrap().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn delete_network_instance_rejects_an_ambiguous_name() {
|
||||
let duplicate_name = format!("duplicate-{}", Uuid::new_v4());
|
||||
let instance_ids = [Uuid::new_v4(), Uuid::new_v4()];
|
||||
for instance_id in instance_ids {
|
||||
let config = TomlConfigLoader::default();
|
||||
config.set_id(instance_id);
|
||||
config.set_inst_name(duplicate_name.clone());
|
||||
ffi_context()
|
||||
.manager
|
||||
.run_network_instance(config, ConfigFileControl::STATIC_CONFIG)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
let duplicate_name = CString::new(duplicate_name).unwrap();
|
||||
let names = [duplicate_name.as_ptr()];
|
||||
assert_eq!(
|
||||
unsafe { delete_network_instance(names.as_ptr(), names.len()) },
|
||||
-1
|
||||
);
|
||||
assert!(take_last_error().unwrap().contains("2 instances match"));
|
||||
assert!(
|
||||
instance_ids
|
||||
.iter()
|
||||
.all(|id| ffi_context().manager.instance(*id).is_some())
|
||||
);
|
||||
|
||||
ffi_context()
|
||||
.runtime
|
||||
.block_on(
|
||||
ffi_context()
|
||||
.process_management
|
||||
.delete_owned_network_instances(instance_ids.to_vec()),
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn config_server_callback_context_rejects_nested_blocking_ffi_calls() {
|
||||
let _callback_scope = ConfigServerCallbackScope::enter();
|
||||
assert_eq!(is_config_server_client_connected(), 0);
|
||||
let service = CString::new("api.logger.LoggerRpcService").unwrap();
|
||||
let method = CString::new("get_logger_config").unwrap();
|
||||
let payload = CString::new("{}").unwrap();
|
||||
let mut response_ptr: *const c_char = std::ptr::null();
|
||||
assert_eq!(
|
||||
unsafe {
|
||||
call_json_rpc(
|
||||
service.as_ptr(),
|
||||
method.as_ptr(),
|
||||
std::ptr::null(),
|
||||
payload.as_ptr(),
|
||||
&mut response_ptr,
|
||||
)
|
||||
},
|
||||
-1
|
||||
);
|
||||
assert!(response_ptr.is_null());
|
||||
assert_eq!(
|
||||
unsafe { collect_network_infos(std::ptr::null_mut(), 0) },
|
||||
-1
|
||||
);
|
||||
assert_eq!(unsafe { list_instance(std::ptr::null_mut(), 0) }, -1);
|
||||
let cfg = CString::new("inst_name = \"callback-test\"\nlisteners = []").unwrap();
|
||||
assert_eq!(unsafe { run_network_instance(cfg.as_ptr()) }, -1);
|
||||
assert_eq!(unsafe { retain_network_instance(std::ptr::null(), 0) }, -1);
|
||||
assert_eq!(unsafe { delete_network_instance(std::ptr::null(), 0) }, -1);
|
||||
let url = CString::new("ring://test/token").unwrap();
|
||||
let machine_id = CString::new("test-machine").unwrap();
|
||||
assert_eq!(
|
||||
unsafe {
|
||||
start_config_server_client(
|
||||
url.as_ptr(),
|
||||
std::ptr::null(),
|
||||
machine_id.as_ptr(),
|
||||
false,
|
||||
None,
|
||||
std::ptr::null_mut(),
|
||||
)
|
||||
},
|
||||
-1
|
||||
);
|
||||
assert_eq!(stop_config_server_client(), -1);
|
||||
|
||||
#[cfg(feature = "ffi-dataplane")]
|
||||
{
|
||||
let mut session = 0;
|
||||
assert_eq!(
|
||||
unsafe { data_plane_session_open(std::ptr::null(), &mut session) },
|
||||
-(easytier_core::gateway::DataPlaneErrorKind::Io as c_int)
|
||||
);
|
||||
assert_eq!(session, 0);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "ffi-dataplane")]
|
||||
#[test]
|
||||
fn active_config_server_rejects_data_plane() {
|
||||
set_active_for_test(true);
|
||||
|
||||
let name = CString::new("missing").unwrap();
|
||||
let mut session = 0;
|
||||
assert_eq!(
|
||||
unsafe { data_plane_session_open(name.as_ptr(), &mut session) },
|
||||
-(easytier_core::gateway::DataPlaneErrorKind::Io as c_int)
|
||||
);
|
||||
assert_eq!(session, 0);
|
||||
|
||||
set_active_for_test(false);
|
||||
}
|
||||
|
||||
#[cfg(feature = "ffi-dataplane")]
|
||||
#[test]
|
||||
fn data_plane_invalid_handle_errors_are_stable() {
|
||||
let closed = -(easytier_core::gateway::DataPlaneErrorKind::HandleClosed as c_int);
|
||||
assert_eq!(data_plane_completion_wait(u64::MAX, 0), closed);
|
||||
assert_eq!(data_plane_operation_cancel(u64::MAX, 1), closed);
|
||||
assert_eq!(data_plane_operation_free(u64::MAX, 1), closed);
|
||||
assert_eq!(data_plane_resource_close(u64::MAX, 1), closed);
|
||||
assert_eq!(
|
||||
data_plane_resource_deadline_set(u64::MAX, 1, DATA_PLANE_DEADLINE_READ, 0),
|
||||
closed
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
use std::ffi::{c_char, c_void};
|
||||
|
||||
#[repr(C)]
|
||||
#[derive(Clone, Copy)]
|
||||
pub struct KeyValuePair {
|
||||
pub key: *const c_char,
|
||||
pub value: *const c_char,
|
||||
}
|
||||
|
||||
pub type ConfigServerEventCallback = Option<unsafe extern "C" fn(*const c_char, *mut c_void)>;
|
||||
|
||||
#[repr(C)]
|
||||
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
|
||||
pub struct DataPlaneSocketAddr {
|
||||
/// `4` for IPv4. Other families are reserved for later ABI versions.
|
||||
pub family: u16,
|
||||
/// Native-endian port number.
|
||||
pub port: u16,
|
||||
/// Network-order address bytes. IPv4 uses the first four bytes.
|
||||
pub address: [u8; 16],
|
||||
}
|
||||
|
||||
#[repr(C)]
|
||||
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
|
||||
pub struct DataPlaneCompletion {
|
||||
pub operation_id: u64,
|
||||
pub operation_kind: u16,
|
||||
/// `0` for success, otherwise a stable `DataPlaneErrorKind` value.
|
||||
pub status: u16,
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
[package]
|
||||
name = "easytier-ios"
|
||||
version = "0.1.0"
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
|
||||
[lib]
|
||||
crate-type = ["staticlib", "rlib"]
|
||||
|
||||
[dependencies]
|
||||
serde_json.workspace = true
|
||||
tracing.workspace = true
|
||||
tracing-subscriber = { workspace = true, default-features = true, features = ["env-filter"] }
|
||||
easytier-ffi = { workspace = true, features = [
|
||||
"c-abi",
|
||||
] }
|
||||
|
||||
[dev-dependencies]
|
||||
uuid.workspace = true
|
||||
easytier-core = { workspace = true, default-features = true }
|
||||
easytier-ffi = { workspace = true, features = [
|
||||
"c-abi",
|
||||
"ffi-dataplane",
|
||||
] }
|
||||
+70
@@ -0,0 +1,70 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# Build the easytier-ios static library slices for the Flutter iOS client.
|
||||
#
|
||||
# This script only runs on macOS: it needs the Apple SDK (aarch64-apple-ios*,
|
||||
# x86_64-apple-ios targets) plus `lipo`. Run it from the EasyTier repository
|
||||
# root or from this crate directory.
|
||||
#
|
||||
# rustup target add aarch64-apple-ios aarch64-apple-ios-sim x86_64-apple-ios
|
||||
# ./build-xcframework.sh
|
||||
#
|
||||
# Output (workspace target directory + ./xcframework/sim):
|
||||
# target/aarch64-apple-ios/release/libeasytier_ios.a (device)
|
||||
# xcframework/sim/libeasytier_ios.a (simulator, lipo merged)
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
CRATE_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
# The crate lives in a workspace; build artifacts land in the workspace root
|
||||
# target directory regardless of the current directory.
|
||||
WORKSPACE_ROOT="$(cd "${CRATE_DIR}/../.." && pwd)"
|
||||
TARGET_DIR="${WORKSPACE_ROOT}/target"
|
||||
OUT_DIR="${CRATE_DIR}/xcframework"
|
||||
|
||||
if [[ "$(uname)" != "Darwin" ]]; then
|
||||
echo "error: build-xcframework.sh must run on macOS (needs Apple SDK, lipo)" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
cd "${WORKSPACE_ROOT}"
|
||||
|
||||
# The Rust iOS targets emit a `___chkstk_darwin` stack-probe call but do not
|
||||
# link the compiler-rt archive that provides it. Point the linker at the
|
||||
# matching device or simulator archive shipped inside the Xcode toolchain.
|
||||
CLANG_BIN="$(xcrun --find clang)" # .../Toolchains/XcodeDefault.xctoolchain/usr/bin/clang
|
||||
TOOLCHAIN_USR="${CLANG_BIN%/bin/clang}" # .../XcodeDefault.xctoolchain/usr
|
||||
CLANG_RT_DIR="$(cd "${TOOLCHAIN_USR}/lib/clang" && cd "$(ls | sort -V | tail -1)/lib/darwin" && pwd)"
|
||||
CLANG_RT_RUSTFLAGS="${RUSTFLAGS:-} -C link-arg=-L${CLANG_RT_DIR}"
|
||||
echo "==> using libclang_rt from ${CLANG_RT_DIR}"
|
||||
|
||||
# kcp-sys's bindgen rejects the `-sim` in the aarch64-apple-ios-sim target
|
||||
# triple; give bindgen an explicit simulator target so the C bindings build.
|
||||
SIM_SDK="$(xcrun --sdk iphonesimulator --show-sdk-path)"
|
||||
|
||||
echo "==> building aarch64-apple-ios (device)"
|
||||
RUSTFLAGS="${CLANG_RT_RUSTFLAGS} -C link-arg=-lclang_rt.ios" \
|
||||
cargo build -p easytier-ios --release --target aarch64-apple-ios
|
||||
|
||||
echo "==> building aarch64-apple-ios-sim (Apple Silicon simulator)"
|
||||
BINDGEN_EXTRA_CLANG_ARGS="--target=arm64-apple-ios17.0-simulator -isysroot ${SIM_SDK}" \
|
||||
RUSTFLAGS="${CLANG_RT_RUSTFLAGS} -C link-arg=-lclang_rt.iossim" \
|
||||
cargo build -p easytier-ios --release --target aarch64-apple-ios-sim
|
||||
|
||||
echo "==> building x86_64-apple-ios (Intel simulator)"
|
||||
BINDGEN_EXTRA_CLANG_ARGS="--target=x86_64-apple-ios17.0-simulator -isysroot ${SIM_SDK}" \
|
||||
RUSTFLAGS="${CLANG_RT_RUSTFLAGS} -C link-arg=-lclang_rt.iossim" \
|
||||
cargo build -p easytier-ios --release --target x86_64-apple-ios
|
||||
|
||||
rm -rf "${OUT_DIR}"
|
||||
mkdir -p "${OUT_DIR}/sim"
|
||||
|
||||
echo "==> lipo: merge simulator slices"
|
||||
lipo -create \
|
||||
"${TARGET_DIR}/aarch64-apple-ios-sim/release/libeasytier_ios.a" \
|
||||
"${TARGET_DIR}/x86_64-apple-ios/release/libeasytier_ios.a" \
|
||||
-output "${OUT_DIR}/sim/libeasytier_ios.a"
|
||||
|
||||
echo "==> done:"
|
||||
echo " device: ${TARGET_DIR}/aarch64-apple-ios/release/libeasytier_ios.a"
|
||||
echo " simulator: ${OUT_DIR}/sim/libeasytier_ios.a"
|
||||
@@ -0,0 +1,158 @@
|
||||
/**
|
||||
* @file easytier-ios.h
|
||||
* @brief iOS-facing C ABI for EasyTier.
|
||||
*
|
||||
* This library embeds EasyTier into an iOS app without a TUN device or
|
||||
* NEPacketTunnel: it manages EasyTier instances and bridges to the EasyTier
|
||||
* management RPC surface. Loopback port forwarding into the virtual network
|
||||
* is configured through easytier_ios_call_json_rpc() with
|
||||
* api.config.ConfigRpcService/PatchConfig port-forward patches; there is no
|
||||
* built-in forwarder.
|
||||
*
|
||||
* Error handling: functions returning `int` return 0 on success and -1 on
|
||||
* failure; functions returning `char *` return NULL on failure. Call
|
||||
* easytier_ios_last_error() on the same thread to retrieve details.
|
||||
*
|
||||
* Threading: all functions are safe to call from any thread. The last-error
|
||||
* buffer is thread-local, so query it on the thread that received the
|
||||
* failure.
|
||||
*/
|
||||
|
||||
#ifndef EASYTIER_IOS_H
|
||||
#define EASYTIER_IOS_H
|
||||
|
||||
#include <stdint.h>
|
||||
|
||||
#ifdef __cplusplus
|
||||
extern "C" {
|
||||
#endif
|
||||
|
||||
/**
|
||||
* @brief Configure persistent EasyTier diagnostic logging.
|
||||
*
|
||||
* Enabling writes targeted connection trace/debug events into rotating log
|
||||
* files in `directory`; disabling turns the filter off and flushes output.
|
||||
*
|
||||
* @param directory UTF-8 directory path. Required when enabling; ignored when
|
||||
* disabling.
|
||||
* @param enabled Non-zero to enable, zero to disable.
|
||||
* @return 0 on success, -1 on failure.
|
||||
*/
|
||||
int easytier_ios_configure_diagnostic_logging(const char *directory,
|
||||
int enabled);
|
||||
|
||||
/**
|
||||
* @brief Append a host lifecycle or network-path marker to the active log.
|
||||
*
|
||||
* This is a no-op while diagnostic logging is disabled.
|
||||
*
|
||||
* @param message Non-null NUL-terminated UTF-8 event text.
|
||||
* @return 0 on success, -1 on failure.
|
||||
*/
|
||||
int easytier_ios_append_diagnostic_event(const char *message);
|
||||
|
||||
/** @brief Flush diagnostic log output. */
|
||||
int easytier_ios_flush_diagnostic_logging(void);
|
||||
|
||||
/** @brief Delete all diagnostic log content and reopen the active log. */
|
||||
int easytier_ios_clear_diagnostic_logs(void);
|
||||
|
||||
/**
|
||||
* @brief Start one EasyTier network instance from a TOML config string.
|
||||
*
|
||||
* The config's `instance_name` must be unique among instances started
|
||||
* through this library.
|
||||
*
|
||||
* @param toml Non-null pointer to a NUL-terminated UTF-8 TOML config string.
|
||||
* @return 0 on success, -1 on failure.
|
||||
*/
|
||||
int easytier_ios_run_instance(const char *toml);
|
||||
|
||||
/**
|
||||
* @brief Keep the named instances and stop all others.
|
||||
*
|
||||
* @param names_json Null, empty, or a NUL-terminated JSON array of instance
|
||||
* name strings. Null / empty / `[]` stops every running
|
||||
* instance.
|
||||
* @return 0 on success, -1 on failure.
|
||||
*/
|
||||
int easytier_ios_retain_instances(const char *names_json);
|
||||
|
||||
/**
|
||||
* @brief Stop exactly one named instance without affecting other instances.
|
||||
*
|
||||
* An unknown name is a no-op.
|
||||
*
|
||||
* @param instance_name Non-null NUL-terminated instance name.
|
||||
* @return 0 on success, -1 on failure.
|
||||
*/
|
||||
int easytier_ios_delete_instance(const char *instance_name);
|
||||
|
||||
/**
|
||||
* @brief Collect running instance information as a JSON object.
|
||||
*
|
||||
* The result maps each instance name to its running info JSON object.
|
||||
*
|
||||
* @param max_length Maximum number of instances to report.
|
||||
* @return A newly allocated NUL-terminated JSON string on success, NULL on
|
||||
* failure.
|
||||
*
|
||||
* @ownership The caller owns the returned string and must release it with
|
||||
* easytier_ios_free_string().
|
||||
*/
|
||||
char *easytier_ios_collect_network_infos(int max_length);
|
||||
|
||||
/**
|
||||
* @brief Call an exposed EasyTier management RPC method using protobuf JSON.
|
||||
*
|
||||
* `service_name` is the protobuf service name (e.g.
|
||||
* "api.config.ConfigRpcService"), `method_name` the RPC method name (e.g.
|
||||
* "PatchConfig"). `payload_json` must contain the protobuf JSON request,
|
||||
* including any `instance` selector required by the target RPC.
|
||||
*
|
||||
* Port forwarding into the virtual network is driven through this bridge
|
||||
* with api.config.ConfigRpcService/PatchConfig port-forward patches.
|
||||
*
|
||||
* @param service_name Non-null NUL-terminated RPC service name.
|
||||
* @param method_name Non-null NUL-terminated RPC method name.
|
||||
* @param payload_json Non-null NUL-terminated protobuf JSON request body.
|
||||
* @return A newly allocated NUL-terminated JSON response string on success,
|
||||
* NULL on failure.
|
||||
*
|
||||
* @ownership The caller owns the returned string and must release it with
|
||||
* easytier_ios_free_string().
|
||||
*/
|
||||
char *easytier_ios_call_json_rpc(const char *service_name,
|
||||
const char *method_name,
|
||||
const char *payload_json);
|
||||
|
||||
/**
|
||||
* @brief Return the last error message on this thread.
|
||||
*
|
||||
* Combines wrapper-side errors recorded by this library with the
|
||||
* easytier-ffi last FFI error.
|
||||
*
|
||||
* @return A newly allocated NUL-terminated string, or NULL when there is no
|
||||
* recorded error.
|
||||
*
|
||||
* @ownership The caller owns the returned string and must release it with
|
||||
* easytier_ios_free_string().
|
||||
*/
|
||||
char *easytier_ios_last_error(void);
|
||||
|
||||
/**
|
||||
* @brief Release a string returned by this library.
|
||||
*
|
||||
* Use this for strings returned by easytier_ios_collect_network_infos(),
|
||||
* easytier_ios_call_json_rpc() and easytier_ios_last_error(). Passing NULL
|
||||
* is a no-op. The string must not be used after this call.
|
||||
*
|
||||
* @param s NULL, or a string previously returned by this library.
|
||||
*/
|
||||
void easytier_ios_free_string(char *s);
|
||||
|
||||
#ifdef __cplusplus
|
||||
}
|
||||
#endif
|
||||
|
||||
#endif /* EASYTIER_IOS_H */
|
||||
@@ -0,0 +1,308 @@
|
||||
use std::{
|
||||
fs::{self, File, OpenOptions},
|
||||
io::{self, Write},
|
||||
path::{Path, PathBuf},
|
||||
sync::{Arc, Mutex},
|
||||
};
|
||||
|
||||
use tracing_subscriber::fmt::MakeWriter;
|
||||
|
||||
pub(crate) const MAX_LOG_BYTES: u64 = 5 * 1024 * 1024;
|
||||
pub(crate) const MAX_LOG_FILES: usize = 4;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub(crate) struct DiagnosticMakeWriter {
|
||||
inner: Arc<Mutex<RotatingLog>>,
|
||||
}
|
||||
|
||||
impl DiagnosticMakeWriter {
|
||||
pub(crate) fn new(directory: &Path) -> io::Result<Self> {
|
||||
Ok(Self {
|
||||
inner: Arc::new(Mutex::new(RotatingLog::open(directory)?)),
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn set_directory(&self, directory: &Path) -> io::Result<()> {
|
||||
self.lock()?.set_directory(directory)
|
||||
}
|
||||
|
||||
pub(crate) fn clear(&self) -> io::Result<()> {
|
||||
self.lock()?.clear()
|
||||
}
|
||||
|
||||
pub(crate) fn flush(&self) -> io::Result<()> {
|
||||
self.lock()?.flush()
|
||||
}
|
||||
|
||||
fn lock(&self) -> io::Result<std::sync::MutexGuard<'_, RotatingLog>> {
|
||||
self.inner
|
||||
.lock()
|
||||
.map_err(|_| io::Error::other("diagnostic log lock poisoned"))
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a> MakeWriter<'a> for DiagnosticMakeWriter {
|
||||
type Writer = BufferedEventWriter;
|
||||
|
||||
fn make_writer(&'a self) -> Self::Writer {
|
||||
BufferedEventWriter {
|
||||
target: self.clone(),
|
||||
buffer: Vec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) struct BufferedEventWriter {
|
||||
target: DiagnosticMakeWriter,
|
||||
buffer: Vec<u8>,
|
||||
}
|
||||
|
||||
impl BufferedEventWriter {
|
||||
fn commit(&mut self) -> io::Result<()> {
|
||||
if self.buffer.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
let buffer = std::mem::take(&mut self.buffer);
|
||||
self.target.lock()?.write_event(&buffer)
|
||||
}
|
||||
}
|
||||
|
||||
impl Write for BufferedEventWriter {
|
||||
fn write(&mut self, buffer: &[u8]) -> io::Result<usize> {
|
||||
self.buffer.extend_from_slice(buffer);
|
||||
Ok(buffer.len())
|
||||
}
|
||||
|
||||
fn flush(&mut self) -> io::Result<()> {
|
||||
self.commit()
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for BufferedEventWriter {
|
||||
fn drop(&mut self) {
|
||||
let _ = self.commit();
|
||||
}
|
||||
}
|
||||
|
||||
struct RotatingLog {
|
||||
directory: PathBuf,
|
||||
active: Option<File>,
|
||||
active_bytes: u64,
|
||||
}
|
||||
|
||||
impl RotatingLog {
|
||||
fn open(directory: &Path) -> io::Result<Self> {
|
||||
fs::create_dir_all(directory)?;
|
||||
let mut log = Self {
|
||||
directory: directory.to_owned(),
|
||||
active: None,
|
||||
active_bytes: 0,
|
||||
};
|
||||
log.truncate_oversized_files()?;
|
||||
log.open_active()?;
|
||||
Ok(log)
|
||||
}
|
||||
|
||||
fn set_directory(&mut self, directory: &Path) -> io::Result<()> {
|
||||
if self.directory == directory && self.active.is_some() {
|
||||
return Ok(());
|
||||
}
|
||||
self.flush()?;
|
||||
self.active = None;
|
||||
self.directory = directory.to_owned();
|
||||
fs::create_dir_all(directory)?;
|
||||
self.truncate_oversized_files()?;
|
||||
self.open_active()
|
||||
}
|
||||
|
||||
fn active_path(&self) -> PathBuf {
|
||||
self.directory.join("easytier.log")
|
||||
}
|
||||
|
||||
fn rotated_path(&self, index: usize) -> PathBuf {
|
||||
self.directory.join(format!("easytier.{index}.log"))
|
||||
}
|
||||
|
||||
fn truncate_oversized_files(&self) -> io::Result<()> {
|
||||
let paths = std::iter::once(self.active_path())
|
||||
.chain((1..MAX_LOG_FILES).map(|index| self.rotated_path(index)));
|
||||
for path in paths {
|
||||
if path
|
||||
.metadata()
|
||||
.is_ok_and(|metadata| metadata.len() > MAX_LOG_BYTES)
|
||||
{
|
||||
OpenOptions::new()
|
||||
.write(true)
|
||||
.open(path)?
|
||||
.set_len(MAX_LOG_BYTES)?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn open_active(&mut self) -> io::Result<()> {
|
||||
let path = self.active_path();
|
||||
let file = OpenOptions::new().create(true).append(true).open(&path)?;
|
||||
self.active_bytes = file.metadata()?.len();
|
||||
self.active = Some(file);
|
||||
if self.active_bytes >= MAX_LOG_BYTES {
|
||||
self.rotate()?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn write_event(&mut self, event: &[u8]) -> io::Result<()> {
|
||||
if event.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
if self.active_bytes > 0
|
||||
&& self.active_bytes.saturating_add(event.len() as u64) > MAX_LOG_BYTES
|
||||
{
|
||||
self.rotate()?;
|
||||
}
|
||||
let remaining = MAX_LOG_BYTES.saturating_sub(self.active_bytes) as usize;
|
||||
let event = &event[..event.len().min(remaining)];
|
||||
if let Some(active) = self.active.as_mut() {
|
||||
active.write_all(event)?;
|
||||
self.active_bytes += event.len() as u64;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn rotate(&mut self) -> io::Result<()> {
|
||||
self.flush()?;
|
||||
self.active = None;
|
||||
|
||||
let oldest = self.rotated_path(MAX_LOG_FILES - 1);
|
||||
if oldest.exists() {
|
||||
fs::remove_file(oldest)?;
|
||||
}
|
||||
for index in (1..MAX_LOG_FILES - 1).rev() {
|
||||
let source = self.rotated_path(index);
|
||||
if source.exists() {
|
||||
fs::rename(source, self.rotated_path(index + 1))?;
|
||||
}
|
||||
}
|
||||
let active = self.active_path();
|
||||
if active.exists() {
|
||||
fs::rename(active, self.rotated_path(1))?;
|
||||
}
|
||||
self.active_bytes = 0;
|
||||
self.active = Some(
|
||||
OpenOptions::new()
|
||||
.create(true)
|
||||
.append(true)
|
||||
.open(self.active_path())?,
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn clear(&mut self) -> io::Result<()> {
|
||||
self.flush()?;
|
||||
self.active = None;
|
||||
for index in 1..MAX_LOG_FILES {
|
||||
let path = self.rotated_path(index);
|
||||
if path.exists() {
|
||||
fs::remove_file(path)?;
|
||||
}
|
||||
}
|
||||
let active = self.active_path();
|
||||
if active.exists() {
|
||||
fs::remove_file(&active)?;
|
||||
}
|
||||
self.active_bytes = 0;
|
||||
self.active = Some(OpenOptions::new().create(true).append(true).open(active)?);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn flush(&mut self) -> io::Result<()> {
|
||||
match self.active.as_mut() {
|
||||
Some(active) => active.flush(),
|
||||
None => Ok(()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
struct TempDir(PathBuf);
|
||||
|
||||
impl TempDir {
|
||||
fn new(name: &str) -> Self {
|
||||
let unique = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_nanos();
|
||||
let path = std::env::temp_dir().join(format!("easytier-ios-{name}-{unique}"));
|
||||
fs::create_dir_all(&path).unwrap();
|
||||
Self(path)
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for TempDir {
|
||||
fn drop(&mut self) {
|
||||
let _ = fs::remove_dir_all(&self.0);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rotates_without_exceeding_file_limit() {
|
||||
let directory = TempDir::new("rotation");
|
||||
let mut log = RotatingLog::open(&directory.0).unwrap();
|
||||
let event = vec![b'x'; (MAX_LOG_BYTES / 2 + 1) as usize];
|
||||
|
||||
for _ in 0..6 {
|
||||
log.write_event(&event).unwrap();
|
||||
}
|
||||
log.flush().unwrap();
|
||||
|
||||
let files = fs::read_dir(&directory.0)
|
||||
.unwrap()
|
||||
.collect::<Result<Vec<_>, _>>()
|
||||
.unwrap();
|
||||
assert_eq!(files.len(), MAX_LOG_FILES);
|
||||
assert!(
|
||||
files
|
||||
.iter()
|
||||
.all(|entry| entry.metadata().unwrap().len() <= MAX_LOG_BYTES)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clear_removes_rotated_content_and_keeps_active_file_writable() {
|
||||
let directory = TempDir::new("clear");
|
||||
let mut log = RotatingLog::open(&directory.0).unwrap();
|
||||
let event = vec![b'x'; (MAX_LOG_BYTES / 2 + 1) as usize];
|
||||
log.write_event(&event).unwrap();
|
||||
log.write_event(&event).unwrap();
|
||||
|
||||
log.clear().unwrap();
|
||||
log.write_event(b"after clear\n").unwrap();
|
||||
log.flush().unwrap();
|
||||
|
||||
assert_eq!(fs::read(log.active_path()).unwrap(), b"after clear\n");
|
||||
assert!(!log.rotated_path(1).exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn opening_truncates_oversized_known_files() {
|
||||
let directory = TempDir::new("oversized");
|
||||
for name in ["easytier.log", "easytier.1.log"] {
|
||||
let file = File::create(directory.0.join(name)).unwrap();
|
||||
file.set_len(MAX_LOG_BYTES + 1).unwrap();
|
||||
}
|
||||
|
||||
let log = RotatingLog::open(&directory.0).unwrap();
|
||||
|
||||
for index in 1..MAX_LOG_FILES {
|
||||
let path = log.rotated_path(index);
|
||||
if path.exists() {
|
||||
assert!(path.metadata().unwrap().len() <= MAX_LOG_BYTES);
|
||||
}
|
||||
}
|
||||
assert!(log.active_path().metadata().unwrap().len() <= MAX_LOG_BYTES);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
use std::{
|
||||
cell::RefCell,
|
||||
ffi::{CStr, CString, c_char},
|
||||
ptr,
|
||||
};
|
||||
|
||||
thread_local! {
|
||||
// Thread-local last error for the easytier-ios C ABI. Wrapper-side
|
||||
// argument/JSON failures are recorded here; easytier-ffi records
|
||||
// instance/RPC failures in its own buffer. `last_error` merges both.
|
||||
static LAST_ERROR: RefCell<Vec<u8>> = const { RefCell::new(Vec::new()) };
|
||||
}
|
||||
|
||||
pub(crate) fn set_error(message: &str) {
|
||||
LAST_ERROR.with(|cell| {
|
||||
let mut buffer = cell.borrow_mut();
|
||||
buffer.clear();
|
||||
buffer.extend_from_slice(message.as_bytes());
|
||||
});
|
||||
}
|
||||
|
||||
pub(crate) fn clear_error() {
|
||||
LAST_ERROR.with(|cell| cell.borrow_mut().clear());
|
||||
}
|
||||
|
||||
fn thread_local_error() -> Option<String> {
|
||||
LAST_ERROR.with(|cell| {
|
||||
let buffer = cell.borrow();
|
||||
if buffer.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(String::from_utf8_lossy(&buffer).into_owned())
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn ffi_error() -> Option<String> {
|
||||
unsafe {
|
||||
let mut error_ptr: *const c_char = ptr::null();
|
||||
easytier_ffi::get_error_msg(&mut error_ptr);
|
||||
if error_ptr.is_null() {
|
||||
None
|
||||
} else {
|
||||
let error_str = CStr::from_ptr(error_ptr).to_string_lossy().into_owned();
|
||||
easytier_ffi::free_string(error_ptr);
|
||||
Some(error_str)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Merge both error layers: this wrapper's own thread-local buffer and
|
||||
/// easytier-ffi's last FFI error.
|
||||
pub(crate) fn last_error() -> Option<String> {
|
||||
match (ffi_error(), thread_local_error()) {
|
||||
(Some(ffi_error), Some(local_error)) => Some(format!("{local_error}; {ffi_error}")),
|
||||
(Some(ffi_error), None) => Some(ffi_error),
|
||||
(None, Some(local_error)) => Some(local_error),
|
||||
(None, None) => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Copy the merged last error into a newly allocated C string (null when
|
||||
/// there is no error). The caller owns the result and must release it with
|
||||
/// `easytier_ios_free_string`.
|
||||
pub(crate) fn last_error_raw() -> *mut c_char {
|
||||
match last_error().and_then(|message| CString::new(message).ok()) {
|
||||
Some(message) => message.into_raw(),
|
||||
None => ptr::null_mut(),
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,11 @@
|
||||
use std::ffi::CString;
|
||||
|
||||
/// Build a NUL-terminated C string from a Rust string for FFI calls.
|
||||
pub(crate) fn cstring_for(value: &str, what: &str) -> std::io::Result<CString> {
|
||||
CString::new(value).map_err(|_| {
|
||||
std::io::Error::new(
|
||||
std::io::ErrorKind::InvalidInput,
|
||||
format!("{what} contains a null byte"),
|
||||
)
|
||||
})
|
||||
}
|
||||
@@ -1,6 +1,23 @@
|
||||
# easytier_magisk版模块
|
||||
magisk安装后重启
|
||||
# EasyTier Magisk 模块
|
||||
|
||||
目录位置:/data/adb/modules/easytier_magisk
|
||||
配置文件位置://data/adb/modules/easytier_magisk/config/config.toml
|
||||
修改config.conf即可,修改后配置文件后去magisk app重新开关模块即可生效
|
||||
安装模块并重启后,EasyTier 会读取
|
||||
`/data/adb/modules/easytier_magisk/config/config.toml` 启动。
|
||||
|
||||
## WebUI
|
||||
|
||||
KernelSU 及支持模块 WebUI 的分支管理器可直接从模块详情页打开配置
|
||||
界面。Magisk 用户可安装兼容 KernelSU 模块 WebUI 的独立管理器后打开。
|
||||
|
||||
WebUI 使用 EasyTier 自身的配置模型加载和校验配置。点击“保存并重启”后,
|
||||
模块会先验证配置,原子替换 `config.toml`,再重启正在运行的 EasyTier
|
||||
进程。校验失败时原配置保持不变。
|
||||
|
||||
如果 `config/command_args` 存在,模块处于启动参数模式并会忽略
|
||||
`config.toml`。WebUI 会显示提示并禁止保存;删除 `command_args` 后即可
|
||||
使用 WebUI 管理配置。
|
||||
|
||||
## 手动配置
|
||||
|
||||
也可以直接编辑
|
||||
`/data/adb/modules/easytier_magisk/config/config.toml`。保存后在模块管理器中
|
||||
禁用 EasyTier,等待 10 秒后重新启用即可让配置生效。
|
||||
|
||||
@@ -1,25 +1,32 @@
|
||||
#!/bin/sh
|
||||
WEBUI_SOURCE="../../easytier-web/config-generator/dist"
|
||||
|
||||
version=$(cat module.prop | grep 'version=' | awk -F '=' '{print $2}' | sed 's/ (.*//')
|
||||
if [ -f "${WEBUI_SOURCE}/index.html" ]; then
|
||||
rm -rf ./webroot
|
||||
mkdir -p ./webroot
|
||||
cp -R "${WEBUI_SOURCE}/." ./webroot/
|
||||
elif [ ! -f "./webroot/index.html" ]; then
|
||||
echo "Error: WebUI 构建产物不存在,请先运行 pnpm --dir ../../easytier-web/config-generator build."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
version='v'$(grep '^version =' ../../easytier/Cargo.toml | cut -d '"' -f 2)
|
||||
version=$(grep '^version =' ../../easytier/Cargo.toml | cut -d '"' -f 2)
|
||||
|
||||
if [ -z "$version" ]; then
|
||||
echo "Error: 版本号不存在."
|
||||
exit 1
|
||||
fi
|
||||
version="v${version}"
|
||||
|
||||
filename="easytier_magisk_${version}.zip"
|
||||
echo $version
|
||||
echo "${version}"
|
||||
|
||||
|
||||
if [ -f "./easytier-core" ] && [ -f "./easytier-cli" ] && [ -f "./easytier-web" ]; then
|
||||
zip -r -o -X "$filename" ./ -x '.git/*' -x '.github/*' -x 'folder/*' -x 'build.sh' -x 'magisk_update.json'
|
||||
else
|
||||
wget -O "easytier_last.zip" https://github.com/EasyTier/EasyTier/releases/download/"$version"/easytier-linux-aarch64-"$version".zip
|
||||
if [ ! -f "./easytier-core" ] || [ ! -f "./easytier-cli" ] || [ ! -f "./easytier-web" ]; then
|
||||
wget -O "easytier_last.zip" "https://github.com/EasyTier/EasyTier/releases/download/${version}/easytier-linux-aarch64-${version}.zip"
|
||||
unzip -o easytier_last.zip -d ./
|
||||
mv ./easytier-linux-aarch64/* ./
|
||||
rm -rf ./easytier_last.zip
|
||||
rm -rf ./easytier-linux-aarch64
|
||||
zip -r -o -X "$filename" ./ -x '.git/*' -x '.github/*' -x 'folder/*' -x 'build.sh' -x 'magisk_update.json'
|
||||
fi
|
||||
fi
|
||||
|
||||
zip -r -o -X "${filename}" ./ -x '.git/*' -x '.github/*' -x 'folder/*' -x 'build.sh' -x 'magisk_update.json'
|
||||
@@ -16,7 +16,7 @@ rpc_portal = "0.0.0.0:15888"
|
||||
network_name = "default"
|
||||
network_secret = ""
|
||||
|
||||
[[peer]]
|
||||
#[[peer]]
|
||||
#uri = "协议://中转ip:端口"
|
||||
|
||||
[flags]
|
||||
@@ -29,7 +29,7 @@ latency_first = false
|
||||
enable_exit_node = false
|
||||
no_tun = false
|
||||
use_smoltcp = false
|
||||
foreign_network_whitelist = "*"
|
||||
relay_network_whitelist = "*"
|
||||
disable_p2p = false
|
||||
relay_all_peer_rpc = false
|
||||
disable_udp_hole_punching = false
|
||||
|
||||
@@ -11,6 +11,7 @@ ui_print "EasyTier 安装位置:/data/adb/modules/easytier_magisk"
|
||||
ui_print "配置文件位置:/data/adb/modules/easytier_magisk/config/config.toml"
|
||||
ui_print "如需使用启动参数模式,请将 /data/adb/modules/easytier_magisk/config/command_args_sample 重命名为 command_args,并修改其中的内容"
|
||||
ui_print "config 目录中存在 command_args 文件时,模块会自动忽略 config.toml 文件"
|
||||
ui_print "支持模块 WebUI 的管理器可直接打开配置界面;Magisk 用户可配合 KsuWebUI 使用"
|
||||
ui_print "----------------------------------"
|
||||
ui_print "注意!启动参数文件中不能存在 \" 和 ',配置文件则没有这个限制"
|
||||
ui_print "----------------------------------"
|
||||
|
||||
@@ -99,7 +99,7 @@ while true; do
|
||||
# 启动后的扫尾工作
|
||||
if pgrep -f "${EASYTIER}" >/dev/null; then
|
||||
|
||||
if ! ip rule show | grep -q "lookup main"; then
|
||||
if ! ip rule show | grep -qE '^[0-9]+:[[:space:]]+from all lookup main$'; then
|
||||
ip rule add from all lookup main
|
||||
fi
|
||||
|
||||
@@ -109,4 +109,4 @@ while true; do
|
||||
fi
|
||||
|
||||
sleep 10s
|
||||
done
|
||||
done
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
id=easytier_magisk
|
||||
name=EasyTier_Magisk
|
||||
version=v2.6.0
|
||||
version=v2.7.0
|
||||
versionCode=1
|
||||
author=EasyTier
|
||||
description=easytier magisk module @EasyTier(https://github.com/EasyTier/EasyTier)
|
||||
|
||||
Executable
+77
@@ -0,0 +1,77 @@
|
||||
#!/system/bin/sh
|
||||
|
||||
MODDIR=$(CDPATH= cd "${0%/*}" 2>/dev/null && pwd -P) || {
|
||||
echo "Failed to resolve module directory" >&2
|
||||
exit 1
|
||||
}
|
||||
CONFIG_FILE="${MODDIR}/config/config.toml"
|
||||
COMMAND_ARGS="${MODDIR}/config/command_args"
|
||||
EASYTIER="${MODDIR}/easytier-core"
|
||||
|
||||
fail() {
|
||||
echo "$1" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
running_pids() {
|
||||
for process_dir in /proc/[0-9]*; do
|
||||
[ "$(readlink "${process_dir}/exe" 2>/dev/null)" = "${EASYTIER}" ] || continue
|
||||
printf '%s\n' "${process_dir#/proc/}"
|
||||
done
|
||||
}
|
||||
|
||||
is_running() {
|
||||
[ -n "$(running_pids)" ]
|
||||
}
|
||||
|
||||
case "$1" in
|
||||
status)
|
||||
if is_running; then
|
||||
echo "running"
|
||||
else
|
||||
echo "stopped"
|
||||
fi
|
||||
;;
|
||||
config-mode)
|
||||
if [ -f "${COMMAND_ARGS}" ]; then
|
||||
echo "command-args"
|
||||
else
|
||||
echo "toml"
|
||||
fi
|
||||
;;
|
||||
read-config)
|
||||
[ -f "${CONFIG_FILE}" ] || fail "Configuration file not found: ${CONFIG_FILE}"
|
||||
cat "${CONFIG_FILE}"
|
||||
;;
|
||||
save-and-restart)
|
||||
[ -f "${COMMAND_ARGS}" ] && fail "command_args is active; config.toml would be ignored"
|
||||
[ -n "$2" ] || fail "Missing base64-encoded configuration"
|
||||
[ -x "${EASYTIER}" ] || fail "easytier-core is not executable"
|
||||
|
||||
TEMP_FILE="${CONFIG_FILE}.webui.$$"
|
||||
trap 'rm -f "${TEMP_FILE}"' EXIT HUP INT TERM
|
||||
umask 077
|
||||
|
||||
printf '%s' "$2" | base64 -d > "${TEMP_FILE}" \
|
||||
|| fail "Failed to decode configuration"
|
||||
|
||||
VALIDATION_OUTPUT=$("${EASYTIER}" --check-config -c "${TEMP_FILE}" 2>&1)
|
||||
if [ $? -ne 0 ]; then
|
||||
fail "${VALIDATION_OUTPUT:-Configuration validation failed}"
|
||||
fi
|
||||
|
||||
chmod 0644 "${TEMP_FILE}"
|
||||
mv -f "${TEMP_FILE}" "${CONFIG_FILE}" \
|
||||
|| fail "Failed to replace configuration"
|
||||
trap - EXIT HUP INT TERM
|
||||
|
||||
RUNNING_PIDS=$(running_pids)
|
||||
for pid in ${RUNNING_PIDS}; do
|
||||
kill "${pid}" || fail "Failed to restart EasyTier"
|
||||
done
|
||||
echo "saved"
|
||||
;;
|
||||
*)
|
||||
fail "Usage: $0 {status|config-mode|read-config|save-and-restart BASE64}"
|
||||
;;
|
||||
esac
|
||||
@@ -0,0 +1,21 @@
|
||||
[package]
|
||||
name = "easytier-mini"
|
||||
description = "Minimal native EasyTier node with TCP/UDP tunnels, TUN and UDP hole punching."
|
||||
version = "2.7.0"
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
license-file = "../../LICENSE"
|
||||
build = "build.rs"
|
||||
|
||||
[dependencies]
|
||||
anyhow.workspace = true
|
||||
easytier = { workspace = true, features = [
|
||||
"aes-gcm",
|
||||
"dhcp-ipv4",
|
||||
"logging",
|
||||
"proxy-cidr-monitor",
|
||||
"smoltcp",
|
||||
"tun",
|
||||
"web-client",
|
||||
] }
|
||||
tokio = { workspace = true, features = ["macros", "rt", "signal"] }
|
||||
@@ -0,0 +1,118 @@
|
||||
# easytier-mini
|
||||
|
||||
`easytier-mini` is a native EasyTier POC binary. It shares EasyTier's TOML
|
||||
configuration model, peer protocol, TCP/UDP tunnel implementations, TUN,
|
||||
dynamic IPv4 allocation, the smoltcp userspace path and STUN/UDP hole-punching
|
||||
core with the full binary. It includes AES-GCM so its default encryption
|
||||
setting interoperates with the full binary's default configuration.
|
||||
|
||||
Download [pre-built binaries](https://github.com/EasyTier/easytier-mini/releases)
|
||||
from the separate [easytier-mini repository](https://github.com/EasyTier/easytier-mini),
|
||||
which handles builds and releases. The source code is maintained here in the
|
||||
main EasyTier repository.
|
||||
|
||||
Build it with:
|
||||
|
||||
```sh
|
||||
cargo build --release -p easytier-mini
|
||||
```
|
||||
|
||||
For the static size target used by this POC:
|
||||
|
||||
```sh
|
||||
cargo build --profile mini --target x86_64-unknown-linux-musl -p easytier-mini
|
||||
```
|
||||
|
||||
MIPS targets use the repository's existing musl-cross toolchains. The helper
|
||||
builds the standard library for size, applies immediate-abort only to the mini
|
||||
MIPS target graph, and can build either or both byte orders:
|
||||
|
||||
```sh
|
||||
./easytier-contrib/easytier-mini/build-mips.sh all
|
||||
./easytier-contrib/easytier-mini/build-mips.sh mips
|
||||
./easytier-contrib/easytier-mini/build-mips.sh mipsel
|
||||
```
|
||||
|
||||
The `mini` profile derives from `release` and applies `opt-level=z` to the
|
||||
entire compact binary dependency graph. Full EasyTier release builds retain
|
||||
their normal `opt-level=3` profile. The musl builds use a mini-only static
|
||||
linker policy to stay below 5,000,000 bytes on x86-64 and 5,500,000 bytes on
|
||||
MIPS without UPX or another executable compressor. The compact x86-64 linker
|
||||
policy retains static PIE, packs relative relocations and folds identical code.
|
||||
MIPS builds omit standard-library backtrace support and use immediate abort;
|
||||
normal workspace MIPS builds are not affected. Compact linker policies omit
|
||||
unwind tables.
|
||||
|
||||
Start it with a normal EasyTier TOML file:
|
||||
|
||||
```sh
|
||||
easytier-mini --config mini.toml
|
||||
```
|
||||
|
||||
`-c` is accepted as the short form of `--config`.
|
||||
|
||||
Start it as an EasyTier Web managed node with a complete config-server URL:
|
||||
|
||||
```sh
|
||||
easytier-mini --config-server udp://config-server.easytier.cn:22020/TOKEN
|
||||
```
|
||||
|
||||
`--machine-id`, `--hostname`, and `--secure-mode` match the full client's Web
|
||||
identity and transport options. `--config` and `--config-server` may be used
|
||||
together: the local instance remains static while Web-owned instances are
|
||||
created, updated, retained, and deleted independently.
|
||||
|
||||
The node also exposes the native EasyTier management RPC protocol on
|
||||
`127.0.0.1:15888`, so the full `easytier-cli` can inspect it:
|
||||
|
||||
```sh
|
||||
easytier-cli node info
|
||||
easytier-cli peer
|
||||
easytier-cli route
|
||||
easytier-cli connector list
|
||||
```
|
||||
|
||||
For example:
|
||||
|
||||
```toml
|
||||
instance_name = "mini"
|
||||
ipv4 = "10.147.0.2"
|
||||
listeners = ["tcp://0.0.0.0:11010", "udp://0.0.0.0:11010"]
|
||||
|
||||
[network_identity]
|
||||
network_name = "mini-poc"
|
||||
network_secret = "change-me"
|
||||
|
||||
[[peer]]
|
||||
uri = "tcp://example.net:11010"
|
||||
```
|
||||
|
||||
Local TOML and Web configuration both retain the complete authoritative model.
|
||||
The compact runtime silently omits unsupported capabilities while normalizing
|
||||
that model into live runtime state. EasyTier Web therefore sees every accepted
|
||||
configuration value unchanged and its consistency checks converge. This also
|
||||
applies to hot patches: for example, a port-forward patch remains visible to
|
||||
the controller while no port-forward service starts in mini. ChaCha20 falls
|
||||
back to AES-GCM rather than plaintext.
|
||||
|
||||
The compact runtime supports `tcp://` and `udp://` listener, mapped-listener
|
||||
and peer URLs. `no_tun = true` runs through smoltcp without an OS TUN device,
|
||||
and `dhcp = true` allocates the virtual IPv4 address dynamically.
|
||||
|
||||
The mini feature set keeps STUN collection, UDP hole punching, Web heartbeats,
|
||||
Web instance lifecycle management and the config hot-patch RPC. It omits TCP
|
||||
hole punching, endpoint discovery (`http://`, `https://`, `txt://` and
|
||||
`srv://` peers), protobuf reflection, logger control and the rest of the full
|
||||
management surface. Unsupported connector URLs are accepted as no-ops. Its
|
||||
local RPC surface remains read-only for node, peer, route and connector
|
||||
queries. OSPF route messages keep their original protobuf wire data, so fields
|
||||
added by future EasyTier versions are forwarded without requiring
|
||||
`prost-reflect`.
|
||||
|
||||
For size, this POC reads one file directly and does not support configuration
|
||||
from stdin or `${VAR}` expansion. It omits the process-management event journal,
|
||||
while the console logger still reports runtime events such as peer, connection,
|
||||
listener, TUN and DHCP changes. The RPC address is currently fixed, so only one
|
||||
mini process can use the default portal on a host. The x86-64 musl POC cannot
|
||||
provide reliable stack backtraces because its release binary has no unwind
|
||||
tables.
|
||||
Executable
+61
@@ -0,0 +1,61 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
|
||||
# Cargo invokes this same file as a rustc wrapper during compact MIPS builds.
|
||||
# Applying immediate-abort here keeps the size policy scoped to easytier-mini;
|
||||
# normal MIPS builds elsewhere in the workspace retain their panic behavior.
|
||||
if [ "${EASYTIER_MINI_MIPS_RUSTC_WRAPPER:-}" = "1" ]; then
|
||||
mini_rustc=$1
|
||||
shift
|
||||
for mini_rustc_arg in "$@"; do
|
||||
case "$mini_rustc_arg" in
|
||||
mips-unknown-linux-musl|mipsel-unknown-linux-musl)
|
||||
exec "$mini_rustc" "$@" \
|
||||
-Zunstable-options \
|
||||
-Cpanic=immediate-abort
|
||||
;;
|
||||
esac
|
||||
done
|
||||
exec "$mini_rustc" "$@"
|
||||
fi
|
||||
|
||||
mini_script_dir=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
|
||||
mini_repo_dir=$(CDPATH= cd -- "$mini_script_dir/../.." && pwd)
|
||||
mini_requested_target=${1:-all}
|
||||
cd "$mini_repo_dir"
|
||||
|
||||
build_mips_target() {
|
||||
mini_target=$1
|
||||
mini_toolchain=$2
|
||||
PATH="$mini_repo_dir/musl_gcc/$mini_toolchain/bin:$PATH" \
|
||||
EASYTIER_MINI_MIPS_RUSTC_WRAPPER=1 \
|
||||
RUSTC_BOOTSTRAP=1 \
|
||||
RUSTC_WRAPPER="$mini_script_dir/build-mips.sh" \
|
||||
cargo build \
|
||||
--manifest-path "$mini_repo_dir/Cargo.toml" \
|
||||
--profile mini \
|
||||
--target "$mini_target" \
|
||||
-Z build-std=std \
|
||||
-Z build-std-features=optimize_for_size \
|
||||
-p easytier-mini
|
||||
}
|
||||
|
||||
case "$mini_requested_target" in
|
||||
all)
|
||||
build_mips_target mips-unknown-linux-musl mips-unknown-linux-muslsf
|
||||
build_mips_target mipsel-unknown-linux-musl mipsel-unknown-linux-muslsf
|
||||
;;
|
||||
mips|mips-unknown-linux-musl)
|
||||
build_mips_target mips-unknown-linux-musl mips-unknown-linux-muslsf
|
||||
;;
|
||||
mipsel|mipsel-unknown-linux-musl)
|
||||
build_mips_target mipsel-unknown-linux-musl mipsel-unknown-linux-muslsf
|
||||
;;
|
||||
-h|--help)
|
||||
echo "usage: $0 [all|mips|mipsel]"
|
||||
;;
|
||||
*)
|
||||
echo "unsupported MIPS target: $mini_requested_target" >&2
|
||||
exit 2
|
||||
;;
|
||||
esac
|
||||
@@ -0,0 +1,32 @@
|
||||
use std::env;
|
||||
use std::path::PathBuf;
|
||||
|
||||
fn main() {
|
||||
let target = env::var("TARGET").unwrap_or_default();
|
||||
let profile = env::var("PROFILE").unwrap_or_default();
|
||||
if !matches!(profile.as_str(), "release" | "mini")
|
||||
|| !matches!(
|
||||
target.as_str(),
|
||||
"x86_64-unknown-linux-musl" | "mips-unknown-linux-musl" | "mipsel-unknown-linux-musl"
|
||||
)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
let script =
|
||||
PathBuf::from(env::var_os("CARGO_MANIFEST_DIR").unwrap()).join("easytier-mini-musl.ld");
|
||||
println!("cargo:rerun-if-changed={}", script.display());
|
||||
// The release-derived mini profile already aborts panics. Keep the compact
|
||||
// binary's linker policy local so full EasyTier musl builds retain their
|
||||
// normal PIE/unwind settings.
|
||||
println!("cargo:rustc-link-arg-bin=easytier-mini=-Wl,--build-id=none");
|
||||
if target == "x86_64-unknown-linux-musl" {
|
||||
println!("cargo:rustc-link-arg-bin=easytier-mini=-Wl,--pack-dyn-relocs=relr");
|
||||
println!("cargo:rustc-link-arg-bin=easytier-mini=-Wl,--icf=all");
|
||||
}
|
||||
println!("cargo:rustc-link-arg-bin=easytier-mini=-Wl,--no-eh-frame-hdr");
|
||||
println!(
|
||||
"cargo:rustc-link-arg-bin=easytier-mini=-Wl,-T,{}",
|
||||
script.display()
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
SECTIONS
|
||||
{
|
||||
.eh_frame :
|
||||
{
|
||||
KEEP(*crtbegin.o(.eh_frame))
|
||||
KEEP(*crtend.o(.eh_frame))
|
||||
}
|
||||
/DISCARD/ :
|
||||
{
|
||||
*(EXCLUDE_FILE (*crtbegin.o *crtend.o) .eh_frame)
|
||||
*(.eh_frame_hdr)
|
||||
}
|
||||
}
|
||||
INSERT AFTER .data;
|
||||
@@ -0,0 +1,264 @@
|
||||
use std::{ffi::OsString, path::PathBuf, sync::Arc};
|
||||
|
||||
use anyhow::Context as _;
|
||||
use easytier::common::MachineIdOptions;
|
||||
use easytier::{
|
||||
common::config::{ConfigFileControl, load_toml_config_from_path},
|
||||
instance::factory::native_compact_instance_manager_with_runtime,
|
||||
rpc_service::ReadOnlyApiRpcServer,
|
||||
web_client::{WebClientHooks, parse_config_server_endpoint, run_web_client},
|
||||
};
|
||||
|
||||
enum Command {
|
||||
Run(RunOptions),
|
||||
Exit,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, PartialEq, Eq)]
|
||||
struct RunOptions {
|
||||
config: Option<PathBuf>,
|
||||
config_server: Option<String>,
|
||||
machine_id: Option<String>,
|
||||
hostname: Option<String>,
|
||||
secure_mode: bool,
|
||||
}
|
||||
|
||||
const USAGE: &str = "usage: easytier-mini [--config <FILE>] [--config-server <URL>] \
|
||||
[--machine-id <ID>] [--hostname <NAME>] [--secure-mode]";
|
||||
|
||||
fn required_value(
|
||||
args: &mut impl Iterator<Item = OsString>,
|
||||
option: &str,
|
||||
) -> anyhow::Result<OsString> {
|
||||
args.next()
|
||||
.with_context(|| format!("{option} requires a value"))
|
||||
}
|
||||
|
||||
fn parse_args(mut args: impl Iterator<Item = OsString>) -> anyhow::Result<Command> {
|
||||
let mut options = RunOptions::default();
|
||||
while let Some(arg) = args.next() {
|
||||
if arg == "-h" || arg == "--help" {
|
||||
println!(
|
||||
"easytier-mini {}\n\nUsage: {USAGE}",
|
||||
env!("CARGO_PKG_VERSION")
|
||||
);
|
||||
return Ok(Command::Exit);
|
||||
}
|
||||
if arg == "-V" || arg == "--version" {
|
||||
println!("easytier-mini {}", env!("CARGO_PKG_VERSION"));
|
||||
return Ok(Command::Exit);
|
||||
}
|
||||
if arg == "-c" || arg == "--config" {
|
||||
if options.config.is_some() {
|
||||
anyhow::bail!("--config may only be specified once");
|
||||
}
|
||||
options.config = Some(PathBuf::from(required_value(&mut args, "--config")?));
|
||||
continue;
|
||||
}
|
||||
if arg == "-w" || arg == "--config-server" {
|
||||
if options.config_server.is_some() {
|
||||
anyhow::bail!("--config-server may only be specified once");
|
||||
}
|
||||
options.config_server = Some(
|
||||
required_value(&mut args, "--config-server")?
|
||||
.into_string()
|
||||
.map_err(|_| anyhow::anyhow!("--config-server must be valid UTF-8"))?,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
if arg == "--machine-id" {
|
||||
options.machine_id = Some(
|
||||
required_value(&mut args, "--machine-id")?
|
||||
.into_string()
|
||||
.map_err(|_| anyhow::anyhow!("--machine-id must be valid UTF-8"))?,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
if arg == "--hostname" {
|
||||
options.hostname = Some(
|
||||
required_value(&mut args, "--hostname")?
|
||||
.into_string()
|
||||
.map_err(|_| anyhow::anyhow!("--hostname must be valid UTF-8"))?,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
if arg == "--secure-mode" {
|
||||
options.secure_mode = true;
|
||||
continue;
|
||||
}
|
||||
anyhow::bail!("unknown argument {arg:?}; {USAGE}");
|
||||
}
|
||||
if options.config.is_none() && options.config_server.is_none() {
|
||||
anyhow::bail!("either --config or --config-server is required; {USAGE}");
|
||||
}
|
||||
Ok(Command::Run(options))
|
||||
}
|
||||
|
||||
fn require_tcp_or_udp(scheme: &str, source: &str) -> anyhow::Result<()> {
|
||||
match scheme {
|
||||
"tcp" | "udp" => Ok(()),
|
||||
scheme => anyhow::bail!(
|
||||
"{source} uses unsupported tunnel scheme {scheme:?}; easytier-mini supports only tcp:// and udp://"
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_config_server(config_server: &str) -> anyhow::Result<()> {
|
||||
let endpoint = parse_config_server_endpoint(config_server)?;
|
||||
require_tcp_or_udp(endpoint.connect_url().scheme(), "config server")
|
||||
}
|
||||
|
||||
struct MiniWebClientHooks;
|
||||
|
||||
impl WebClientHooks for MiniWebClientHooks {
|
||||
fn manages_remote_config_instances(&self) -> bool {
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::main(flavor = "current_thread")]
|
||||
async fn main() -> anyhow::Result<()> {
|
||||
let Command::Run(options) = parse_args(std::env::args_os().skip(1))? else {
|
||||
return Ok(());
|
||||
};
|
||||
easytier::common::log::init_console()?;
|
||||
let local_config = options
|
||||
.config
|
||||
.as_ref()
|
||||
.map(|config_path| {
|
||||
load_toml_config_from_path(config_path)
|
||||
.with_context(|| format!("failed to load {}", config_path.display()))
|
||||
})
|
||||
.transpose()?;
|
||||
if let Some(config_server) = options.config_server.as_deref() {
|
||||
validate_config_server(config_server)?;
|
||||
}
|
||||
|
||||
let instances = Arc::new(native_compact_instance_manager_with_runtime(
|
||||
tokio::runtime::Handle::current(),
|
||||
));
|
||||
let local_instance_id = local_config
|
||||
.map(|config| instances.run_network_instance(config, ConfigFileControl::STATIC_CONFIG))
|
||||
.transpose()?;
|
||||
let _web_client = if let Some(config_server) = options.config_server.as_deref() {
|
||||
Some(
|
||||
run_web_client(
|
||||
config_server,
|
||||
MachineIdOptions {
|
||||
explicit_machine_id: options.machine_id,
|
||||
state_dir: None,
|
||||
},
|
||||
options.hostname,
|
||||
options.secure_mode,
|
||||
instances.clone(),
|
||||
Some(Arc::new(MiniWebClientHooks)),
|
||||
)
|
||||
.await?,
|
||||
)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let _rpc_server =
|
||||
ReadOnlyApiRpcServer::new(Some("127.0.0.1:15888".to_owned()), None, instances.clone())?
|
||||
.serve()
|
||||
.await?;
|
||||
eprintln!(
|
||||
"easytier-mini started: local={local_instance_id:?}, web={}; RPC: 127.0.0.1:15888",
|
||||
options.config_server.is_some()
|
||||
);
|
||||
|
||||
let stopped_unexpectedly = tokio::select! {
|
||||
signal = tokio::signal::ctrl_c() => {
|
||||
signal.context("failed to listen for Ctrl-C")?;
|
||||
false
|
||||
},
|
||||
_ = instances.wait() => true,
|
||||
};
|
||||
|
||||
for instance in instances.instances() {
|
||||
instance.stop().await;
|
||||
}
|
||||
if stopped_unexpectedly {
|
||||
anyhow::bail!("EasyTier instance stopped unexpectedly");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use easytier::common::config::{ConfigLoader as _, TomlConfigLoader};
|
||||
|
||||
#[test]
|
||||
fn parses_minimal_config_argument() {
|
||||
let Command::Run(options) =
|
||||
parse_args([OsString::from("--config"), OsString::from("mini.toml")].into_iter())
|
||||
.unwrap()
|
||||
else {
|
||||
panic!("expected run command");
|
||||
};
|
||||
|
||||
assert_eq!(options.config, Some(PathBuf::from("mini.toml")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_web_client_arguments_without_a_local_config() {
|
||||
let Command::Run(options) = parse_args(
|
||||
[
|
||||
OsString::from("--config-server"),
|
||||
OsString::from("token"),
|
||||
OsString::from("--machine-id"),
|
||||
OsString::from("machine"),
|
||||
OsString::from("--hostname"),
|
||||
OsString::from("mini"),
|
||||
OsString::from("--secure-mode"),
|
||||
]
|
||||
.into_iter(),
|
||||
)
|
||||
.unwrap() else {
|
||||
panic!("expected run command");
|
||||
};
|
||||
|
||||
assert_eq!(options.config_server.as_deref(), Some("token"));
|
||||
assert_eq!(options.machine_id.as_deref(), Some("machine"));
|
||||
assert_eq!(options.hostname.as_deref(), Some("mini"));
|
||||
assert!(options.secure_mode);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_unknown_arguments() {
|
||||
let result = parse_args([OsString::from("extra")].into_iter());
|
||||
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accepts_tcp_udp_config_server() {
|
||||
assert!(validate_config_server("udp://127.0.0.1:22020/token").is_ok());
|
||||
assert!(validate_config_server("quic://127.0.0.1:22020/token").is_err());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn compact_factory_accepts_unsupported_config_without_changing_it() {
|
||||
let config = TomlConfigLoader::new_from_str(
|
||||
r#"
|
||||
dhcp = true
|
||||
listeners = ["quic://127.0.0.1:11010"]
|
||||
proxy_network = [{ cidr = "10.20.0.0/16" }]
|
||||
|
||||
[flags]
|
||||
encryption_algorithm = "chacha20"
|
||||
data_compress_algo = "Zstd"
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
config.get_id();
|
||||
let before = config.dump();
|
||||
let manager =
|
||||
native_compact_instance_manager_with_runtime(tokio::runtime::Handle::current());
|
||||
|
||||
let instance = manager.create(config, ()).unwrap();
|
||||
|
||||
assert_eq!(instance.toml_config().unwrap().dump(), before);
|
||||
}
|
||||
}
|
||||
-6161
File diff suppressed because it is too large
Load Diff
@@ -1,14 +1,26 @@
|
||||
[package]
|
||||
name = "easytier-ohrs"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
|
||||
[lib]
|
||||
crate-type=["cdylib"]
|
||||
crate-type = ["cdylib"]
|
||||
|
||||
[dependencies]
|
||||
ohos-hilog-binding = {version = "*", features = ["redirect"]}
|
||||
easytier = { path = "../../easytier" }
|
||||
anyhow.workspace = true
|
||||
async-trait.workspace = true
|
||||
bytes.workspace = true
|
||||
easytier = { workspace = true, default-features = true }
|
||||
easytier-core.workspace = true
|
||||
easytier-ohos-core.workspace = true
|
||||
easytier-ohos-features.workspace = true
|
||||
easytier-proto = { workspace = true, features = [
|
||||
"api",
|
||||
"core",
|
||||
"json-rpc",
|
||||
] }
|
||||
futures = { workspace = true, features = ["default"] }
|
||||
napi-derive-ohos = "1.1"
|
||||
napi-ohos = { version = "1.1", default-features = false, features = [
|
||||
"serde-json",
|
||||
@@ -22,15 +34,16 @@ napi-ohos = { version = "1.1", default-features = false, features = [
|
||||
"tokio_io_util",
|
||||
"deferred_trace",
|
||||
"napi8",
|
||||
"node_version_detect",
|
||||
"web_stream",
|
||||
] }
|
||||
once_cell = "1.21.3"
|
||||
serde_json = "1.0.125"
|
||||
tracing-subscriber = "0.3.19"
|
||||
tracing-core = "0.1.33"
|
||||
tracing = "0.1.41"
|
||||
uuid = { version = "1.5.0", features = [
|
||||
once_cell.workspace = true
|
||||
serde = { workspace = true, features = ["derive"] }
|
||||
serde_json.workspace = true
|
||||
tokio = { workspace = true, features = ["rt-multi-thread", "sync", "time"] }
|
||||
tracing.workspace = true
|
||||
tracing-subscriber = { workspace = true, default-features = true }
|
||||
url.workspace = true
|
||||
uuid = { workspace = true, features = [
|
||||
"v4",
|
||||
"fast-rng",
|
||||
"macro-diagnostics",
|
||||
@@ -39,13 +52,3 @@ uuid = { version = "1.5.0", features = [
|
||||
|
||||
[build-dependencies]
|
||||
napi-build-ohos = "1.1"
|
||||
[profile.dev]
|
||||
panic = "unwind"
|
||||
debug = true
|
||||
|
||||
[profile.release]
|
||||
panic = "abort"
|
||||
lto = true
|
||||
codegen-units = 1
|
||||
opt-level = 3
|
||||
strip = true
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user