mirror of
https://github.com/EasyTier/EasyTier.git
synced 2026-10-30 16:05:31 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
18f8e41fa1 | ||
|
|
c66076b8f3 | ||
|
|
3ed7fcece6 | ||
|
|
4bdba58ca9 | ||
|
|
6487a5e463 | ||
|
|
48ccc56a1a | ||
|
|
b974acd737 | ||
|
|
3463190838 | ||
|
|
cf6531b302 | ||
|
|
4ae9deb72b | ||
|
|
6a5507b41e | ||
|
|
dccb85ab36 | ||
|
|
4a91eeb379 | ||
|
|
79dd97dd7f | ||
|
|
2eca40c56a | ||
|
|
fe18f32c8b | ||
|
|
5565251562 | ||
|
|
ffcfba4afd | ||
|
|
7f2e9dddf2 | ||
|
|
887c701ee8 | ||
|
|
82dd835c26 | ||
|
|
411faae131 | ||
|
|
6d9adc4fb9 | ||
|
|
54f2850d9d | ||
|
|
e307dadb48 | ||
|
|
318a0b0686 | ||
|
|
c7e636f918 | ||
|
|
5af90471bf | ||
|
|
a144597d02 | ||
|
|
4ff27c7ef4 | ||
|
|
f1b910da4e | ||
|
|
e25c6bdd0c | ||
|
|
585628ecf1 | ||
|
|
8c2f67e13e | ||
|
|
c7442868ab | ||
|
|
3384cc1162 | ||
|
|
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 |
@@ -3,3 +3,29 @@ slow-timeout = { period = "60s", terminate-after = 1 }
|
|||||||
|
|
||||||
[profile.default.junit]
|
[profile.default.junit]
|
||||||
path = "junit.xml"
|
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.
|
||||||
|
# Foreign-network listener discovery also enumerates the default namespace.
|
||||||
|
# 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) | test(tests::three_node::foreign_network_advertises_listeners_for_direct_connect)'
|
||||||
|
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,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,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}.`);
|
||||||
|
}
|
||||||
|
};
|
||||||
+79
-17
@@ -2,6 +2,11 @@ name: EasyTier Core
|
|||||||
|
|
||||||
on:
|
on:
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
sign_windows:
|
||||||
|
description: Submit Windows artifacts for SignPath test signing
|
||||||
|
type: boolean
|
||||||
|
default: true
|
||||||
push:
|
push:
|
||||||
branches: [ "develop", "main", "releases/**" ]
|
branches: [ "develop", "main", "releases/**" ]
|
||||||
pull_request:
|
pull_request:
|
||||||
@@ -9,7 +14,8 @@ on:
|
|||||||
types: [ opened, synchronize, reopened, ready_for_review ]
|
types: [ opened, synchronize, reopened, ready_for_review ]
|
||||||
|
|
||||||
concurrency:
|
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
|
cancel-in-progress: true
|
||||||
|
|
||||||
env:
|
env:
|
||||||
@@ -31,7 +37,17 @@ jobs:
|
|||||||
"easytier-core/**", "easytier-proto/**", "easytier-rpc-build/**",
|
"easytier-core/**", "easytier-proto/**", "easytier-rpc-build/**",
|
||||||
"easytier-web/**", "easytier-contrib/easytier-magisk/**",
|
"easytier-web/**", "easytier-contrib/easytier-magisk/**",
|
||||||
".github/workflows/core.yml", ".github/actions/**"]
|
".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:
|
outputs:
|
||||||
|
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.
|
# Release builds must run even if they only change release metadata.
|
||||||
should_skip: >-
|
should_skip: >-
|
||||||
${{
|
${{
|
||||||
@@ -42,15 +58,16 @@ jobs:
|
|||||||
steps.path_filter.outputs.relevant != 'true'
|
steps.path_filter.outputs.relevant != 'true'
|
||||||
)
|
)
|
||||||
) &&
|
) &&
|
||||||
|
github.event_name != 'workflow_dispatch' &&
|
||||||
!startsWith(github.ref_name, 'releases/')
|
!startsWith(github.ref_name, 'releases/')
|
||||||
}}
|
}}
|
||||||
steps:
|
steps:
|
||||||
- id: skip_check
|
- id: skip_check
|
||||||
|
if: github.event_name != 'workflow_dispatch' && !startsWith(github.ref_name, 'releases/')
|
||||||
uses: fkirc/skip-duplicate-actions@v5
|
uses: fkirc/skip-duplicate-actions@v5
|
||||||
with:
|
with:
|
||||||
concurrent_skipping: 'same_content_newer'
|
concurrent_skipping: 'same_content_newer'
|
||||||
skip_after_successful_duplicate: 'true'
|
skip_after_successful_duplicate: 'true'
|
||||||
cancel_others: 'true'
|
|
||||||
paths: ${{ env.RELEVANT_PATHS }}
|
paths: ${{ env.RELEVANT_PATHS }}
|
||||||
|
|
||||||
- id: path_filter
|
- id: path_filter
|
||||||
@@ -61,10 +78,54 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
filters: |
|
filters: |
|
||||||
relevant: ${{ env.RELEVANT_PATHS }}
|
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:
|
build_web:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
needs: pre_job
|
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:
|
steps:
|
||||||
- uses: actions/checkout@v5
|
- uses: actions/checkout@v5
|
||||||
|
|
||||||
@@ -75,6 +136,7 @@ jobs:
|
|||||||
build-filter: './easytier-web/*'
|
build-filter: './easytier-web/*'
|
||||||
|
|
||||||
- name: Archive artifact
|
- name: Archive artifact
|
||||||
|
if: needs.pre_job.outputs.full_build == 'true'
|
||||||
uses: actions/upload-artifact@v5
|
uses: actions/upload-artifact@v5
|
||||||
with:
|
with:
|
||||||
name: easytier-web-dashboard
|
name: easytier-web-dashboard
|
||||||
@@ -82,7 +144,7 @@ jobs:
|
|||||||
easytier-web/frontend/dist/*
|
easytier-web/frontend/dist/*
|
||||||
build:
|
build:
|
||||||
strategy:
|
strategy:
|
||||||
fail-fast: true
|
fail-fast: false
|
||||||
matrix:
|
matrix:
|
||||||
include:
|
include:
|
||||||
- TARGET: x86_64-unknown-linux-musl
|
- TARGET: x86_64-unknown-linux-musl
|
||||||
@@ -150,7 +212,7 @@ jobs:
|
|||||||
needs:
|
needs:
|
||||||
- pre_job
|
- pre_job
|
||||||
- build_web
|
- 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:
|
steps:
|
||||||
- uses: actions/checkout@v5
|
- uses: actions/checkout@v5
|
||||||
|
|
||||||
@@ -168,8 +230,8 @@ jobs:
|
|||||||
uses: ./.github/actions/prepare-build
|
uses: ./.github/actions/prepare-build
|
||||||
with:
|
with:
|
||||||
target: ${{ matrix.TARGET }}
|
target: ${{ matrix.TARGET }}
|
||||||
gui: true
|
gui: false
|
||||||
pnpm: true
|
pnpm: false
|
||||||
token: ${{ secrets.GITHUB_TOKEN }}
|
token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
- uses: Swatinem/rust-cache@v2
|
- uses: Swatinem/rust-cache@v2
|
||||||
@@ -177,8 +239,8 @@ jobs:
|
|||||||
# The prefix cache key, this can be changed to start a new cache manually.
|
# The prefix cache key, this can be changed to start a new cache manually.
|
||||||
# default: "v0-rust"
|
# default: "v0-rust"
|
||||||
prefix-key: ""
|
prefix-key: ""
|
||||||
shared-key: "core-registry"
|
shared-key: core-release-${{ matrix.TARGET }}
|
||||||
cache-targets: "false"
|
cache-targets: "true"
|
||||||
|
|
||||||
- uses: mlugg/setup-zig@v2
|
- uses: mlugg/setup-zig@v2
|
||||||
if: ${{ contains(matrix.OS, 'ubuntu') }}
|
if: ${{ contains(matrix.OS, 'ubuntu') }}
|
||||||
@@ -286,7 +348,7 @@ jobs:
|
|||||||
./artifacts/*
|
./artifacts/*
|
||||||
|
|
||||||
- name: Prepare SignPath test artifact
|
- name: Prepare SignPath test artifact
|
||||||
if: ${{ github.event_name == 'workflow_dispatch' && contains(matrix.TARGET, 'windows') }}
|
if: ${{ github.event_name == 'workflow_dispatch' && inputs.sign_windows && contains(matrix.TARGET, 'windows') }}
|
||||||
run: |
|
run: |
|
||||||
mkdir -p ./signpath-artifacts
|
mkdir -p ./signpath-artifacts
|
||||||
cp \
|
cp \
|
||||||
@@ -297,7 +359,7 @@ jobs:
|
|||||||
./signpath-artifacts/
|
./signpath-artifacts/
|
||||||
|
|
||||||
- name: Upload unsigned SignPath test artifact
|
- name: Upload unsigned SignPath test artifact
|
||||||
if: ${{ github.event_name == 'workflow_dispatch' && contains(matrix.TARGET, 'windows') }}
|
if: ${{ github.event_name == 'workflow_dispatch' && inputs.sign_windows && contains(matrix.TARGET, 'windows') }}
|
||||||
id: signpath-upload
|
id: signpath-upload
|
||||||
uses: actions/upload-artifact@v5
|
uses: actions/upload-artifact@v5
|
||||||
with:
|
with:
|
||||||
@@ -306,7 +368,7 @@ jobs:
|
|||||||
if-no-files-found: error
|
if-no-files-found: error
|
||||||
|
|
||||||
- name: Submit SignPath test-signing request
|
- name: Submit SignPath test-signing request
|
||||||
if: ${{ github.event_name == 'workflow_dispatch' && contains(matrix.TARGET, 'windows') }}
|
if: ${{ github.event_name == 'workflow_dispatch' && inputs.sign_windows && contains(matrix.TARGET, 'windows') }}
|
||||||
uses: signpath/github-action-submit-signing-request@v2
|
uses: signpath/github-action-submit-signing-request@v2
|
||||||
with:
|
with:
|
||||||
api-token: ${{ secrets.SIGNPATH_API_TOKEN }}
|
api-token: ${{ secrets.SIGNPATH_API_TOKEN }}
|
||||||
@@ -319,7 +381,7 @@ jobs:
|
|||||||
output-artifact-directory: ./signpath-signed-artifacts
|
output-artifact-directory: ./signpath-signed-artifacts
|
||||||
|
|
||||||
- name: Upload signed SignPath test artifact
|
- name: Upload signed SignPath test artifact
|
||||||
if: ${{ github.event_name == 'workflow_dispatch' && contains(matrix.TARGET, 'windows') }}
|
if: ${{ github.event_name == 'workflow_dispatch' && inputs.sign_windows && contains(matrix.TARGET, 'windows') }}
|
||||||
uses: actions/upload-artifact@v5
|
uses: actions/upload-artifact@v5
|
||||||
with:
|
with:
|
||||||
name: signpath-signed-${{ matrix.ARTIFACT_NAME }}
|
name: signpath-signed-${{ matrix.ARTIFACT_NAME }}
|
||||||
@@ -329,7 +391,7 @@ jobs:
|
|||||||
build_magisk:
|
build_magisk:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
needs: [ pre_job, build_web, build ]
|
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:
|
steps:
|
||||||
- name: Checkout Code
|
- name: Checkout Code
|
||||||
uses: actions/checkout@v5 # 必须先检出代码才能获取模块配置
|
uses: actions/checkout@v5 # 必须先检出代码才能获取模块配置
|
||||||
@@ -371,9 +433,9 @@ jobs:
|
|||||||
|
|
||||||
core-result:
|
core-result:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
needs: [ pre_job, build_web, build, build_magisk ]
|
needs: [ pre_job, check, build_web, build, build_magisk ]
|
||||||
if: needs.pre_job.result == 'success' && needs.pre_job.outputs.should_skip != 'true' && !cancelled()
|
if: ${{ always() }}
|
||||||
steps:
|
steps:
|
||||||
- name: Mark result as failed
|
- name: Mark result as failed
|
||||||
if: contains(needs.*.result, 'failure')
|
if: contains(needs.*.result, 'failure') || contains(needs.*.result, 'cancelled')
|
||||||
run: exit 1
|
run: exit 1
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
name: EasyTier GUI
|
name: EasyTier GUI
|
||||||
|
|
||||||
on:
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
push:
|
push:
|
||||||
branches: ["develop", "main", "releases/**"]
|
branches: ["develop", "main", "releases/**"]
|
||||||
pull_request:
|
pull_request:
|
||||||
@@ -8,7 +9,8 @@ on:
|
|||||||
types: [opened, synchronize, reopened, ready_for_review]
|
types: [opened, synchronize, reopened, ready_for_review]
|
||||||
|
|
||||||
concurrency:
|
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
|
cancel-in-progress: true
|
||||||
|
|
||||||
env:
|
env:
|
||||||
@@ -32,6 +34,7 @@ jobs:
|
|||||||
"easytier-web/frontend-lib/**", ".github/workflows/gui.yml",
|
"easytier-web/frontend-lib/**", ".github/workflows/gui.yml",
|
||||||
".github/actions/**"]
|
".github/actions/**"]
|
||||||
outputs:
|
outputs:
|
||||||
|
full_build: ${{ github.event_name == 'workflow_dispatch' || startsWith(github.ref_name, 'releases/') }}
|
||||||
# Release builds must run even if they only change release metadata.
|
# Release builds must run even if they only change release metadata.
|
||||||
should_skip: >-
|
should_skip: >-
|
||||||
${{
|
${{
|
||||||
@@ -42,15 +45,16 @@ jobs:
|
|||||||
steps.path_filter.outputs.relevant != 'true'
|
steps.path_filter.outputs.relevant != 'true'
|
||||||
)
|
)
|
||||||
) &&
|
) &&
|
||||||
|
github.event_name != 'workflow_dispatch' &&
|
||||||
!startsWith(github.ref_name, 'releases/')
|
!startsWith(github.ref_name, 'releases/')
|
||||||
}}
|
}}
|
||||||
steps:
|
steps:
|
||||||
- id: skip_check
|
- id: skip_check
|
||||||
|
if: github.event_name != 'workflow_dispatch' && !startsWith(github.ref_name, 'releases/')
|
||||||
uses: fkirc/skip-duplicate-actions@v5
|
uses: fkirc/skip-duplicate-actions@v5
|
||||||
with:
|
with:
|
||||||
concurrent_skipping: 'same_content_newer'
|
concurrent_skipping: 'same_content_newer'
|
||||||
skip_after_successful_duplicate: 'true'
|
skip_after_successful_duplicate: 'true'
|
||||||
cancel_others: 'true'
|
|
||||||
paths: ${{ env.RELEVANT_PATHS }}
|
paths: ${{ env.RELEVANT_PATHS }}
|
||||||
|
|
||||||
- id: path_filter
|
- id: path_filter
|
||||||
@@ -62,6 +66,29 @@ jobs:
|
|||||||
filters: |
|
filters: |
|
||||||
relevant: ${{ env.RELEVANT_PATHS }}
|
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:
|
build-gui:
|
||||||
strategy:
|
strategy:
|
||||||
fail-fast: true
|
fail-fast: true
|
||||||
@@ -106,7 +133,7 @@ jobs:
|
|||||||
GUI_TARGET: ${{ matrix.GUI_TARGET }}
|
GUI_TARGET: ${{ matrix.GUI_TARGET }}
|
||||||
OSS_BUCKET: ${{ secrets.ALIYUN_OSS_BUCKET }}
|
OSS_BUCKET: ${{ secrets.ALIYUN_OSS_BUCKET }}
|
||||||
needs: pre_job
|
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:
|
steps:
|
||||||
- uses: actions/checkout@v5
|
- uses: actions/checkout@v5
|
||||||
|
|
||||||
@@ -120,7 +147,7 @@ jobs:
|
|||||||
target: ${{ matrix.TARGET }}
|
target: ${{ matrix.TARGET }}
|
||||||
gui: true
|
gui: true
|
||||||
pnpm: true
|
pnpm: true
|
||||||
pnpm-build-filter: ''
|
pnpm-build-filter: easytier-gui...
|
||||||
token: ${{ secrets.GITHUB_TOKEN }}
|
token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
- uses: Swatinem/rust-cache@v2
|
- uses: Swatinem/rust-cache@v2
|
||||||
@@ -128,8 +155,8 @@ jobs:
|
|||||||
# The prefix cache key, this can be changed to start a new cache manually.
|
# The prefix cache key, this can be changed to start a new cache manually.
|
||||||
# default: "v0-rust"
|
# default: "v0-rust"
|
||||||
prefix-key: ""
|
prefix-key: ""
|
||||||
shared-key: "gui-registry"
|
shared-key: gui-release-${{ matrix.GUI_TARGET }}
|
||||||
cache-targets: "false"
|
cache-targets: "true"
|
||||||
|
|
||||||
- name: copy correct DLLs
|
- name: copy correct DLLs
|
||||||
if: ${{ contains(matrix.GUI_TARGET, 'windows') }}
|
if: ${{ contains(matrix.GUI_TARGET, 'windows') }}
|
||||||
@@ -262,9 +289,9 @@ jobs:
|
|||||||
|
|
||||||
gui-result:
|
gui-result:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
needs: [ pre_job, build-gui ]
|
needs: [ pre_job, check, build-gui ]
|
||||||
if: needs.pre_job.result == 'success' && needs.pre_job.outputs.should_skip != 'true' && !cancelled()
|
if: ${{ always() }}
|
||||||
steps:
|
steps:
|
||||||
- name: Mark result as failed
|
- name: Mark result as failed
|
||||||
if: contains(needs.*.result, 'failure')
|
if: contains(needs.*.result, 'failure') || contains(needs.*.result, 'cancelled')
|
||||||
run: exit 1
|
run: exit 1
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
name: EasyTier Mobile
|
name: EasyTier Mobile
|
||||||
|
|
||||||
on:
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
push:
|
push:
|
||||||
branches: ["develop", "main", "releases/**"]
|
branches: ["develop", "main", "releases/**"]
|
||||||
pull_request:
|
pull_request:
|
||||||
@@ -8,7 +9,8 @@ on:
|
|||||||
types: [opened, synchronize, reopened, ready_for_review]
|
types: [opened, synchronize, reopened, ready_for_review]
|
||||||
|
|
||||||
concurrency:
|
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
|
cancel-in-progress: true
|
||||||
|
|
||||||
env:
|
env:
|
||||||
@@ -32,6 +34,7 @@ jobs:
|
|||||||
"easytier-web/frontend-lib/**", ".github/workflows/mobile.yml",
|
"easytier-web/frontend-lib/**", ".github/workflows/mobile.yml",
|
||||||
".github/actions/**"]
|
".github/actions/**"]
|
||||||
outputs:
|
outputs:
|
||||||
|
full_build: ${{ github.event_name == 'workflow_dispatch' || startsWith(github.ref_name, 'releases/') }}
|
||||||
# Release builds must run even if they only change release metadata.
|
# Release builds must run even if they only change release metadata.
|
||||||
should_skip: >-
|
should_skip: >-
|
||||||
${{
|
${{
|
||||||
@@ -42,15 +45,16 @@ jobs:
|
|||||||
steps.path_filter.outputs.relevant != 'true'
|
steps.path_filter.outputs.relevant != 'true'
|
||||||
)
|
)
|
||||||
) &&
|
) &&
|
||||||
|
github.event_name != 'workflow_dispatch' &&
|
||||||
!startsWith(github.ref_name, 'releases/')
|
!startsWith(github.ref_name, 'releases/')
|
||||||
}}
|
}}
|
||||||
steps:
|
steps:
|
||||||
- id: skip_check
|
- id: skip_check
|
||||||
|
if: github.event_name != 'workflow_dispatch' && !startsWith(github.ref_name, 'releases/')
|
||||||
uses: fkirc/skip-duplicate-actions@v5
|
uses: fkirc/skip-duplicate-actions@v5
|
||||||
with:
|
with:
|
||||||
concurrent_skipping: 'same_content_newer'
|
concurrent_skipping: 'same_content_newer'
|
||||||
skip_after_successful_duplicate: 'true'
|
skip_after_successful_duplicate: 'true'
|
||||||
cancel_others: 'true'
|
|
||||||
paths: ${{ env.RELEVANT_PATHS }}
|
paths: ${{ env.RELEVANT_PATHS }}
|
||||||
|
|
||||||
- id: path_filter
|
- id: path_filter
|
||||||
@@ -66,19 +70,11 @@ jobs:
|
|||||||
strategy:
|
strategy:
|
||||||
fail-fast: true
|
fail-fast: true
|
||||||
matrix:
|
matrix:
|
||||||
include:
|
ARCH: ${{ fromJSON(needs.pre_job.outputs.full_build == 'true' && '["aarch64", "armv7", "i686", "x86_64"]' || '["aarch64"]') }}
|
||||||
- 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
|
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
env:
|
env:
|
||||||
NAME: easytier
|
NAME: easytier
|
||||||
TARGET: ${{ matrix.TARGET }}
|
TARGET: ${{ matrix.ARCH == 'armv7' && 'armv7-linux-androideabi' || format('{0}-linux-android', matrix.ARCH) }}
|
||||||
ARCH: ${{ matrix.ARCH }}
|
ARCH: ${{ matrix.ARCH }}
|
||||||
OSS_BUCKET: ${{ secrets.ALIYUN_OSS_BUCKET }}
|
OSS_BUCKET: ${{ secrets.ALIYUN_OSS_BUCKET }}
|
||||||
needs: pre_job
|
needs: pre_job
|
||||||
@@ -98,7 +94,7 @@ jobs:
|
|||||||
- name: Setup Android SDK
|
- name: Setup Android SDK
|
||||||
uses: android-actions/setup-android@v3
|
uses: android-actions/setup-android@v3
|
||||||
with:
|
with:
|
||||||
cmdline-tools-version: 12.0
|
cmdline-tools-version: '11076708'
|
||||||
packages: 'build-tools;34.0.0 ndk;26.0.10792818 platform-tools platforms;android-34 '
|
packages: 'build-tools;34.0.0 ndk;26.0.10792818 platform-tools platforms;android-34 '
|
||||||
|
|
||||||
- name: Setup Android Environment
|
- name: Setup Android Environment
|
||||||
@@ -110,10 +106,10 @@ jobs:
|
|||||||
- name: Prepare build environment
|
- name: Prepare build environment
|
||||||
uses: ./.github/actions/prepare-build
|
uses: ./.github/actions/prepare-build
|
||||||
with:
|
with:
|
||||||
target: ${{ matrix.TARGET }}
|
target: ${{ env.TARGET }}
|
||||||
gui: false
|
gui: false
|
||||||
pnpm: true
|
pnpm: true
|
||||||
pnpm-build-filter: ''
|
pnpm-build-filter: easytier-gui...
|
||||||
token: ${{ secrets.GITHUB_TOKEN }}
|
token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
- uses: Swatinem/rust-cache@v2
|
- uses: Swatinem/rust-cache@v2
|
||||||
@@ -121,15 +117,22 @@ jobs:
|
|||||||
# The prefix cache key, this can be changed to start a new cache manually.
|
# The prefix cache key, this can be changed to start a new cache manually.
|
||||||
# default: "v0-rust"
|
# default: "v0-rust"
|
||||||
prefix-key: ""
|
prefix-key: ""
|
||||||
shared-key: "gui-registry"
|
shared-key: mobile-${{ env.TARGET }}-${{ needs.pre_job.outputs.full_build }}
|
||||||
cache-targets: "false"
|
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
|
- name: Build
|
||||||
|
if: needs.pre_job.outputs.full_build == 'true'
|
||||||
run: |
|
run: |
|
||||||
cd easytier-gui
|
cd easytier-gui
|
||||||
pnpm tauri android build --apk --target "$ARCH" --split-per-abi
|
pnpm tauri android build --apk --target "$ARCH" --split-per-abi
|
||||||
|
|
||||||
- name: Collect artifact
|
- name: Collect artifact
|
||||||
|
if: needs.pre_job.outputs.full_build == 'true'
|
||||||
run: |
|
run: |
|
||||||
mkdir -p ./artifacts/objects/
|
mkdir -p ./artifacts/objects/
|
||||||
mv easytier-gui/src-tauri/gen/android/app/build/outputs/apk/*/release/*.apk ./artifacts/objects/
|
mv easytier-gui/src-tauri/gen/android/app/build/outputs/apk/*/release/*.apk ./artifacts/objects/
|
||||||
@@ -144,6 +147,7 @@ jobs:
|
|||||||
rm -rf ./artifacts/objects/
|
rm -rf ./artifacts/objects/
|
||||||
|
|
||||||
- name: Archive artifact
|
- name: Archive artifact
|
||||||
|
if: needs.pre_job.outputs.full_build == 'true'
|
||||||
uses: actions/upload-artifact@v5
|
uses: actions/upload-artifact@v5
|
||||||
with:
|
with:
|
||||||
name: easytier-mobile-android-${{ matrix.ARCH }}
|
name: easytier-mobile-android-${{ matrix.ARCH }}
|
||||||
@@ -153,8 +157,8 @@ jobs:
|
|||||||
mobile-result:
|
mobile-result:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
needs: [ pre_job, build-mobile ]
|
needs: [ pre_job, build-mobile ]
|
||||||
if: needs.pre_job.result == 'success' && needs.pre_job.outputs.should_skip != 'true' && !cancelled()
|
if: ${{ always() }}
|
||||||
steps:
|
steps:
|
||||||
- name: Mark result as failed
|
- name: Mark result as failed
|
||||||
if: contains(needs.*.result, 'failure')
|
if: contains(needs.*.result, 'failure') || contains(needs.*.result, 'cancelled')
|
||||||
run: exit 1
|
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 }}
|
||||||
+40
-16
@@ -80,32 +80,66 @@ jobs:
|
|||||||
uses: ErBWs/setup-ohos@v1
|
uses: ErBWs/setup-ohos@v1
|
||||||
|
|
||||||
- name: Install ohrs
|
- name: Install ohrs
|
||||||
|
if: github.event_name != 'pull_request'
|
||||||
uses: taiki-e/install-action@v2
|
uses: taiki-e/install-action@v2
|
||||||
with:
|
with:
|
||||||
tool: ohrs
|
tool: ohrs
|
||||||
|
|
||||||
- name: Build HAR
|
- name: Prepare OHOS tools
|
||||||
id: package
|
|
||||||
env:
|
|
||||||
DEFAULT_BRANCH: ${{ github.event.repository.default_branch }}
|
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
sudo apt-get install -qqy \
|
sudo apt-get install -qqy \
|
||||||
pkg-config curl libgl1-mesa-dev expect llvm clang lldb lld
|
pkg-config curl libgl1-mesa-dev expect llvm clang lldb lld
|
||||||
rustup component add rustfmt
|
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" \
|
||||||
|
-target aarch64-linux-ohos \
|
||||||
|
--sysroot="$OHOS_NDK_HOME/native/sysroot" \
|
||||||
|
-D__MUSL__ "$@"
|
||||||
|
EOF
|
||||||
|
sudo chmod +x \
|
||||||
|
"$OHOS_NDK_HOME/native/llvm/aarch64-unknown-linux-ohos-clang.sh"
|
||||||
|
|
||||||
|
- name: Check formatting
|
||||||
|
run: |
|
||||||
cargo fmt --all --manifest-path \
|
cargo fmt --all --manifest-path \
|
||||||
easytier-contrib/easytier-ohrs/Cargo.toml -- --check
|
easytier-contrib/easytier-ohrs/Cargo.toml -- --check
|
||||||
|
|
||||||
|
- name: Test OHOS bridge
|
||||||
|
run: |
|
||||||
cargo test --locked --manifest-path \
|
cargo test --locked --manifest-path \
|
||||||
easytier-contrib/easytier-ohrs/Cargo.toml \
|
easytier-contrib/easytier-ohrs/Cargo.toml \
|
||||||
-p easytier-ohos-core -p easytier-ohos-features \
|
-p easytier-ohos-core -p easytier-ohos-features \
|
||||||
--lib -- --test-threads=1
|
--lib -- --test-threads=1
|
||||||
|
|
||||||
|
- name: Check OHOS bindings
|
||||||
|
run: |
|
||||||
cargo check --locked --manifest-path \
|
cargo check --locked --manifest-path \
|
||||||
easytier-contrib/easytier-ohrs/Cargo.toml \
|
easytier-contrib/easytier-ohrs/Cargo.toml \
|
||||||
-p easytier-ohrs --tests
|
-p easytier-ohrs --tests
|
||||||
|
|
||||||
|
- name: Check OHOS target
|
||||||
|
if: github.event_name == 'pull_request'
|
||||||
|
working-directory: easytier-contrib/easytier-ohrs
|
||||||
|
run: |
|
||||||
|
source env.sh
|
||||||
|
cargo check --locked -p easytier-ohrs --tests \
|
||||||
|
--target aarch64-unknown-linux-ohos
|
||||||
|
|
||||||
|
- name: Build HAR
|
||||||
|
if: github.event_name != 'pull_request'
|
||||||
|
id: package
|
||||||
|
env:
|
||||||
|
DEFAULT_BRANCH: ${{ github.event.repository.default_branch }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
cargo_version=$(cargo metadata --format-version 1 --no-deps \
|
cargo_version=$(cargo metadata --format-version 1 --no-deps \
|
||||||
--manifest-path easytier/Cargo.toml | jq -r '.packages[0].version')
|
--manifest-path easytier/Cargo.toml | jq -r '.packages[0].version')
|
||||||
last_tag=$(git describe --tags --abbrev=0 HEAD 2>/dev/null || true)
|
# Rolling tags such as nightly are not package versions.
|
||||||
|
last_tag=$(git describe --tags --match 'v[0-9]*' --abbrev=0 HEAD 2>/dev/null || true)
|
||||||
if [ -n "$last_tag" ]; then
|
if [ -n "$last_tag" ]; then
|
||||||
base_version=$(printf '%s\n' "$cargo_version" "${last_tag#v}" \
|
base_version=$(printf '%s\n' "$cargo_version" "${last_tag#v}" \
|
||||||
| sort -V | tail -n 1)
|
| sort -V | tail -n 1)
|
||||||
@@ -149,17 +183,6 @@ jobs:
|
|||||||
echo
|
echo
|
||||||
} > "$package_dir/CHANGELOG.md"
|
} > "$package_dir/CHANGELOG.md"
|
||||||
|
|
||||||
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" \
|
|
||||||
-target aarch64-linux-ohos \
|
|
||||||
--sysroot="$OHOS_NDK_HOME/native/sysroot" \
|
|
||||||
-D__MUSL__ "$@"
|
|
||||||
EOF
|
|
||||||
sudo chmod +x \
|
|
||||||
"$OHOS_NDK_HOME/native/llvm/aarch64-unknown-linux-ohos-clang.sh"
|
|
||||||
|
|
||||||
cd easytier-contrib/easytier-ohrs
|
cd easytier-contrib/easytier-ohrs
|
||||||
source env.sh
|
source env.sh
|
||||||
ohrs build --release --arch aarch
|
ohrs build --release --arch aarch
|
||||||
@@ -167,6 +190,7 @@ jobs:
|
|||||||
mv package.har "$package_name.har"
|
mv package.har "$package_name.har"
|
||||||
|
|
||||||
- name: Upload HAR
|
- name: Upload HAR
|
||||||
|
if: github.event_name != 'pull_request'
|
||||||
uses: actions/upload-artifact@v5
|
uses: actions/upload-artifact@v5
|
||||||
with:
|
with:
|
||||||
name: ${{ steps.package.outputs.name }}
|
name: ${{ steps.package.outputs.name }}
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ on:
|
|||||||
required: true
|
required: true
|
||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
|
actions: read
|
||||||
contents: write
|
contents: write
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
@@ -37,6 +38,17 @@ jobs:
|
|||||||
name: Checkout
|
name: Checkout
|
||||||
uses: actions/checkout@v5
|
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
|
- name: Download Core Artifact
|
||||||
uses: dawidd6/action-download-artifact@v11
|
uses: dawidd6/action-download-artifact@v11
|
||||||
with:
|
with:
|
||||||
|
|||||||
+47
-28
@@ -1,13 +1,16 @@
|
|||||||
name: EasyTier Test
|
name: EasyTier Test
|
||||||
|
|
||||||
on:
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
push:
|
push:
|
||||||
branches: [ "develop", "main" ]
|
branches: [ "develop", "main", "releases/**" ]
|
||||||
pull_request:
|
pull_request:
|
||||||
branches: [ "develop", "main" ]
|
branches: [ "develop", "main" ]
|
||||||
|
types: [ opened, synchronize, reopened, ready_for_review ]
|
||||||
|
|
||||||
concurrency:
|
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
|
cancel-in-progress: true
|
||||||
|
|
||||||
env:
|
env:
|
||||||
@@ -21,27 +24,48 @@ defaults:
|
|||||||
shell: bash
|
shell: bash
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
|
ci-scripts:
|
||||||
|
name: Test CI scripts
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
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:
|
pre_job:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
if: github.event_name != 'pull_request' || !github.event.pull_request.draft
|
||||||
env:
|
env:
|
||||||
RELEVANT_PATHS: >-
|
RELEVANT_PATHS: >-
|
||||||
["Cargo.toml", "Cargo.lock", "rust-toolchain.toml", ".cargo/**",
|
["Cargo.toml", "Cargo.lock", "rust-toolchain.toml", ".cargo/**",
|
||||||
|
".config/nextest.toml",
|
||||||
"pnpm-lock.yaml", "pnpm-workspace.yaml", "package.json", "easytier/**",
|
"pnpm-lock.yaml", "pnpm-workspace.yaml", "package.json", "easytier/**",
|
||||||
"easytier-core/**", "easytier-proto/**", "easytier-rpc-build/**",
|
"easytier-core/**", "easytier-proto/**", "easytier-rpc-build/**",
|
||||||
"easytier-web/**", "easytier-gui/src-tauri/**",
|
"easytier-web/**", "easytier-gui/src-tauri/**",
|
||||||
"tauri-plugin-vpnservice/**", "easytier-contrib/**",
|
"tauri-plugin-vpnservice/**", "easytier-contrib/**",
|
||||||
".github/workflows/test.yml", ".github/actions/**"]
|
".github/workflows/test.yml", ".github/actions/**"]
|
||||||
outputs:
|
outputs:
|
||||||
|
full_build: ${{ github.event_name == 'workflow_dispatch' || startsWith(github.ref_name, 'releases/') }}
|
||||||
should_skip: >-
|
should_skip: >-
|
||||||
${{
|
${{
|
||||||
steps.skip_check.outputs.should_skip == 'true' ||
|
|
||||||
(
|
(
|
||||||
github.event_name == 'pull_request' &&
|
steps.skip_check.outputs.should_skip == 'true' ||
|
||||||
steps.path_filter.outputs.relevant != 'true'
|
(
|
||||||
)
|
github.event_name == 'pull_request' &&
|
||||||
|
steps.path_filter.outputs.relevant != 'true'
|
||||||
|
)
|
||||||
|
) &&
|
||||||
|
github.event_name != 'workflow_dispatch' &&
|
||||||
|
!startsWith(github.ref_name, 'releases/')
|
||||||
}}
|
}}
|
||||||
steps:
|
steps:
|
||||||
- id: skip_check
|
- id: skip_check
|
||||||
|
if: github.event_name != 'workflow_dispatch' && !startsWith(github.ref_name, 'releases/')
|
||||||
uses: fkirc/skip-duplicate-actions@v5
|
uses: fkirc/skip-duplicate-actions@v5
|
||||||
with:
|
with:
|
||||||
concurrent_skipping: 'never'
|
concurrent_skipping: 'never'
|
||||||
@@ -69,16 +93,23 @@ jobs:
|
|||||||
uses: ./.github/actions/prepare-build
|
uses: ./.github/actions/prepare-build
|
||||||
with:
|
with:
|
||||||
gui: true
|
gui: true
|
||||||
pnpm: true
|
pnpm: ${{ needs.pre_job.outputs.full_build == 'true' }}
|
||||||
token: ${{ secrets.GITHUB_TOKEN }}
|
token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
- uses: actions-rust-lang/setup-rust-toolchain@v1
|
- uses: actions-rust-lang/setup-rust-toolchain@v1
|
||||||
with:
|
with:
|
||||||
components: rustfmt,clippy
|
components: rustfmt,clippy
|
||||||
target: wasm32-wasip1
|
target: wasm32-wasip1
|
||||||
|
cache: false
|
||||||
rustflags: ''
|
rustflags: ''
|
||||||
|
|
||||||
|
- uses: Swatinem/rust-cache@v2
|
||||||
|
with:
|
||||||
|
shared-key: test-check
|
||||||
|
save-if: ${{ github.event_name != 'pull_request' }}
|
||||||
|
|
||||||
- uses: taiki-e/install-action@cargo-hack
|
- uses: taiki-e/install-action@cargo-hack
|
||||||
|
if: needs.pre_job.outputs.full_build == 'true'
|
||||||
|
|
||||||
- name: Check formatting
|
- name: Check formatting
|
||||||
if: ${{ !cancelled() }}
|
if: ${{ !cancelled() }}
|
||||||
@@ -89,7 +120,7 @@ jobs:
|
|||||||
run: cargo clippy --all-targets --features full --all -- -D warnings
|
run: cargo clippy --all-targets --features full --all -- -D warnings
|
||||||
|
|
||||||
- name: Check features
|
- 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
|
run: cargo hack check --package easytier --each-feature --exclude-features macos-ne --verbose
|
||||||
|
|
||||||
- name: Check WASI
|
- name: Check WASI
|
||||||
@@ -117,8 +148,8 @@ jobs:
|
|||||||
- name: Prepare build environment
|
- name: Prepare build environment
|
||||||
uses: ./.github/actions/prepare-build
|
uses: ./.github/actions/prepare-build
|
||||||
with:
|
with:
|
||||||
gui: true
|
gui: false
|
||||||
pnpm: true
|
pnpm: false
|
||||||
token: ${{ secrets.GITHUB_TOKEN }}
|
token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
- uses: Swatinem/rust-cache@v2
|
- uses: Swatinem/rust-cache@v2
|
||||||
@@ -136,23 +167,11 @@ jobs:
|
|||||||
path: tests.tar.zst
|
path: tests.tar.zst
|
||||||
retention-days: 1
|
retention-days: 1
|
||||||
|
|
||||||
test_matrix:
|
run-tests:
|
||||||
name: Test (${{ matrix.name }})
|
name: Run tests
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
needs: [ pre_job, pre-test ]
|
needs: [ pre_job, pre-test ]
|
||||||
if: needs.pre_job.outputs.should_skip != 'true'
|
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:
|
steps:
|
||||||
- uses: actions/checkout@v5
|
- uses: actions/checkout@v5
|
||||||
|
|
||||||
@@ -187,13 +206,13 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
sudo prlimit --pid $$ --nofile=1048576:1048576
|
sudo prlimit --pid $$ --nofile=1048576:1048576
|
||||||
sudo -E env "PATH=$PATH" EASYTIER_LINUX_BPF_INTEGRATION=required \
|
sudo -E env "PATH=$PATH" EASYTIER_LINUX_BPF_INTEGRATION=required \
|
||||||
cargo nextest run --archive-file tests.tar.zst ${{ matrix.opts }}
|
cargo nextest run --archive-file tests.tar.zst --profile ci --no-fail-fast
|
||||||
|
|
||||||
test:
|
test:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
needs: [ pre_job, check, test_matrix ]
|
needs: [ pre_job, ci-scripts, check, pre-test, run-tests ]
|
||||||
if: needs.pre_job.result == 'success' && needs.pre_job.outputs.should_skip != 'true' && !cancelled()
|
if: ${{ always() }}
|
||||||
steps:
|
steps:
|
||||||
- name: Mark result as failed
|
- name: Mark result as failed
|
||||||
if: contains(needs.*.result, 'failure')
|
if: contains(needs.*.result, 'failure') || contains(needs.*.result, 'cancelled')
|
||||||
run: exit 1
|
run: exit 1
|
||||||
|
|||||||
@@ -91,6 +91,14 @@ model for management readback, while the shared host-aware normalization path
|
|||||||
omits capabilities that the compact runtime cannot execute. Omitted settings
|
omits capabilities that the compact runtime cannot execute. Omitted settings
|
||||||
are silent no-ops and must not be advertised as live network capabilities.
|
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
|
## Web compatibility Host
|
||||||
|
|
||||||
The Web compatibility Host runs the portable EasyTier guest in JavaScript
|
The Web compatibility Host runs the portable EasyTier guest in JavaScript
|
||||||
|
|||||||
Generated
+117
-143
@@ -75,25 +75,11 @@ dependencies = [
|
|||||||
"aead 0.5.2",
|
"aead 0.5.2",
|
||||||
"aes 0.8.4",
|
"aes 0.8.4",
|
||||||
"cipher 0.4.4",
|
"cipher 0.4.4",
|
||||||
"ctr 0.9.2",
|
"ctr",
|
||||||
"ghash 0.5.1",
|
"ghash",
|
||||||
"subtle",
|
"subtle",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "aes-gcm"
|
|
||||||
version = "0.11.1"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "7f2b8006a0c83f52b62ba44a97b58bf76fe2f70a329e588f67f89691d93d498f"
|
|
||||||
dependencies = [
|
|
||||||
"aead 0.6.1",
|
|
||||||
"aes 0.9.3",
|
|
||||||
"cipher 0.5.2",
|
|
||||||
"ctr 0.10.1",
|
|
||||||
"ctutils",
|
|
||||||
"ghash 0.6.0",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ahash"
|
name = "ahash"
|
||||||
version = "0.8.12"
|
version = "0.8.12"
|
||||||
@@ -230,7 +216,7 @@ version = "1.1.5"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc"
|
checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"windows-sys 0.61.2",
|
"windows-sys 0.60.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -241,7 +227,7 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"anstyle",
|
"anstyle",
|
||||||
"once_cell_polyfill",
|
"once_cell_polyfill",
|
||||||
"windows-sys 0.61.2",
|
"windows-sys 0.60.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -1957,15 +1943,6 @@ dependencies = [
|
|||||||
"cipher 0.4.4",
|
"cipher 0.4.4",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "ctr"
|
|
||||||
version = "0.10.1"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "baaca1c4b237092596f64d571e9db6ce4109c4ef9742e27590f1709594461f21"
|
|
||||||
dependencies = [
|
|
||||||
"cipher 0.5.2",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ctutils"
|
name = "ctutils"
|
||||||
version = "0.4.2"
|
version = "0.4.2"
|
||||||
@@ -2388,7 +2365,7 @@ dependencies = [
|
|||||||
"libc",
|
"libc",
|
||||||
"option-ext",
|
"option-ext",
|
||||||
"redox_users 0.5.3",
|
"redox_users 0.5.3",
|
||||||
"windows-sys 0.61.2",
|
"windows-sys 0.60.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -2537,7 +2514,7 @@ dependencies = [
|
|||||||
"atomic-shim",
|
"atomic-shim",
|
||||||
"atomic-write-file",
|
"atomic-write-file",
|
||||||
"atomic_refcell",
|
"atomic_refcell",
|
||||||
"base64 0.23.1",
|
"base64 0.22.1",
|
||||||
"bon",
|
"bon",
|
||||||
"boringtun-easytier",
|
"boringtun-easytier",
|
||||||
"bytecodec",
|
"bytecodec",
|
||||||
@@ -2570,7 +2547,7 @@ dependencies = [
|
|||||||
"hickory-proto",
|
"hickory-proto",
|
||||||
"hickory-resolver",
|
"hickory-resolver",
|
||||||
"hickory-server",
|
"hickory-server",
|
||||||
"hkdf 0.13.0",
|
"hkdf",
|
||||||
"http",
|
"http",
|
||||||
"http-body-util",
|
"http-body-util",
|
||||||
"humansize",
|
"humansize",
|
||||||
@@ -2595,6 +2572,7 @@ dependencies = [
|
|||||||
"pin-project-lite",
|
"pin-project-lite",
|
||||||
"pnet_datalink",
|
"pnet_datalink",
|
||||||
"prost 0.14.4",
|
"prost 0.14.4",
|
||||||
|
"prost-types 0.14.4",
|
||||||
"quanta",
|
"quanta",
|
||||||
"quinn",
|
"quinn",
|
||||||
"quinn-proto",
|
"quinn-proto",
|
||||||
@@ -2609,7 +2587,7 @@ dependencies = [
|
|||||||
"serde_json",
|
"serde_json",
|
||||||
"serial_test",
|
"serial_test",
|
||||||
"service-manager",
|
"service-manager",
|
||||||
"sha2 0.11.0",
|
"sha2 0.10.9",
|
||||||
"shellexpand",
|
"shellexpand",
|
||||||
"smoltcp",
|
"smoltcp",
|
||||||
"socket2",
|
"socket2",
|
||||||
@@ -2663,7 +2641,7 @@ dependencies = [
|
|||||||
name = "easytier-core"
|
name = "easytier-core"
|
||||||
version = "2.7.0"
|
version = "2.7.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"aes-gcm 0.11.1",
|
"aes-gcm",
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"arc-swap",
|
"arc-swap",
|
||||||
"ariadne",
|
"ariadne",
|
||||||
@@ -2671,27 +2649,28 @@ dependencies = [
|
|||||||
"async-trait",
|
"async-trait",
|
||||||
"atomic-shim",
|
"atomic-shim",
|
||||||
"auto_impl",
|
"auto_impl",
|
||||||
"base64 0.23.1",
|
"base64 0.22.1",
|
||||||
"bitflags 2.13.2",
|
"bitflags 2.13.2",
|
||||||
"bon",
|
"bon",
|
||||||
"bytecodec",
|
"bytecodec",
|
||||||
"bytes",
|
"bytes",
|
||||||
"chacha20poly1305 0.11.0",
|
"chacha20poly1305 0.10.1",
|
||||||
"chrono",
|
"chrono",
|
||||||
"cidr",
|
"cidr",
|
||||||
"crossbeam",
|
"crossbeam-utils",
|
||||||
"dashmap",
|
"dashmap",
|
||||||
"easytier-proto",
|
"easytier-proto",
|
||||||
"futures",
|
"futures",
|
||||||
"getrandom 0.2.17",
|
"getrandom 0.2.17",
|
||||||
"getrandom 0.3.4",
|
"getrandom 0.3.4",
|
||||||
"guarden",
|
"guarden",
|
||||||
"hmac 0.13.0",
|
"hmac 0.12.1",
|
||||||
"http-body-util",
|
"http-body-util",
|
||||||
"hyper",
|
"hyper",
|
||||||
"hyper-util",
|
"hyper-util",
|
||||||
"idna",
|
"idna",
|
||||||
"openssl",
|
"openssl",
|
||||||
|
"optionize",
|
||||||
"ordered_hash_map",
|
"ordered_hash_map",
|
||||||
"parking_lot",
|
"parking_lot",
|
||||||
"percent-encoding",
|
"percent-encoding",
|
||||||
@@ -2699,14 +2678,17 @@ dependencies = [
|
|||||||
"pin-project-lite",
|
"pin-project-lite",
|
||||||
"prefix-trie 0.10.1",
|
"prefix-trie 0.10.1",
|
||||||
"prost 0.14.4",
|
"prost 0.14.4",
|
||||||
|
"prost-reflect 0.16.5",
|
||||||
"prost-types 0.14.4",
|
"prost-types 0.14.4",
|
||||||
"quanta",
|
"quanta",
|
||||||
"rand 0.8.8",
|
"rand 0.8.8",
|
||||||
"ring",
|
"ring",
|
||||||
"rustls",
|
"rustls",
|
||||||
|
"rustls-pki-types",
|
||||||
|
"rustls-platform-verifier",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"sha2 0.11.0",
|
"sha2 0.10.9",
|
||||||
"smoltcp",
|
"smoltcp",
|
||||||
"snow",
|
"snow",
|
||||||
"strum 0.28.0",
|
"strum 0.28.0",
|
||||||
@@ -2720,7 +2702,7 @@ dependencies = [
|
|||||||
"url",
|
"url",
|
||||||
"uuid",
|
"uuid",
|
||||||
"wasm-bindgen",
|
"wasm-bindgen",
|
||||||
"webpki-roots 1.0.9",
|
"webpki-roots 1.0.11",
|
||||||
"wildmatch",
|
"wildmatch",
|
||||||
"x25519-dalek 2.0.1",
|
"x25519-dalek 2.0.1",
|
||||||
"zerocopy 0.7.35",
|
"zerocopy 0.7.35",
|
||||||
@@ -2811,12 +2793,12 @@ dependencies = [
|
|||||||
name = "easytier-ohos-features"
|
name = "easytier-ohos-features"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"base64 0.23.1",
|
"base64 0.22.1",
|
||||||
"easytier",
|
"easytier",
|
||||||
"flate2",
|
"flate2",
|
||||||
"gethostname",
|
"gethostname",
|
||||||
"once_cell",
|
"once_cell",
|
||||||
"prost-reflect",
|
"prost-reflect 0.14.7",
|
||||||
"rusqlite",
|
"rusqlite",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
@@ -2858,24 +2840,27 @@ dependencies = [
|
|||||||
"anyhow",
|
"anyhow",
|
||||||
"async-trait",
|
"async-trait",
|
||||||
"auto_impl",
|
"auto_impl",
|
||||||
"base64 0.23.1",
|
"base64 0.22.1",
|
||||||
"bytes",
|
"bytes",
|
||||||
"chrono",
|
"chrono",
|
||||||
"cidr",
|
"cidr",
|
||||||
"hmac 0.13.0",
|
"hmac 0.12.1",
|
||||||
"indoc",
|
"indoc",
|
||||||
|
"optionize",
|
||||||
"pbjson",
|
"pbjson",
|
||||||
"pbjson-build",
|
"pbjson-build",
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"prost 0.14.4",
|
"prost 0.14.4",
|
||||||
"prost-build",
|
"prost-build",
|
||||||
|
"prost-reflect 0.16.5",
|
||||||
"prost-types 0.14.4",
|
"prost-types 0.14.4",
|
||||||
"prost-wkt-types",
|
"prost-wkt-types",
|
||||||
"quote",
|
"quote",
|
||||||
"reqwest 0.13.5",
|
"reqwest",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"sha2 0.11.0",
|
"sha2 0.10.9",
|
||||||
|
"strum 0.28.0",
|
||||||
"thiserror 2.0.20",
|
"thiserror 2.0.20",
|
||||||
"tokio",
|
"tokio",
|
||||||
"url",
|
"url",
|
||||||
@@ -2924,7 +2909,7 @@ dependencies = [
|
|||||||
"axum-embed",
|
"axum-embed",
|
||||||
"axum-login",
|
"axum-login",
|
||||||
"axum-messages",
|
"axum-messages",
|
||||||
"base64 0.23.1",
|
"base64 0.22.1",
|
||||||
"chrono",
|
"chrono",
|
||||||
"cidr",
|
"cidr",
|
||||||
"clap",
|
"clap",
|
||||||
@@ -2937,18 +2922,20 @@ dependencies = [
|
|||||||
"imageproc",
|
"imageproc",
|
||||||
"maxminddb",
|
"maxminddb",
|
||||||
"mimalloc",
|
"mimalloc",
|
||||||
|
"oauth2-reqwest",
|
||||||
"openidconnect",
|
"openidconnect",
|
||||||
|
"optionize",
|
||||||
"password-auth",
|
"password-auth",
|
||||||
"prost 0.14.4",
|
"prost 0.14.4",
|
||||||
"rand 0.8.8",
|
"rand 0.8.8",
|
||||||
"reqwest 0.13.5",
|
"reqwest",
|
||||||
"rust-embed",
|
"rust-embed",
|
||||||
"rust-i18n",
|
"rust-i18n",
|
||||||
"sea-orm",
|
"sea-orm",
|
||||||
"sea-orm-migration",
|
"sea-orm-migration",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"sha2 0.11.0",
|
"sha2 0.10.9",
|
||||||
"sqlx",
|
"sqlx",
|
||||||
"subtle",
|
"subtle",
|
||||||
"sys-locale",
|
"sys-locale",
|
||||||
@@ -2956,6 +2943,7 @@ dependencies = [
|
|||||||
"thunk-rs",
|
"thunk-rs",
|
||||||
"tokio",
|
"tokio",
|
||||||
"tokio-util",
|
"tokio-util",
|
||||||
|
"toml 1.1.6+spec-1.1.0",
|
||||||
"tower",
|
"tower",
|
||||||
"tower-http 0.7.1",
|
"tower-http 0.7.1",
|
||||||
"tower-sessions",
|
"tower-sessions",
|
||||||
@@ -3025,7 +3013,7 @@ dependencies = [
|
|||||||
"ff",
|
"ff",
|
||||||
"generic-array",
|
"generic-array",
|
||||||
"group",
|
"group",
|
||||||
"hkdf 0.12.4",
|
"hkdf",
|
||||||
"pem-rfc7468",
|
"pem-rfc7468",
|
||||||
"pkcs8",
|
"pkcs8",
|
||||||
"rand_core 0.6.4",
|
"rand_core 0.6.4",
|
||||||
@@ -3232,7 +3220,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
|
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"libc",
|
"libc",
|
||||||
"windows-sys 0.61.2",
|
"windows-sys 0.60.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -3778,16 +3766,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "f0d8a4362ccb29cb0b265253fb0a2728f592895ee6854fd9bc13f2ffda266ff1"
|
checksum = "f0d8a4362ccb29cb0b265253fb0a2728f592895ee6854fd9bc13f2ffda266ff1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"opaque-debug",
|
"opaque-debug",
|
||||||
"polyval 0.6.2",
|
"polyval",
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "ghash"
|
|
||||||
version = "0.6.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "2eecf2d5dc9b66b732b97707a0210906b1d30523eb773193ab777c0c84b3e8d5"
|
|
||||||
dependencies = [
|
|
||||||
"polyval 0.7.3",
|
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -4297,15 +4276,6 @@ dependencies = [
|
|||||||
"hmac 0.12.1",
|
"hmac 0.12.1",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "hkdf"
|
|
||||||
version = "0.13.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "4aaa26c720c68b866f2c96ef5c1264b3e6f473fe5d4ce61cd44bbe913e553018"
|
|
||||||
dependencies = [
|
|
||||||
"hmac 0.13.0",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "hmac"
|
name = "hmac"
|
||||||
version = "0.12.1"
|
version = "0.12.1"
|
||||||
@@ -5538,7 +5508,7 @@ dependencies = [
|
|||||||
"png 0.18.1",
|
"png 0.18.1",
|
||||||
"serde",
|
"serde",
|
||||||
"thiserror 2.0.20",
|
"thiserror 2.0.20",
|
||||||
"windows-sys 0.61.2",
|
"windows-sys 0.60.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -5940,7 +5910,7 @@ version = "0.50.3"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5"
|
checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"windows-sys 0.61.2",
|
"windows-sys 0.60.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -6070,7 +6040,6 @@ dependencies = [
|
|||||||
"getrandom 0.2.17",
|
"getrandom 0.2.17",
|
||||||
"http",
|
"http",
|
||||||
"rand 0.8.8",
|
"rand 0.8.8",
|
||||||
"reqwest 0.12.28",
|
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"serde_path_to_error",
|
"serde_path_to_error",
|
||||||
@@ -6079,6 +6048,16 @@ dependencies = [
|
|||||||
"url",
|
"url",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "oauth2-reqwest"
|
||||||
|
version = "0.1.0-alpha.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "234fb5c965bbce983ee5de636a7a51d6a3223da8067ea02f9ab2d2d78ac08be2"
|
||||||
|
dependencies = [
|
||||||
|
"oauth2",
|
||||||
|
"reqwest",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "objc2"
|
name = "objc2"
|
||||||
version = "0.6.4"
|
version = "0.6.4"
|
||||||
@@ -6414,6 +6393,30 @@ version = "0.2.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d"
|
checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "optionize"
|
||||||
|
version = "0.5.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "2b6a0111dde86433acde952fdb2757eb65a79ae0e3e58acf378544280d9c3104"
|
||||||
|
dependencies = [
|
||||||
|
"derive_more",
|
||||||
|
"optionize-macros",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "optionize-macros"
|
||||||
|
version = "0.5.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "92f30b98a08383cf48fd3d5cc3e3dd27b4a537d94e8488aadff7f3625288b360"
|
||||||
|
dependencies = [
|
||||||
|
"darling 0.24.1",
|
||||||
|
"derive_more",
|
||||||
|
"proc-macro-crate 3.5.0",
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn 3.0.6",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ordered-float"
|
name = "ordered-float"
|
||||||
version = "2.10.1"
|
version = "2.10.1"
|
||||||
@@ -6474,7 +6477,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967"
|
checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"libc",
|
"libc",
|
||||||
"windows-sys 0.61.2",
|
"windows-sys 0.45.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -7001,17 +7004,6 @@ dependencies = [
|
|||||||
"universal-hash 0.5.1",
|
"universal-hash 0.5.1",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "polyval"
|
|
||||||
version = "0.7.3"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "f0fa31d631f2b2cb2a544d0aa321ce847a94764d701ca2becc411138b93d49cd"
|
|
||||||
dependencies = [
|
|
||||||
"cpubits",
|
|
||||||
"cpufeatures 0.3.1",
|
|
||||||
"universal-hash 0.6.1",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "portable-atomic"
|
name = "portable-atomic"
|
||||||
version = "1.15.0"
|
version = "1.15.0"
|
||||||
@@ -7289,6 +7281,16 @@ dependencies = [
|
|||||||
"prost-types 0.13.5",
|
"prost-types 0.13.5",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "prost-reflect"
|
||||||
|
version = "0.16.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "01b80ea363c31af2de2b92e3c07ed1156628f7838c4afb4df75ee78a37fedbd1"
|
||||||
|
dependencies = [
|
||||||
|
"prost 0.14.4",
|
||||||
|
"prost-types 0.14.4",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "prost-reflect-derive"
|
name = "prost-reflect-derive"
|
||||||
version = "0.14.0"
|
version = "0.14.0"
|
||||||
@@ -7465,7 +7467,7 @@ dependencies = [
|
|||||||
"once_cell",
|
"once_cell",
|
||||||
"socket2",
|
"socket2",
|
||||||
"tracing",
|
"tracing",
|
||||||
"windows-sys 0.61.2",
|
"windows-sys 0.60.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -7743,38 +7745,6 @@ version = "1.9.3"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "ba39f3699c378cd8970968dcbff9c43159ea4cfbd88d43c00b22f2ef10a435d2"
|
checksum = "ba39f3699c378cd8970968dcbff9c43159ea4cfbd88d43c00b22f2ef10a435d2"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "reqwest"
|
|
||||||
version = "0.12.28"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147"
|
|
||||||
dependencies = [
|
|
||||||
"base64 0.22.1",
|
|
||||||
"bytes",
|
|
||||||
"futures-core",
|
|
||||||
"http",
|
|
||||||
"http-body",
|
|
||||||
"http-body-util",
|
|
||||||
"hyper",
|
|
||||||
"hyper-util",
|
|
||||||
"js-sys",
|
|
||||||
"log",
|
|
||||||
"percent-encoding",
|
|
||||||
"pin-project-lite",
|
|
||||||
"serde",
|
|
||||||
"serde_json",
|
|
||||||
"serde_urlencoded",
|
|
||||||
"sync_wrapper",
|
|
||||||
"tokio",
|
|
||||||
"tower",
|
|
||||||
"tower-http 0.6.11",
|
|
||||||
"tower-service",
|
|
||||||
"url",
|
|
||||||
"wasm-bindgen",
|
|
||||||
"wasm-bindgen-futures",
|
|
||||||
"web-sys",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "reqwest"
|
name = "reqwest"
|
||||||
version = "0.13.5"
|
version = "0.13.5"
|
||||||
@@ -8089,14 +8059,14 @@ dependencies = [
|
|||||||
"errno",
|
"errno",
|
||||||
"libc",
|
"libc",
|
||||||
"linux-raw-sys 0.12.1",
|
"linux-raw-sys 0.12.1",
|
||||||
"windows-sys 0.61.2",
|
"windows-sys 0.60.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rustls"
|
name = "rustls"
|
||||||
version = "0.23.45"
|
version = "0.23.46"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "0d41d731c7d2f962d1ccc364cec258de3c0e93b38c2fb3ba97ac74513048d634"
|
checksum = "48e13bd8c0e9365c43cfa5c9e8f9ad49d3c8444926c9aac819e0e4dc503c8fdf"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"aws-lc-rs",
|
"aws-lc-rs",
|
||||||
"once_cell",
|
"once_cell",
|
||||||
@@ -8147,7 +8117,7 @@ dependencies = [
|
|||||||
"security-framework",
|
"security-framework",
|
||||||
"security-framework-sys",
|
"security-framework-sys",
|
||||||
"webpki-root-certs",
|
"webpki-root-certs",
|
||||||
"windows-sys 0.61.2",
|
"windows-sys 0.60.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -8158,9 +8128,9 @@ checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rustls-webpki"
|
name = "rustls-webpki"
|
||||||
version = "0.103.15"
|
version = "0.103.16"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2"
|
checksum = "4443b1b6d39d7c663139942cc4558262562d2c84123bb6282ef177009ca8b281"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"aws-lc-rs",
|
"aws-lc-rs",
|
||||||
"ring",
|
"ring",
|
||||||
@@ -8788,6 +8758,12 @@ dependencies = [
|
|||||||
"digest 0.11.3",
|
"digest 0.11.3",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "sha1_smol"
|
||||||
|
version = "1.0.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "bbfa15b3dddfee50a0fff136974b3e1bde555604ba463834a7eb7deb6417705d"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "sha2"
|
name = "sha2"
|
||||||
version = "0.10.9"
|
version = "0.10.9"
|
||||||
@@ -8979,12 +8955,9 @@ version = "0.10.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "599b506ccc4aff8cf7844bc42cf783009a434c1e26c964432560fb6d6ad02d82"
|
checksum = "599b506ccc4aff8cf7844bc42cf783009a434c1e26c964432560fb6d6ad02d82"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"aes-gcm 0.10.3",
|
|
||||||
"blake2 0.10.6",
|
|
||||||
"chacha20poly1305 0.10.1",
|
"chacha20poly1305 0.10.1",
|
||||||
"curve25519-dalek 4.1.3",
|
"curve25519-dalek 4.1.3",
|
||||||
"getrandom 0.3.4",
|
"getrandom 0.3.4",
|
||||||
"ring",
|
|
||||||
"rustc_version",
|
"rustc_version",
|
||||||
"sha2 0.10.9",
|
"sha2 0.10.9",
|
||||||
"subtle",
|
"subtle",
|
||||||
@@ -8997,7 +8970,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4"
|
checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"libc",
|
"libc",
|
||||||
"windows-sys 0.61.2",
|
"windows-sys 0.60.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -9182,7 +9155,7 @@ dependencies = [
|
|||||||
"futures-util",
|
"futures-util",
|
||||||
"generic-array",
|
"generic-array",
|
||||||
"hex",
|
"hex",
|
||||||
"hkdf 0.12.4",
|
"hkdf",
|
||||||
"hmac 0.12.1",
|
"hmac 0.12.1",
|
||||||
"itoa",
|
"itoa",
|
||||||
"log",
|
"log",
|
||||||
@@ -9225,7 +9198,7 @@ dependencies = [
|
|||||||
"futures-core",
|
"futures-core",
|
||||||
"futures-util",
|
"futures-util",
|
||||||
"hex",
|
"hex",
|
||||||
"hkdf 0.12.4",
|
"hkdf",
|
||||||
"hmac 0.12.1",
|
"hmac 0.12.1",
|
||||||
"home",
|
"home",
|
||||||
"itoa",
|
"itoa",
|
||||||
@@ -9633,7 +9606,7 @@ dependencies = [
|
|||||||
"percent-encoding",
|
"percent-encoding",
|
||||||
"plist",
|
"plist",
|
||||||
"raw-window-handle",
|
"raw-window-handle",
|
||||||
"reqwest 0.13.5",
|
"reqwest",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"serde_repr",
|
"serde_repr",
|
||||||
@@ -9929,10 +9902,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
|
checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"fastrand",
|
"fastrand",
|
||||||
"getrandom 0.4.3",
|
"getrandom 0.3.4",
|
||||||
"once_cell",
|
"once_cell",
|
||||||
"rustix 1.1.5",
|
"rustix 1.1.5",
|
||||||
"windows-sys 0.61.2",
|
"windows-sys 0.60.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -9951,7 +9924,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "230a1b821ccbd75b185820a1f1ff7b14d21da1e442e22c0863ea5f08771a8874"
|
checksum = "230a1b821ccbd75b185820a1f1ff7b14d21da1e442e22c0863ea5f08771a8874"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"rustix 1.1.5",
|
"rustix 1.1.5",
|
||||||
"windows-sys 0.61.2",
|
"windows-sys 0.60.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -10217,7 +10190,7 @@ dependencies = [
|
|||||||
"tokio",
|
"tokio",
|
||||||
"tokio-rustls",
|
"tokio-rustls",
|
||||||
"tokio-util",
|
"tokio-util",
|
||||||
"webpki-roots 1.0.9",
|
"webpki-roots 1.0.11",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -10635,7 +10608,7 @@ dependencies = [
|
|||||||
"png 0.18.1",
|
"png 0.18.1",
|
||||||
"serde",
|
"serde",
|
||||||
"thiserror 2.0.20",
|
"thiserror 2.0.20",
|
||||||
"windows-sys 0.61.2",
|
"windows-sys 0.60.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -10675,7 +10648,7 @@ checksum = "d2df906b07856748fa3f6e0ad0cbaa047052d4a7dd609e231c4f72cee8c36f31"
|
|||||||
[[package]]
|
[[package]]
|
||||||
name = "tun-easytier"
|
name = "tun-easytier"
|
||||||
version = "1.1.1"
|
version = "1.1.1"
|
||||||
source = "git+https://github.com/EasyTier/rust-tun#12378839e7985283df0e4fb536b7137230356db5"
|
source = "git+https://github.com/EasyTier/rust-tun?rev=ac0c674c900dbdcf0b3fe9f5f6134c11579d1f7c#ac0c674c900dbdcf0b3fe9f5f6134c11579d1f7c"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bytes",
|
"bytes",
|
||||||
"cfg-if",
|
"cfg-if",
|
||||||
@@ -10742,7 +10715,7 @@ checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"memoffset",
|
"memoffset",
|
||||||
"tempfile",
|
"tempfile",
|
||||||
"windows-sys 0.61.2",
|
"windows-sys 0.60.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -10916,6 +10889,7 @@ dependencies = [
|
|||||||
"js-sys",
|
"js-sys",
|
||||||
"rand 0.10.2",
|
"rand 0.10.2",
|
||||||
"serde_core",
|
"serde_core",
|
||||||
|
"sha1_smol",
|
||||||
"wasm-bindgen",
|
"wasm-bindgen",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -11248,9 +11222,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "webpki-root-certs"
|
name = "webpki-root-certs"
|
||||||
version = "1.0.9"
|
version = "1.0.11"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "b96554aa2acc8ccdb7e1c9a58a7a68dd5d13bccc69cd124cb09406db612a1c9b"
|
checksum = "e49d0e65abc00c142e8f9428da991da9f1a250c39be97459901eb12b9b49f62a"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"rustls-pki-types",
|
"rustls-pki-types",
|
||||||
]
|
]
|
||||||
@@ -11261,14 +11235,14 @@ version = "0.26.11"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9"
|
checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"webpki-roots 1.0.9",
|
"webpki-roots 1.0.11",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "webpki-roots"
|
name = "webpki-roots"
|
||||||
version = "1.0.9"
|
version = "1.0.11"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "7dcd9d09a39985f5344844e66b0c530a33843579125f23e21e9f0f220850f22a"
|
checksum = "f5f280bbb2b287bc8e7de7e3852945a0ff50d1bbcb5ed42b6c7a568c546df171"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"rustls-pki-types",
|
"rustls-pki-types",
|
||||||
]
|
]
|
||||||
@@ -11387,7 +11361,7 @@ version = "0.1.11"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
|
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"windows-sys 0.61.2",
|
"windows-sys 0.48.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -12012,7 +11986,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "7d6f32a0ff4a9f6f01231eb2059cc85479330739333e0e58cadf03b6af2cca10"
|
checksum = "7d6f32a0ff4a9f6f01231eb2059cc85479330739333e0e58cadf03b6af2cca10"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"cfg-if",
|
"cfg-if",
|
||||||
"windows-sys 0.61.2",
|
"windows-sys 0.60.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|||||||
+10
-4
@@ -22,6 +22,7 @@ edition = "2024"
|
|||||||
rust-version = "1.95"
|
rust-version = "1.95"
|
||||||
|
|
||||||
[workspace.dependencies]
|
[workspace.dependencies]
|
||||||
|
optionize = "0.5"
|
||||||
# Shared versions and sources; members select features and optional dependencies.
|
# Shared versions and sources; members select features and optional dependencies.
|
||||||
anyhow = "1.0"
|
anyhow = "1.0"
|
||||||
arc-swap = "1.9"
|
arc-swap = "1.9"
|
||||||
@@ -29,7 +30,8 @@ async-trait = "0.1.92"
|
|||||||
atomic-shim = "0.2.0"
|
atomic-shim = "0.2.0"
|
||||||
auto_impl = "1.3.0"
|
auto_impl = "1.3.0"
|
||||||
axum = "0.8"
|
axum = "0.8"
|
||||||
base64 = "0.23"
|
# Match pbjson 0.9's base64 dependency; upgrade together to avoid two codecs.
|
||||||
|
base64 = "0.22.1"
|
||||||
bon = "3.10.1"
|
bon = "3.10.1"
|
||||||
bytecodec = "0.5.0"
|
bytecodec = "0.5.0"
|
||||||
bytes = "1.12.1"
|
bytes = "1.12.1"
|
||||||
@@ -37,6 +39,7 @@ chrono = "0.4.45"
|
|||||||
cidr = "0.3.2"
|
cidr = "0.3.2"
|
||||||
clap = "4.6.7"
|
clap = "4.6.7"
|
||||||
crossbeam = "0.8.5"
|
crossbeam = "0.8.5"
|
||||||
|
crossbeam-utils = "0.8.23"
|
||||||
dashmap = "6.2.1"
|
dashmap = "6.2.1"
|
||||||
easytier = { version = "2.7.0", path = "easytier", default-features = false }
|
easytier = { version = "2.7.0", path = "easytier", default-features = false }
|
||||||
easytier-core = { version = "2.7.0", path = "easytier-core", default-features = false }
|
easytier-core = { version = "2.7.0", path = "easytier-core", default-features = false }
|
||||||
@@ -44,10 +47,12 @@ easytier-ffi = { version = "0.1.0", path = "easytier-contrib/easytier-ffi", defa
|
|||||||
easytier-ohos-core = { version = "0.1.0", path = "easytier-contrib/easytier-ohrs/crates/easytier-ohos-core", 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-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 }
|
easytier-proto = { version = "2.7.0", path = "easytier-proto", default-features = false }
|
||||||
futures = "0.3"
|
futures = { version = "0.3", default-features = false }
|
||||||
gethostname = "1.1"
|
gethostname = "1.1"
|
||||||
guarden = "0.3"
|
guarden = "0.3"
|
||||||
hmac = "0.13.0"
|
# 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"
|
http-body-util = "0.1"
|
||||||
hyper = { version = "1", default-features = false }
|
hyper = { version = "1", default-features = false }
|
||||||
hyper-util = { version = "0.1", default-features = false }
|
hyper-util = { version = "0.1", default-features = false }
|
||||||
@@ -71,7 +76,8 @@ sea-orm = "1.1.20"
|
|||||||
sea-orm-migration = "1.1.20"
|
sea-orm-migration = "1.1.20"
|
||||||
serde = "1.0.229"
|
serde = "1.0.229"
|
||||||
serde_json = "1.0"
|
serde_json = "1.0"
|
||||||
sha2 = "0.11.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 }
|
smoltcp = { version = "0.14.0", default-features = false }
|
||||||
sqlx = "0.8.6"
|
sqlx = "0.8.6"
|
||||||
strum = "0.28.0"
|
strum = "0.28.0"
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
[](https://github.com/EasyTier/EasyTier/actions/workflows/core.yml)
|
[](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/gui.yml)
|
||||||
[](https://github.com/EasyTier/EasyTier/actions/workflows/test.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)
|
[简体中文](/README_CN.md) | [English](/README.md)
|
||||||
|
|
||||||
@@ -18,7 +18,7 @@
|
|||||||
<img src="assets/running-page.png" width="300" alt="running page">
|
<img src="assets/running-page.png" width="300" alt="running page">
|
||||||
</p>
|
</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
|
## Features
|
||||||
|
|
||||||
@@ -69,8 +69,17 @@ Install via cargo (Latest development version):
|
|||||||
cargo install --git https://github.com/EasyTier/EasyTier.git easytier
|
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)
|
[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 via Docker](https://easytier.cn/en/guide/installation.html#installation-methods)
|
||||||
|
|
||||||
[Install OpenWrt ipk package](https://github.com/EasyTier/luci-app-easytier)
|
[Install OpenWrt ipk package](https://github.com/EasyTier/luci-app-easytier)
|
||||||
|
|||||||
+11
-2
@@ -7,7 +7,7 @@
|
|||||||
[](https://github.com/EasyTier/EasyTier/actions/workflows/core.yml)
|
[](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/gui.yml)
|
||||||
[](https://github.com/EasyTier/EasyTier/actions/workflows/test.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)
|
[简体中文](/README_CN.md) | [English](/README.md)
|
||||||
|
|
||||||
@@ -18,7 +18,7 @@
|
|||||||
<img src="assets/running-page.png" width="300" alt="运行页面">
|
<img src="assets/running-page.png" width="300" alt="运行页面">
|
||||||
</p>
|
</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 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)(推荐,支持所有平台)
|
[下载预编译文件](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)
|
[通过 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)
|
[安装 OpenWrt ipk 软件包](https://github.com/EasyTier/luci-app-easytier)
|
||||||
|
|||||||
@@ -179,6 +179,21 @@ wire codecs. It does not own socket I/O or connection policy.
|
|||||||
- UDP session multiplexing, classification, and lifecycle;
|
- UDP session multiplexing, classification, and lifecycle;
|
||||||
- in-process Ring sockets.
|
- 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:
|
`host/` is the single home of Host capability seams:
|
||||||
|
|
||||||
- DNS and DNS record resolution;
|
- DNS and DNS record resolution;
|
||||||
@@ -349,6 +364,19 @@ A core instance:
|
|||||||
`CoreProcessRuntime`, plus optional protocol and platform capabilities. The
|
`CoreProcessRuntime`, plus optional protocol and platform capabilities. The
|
||||||
bundle carries capabilities, not preconstructed portable managers.
|
bundle carries capabilities, not preconstructed portable managers.
|
||||||
|
|
||||||
|
By default, 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. The `easytier-core --shared-runtime` option instead shares one
|
||||||
|
multi-thread executor across the factory and all its instances. Its worker count
|
||||||
|
defaults to the available CPU count and can be set with the process-level
|
||||||
|
`--multi-thread-count` option, with a minimum of one; per-instance runtime flags
|
||||||
|
are ignored in this mode. Construction, start/stop, and FFI data-plane tasks use
|
||||||
|
the selected executor, independent of the process management runtime. Dropping
|
||||||
|
the last factory or instance 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.
|
Each Module owns partial-start cleanup for its internal resources.
|
||||||
`CoreInstance` has one outer cancellation and recovery path for the complete
|
`CoreInstance` has one outer cancellation and recovery path for the complete
|
||||||
serial startup. `Running` therefore means the Host runtime and every enabled
|
serial startup. `Running` therefore means the Host runtime and every enabled
|
||||||
@@ -390,6 +418,79 @@ Process-global capability objects may contain stateless or shared platform
|
|||||||
mechanisms. They must not contain instance-specific peer, route,
|
mechanisms. They must not contain instance-specific peer, route,
|
||||||
configuration, or connectivity state.
|
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
|
## Runtime configuration authority
|
||||||
|
|
||||||
`TomlConfig` is the authoritative desired configuration used for management
|
`TomlConfig` is the authoritative desired configuration used for management
|
||||||
@@ -406,6 +507,28 @@ behavior:
|
|||||||
- gateway and connectivity settings;
|
- gateway and connectivity settings;
|
||||||
- runtime configuration patches.
|
- 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
|
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
|
Adapter must not call back into an instance to obtain a hidden configuration
|
||||||
snapshot while core is applying an operation.
|
snapshot while core is applying an operation.
|
||||||
@@ -434,6 +557,13 @@ optional `tracing` feature selects the tokio-console subscriber integration;
|
|||||||
only that diagnostic profile pulls the main crate's `tracing-subscriber` and
|
only that diagnostic profile pulls the main crate's `tracing-subscriber` and
|
||||||
`console-subscriber` dependencies.
|
`console-subscriber` dependencies.
|
||||||
|
|
||||||
|
The native CLI enables the `CORE` and `easytier_core::instance::manager` console
|
||||||
|
targets by default. Instance startup failures are reported immediately with the
|
||||||
|
instance ID and complete error chain, including the listener URL for bind
|
||||||
|
failures. When all instances stop, the CLI's failure summary retains each failed
|
||||||
|
instance's ID and saved error. Explicit console levels and `RUST_LOG` directives
|
||||||
|
still control which records are emitted.
|
||||||
|
|
||||||
Contrib applications and platform integrations may have independent logging
|
Contrib applications and platform integrations may have independent logging
|
||||||
requirements and are not implicitly wired to the native process logger.
|
requirements and are not implicitly wired to the native process logger.
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -290,7 +290,11 @@ Each resource owns explicit leases:
|
|||||||
lease through the backend connection wrapper;
|
lease through the backend connection wrapper;
|
||||||
- a listener owns its wildcard listener lease;
|
- a listener owns its wildcard listener lease;
|
||||||
- an accepted stream owns an independent exact flow lease;
|
- an accepted stream owns an independent exact flow lease;
|
||||||
- a UDP socket owns the destination flow leases it created;
|
- a UDP socket owns one bound local-endpoint lease, independent of its remote
|
||||||
|
destinations; an unconnected socket can receive before its first send;
|
||||||
|
- UDP binds allocate and reserve ports within the instance, independently of
|
||||||
|
TUN and Host sockets. Bound endpoints are owned by the data plane; unmatched
|
||||||
|
traffic continues through the peer-packet pipeline;
|
||||||
- a direct or local stream reports its actual address and does not fabricate a
|
- a direct or local stream reports its actual address and does not fabricate a
|
||||||
smoltcp address.
|
smoltcp address.
|
||||||
|
|
||||||
@@ -305,12 +309,25 @@ flow table using pointer equality.
|
|||||||
|
|
||||||
Packet classification behavior must remain compatible:
|
Packet classification behavior must remain compatible:
|
||||||
|
|
||||||
- malformed or unmatched packets pass to the next pipeline;
|
- malformed or unmatched nonfragmented packets pass to the next pipeline;
|
||||||
- exact TCP flows win over wildcard listeners;
|
- exact TCP flows win over wildcard listeners;
|
||||||
- only supported EasyTier data packet kinds are consumed;
|
- only supported EasyTier data packet kinds are consumed;
|
||||||
- modified-source packets that are not local loopback traffic pass through;
|
- modified-source packets that are not local loopback traffic pass through;
|
||||||
- fragmented UDP response routing remains supported;
|
- local UDP fragments follow the route selected by the first fragment's
|
||||||
- a packet is consumed only when the owning flow exists.
|
destination port. Earlier tails wait for that decision, then continue as
|
||||||
|
original packets, without mirroring or changing packet sizes;
|
||||||
|
- a shallow cache retains up to 1,024 routes and 1 MiB of pending packet
|
||||||
|
bytes, with a one-second idle timeout. Expiry and LRU eviction discard
|
||||||
|
pending fragments; they never fall back to a different route. Each new
|
||||||
|
data-plane smoltcp stack uses a random seed to avoid replaying its IP ID
|
||||||
|
sequence. Residual 16-bit ID collisions can still reuse a cached route,
|
||||||
|
causing UDP loss or unintended downstream delivery. This tradeoff is
|
||||||
|
accepted to avoid tracking fragment ranges and completion. Cache hits
|
||||||
|
refresh the idle timeout, so repeated collisions can prolong the effect;
|
||||||
|
- a closed UDP binding cannot transfer its cached fragments to a new binding.
|
||||||
|
Address changes and runtime shutdown clear the cache;
|
||||||
|
- released batches resume at the next filter in the current pipeline snapshot;
|
||||||
|
- nonfragmented packets are consumed only when the owning flow exists.
|
||||||
|
|
||||||
`DataPlaneRuntime`, not the SOCKS5 Adapter, registers and owns the peer-packet
|
`DataPlaneRuntime`, not the SOCKS5 Adapter, registers and owns the peer-packet
|
||||||
pipeline.
|
pipeline.
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ ffi-dataplane = [
|
|||||||
"easytier/ffi-dataplane",
|
"easytier/ffi-dataplane",
|
||||||
"easytier-core/proxy-smoltcp-stack",
|
"easytier-core/proxy-smoltcp-stack",
|
||||||
]
|
]
|
||||||
|
macos-ne = ["easytier/macos-ne"]
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
easytier = { workspace = true, default-features = true, features = ["tracing-log"] }
|
easytier = { workspace = true, default-features = true, features = ["tracing-log"] }
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ pub(crate) unsafe fn set_tun_fd(inst_name: *const c_char, fd: c_int) -> c_int {
|
|||||||
let inst_id = match resolve_instance_id_by_name(&inst_name) {
|
let inst_id = match resolve_instance_id_by_name(&inst_name) {
|
||||||
Ok(Some(instance_id)) => instance_id,
|
Ok(Some(instance_id)) => instance_id,
|
||||||
Ok(None) => {
|
Ok(None) => {
|
||||||
set_error_msg("instance not found");
|
set_error_msg(&format!("instance not found: {inst_name}"));
|
||||||
return -1;
|
return -1;
|
||||||
}
|
}
|
||||||
Err(error) => {
|
Err(error) => {
|
||||||
@@ -32,7 +32,10 @@ pub(crate) unsafe fn set_tun_fd(inst_name: *const c_char, fd: c_int) -> c_int {
|
|||||||
|
|
||||||
match ffi_context().manager.attach_tun_fd(inst_id, fd) {
|
match ffi_context().manager.attach_tun_fd(inst_id, fd) {
|
||||||
Ok(_) => 0,
|
Ok(_) => 0,
|
||||||
Err(_) => -1,
|
Err(e) => {
|
||||||
|
set_error_msg(&format!("failed to set tun fd: {}", e));
|
||||||
|
-1
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,11 @@ 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
|
core with the full binary. It includes AES-GCM so its default encryption
|
||||||
setting interoperates with the full binary's default configuration.
|
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:
|
Build it with:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ easytier-proto = { workspace = true, features = [
|
|||||||
"core",
|
"core",
|
||||||
"json-rpc",
|
"json-rpc",
|
||||||
] }
|
] }
|
||||||
futures.workspace = true
|
futures = { workspace = true, features = ["default"] }
|
||||||
napi-derive-ohos = "1.1"
|
napi-derive-ohos = "1.1"
|
||||||
napi-ohos = { version = "1.1", default-features = false, features = [
|
napi-ohos = { version = "1.1", default-features = false, features = [
|
||||||
"serde-json",
|
"serde-json",
|
||||||
|
|||||||
+55
-18
@@ -1,6 +1,8 @@
|
|||||||
use easytier::proto::ALL_DESCRIPTOR_BYTES;
|
use easytier::proto::ALL_DESCRIPTOR_BYTES;
|
||||||
use once_cell::sync::Lazy;
|
use once_cell::sync::Lazy;
|
||||||
use prost_reflect::{Cardinality, DescriptorPool, FieldDescriptor, Kind, MessageDescriptor};
|
use prost_reflect::{
|
||||||
|
Cardinality, DescriptorPool, FieldDescriptor, FileDescriptor, Kind, MessageDescriptor,
|
||||||
|
};
|
||||||
use serde::Serialize;
|
use serde::Serialize;
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize)]
|
#[derive(Debug, Clone, Serialize)]
|
||||||
@@ -204,7 +206,15 @@ fn build_node(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn build_map_entry_node(message_desc: &MessageDescriptor) -> NetworkConfigSchema {
|
/// The messages being expanded further up, by full name. The pool carries the
|
||||||
|
/// descriptor's own schema, whose messages refer back to each other, so a type
|
||||||
|
/// that reaches one it is already inside stops instead of recursing forever.
|
||||||
|
type ExpansionPath = Vec<String>;
|
||||||
|
|
||||||
|
fn build_map_entry_node(
|
||||||
|
message_desc: &MessageDescriptor,
|
||||||
|
path: &mut ExpansionPath,
|
||||||
|
) -> NetworkConfigSchema {
|
||||||
let key_field = message_desc.map_entry_key_field();
|
let key_field = message_desc.map_entry_key_field();
|
||||||
let value_field = message_desc.map_entry_value_field();
|
let value_field = message_desc.map_entry_value_field();
|
||||||
|
|
||||||
@@ -221,35 +231,49 @@ fn build_map_entry_node(message_desc: &MessageDescriptor) -> NetworkConfigSchema
|
|||||||
Vec::new(),
|
Vec::new(),
|
||||||
Vec::new(),
|
Vec::new(),
|
||||||
vec![
|
vec![
|
||||||
build_schema_field_node(&key_field),
|
build_schema_field_node(&key_field, path),
|
||||||
build_schema_field_node(&value_field),
|
build_schema_field_node(&value_field, path),
|
||||||
],
|
],
|
||||||
Vec::new(),
|
Vec::new(),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn field_children(field: &FieldDescriptor) -> Vec<NetworkConfigSchema> {
|
fn field_children(field: &FieldDescriptor, path: &mut ExpansionPath) -> Vec<NetworkConfigSchema> {
|
||||||
if field.is_map()
|
if field.is_map()
|
||||||
&& let Kind::Message(message_desc) = field.kind()
|
&& let Kind::Message(message_desc) = field.kind()
|
||||||
{
|
{
|
||||||
return vec![build_map_entry_node(&message_desc)];
|
return vec![build_map_entry_node(&message_desc, path)];
|
||||||
}
|
}
|
||||||
|
|
||||||
match field.kind() {
|
match field.kind() {
|
||||||
Kind::Message(message_desc) => build_message_children(&message_desc),
|
Kind::Message(message_desc) => build_message_children(&message_desc, path),
|
||||||
_ => Vec::new(),
|
_ => Vec::new(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn build_message_children(message_desc: &MessageDescriptor) -> Vec<NetworkConfigSchema> {
|
fn build_message_children(
|
||||||
message_desc
|
message_desc: &MessageDescriptor,
|
||||||
.fields()
|
path: &mut ExpansionPath,
|
||||||
.filter(should_expose_field)
|
) -> Vec<NetworkConfigSchema> {
|
||||||
.map(|field| build_schema_field_node(&field))
|
let full_name = message_desc.full_name().to_string();
|
||||||
.collect()
|
if path.contains(&full_name) {
|
||||||
|
return Vec::new();
|
||||||
|
}
|
||||||
|
|
||||||
|
path.push(full_name);
|
||||||
|
let mut children = Vec::new();
|
||||||
|
for field in message_desc.fields().filter(should_expose_field) {
|
||||||
|
children.push(build_schema_field_node(&field, path));
|
||||||
|
}
|
||||||
|
path.pop();
|
||||||
|
|
||||||
|
children
|
||||||
}
|
}
|
||||||
|
|
||||||
fn build_schema_field_node(field: &FieldDescriptor) -> NetworkConfigSchema {
|
fn build_schema_field_node(
|
||||||
|
field: &FieldDescriptor,
|
||||||
|
path: &mut ExpansionPath,
|
||||||
|
) -> NetworkConfigSchema {
|
||||||
build_node(
|
build_node(
|
||||||
"field",
|
"field",
|
||||||
field.name().to_string(),
|
field.name().to_string(),
|
||||||
@@ -262,17 +286,26 @@ fn build_schema_field_node(field: &FieldDescriptor) -> NetworkConfigSchema {
|
|||||||
field_default_value_text(field),
|
field_default_value_text(field),
|
||||||
enum_options(field.kind()),
|
enum_options(field.kind()),
|
||||||
build_validations(field),
|
build_validations(field),
|
||||||
field_children(field),
|
field_children(field, path),
|
||||||
Vec::new(),
|
Vec::new(),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The descriptor's own schema is not configuration surface, and its messages
|
||||||
|
/// are the ones that refer back to each other.
|
||||||
|
fn is_descriptor_schema(file: &FileDescriptor) -> bool {
|
||||||
|
file.name() == "google/protobuf/descriptor.proto"
|
||||||
|
}
|
||||||
|
|
||||||
fn collect_definitions() -> Vec<NetworkConfigSchema> {
|
fn collect_definitions() -> Vec<NetworkConfigSchema> {
|
||||||
let mut definitions = Vec::new();
|
let mut definitions = Vec::new();
|
||||||
|
|
||||||
for message_desc in descriptor_pool().all_messages() {
|
for message_desc in descriptor_pool().all_messages() {
|
||||||
let full_name = message_desc.full_name();
|
let full_name = message_desc.full_name();
|
||||||
if full_name == NETWORK_CONFIG_MESSAGE_NAME || message_desc.is_map_entry() {
|
if full_name == NETWORK_CONFIG_MESSAGE_NAME
|
||||||
|
|| message_desc.is_map_entry()
|
||||||
|
|| is_descriptor_schema(&message_desc.parent_file())
|
||||||
|
{
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -288,12 +321,16 @@ fn collect_definitions() -> Vec<NetworkConfigSchema> {
|
|||||||
None,
|
None,
|
||||||
Vec::new(),
|
Vec::new(),
|
||||||
Vec::new(),
|
Vec::new(),
|
||||||
build_message_children(&message_desc),
|
build_message_children(&message_desc, &mut Vec::new()),
|
||||||
Vec::new(),
|
Vec::new(),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
for enum_desc in descriptor_pool().all_enums() {
|
for enum_desc in descriptor_pool().all_enums() {
|
||||||
|
if is_descriptor_schema(&enum_desc.parent_file()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
definitions.push(build_node(
|
definitions.push(build_node(
|
||||||
"enum",
|
"enum",
|
||||||
enum_desc.full_name().to_string(),
|
enum_desc.full_name().to_string(),
|
||||||
@@ -329,7 +366,7 @@ fn build_network_config_schema() -> NetworkConfigSchema {
|
|||||||
None,
|
None,
|
||||||
Vec::new(),
|
Vec::new(),
|
||||||
Vec::new(),
|
Vec::new(),
|
||||||
build_message_children(&network_config),
|
build_message_children(&network_config, &mut Vec::new()),
|
||||||
collect_definitions(),
|
collect_definitions(),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
+38
-11
@@ -18,6 +18,7 @@ crate-type = ["rlib", "cdylib"]
|
|||||||
wasm-opt = ["-Oz", "--enable-bulk-memory", "--enable-nontrapping-float-to-int"]
|
wasm-opt = ["-Oz", "--enable-bulk-memory", "--enable-nontrapping-float-to-int"]
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
optionize.workspace = true
|
||||||
anyhow.workspace = true
|
anyhow.workspace = true
|
||||||
ariadne = { version = "0.6", optional = true }
|
ariadne = { version = "0.6", optional = true }
|
||||||
arc-swap.workspace = true
|
arc-swap.workspace = true
|
||||||
@@ -30,11 +31,11 @@ bytecodec.workspace = true
|
|||||||
bytes.workspace = true
|
bytes.workspace = true
|
||||||
chrono = { workspace = true, features = ["clock"] }
|
chrono = { workspace = true, features = ["clock"] }
|
||||||
cidr = { workspace = true, features = ["serde"] }
|
cidr = { workspace = true, features = ["serde"] }
|
||||||
crossbeam.workspace = true
|
crossbeam-utils.workspace = true
|
||||||
dashmap.workspace = true
|
dashmap.workspace = true
|
||||||
bon.workspace = true
|
bon.workspace = true
|
||||||
easytier-proto = { workspace = true, features = ["core"] }
|
easytier-proto = { workspace = true, features = ["core"] }
|
||||||
futures.workspace = true
|
futures = { workspace = true, features = ["std", "async-await"] }
|
||||||
guarden.workspace = true
|
guarden.workspace = true
|
||||||
hmac.workspace = true
|
hmac.workspace = true
|
||||||
http-body-util = { workspace = true, optional = true }
|
http-body-util = { workspace = true, optional = true }
|
||||||
@@ -54,10 +55,19 @@ rand.workspace = true
|
|||||||
quanta.workspace = true
|
quanta.workspace = true
|
||||||
ring = { version = "0.17", optional = true }
|
ring = { version = "0.17", optional = true }
|
||||||
rustls = { workspace = true, features = ["ring", "std", "tls12"], optional = true }
|
rustls = { workspace = true, features = ["ring", "std", "tls12"], optional = true }
|
||||||
|
rustls-platform-verifier = { version = "0.7", optional = true }
|
||||||
serde = { workspace = true, features = ["derive"] }
|
serde = { workspace = true, features = ["derive"] }
|
||||||
serde_json.workspace = true
|
serde_json.workspace = true
|
||||||
sha2.workspace = true
|
sha2.workspace = true
|
||||||
smoltcp = { workspace = true, optional = true }
|
smoltcp = { workspace = true, optional = true }
|
||||||
|
# All core Noise handshakes use 25519_ChaChaPoly_SHA256. Snow's std feature
|
||||||
|
# also enables unused ring and BLAKE2 dependencies, so use its alloc support.
|
||||||
|
snow = { version = "0.10.0", default-features = false, features = [
|
||||||
|
"use-chacha20poly1305",
|
||||||
|
"use-sha2",
|
||||||
|
"use-curve25519",
|
||||||
|
"use-getrandom",
|
||||||
|
] }
|
||||||
stun_codec.workspace = true
|
stun_codec.workspace = true
|
||||||
thiserror.workspace = true
|
thiserror.workspace = true
|
||||||
tracing.workspace = true
|
tracing.workspace = true
|
||||||
@@ -74,25 +84,25 @@ tokio-util = { workspace = true, features = ["io", "rt"] }
|
|||||||
tokio-rustls = { workspace = true, optional = true }
|
tokio-rustls = { workspace = true, optional = true }
|
||||||
url = { workspace = true, features = ["serde"] }
|
url = { workspace = true, features = ["serde"] }
|
||||||
wildmatch = "2.6.1"
|
wildmatch = "2.6.1"
|
||||||
uuid = { workspace = true, features = ["v4", "fast-rng", "serde"] }
|
uuid = { workspace = true, features = ["v4", "serde"] }
|
||||||
webpki-roots = { version = "1.0", optional = true }
|
webpki-roots = { version = "1.0", optional = true }
|
||||||
x25519-dalek = { workspace = true, features = ["static_secrets"] }
|
x25519-dalek = { workspace = true, features = ["static_secrets"] }
|
||||||
zerocopy = { workspace = true, features = ["derive", "simd"] }
|
zerocopy = { workspace = true, features = ["derive", "simd"] }
|
||||||
zstd = { version = "0.14", optional = true }
|
zstd = { version = "0.14", optional = true }
|
||||||
aes-gcm = { version = "0.11.1", optional = true }
|
# Match snow 0.10's AEAD generation to share aead, cipher and crypto-common.
|
||||||
chacha20poly1305 = { version = "0.11.0", optional = true }
|
# Upgrade together with snow rather than pulling in a second crypto stack.
|
||||||
|
aes-gcm = { version = "0.10.3", optional = true }
|
||||||
|
chacha20poly1305 = { version = "0.10.1", optional = true }
|
||||||
openssl = { version = "0.10", optional = true, features = ["vendored"] }
|
openssl = { version = "0.10", optional = true, features = ["vendored"] }
|
||||||
|
|
||||||
[target.'cfg(all(target_arch = "wasm32", target_os = "unknown"))'.dependencies]
|
[target.'cfg(all(target_arch = "wasm32", target_os = "unknown"))'.dependencies]
|
||||||
getrandom-02 = { package = "getrandom", version = "0.2.17", features = ["js"] }
|
getrandom-02 = { package = "getrandom", version = "0.2.17", features = ["js"] }
|
||||||
getrandom-03 = { package = "getrandom", version = "0.3.4", features = ["wasm_js"] }
|
getrandom-03 = { package = "getrandom", version = "0.3.4", features = ["wasm_js"] }
|
||||||
snow = { version = "0.10.0", default-features = false, features = ["default-resolver", "default-resolver-crypto"] }
|
ring = { version = "0.17", features = ["wasm32_unknown_unknown_js"], optional = true }
|
||||||
|
rustls-pki-types = { version = "1.15.1", features = ["web"], optional = true }
|
||||||
uuid = { workspace = true, features = ["js"] }
|
uuid = { workspace = true, features = ["js"] }
|
||||||
wasm-bindgen = "0.2"
|
wasm-bindgen = "0.2"
|
||||||
|
|
||||||
[target.'cfg(not(all(target_arch = "wasm32", target_os = "unknown")))'.dependencies]
|
|
||||||
snow = "0.10.0"
|
|
||||||
|
|
||||||
[features]
|
[features]
|
||||||
default = ["aes-gcm", "endpoint-discovery", "extended-services", "management", "tcp-hole-punch"]
|
default = ["aes-gcm", "endpoint-discovery", "extended-services", "management", "tcp-hole-punch"]
|
||||||
aes-gcm = ["dep:aes-gcm"]
|
aes-gcm = ["dep:aes-gcm"]
|
||||||
@@ -100,22 +110,31 @@ browser-config = ["config-write", "easytier-proto/api", "easytier-proto/json-rpc
|
|||||||
chacha20 = ["dep:chacha20poly1305"]
|
chacha20 = ["dep:chacha20poly1305"]
|
||||||
openssl-crypto = ["dep:openssl"]
|
openssl-crypto = ["dep:openssl"]
|
||||||
ring-crypto = ["dep:ring"]
|
ring-crypto = ["dep:ring"]
|
||||||
wasi-crypto-offload = ["ring-crypto"]
|
# The WASI host performs AEAD for the guest (easytier_host.crypto_aead_seal/open),
|
||||||
|
# so the guest must not link a software backend: ring would emit env.ring_core_* imports
|
||||||
|
# that the host module cannot provide.
|
||||||
|
wasi-crypto-offload = []
|
||||||
config-write = []
|
config-write = []
|
||||||
endpoint-discovery = [
|
endpoint-discovery = [
|
||||||
"dep:http-body-util",
|
"dep:http-body-util",
|
||||||
"dep:hyper",
|
"dep:hyper",
|
||||||
"dep:hyper-util",
|
"dep:hyper-util",
|
||||||
|
"dep:ring",
|
||||||
"dep:rustls",
|
"dep:rustls",
|
||||||
|
"dep:rustls-pki-types",
|
||||||
"dep:tokio-rustls",
|
"dep:tokio-rustls",
|
||||||
"dep:webpki-roots",
|
"dep:webpki-roots",
|
||||||
]
|
]
|
||||||
|
# Opt in to platform trust for HTTPS discovery; excluded from default features.
|
||||||
|
system-certificates = ["endpoint-discovery", "dep:rustls-platform-verifier"]
|
||||||
dhcp-ipv4 = []
|
dhcp-ipv4 = []
|
||||||
|
dhcp-ipv6 = []
|
||||||
public-ipv6-provider = []
|
public-ipv6-provider = []
|
||||||
vpn-portal = []
|
vpn-portal = []
|
||||||
wrapped-transport = []
|
wrapped-transport = []
|
||||||
extended-services = [
|
extended-services = [
|
||||||
"dhcp-ipv4",
|
"dhcp-ipv4",
|
||||||
|
"dhcp-ipv6",
|
||||||
"public-ipv6-provider",
|
"public-ipv6-provider",
|
||||||
"vpn-portal",
|
"vpn-portal",
|
||||||
"wrapped-transport",
|
"wrapped-transport",
|
||||||
@@ -127,6 +146,7 @@ management-rpc = ["easytier-proto/api"]
|
|||||||
proxy-cidr-monitor = []
|
proxy-cidr-monitor = []
|
||||||
rich-config-errors = ["dep:ariadne"]
|
rich-config-errors = ["dep:ariadne"]
|
||||||
tcp-hole-punch = []
|
tcp-hole-punch = []
|
||||||
|
smoltcp = ["proxy-packet"]
|
||||||
proxy-packet = [
|
proxy-packet = [
|
||||||
"wrapped-transport",
|
"wrapped-transport",
|
||||||
"dep:smoltcp",
|
"dep:smoltcp",
|
||||||
@@ -152,12 +172,19 @@ test-utils = []
|
|||||||
tracing-log = ["tracing/log"]
|
tracing-log = ["tracing/log"]
|
||||||
zstd = ["dep:zstd"]
|
zstd = ["dep:zstd"]
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
futures = { workspace = true, features = ["executor"] }
|
||||||
|
# Tests read the (easytier.flag) annotations off the schema itself.
|
||||||
|
prost-reflect = "0.16.4"
|
||||||
|
|
||||||
[target.'cfg(not(target_os = "wasi"))'.dev-dependencies]
|
[target.'cfg(not(target_os = "wasi"))'.dev-dependencies]
|
||||||
tokio = { workspace = true, features = ["rt-multi-thread", "test-util"] }
|
tokio = { workspace = true, features = ["rt-multi-thread", "test-util"] }
|
||||||
|
|
||||||
[package.metadata.cargo-machete]
|
[package.metadata.cargo-machete]
|
||||||
ignored = [
|
ignored = [
|
||||||
# Enable browser entropy backends for transitive rand/snow dependencies.
|
# Enable browser entropy backends for transitive rand/snow/AEAD dependencies.
|
||||||
"getrandom-02",
|
"getrandom-02",
|
||||||
"getrandom-03",
|
"getrandom-03",
|
||||||
|
# Enable browser time for rustls certificate validation.
|
||||||
|
"rustls-pki-types",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -4,8 +4,10 @@ use easytier_proto::api::manage::{
|
|||||||
self, NetworkConfig, NetworkingMethod, PortForwardConfig as ApiPortForwardConfig,
|
self, NetworkConfig, NetworkingMethod, PortForwardConfig as ApiPortForwardConfig,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
use optionize::Optionizable as _;
|
||||||
|
|
||||||
use super::{
|
use super::{
|
||||||
api_input::managed_credential_to_proto,
|
api_input::set_network_flags,
|
||||||
toml::{ConfigLoader as _, TomlConfig},
|
toml::{ConfigLoader as _, TomlConfig},
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -14,6 +16,12 @@ pub fn network_config_from_toml(config: &TomlConfig) -> NetworkConfig {
|
|||||||
let mut result = NetworkConfig {
|
let mut result = NetworkConfig {
|
||||||
instance_id: Some(config.get_id().to_string()),
|
instance_id: Some(config.get_id().to_string()),
|
||||||
dhcp: Some(config.get_dhcp()),
|
dhcp: Some(config.get_dhcp()),
|
||||||
|
dhcp_ipv4_cidr: config.get_dhcp_config().cidr().map(|cidr| cidr.to_string()),
|
||||||
|
dhcpv6: Some(config.get_dhcpv6()),
|
||||||
|
dhcp_ipv6_cidr: config
|
||||||
|
.get_dhcpv6_config()
|
||||||
|
.cidr()
|
||||||
|
.map(|cidr| cidr.to_string()),
|
||||||
..Default::default()
|
..Default::default()
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -99,9 +107,7 @@ pub fn network_config_from_toml(config: &TomlConfig) -> NetworkConfig {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Some(routes) = config.get_routes()
|
if let Some(routes) = config.get_routes() {
|
||||||
&& !routes.is_empty()
|
|
||||||
{
|
|
||||||
result.enable_manual_routes = Some(true);
|
result.enable_manual_routes = Some(true);
|
||||||
result.routes = routes.iter().map(ToString::to_string).collect();
|
result.routes = routes.iter().map(ToString::to_string).collect();
|
||||||
}
|
}
|
||||||
@@ -125,61 +131,12 @@ pub fn network_config_from_toml(config: &TomlConfig) -> NetworkConfig {
|
|||||||
result.managed_credentials = config
|
result.managed_credentials = config
|
||||||
.get_managed_credentials()
|
.get_managed_credentials()
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.map(managed_credential_to_proto)
|
.map(|credential| credential.downgrade())
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
let flags = config.get_flags();
|
set_network_flags(&mut result, config.get_flags_patch());
|
||||||
let default_flags = default_config.get_flags();
|
|
||||||
result.latency_first = Some(flags.latency_first);
|
|
||||||
result.dev_name = Some(flags.dev_name.clone());
|
|
||||||
result.use_smoltcp = Some(flags.use_smoltcp);
|
|
||||||
result.disable_ipv6 = Some(!flags.enable_ipv6);
|
|
||||||
result.enable_kcp_proxy = Some(flags.enable_kcp_proxy);
|
|
||||||
result.disable_kcp_input = Some(flags.disable_kcp_input);
|
|
||||||
result.enable_quic_proxy = Some(flags.enable_quic_proxy);
|
|
||||||
result.disable_quic_input = Some(flags.disable_quic_input);
|
|
||||||
result.disable_p2p = Some(flags.disable_p2p);
|
|
||||||
result.p2p_only = Some(flags.p2p_only);
|
|
||||||
result.lazy_p2p = Some(flags.lazy_p2p);
|
|
||||||
result.bind_device = Some(flags.bind_device);
|
|
||||||
result.socket_mark = flags.socket_mark;
|
|
||||||
result.no_tun = Some(flags.no_tun);
|
|
||||||
result.enable_exit_node = Some(flags.enable_exit_node);
|
|
||||||
result.relay_all_peer_rpc = Some(flags.relay_all_peer_rpc);
|
|
||||||
result.need_p2p = Some(flags.need_p2p);
|
|
||||||
result.multi_thread = Some(flags.multi_thread);
|
|
||||||
result.proxy_forward_by_system = Some(flags.proxy_forward_by_system);
|
|
||||||
result.disable_encryption = Some(!flags.enable_encryption);
|
|
||||||
result.disable_tcp_hole_punching = Some(flags.disable_tcp_hole_punching);
|
|
||||||
result.disable_udp_hole_punching = Some(flags.disable_udp_hole_punching);
|
|
||||||
result.disable_upnp = Some(flags.disable_upnp);
|
|
||||||
result.disable_relay_data = Some(flags.disable_relay_data);
|
|
||||||
result.prefer_peer_relay = Some(flags.prefer_peer_relay);
|
|
||||||
result.enable_udp_broadcast_relay = Some(flags.enable_udp_broadcast_relay);
|
|
||||||
result.disable_sym_hole_punching = Some(flags.disable_sym_hole_punching);
|
|
||||||
result.enable_magic_dns = Some(flags.accept_dns);
|
|
||||||
result.mtu = Some(flags.mtu as i32);
|
|
||||||
result.data_compress_algo = (flags.data_compress_algo != default_flags.data_compress_algo)
|
|
||||||
.then_some(flags.data_compress_algo);
|
|
||||||
result.encryption_algorithm = (flags.encryption_algorithm
|
|
||||||
!= default_flags.encryption_algorithm)
|
|
||||||
.then_some(flags.encryption_algorithm);
|
|
||||||
result.instance_recv_bps_limit =
|
|
||||||
(flags.instance_recv_bps_limit != u64::MAX).then_some(flags.instance_recv_bps_limit);
|
|
||||||
result.enable_private_mode = Some(flags.private_mode);
|
|
||||||
result.acl = config.get_acl();
|
result.acl = config.get_acl();
|
||||||
|
|
||||||
if flags.relay_network_whitelist == "*" {
|
|
||||||
result.enable_relay_network_whitelist = Some(false);
|
|
||||||
} else {
|
|
||||||
result.enable_relay_network_whitelist = Some(true);
|
|
||||||
result.relay_network_whitelist = flags
|
|
||||||
.relay_network_whitelist
|
|
||||||
.split_whitespace()
|
|
||||||
.map(ToOwned::to_owned)
|
|
||||||
.collect();
|
|
||||||
}
|
|
||||||
|
|
||||||
result
|
result
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,13 +3,20 @@
|
|||||||
use std::net::SocketAddr;
|
use std::net::SocketAddr;
|
||||||
|
|
||||||
use anyhow::Context;
|
use anyhow::Context;
|
||||||
use easytier_proto::api::manage;
|
use easytier_proto::{api::manage, common::FlagsPatch};
|
||||||
|
|
||||||
|
#[cfg(all(
|
||||||
|
feature = "browser-config",
|
||||||
|
any(test, all(target_arch = "wasm32", target_os = "unknown"))
|
||||||
|
))]
|
||||||
|
use easytier_proto::common::Flags;
|
||||||
|
use optionize::{Optionizable, Optionized};
|
||||||
|
|
||||||
use crate::config::{
|
use crate::config::{
|
||||||
MappedListenerPolicy, normalize_secure_mode_config,
|
MappedListenerPolicy, normalize_secure_mode_config,
|
||||||
toml::{
|
toml::{
|
||||||
ConfigLoader, ManagedCredentialConfig, NetworkIdentity, PeerConfig, PortForwardConfig,
|
ConfigLoader, NetworkIdentity, PeerConfig, PortForwardConfig, TomlConfigLoader,
|
||||||
TomlConfigLoader, VpnPortalClientConfig, VpnPortalConfig, gen_default_flags,
|
VpnPortalClientConfig, VpnPortalConfig,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -51,32 +58,51 @@ pub fn add_proxy_network_to_config(
|
|||||||
pub type NetworkingMethod = easytier_proto::api::manage::NetworkingMethod;
|
pub type NetworkingMethod = easytier_proto::api::manage::NetworkingMethod;
|
||||||
pub type NetworkConfig = easytier_proto::api::manage::NetworkConfig;
|
pub type NetworkConfig = easytier_proto::api::manage::NetworkConfig;
|
||||||
|
|
||||||
pub(crate) fn managed_credential_from_proto(
|
pub(crate) fn set_network_flags(result: &mut NetworkConfig, flags: FlagsPatch) {
|
||||||
credential: &manage::ManagedCredentialConfig,
|
result.latency_first = flags.latency_first;
|
||||||
) -> ManagedCredentialConfig {
|
result.dev_name = flags.dev_name;
|
||||||
ManagedCredentialConfig {
|
result.use_smoltcp = flags.use_smoltcp;
|
||||||
credential_id: credential.credential_id.clone(),
|
result.disable_ipv6 = flags.enable_ipv6.map(|enabled| !enabled);
|
||||||
credential_secret: credential.credential_secret.clone(),
|
result.enable_kcp_proxy = flags.enable_kcp_proxy;
|
||||||
groups: credential.groups.clone(),
|
result.disable_kcp_input = flags.disable_kcp_input;
|
||||||
allow_relay: credential.allow_relay,
|
result.enable_quic_proxy = flags.enable_quic_proxy;
|
||||||
allowed_proxy_cidrs: credential.allowed_proxy_cidrs.clone(),
|
result.disable_quic_input = flags.disable_quic_input;
|
||||||
expiry_unix: credential.expiry_unix,
|
result.disable_p2p = flags.disable_p2p;
|
||||||
reusable: credential.reusable.unwrap_or(true),
|
result.p2p_only = flags.p2p_only;
|
||||||
}
|
result.lazy_p2p = flags.lazy_p2p;
|
||||||
}
|
result.bind_device = flags.bind_device;
|
||||||
|
result.socket_mark = flags.socket_mark;
|
||||||
|
result.no_tun = flags.no_tun;
|
||||||
|
result.enable_exit_node = flags.enable_exit_node;
|
||||||
|
result.relay_all_peer_rpc = flags.relay_all_peer_rpc;
|
||||||
|
result.need_p2p = flags.need_p2p;
|
||||||
|
result.multi_thread = flags.multi_thread;
|
||||||
|
result.proxy_forward_by_system = flags.proxy_forward_by_system;
|
||||||
|
result.disable_encryption = flags.enable_encryption.map(|enabled| !enabled);
|
||||||
|
result.disable_tcp_hole_punching = flags.disable_tcp_hole_punching;
|
||||||
|
result.disable_udp_hole_punching = flags.disable_udp_hole_punching;
|
||||||
|
result.disable_upnp = flags.disable_upnp;
|
||||||
|
result.disable_relay_data = flags.disable_relay_data;
|
||||||
|
result.prefer_peer_relay = flags.prefer_peer_relay;
|
||||||
|
result.enable_udp_broadcast_relay = flags.enable_udp_broadcast_relay;
|
||||||
|
result.disable_sym_hole_punching = flags.disable_sym_hole_punching;
|
||||||
|
result.enable_magic_dns = flags.accept_dns;
|
||||||
|
result.tld_dns_zone = flags.tld_dns_zone;
|
||||||
|
result.mtu = flags.mtu.map(|mtu| mtu as i32);
|
||||||
|
result.data_compress_algo = flags.data_compress_algo;
|
||||||
|
result.encryption_algorithm = flags.encryption_algorithm;
|
||||||
|
result.instance_recv_bps_limit = flags.instance_recv_bps_limit;
|
||||||
|
result.enable_private_mode = flags.private_mode;
|
||||||
|
|
||||||
pub(crate) fn managed_credential_to_proto(
|
result.enable_relay_network_whitelist = flags
|
||||||
credential: ManagedCredentialConfig,
|
.relay_network_whitelist
|
||||||
) -> manage::ManagedCredentialConfig {
|
.as_ref()
|
||||||
manage::ManagedCredentialConfig {
|
.map(|list| list != "*");
|
||||||
credential_id: credential.credential_id,
|
result.relay_network_whitelist = flags
|
||||||
credential_secret: credential.credential_secret,
|
.relay_network_whitelist
|
||||||
groups: credential.groups,
|
.filter(|list| list != "*")
|
||||||
allow_relay: credential.allow_relay,
|
.map(|list| list.split_whitespace().map(ToOwned::to_owned).collect())
|
||||||
allowed_proxy_cidrs: credential.allowed_proxy_cidrs,
|
.unwrap_or_default();
|
||||||
expiry_unix: credential.expiry_unix,
|
|
||||||
reusable: Some(credential.reusable),
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub trait NetworkConfigExt {
|
pub trait NetworkConfigExt {
|
||||||
@@ -96,6 +122,7 @@ const FORM_MANAGED_TOML_FIELDS: &[&str] = &[
|
|||||||
"ipv6_public_addr_auto",
|
"ipv6_public_addr_auto",
|
||||||
"ipv6_public_addr_prefix",
|
"ipv6_public_addr_prefix",
|
||||||
"dhcp",
|
"dhcp",
|
||||||
|
"dhcpv6",
|
||||||
"network_identity",
|
"network_identity",
|
||||||
"listeners",
|
"listeners",
|
||||||
"mapped_listeners",
|
"mapped_listeners",
|
||||||
@@ -112,47 +139,6 @@ const FORM_MANAGED_TOML_FIELDS: &[&str] = &[
|
|||||||
"managed_credentials",
|
"managed_credentials",
|
||||||
];
|
];
|
||||||
|
|
||||||
#[cfg(all(
|
|
||||||
feature = "browser-config",
|
|
||||||
any(test, all(target_arch = "wasm32", target_os = "unknown"))
|
|
||||||
))]
|
|
||||||
const FORM_MANAGED_FLAG_FIELDS: &[&str] = &[
|
|
||||||
"latency_first",
|
|
||||||
"dev_name",
|
|
||||||
"use_smoltcp",
|
|
||||||
"enable_ipv6",
|
|
||||||
"enable_kcp_proxy",
|
|
||||||
"disable_kcp_input",
|
|
||||||
"enable_quic_proxy",
|
|
||||||
"disable_quic_input",
|
|
||||||
"disable_p2p",
|
|
||||||
"p2p_only",
|
|
||||||
"lazy_p2p",
|
|
||||||
"bind_device",
|
|
||||||
"socket_mark",
|
|
||||||
"no_tun",
|
|
||||||
"enable_exit_node",
|
|
||||||
"relay_all_peer_rpc",
|
|
||||||
"need_p2p",
|
|
||||||
"multi_thread",
|
|
||||||
"proxy_forward_by_system",
|
|
||||||
"enable_encryption",
|
|
||||||
"relay_network_whitelist",
|
|
||||||
"disable_tcp_hole_punching",
|
|
||||||
"disable_udp_hole_punching",
|
|
||||||
"disable_upnp",
|
|
||||||
"disable_relay_data",
|
|
||||||
"prefer_peer_relay",
|
|
||||||
"enable_udp_broadcast_relay",
|
|
||||||
"disable_sym_hole_punching",
|
|
||||||
"accept_dns",
|
|
||||||
"mtu",
|
|
||||||
"instance_recv_bps_limit",
|
|
||||||
"private_mode",
|
|
||||||
"encryption_algorithm",
|
|
||||||
"data_compress_algo",
|
|
||||||
];
|
|
||||||
|
|
||||||
#[cfg(all(
|
#[cfg(all(
|
||||||
feature = "browser-config",
|
feature = "browser-config",
|
||||||
any(test, all(target_arch = "wasm32", target_os = "unknown"))
|
any(test, all(target_arch = "wasm32", target_os = "unknown"))
|
||||||
@@ -184,7 +170,22 @@ pub(crate) fn merge_network_config_toml(
|
|||||||
.remove("flags")
|
.remove("flags")
|
||||||
.and_then(|value| value.try_into().ok())
|
.and_then(|value| value.try_into().ok())
|
||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
for key in FORM_MANAGED_FLAG_FIELDS {
|
|
||||||
|
// Normalize any camelCase aliases in original [flags] to their canonical field names
|
||||||
|
// so each field appears at most once in the merged table.
|
||||||
|
for field in Flags::flags() {
|
||||||
|
if let (Some(name), Some(json_name)) = (field.name.as_deref(), field.json_name.as_deref()) {
|
||||||
|
if name != json_name
|
||||||
|
&& let Some(val) = merged_flags.remove(json_name)
|
||||||
|
{
|
||||||
|
merged_flags.entry(name.to_owned()).or_insert(val);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The keys a config form owns are the flags it offers a control for, which
|
||||||
|
// the schema declares with `(easytier.flag)`.
|
||||||
|
for key in Flags::form() {
|
||||||
if let Some(value) = generated_flags.get(*key) {
|
if let Some(value) = generated_flags.get(*key) {
|
||||||
merged_flags.insert((*key).to_owned(), value.clone());
|
merged_flags.insert((*key).to_owned(), value.clone());
|
||||||
} else {
|
} else {
|
||||||
@@ -251,7 +252,18 @@ impl NetworkConfigExt for NetworkConfig {
|
|||||||
.with_context(|| format!("failed to parse instance id: {:?}", self.instance_id))?,
|
.with_context(|| format!("failed to parse instance id: {:?}", self.instance_id))?,
|
||||||
);
|
);
|
||||||
cfg.set_hostname(self.hostname.clone());
|
cfg.set_hostname(self.hostname.clone());
|
||||||
cfg.set_dhcp(self.dhcp.unwrap_or_default());
|
cfg.set_dhcp(self.dhcp.unwrap_or(self.dhcp_ipv4_cidr.is_some()));
|
||||||
|
if cfg.get_dhcp()
|
||||||
|
&& let Some(cidr) = &self.dhcp_ipv4_cidr
|
||||||
|
{
|
||||||
|
cfg.set_dhcp_config(super::DhcpConfig::from_cidr(cidr)?);
|
||||||
|
}
|
||||||
|
cfg.set_dhcpv6(self.dhcpv6.unwrap_or(self.dhcp_ipv6_cidr.is_some()));
|
||||||
|
if cfg.get_dhcpv6()
|
||||||
|
&& let Some(cidr) = &self.dhcp_ipv6_cidr
|
||||||
|
{
|
||||||
|
cfg.set_dhcpv6_config(super::DhcpConfig::from_cidr(cidr)?);
|
||||||
|
}
|
||||||
cfg.set_inst_name(self.network_name.clone().unwrap_or_default());
|
cfg.set_inst_name(self.network_name.clone().unwrap_or_default());
|
||||||
|
|
||||||
// The web UI does not expose credential inputs directly, but imported/saved
|
// The web UI does not expose credential inputs directly, but imported/saved
|
||||||
@@ -444,8 +456,8 @@ impl NetworkConfigExt for NetworkConfig {
|
|||||||
cfg.set_managed_credentials(
|
cfg.set_managed_credentials(
|
||||||
self.managed_credentials
|
self.managed_credentials
|
||||||
.iter()
|
.iter()
|
||||||
.map(managed_credential_from_proto)
|
.map(|credential| credential.clone().upgrade())
|
||||||
.collect(),
|
.collect::<Result<Vec<_>, _>>()?,
|
||||||
);
|
);
|
||||||
|
|
||||||
if let Some(credential_secret) = credential_secret {
|
if let Some(credential_secret) = credential_secret {
|
||||||
@@ -465,19 +477,6 @@ impl NetworkConfigExt for NetworkConfig {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut flags = gen_default_flags();
|
|
||||||
if let Some(latency_first) = self.latency_first {
|
|
||||||
flags.latency_first = latency_first;
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Some(dev_name) = self.dev_name.clone() {
|
|
||||||
flags.dev_name = dev_name;
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Some(use_smoltcp) = self.use_smoltcp {
|
|
||||||
flags.use_smoltcp = use_smoltcp;
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Some(ipv6_public_addr_provider) = self.ipv6_public_addr_provider {
|
if let Some(ipv6_public_addr_provider) = self.ipv6_public_addr_provider {
|
||||||
cfg.set_ipv6_public_addr_provider(ipv6_public_addr_provider);
|
cfg.set_ipv6_public_addr_provider(ipv6_public_addr_provider);
|
||||||
}
|
}
|
||||||
@@ -496,145 +495,60 @@ impl NetworkConfigExt for NetworkConfig {
|
|||||||
)?));
|
)?));
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Some(disable_ipv6) = self.disable_ipv6 {
|
|
||||||
flags.enable_ipv6 = !disable_ipv6;
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Some(enable_kcp_proxy) = self.enable_kcp_proxy {
|
|
||||||
flags.enable_kcp_proxy = enable_kcp_proxy;
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Some(disable_kcp_input) = self.disable_kcp_input {
|
|
||||||
flags.disable_kcp_input = disable_kcp_input;
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Some(enable_quic_proxy) = self.enable_quic_proxy {
|
|
||||||
flags.enable_quic_proxy = enable_quic_proxy;
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Some(disable_quic_input) = self.disable_quic_input {
|
|
||||||
flags.disable_quic_input = disable_quic_input;
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Some(disable_p2p) = self.disable_p2p {
|
|
||||||
flags.disable_p2p = disable_p2p;
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Some(p2p_only) = self.p2p_only {
|
|
||||||
flags.p2p_only = p2p_only;
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Some(lazy_p2p) = self.lazy_p2p {
|
|
||||||
flags.lazy_p2p = lazy_p2p;
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Some(bind_device) = self.bind_device {
|
|
||||||
flags.bind_device = bind_device;
|
|
||||||
}
|
|
||||||
|
|
||||||
if self.socket_mark.is_some() {
|
|
||||||
flags.socket_mark = self.socket_mark;
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Some(no_tun) = self.no_tun {
|
|
||||||
flags.no_tun = no_tun;
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Some(enable_exit_node) = self.enable_exit_node {
|
|
||||||
flags.enable_exit_node = enable_exit_node;
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Some(relay_all_peer_rpc) = self.relay_all_peer_rpc {
|
|
||||||
flags.relay_all_peer_rpc = relay_all_peer_rpc;
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Some(need_p2p) = self.need_p2p {
|
|
||||||
flags.need_p2p = need_p2p;
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Some(multi_thread) = self.multi_thread {
|
|
||||||
flags.multi_thread = multi_thread;
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Some(proxy_forward_by_system) = self.proxy_forward_by_system {
|
|
||||||
flags.proxy_forward_by_system = proxy_forward_by_system;
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Some(disable_encryption) = self.disable_encryption {
|
|
||||||
flags.enable_encryption = !disable_encryption;
|
|
||||||
}
|
|
||||||
|
|
||||||
if self.enable_relay_network_whitelist.unwrap_or_default() {
|
|
||||||
if !self.relay_network_whitelist.is_empty() {
|
|
||||||
flags.relay_network_whitelist = self.relay_network_whitelist.join(" ");
|
|
||||||
} else {
|
|
||||||
flags.relay_network_whitelist = "".to_string();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Some(disable_tcp_hole_punching) = self.disable_tcp_hole_punching {
|
|
||||||
flags.disable_tcp_hole_punching = disable_tcp_hole_punching;
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Some(disable_udp_hole_punching) = self.disable_udp_hole_punching {
|
|
||||||
flags.disable_udp_hole_punching = disable_udp_hole_punching;
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Some(disable_upnp) = self.disable_upnp {
|
|
||||||
flags.disable_upnp = disable_upnp;
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Some(disable_relay_data) = self.disable_relay_data {
|
|
||||||
flags.disable_relay_data = disable_relay_data;
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Some(prefer_peer_relay) = self.prefer_peer_relay {
|
|
||||||
flags.prefer_peer_relay = prefer_peer_relay;
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Some(enable_udp_broadcast_relay) = self.enable_udp_broadcast_relay {
|
|
||||||
flags.enable_udp_broadcast_relay = enable_udp_broadcast_relay;
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Some(disable_sym_hole_punching) = self.disable_sym_hole_punching {
|
|
||||||
flags.disable_sym_hole_punching = disable_sym_hole_punching;
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Some(enable_magic_dns) = self.enable_magic_dns {
|
|
||||||
flags.accept_dns = enable_magic_dns;
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Some(mtu) = self.mtu {
|
|
||||||
flags.mtu = mtu as u32;
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Some(instance_recv_bps_limit) = self.instance_recv_bps_limit {
|
|
||||||
flags.instance_recv_bps_limit = instance_recv_bps_limit;
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Some(enable_private_mode) = self.enable_private_mode {
|
|
||||||
flags.private_mode = enable_private_mode;
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Some(encryption_algorithm) = self.encryption_algorithm.clone() {
|
|
||||||
flags.encryption_algorithm = encryption_algorithm;
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Some(acl) = self.acl.as_ref()
|
if let Some(acl) = self.acl.as_ref()
|
||||||
&& !acl.is_empty()
|
&& !acl.is_empty()
|
||||||
{
|
{
|
||||||
cfg.set_acl(Some(acl.clone()));
|
cfg.set_acl(Some(acl.clone()));
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Some(data_compress_algo) = self.data_compress_algo {
|
cfg.patch_flags(FlagsPatch {
|
||||||
if data_compress_algo < 1 {
|
dev_name: self.dev_name.clone(),
|
||||||
flags.data_compress_algo = 1;
|
enable_ipv6: self.disable_ipv6.map(|disabled| !disabled),
|
||||||
} else {
|
socket_mark: self.socket_mark,
|
||||||
flags.data_compress_algo = data_compress_algo
|
enable_encryption: self.disable_encryption.map(|disabled| !disabled),
|
||||||
}
|
relay_network_whitelist: self.enable_relay_network_whitelist.map(|enabled| {
|
||||||
}
|
if enabled {
|
||||||
|
self.relay_network_whitelist.join(" ")
|
||||||
cfg.set_flags(flags);
|
} else {
|
||||||
|
"*".to_owned()
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
accept_dns: self.enable_magic_dns,
|
||||||
|
tld_dns_zone: self.tld_dns_zone.clone(),
|
||||||
|
mtu: self
|
||||||
|
.mtu
|
||||||
|
.map(u32::try_from)
|
||||||
|
.transpose()
|
||||||
|
.context("invalid mtu: expected a non-negative integer")?,
|
||||||
|
private_mode: self.enable_private_mode,
|
||||||
|
encryption_algorithm: self.encryption_algorithm.clone(),
|
||||||
|
data_compress_algo: self.data_compress_algo.map(|algorithm| algorithm.max(1)),
|
||||||
|
latency_first: self.latency_first,
|
||||||
|
use_smoltcp: self.use_smoltcp,
|
||||||
|
enable_kcp_proxy: self.enable_kcp_proxy,
|
||||||
|
disable_kcp_input: self.disable_kcp_input,
|
||||||
|
enable_quic_proxy: self.enable_quic_proxy,
|
||||||
|
disable_quic_input: self.disable_quic_input,
|
||||||
|
disable_p2p: self.disable_p2p,
|
||||||
|
p2p_only: self.p2p_only,
|
||||||
|
lazy_p2p: self.lazy_p2p,
|
||||||
|
bind_device: self.bind_device,
|
||||||
|
no_tun: self.no_tun,
|
||||||
|
enable_exit_node: self.enable_exit_node,
|
||||||
|
relay_all_peer_rpc: self.relay_all_peer_rpc,
|
||||||
|
need_p2p: self.need_p2p,
|
||||||
|
multi_thread: self.multi_thread,
|
||||||
|
proxy_forward_by_system: self.proxy_forward_by_system,
|
||||||
|
disable_tcp_hole_punching: self.disable_tcp_hole_punching,
|
||||||
|
disable_udp_hole_punching: self.disable_udp_hole_punching,
|
||||||
|
disable_upnp: self.disable_upnp,
|
||||||
|
disable_relay_data: self.disable_relay_data,
|
||||||
|
prefer_peer_relay: self.prefer_peer_relay,
|
||||||
|
enable_udp_broadcast_relay: self.enable_udp_broadcast_relay,
|
||||||
|
disable_sym_hole_punching: self.disable_sym_hole_punching,
|
||||||
|
instance_recv_bps_limit: self.instance_recv_bps_limit,
|
||||||
|
..Default::default()
|
||||||
|
});
|
||||||
Ok(cfg)
|
Ok(cfg)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -651,6 +565,12 @@ impl NetworkConfigExt for NetworkConfig {
|
|||||||
}
|
}
|
||||||
|
|
||||||
result.dhcp = Some(config.get_dhcp());
|
result.dhcp = Some(config.get_dhcp());
|
||||||
|
result.dhcp_ipv4_cidr = config.get_dhcp_config().cidr().map(|cidr| cidr.to_string());
|
||||||
|
result.dhcpv6 = Some(config.get_dhcpv6());
|
||||||
|
result.dhcp_ipv6_cidr = config
|
||||||
|
.get_dhcpv6_config()
|
||||||
|
.cidr()
|
||||||
|
.map(|cidr| cidr.to_string());
|
||||||
|
|
||||||
let network_identity = config.get_network_identity();
|
let network_identity = config.get_network_identity();
|
||||||
result.network_name = Some(network_identity.network_name.clone());
|
result.network_name = Some(network_identity.network_name.clone());
|
||||||
@@ -735,9 +655,7 @@ impl NetworkConfigExt for NetworkConfig {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Some(routes) = config.get_routes()
|
if let Some(routes) = config.get_routes() {
|
||||||
&& !routes.is_empty()
|
|
||||||
{
|
|
||||||
result.enable_manual_routes = Some(true);
|
result.enable_manual_routes = Some(true);
|
||||||
result.routes = routes.iter().map(|r| r.to_string()).collect();
|
result.routes = routes.iter().map(|r| r.to_string()).collect();
|
||||||
}
|
}
|
||||||
@@ -764,65 +682,11 @@ impl NetworkConfigExt for NetworkConfig {
|
|||||||
result.managed_credentials = config
|
result.managed_credentials = config
|
||||||
.get_managed_credentials()
|
.get_managed_credentials()
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.map(managed_credential_to_proto)
|
.map(|credential| credential.downgrade())
|
||||||
.collect();
|
.collect();
|
||||||
let flags = config.get_flags();
|
set_network_flags(&mut result, config.get_flags_patch());
|
||||||
let default_flags = default_config.get_flags();
|
|
||||||
result.latency_first = Some(flags.latency_first);
|
|
||||||
result.dev_name = Some(flags.dev_name.clone());
|
|
||||||
result.use_smoltcp = Some(flags.use_smoltcp);
|
|
||||||
result.disable_ipv6 = Some(!flags.enable_ipv6);
|
|
||||||
result.enable_kcp_proxy = Some(flags.enable_kcp_proxy);
|
|
||||||
result.disable_kcp_input = Some(flags.disable_kcp_input);
|
|
||||||
result.enable_quic_proxy = Some(flags.enable_quic_proxy);
|
|
||||||
result.disable_quic_input = Some(flags.disable_quic_input);
|
|
||||||
result.disable_p2p = Some(flags.disable_p2p);
|
|
||||||
result.p2p_only = Some(flags.p2p_only);
|
|
||||||
result.lazy_p2p = Some(flags.lazy_p2p);
|
|
||||||
result.bind_device = Some(flags.bind_device);
|
|
||||||
result.socket_mark = flags.socket_mark;
|
|
||||||
result.no_tun = Some(flags.no_tun);
|
|
||||||
result.enable_exit_node = Some(flags.enable_exit_node);
|
|
||||||
result.relay_all_peer_rpc = Some(flags.relay_all_peer_rpc);
|
|
||||||
result.need_p2p = Some(flags.need_p2p);
|
|
||||||
result.multi_thread = Some(flags.multi_thread);
|
|
||||||
result.proxy_forward_by_system = Some(flags.proxy_forward_by_system);
|
|
||||||
result.disable_encryption = Some(!flags.enable_encryption);
|
|
||||||
result.disable_tcp_hole_punching = Some(flags.disable_tcp_hole_punching);
|
|
||||||
result.disable_udp_hole_punching = Some(flags.disable_udp_hole_punching);
|
|
||||||
result.disable_upnp = Some(flags.disable_upnp);
|
|
||||||
result.disable_relay_data = Some(flags.disable_relay_data);
|
|
||||||
result.prefer_peer_relay = Some(flags.prefer_peer_relay);
|
|
||||||
result.enable_udp_broadcast_relay = Some(flags.enable_udp_broadcast_relay);
|
|
||||||
result.disable_sym_hole_punching = Some(flags.disable_sym_hole_punching);
|
|
||||||
result.enable_magic_dns = Some(flags.accept_dns);
|
|
||||||
result.mtu = Some(flags.mtu as i32);
|
|
||||||
result.data_compress_algo = (flags.data_compress_algo != default_flags.data_compress_algo)
|
|
||||||
.then_some(flags.data_compress_algo);
|
|
||||||
result.encryption_algorithm = (flags.encryption_algorithm
|
|
||||||
!= default_flags.encryption_algorithm)
|
|
||||||
.then_some(flags.encryption_algorithm.clone());
|
|
||||||
result.instance_recv_bps_limit =
|
|
||||||
(flags.instance_recv_bps_limit != u64::MAX).then_some(flags.instance_recv_bps_limit);
|
|
||||||
result.enable_private_mode = Some(flags.private_mode);
|
|
||||||
|
|
||||||
result.acl = config.get_acl();
|
result.acl = config.get_acl();
|
||||||
|
|
||||||
if flags.relay_network_whitelist == "*" {
|
|
||||||
result.enable_relay_network_whitelist = Some(false);
|
|
||||||
} else {
|
|
||||||
result.enable_relay_network_whitelist = Some(true);
|
|
||||||
if flags.relay_network_whitelist.is_empty() {
|
|
||||||
result.relay_network_whitelist = vec![];
|
|
||||||
} else {
|
|
||||||
result.relay_network_whitelist = flags
|
|
||||||
.relay_network_whitelist
|
|
||||||
.split_whitespace()
|
|
||||||
.map(|s| s.to_string())
|
|
||||||
.collect();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(result)
|
Ok(result)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -832,6 +696,7 @@ mod tests {
|
|||||||
#![allow(deprecated)]
|
#![allow(deprecated)]
|
||||||
|
|
||||||
use super::*;
|
use super::*;
|
||||||
|
use easytier_proto::common::Flags;
|
||||||
|
|
||||||
fn api_portal_config() -> manage::VpnPortalConfig {
|
fn api_portal_config() -> manage::VpnPortalConfig {
|
||||||
manage::VpnPortalConfig {
|
manage::VpnPortalConfig {
|
||||||
@@ -853,6 +718,234 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn manual_routes_preserve_empty_and_automatic_modes() {
|
||||||
|
for routes in [Vec::new(), vec!["10.20.0.0/24".to_owned()]] {
|
||||||
|
for enabled in [None, Some(false), Some(true)] {
|
||||||
|
let mut input = standalone_config();
|
||||||
|
input.enable_manual_routes = enabled;
|
||||||
|
input.routes = routes.clone();
|
||||||
|
let config = input.gen_config().unwrap();
|
||||||
|
let expected = if enabled == Some(true) {
|
||||||
|
Some(routes.iter().map(|route| route.parse().unwrap()).collect())
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
assert_eq!(config.get_routes(), expected);
|
||||||
|
let readback = NetworkConfig::new_from_config(config.clone()).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
readback.enable_manual_routes,
|
||||||
|
enabled.filter(|value| *value)
|
||||||
|
);
|
||||||
|
assert_eq!(readback.gen_config().unwrap().get_routes(), expected);
|
||||||
|
assert_eq!(
|
||||||
|
TomlConfigLoader::new_from_str(&config.dump())
|
||||||
|
.unwrap()
|
||||||
|
.get_routes(),
|
||||||
|
expected
|
||||||
|
);
|
||||||
|
#[cfg(feature = "web-client")]
|
||||||
|
{
|
||||||
|
let projected = super::super::api::network_config_from_toml(&config);
|
||||||
|
assert_eq!(
|
||||||
|
projected.enable_manual_routes,
|
||||||
|
readback.enable_manual_routes
|
||||||
|
);
|
||||||
|
assert_eq!(projected.routes, readback.routes);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn dhcp_cidr_survives_api_and_toml_round_trips() {
|
||||||
|
let mut input = standalone_config();
|
||||||
|
input.dhcp_ipv4_cidr = Some("192.168.100.0/24".to_owned());
|
||||||
|
for enabled in [None, Some(true), Some(false)] {
|
||||||
|
input.dhcp = enabled;
|
||||||
|
let config = input.gen_config().unwrap();
|
||||||
|
assert_eq!(config.get_dhcp(), enabled.unwrap_or(true));
|
||||||
|
let readback = NetworkConfig::new_from_config(config.clone()).unwrap();
|
||||||
|
let expected_cidr = if enabled == Some(false) {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
input.dhcp_ipv4_cidr.clone()
|
||||||
|
};
|
||||||
|
assert_eq!(readback.dhcp_ipv4_cidr, expected_cidr);
|
||||||
|
assert_eq!(
|
||||||
|
TomlConfigLoader::new_from_str(&config.dump())
|
||||||
|
.unwrap()
|
||||||
|
.get_dhcp_config(),
|
||||||
|
readback.gen_config().unwrap().get_dhcp_config()
|
||||||
|
);
|
||||||
|
#[cfg(feature = "web-client")]
|
||||||
|
assert_eq!(
|
||||||
|
super::super::api::network_config_from_toml(&config).dhcp_ipv4_cidr,
|
||||||
|
expected_cidr
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn dhcpv6_cidr_survives_management_round_trips() {
|
||||||
|
let mut input = NetworkConfig {
|
||||||
|
dhcp: Some(true),
|
||||||
|
dhcp_ipv6_cidr: Some("fd12::/64".to_owned()),
|
||||||
|
..standalone_config()
|
||||||
|
};
|
||||||
|
for enabled in [None, Some(true), Some(false)] {
|
||||||
|
input.dhcpv6 = enabled;
|
||||||
|
let config = input.gen_config().unwrap();
|
||||||
|
assert_eq!(config.get_dhcpv6(), enabled.unwrap_or(true));
|
||||||
|
let readback = NetworkConfig::new_from_config(&config).unwrap();
|
||||||
|
assert_eq!(readback.dhcpv6, Some(enabled.unwrap_or(true)));
|
||||||
|
assert_eq!(
|
||||||
|
readback.dhcp_ipv6_cidr,
|
||||||
|
if enabled == Some(false) {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
input.dhcp_ipv6_cidr.clone()
|
||||||
|
}
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
readback.gen_config().unwrap().get_dhcpv6_config(),
|
||||||
|
config.get_dhcpv6_config()
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
super::super::api::network_config_from_toml(&config).dhcp_ipv6_cidr,
|
||||||
|
readback.dhcp_ipv6_cidr
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn dhcp_api_rejects_invalid_or_unusable_subnets() {
|
||||||
|
let mut input = standalone_config();
|
||||||
|
for value in [
|
||||||
|
"true",
|
||||||
|
"false",
|
||||||
|
"yes",
|
||||||
|
"::/64",
|
||||||
|
"192.168.100.0/31",
|
||||||
|
"192.168.100.1/32",
|
||||||
|
] {
|
||||||
|
input.dhcp_ipv4_cidr = Some(value.to_owned());
|
||||||
|
assert!(input.gen_config().is_err(), "accepted {value}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The management API's name for a flag is the schema's to state, and the
|
||||||
|
/// projection is what actually names the fields. Turning the flag the
|
||||||
|
/// annotation names must turn the field it points at, in the direction the
|
||||||
|
/// annotation declares.
|
||||||
|
#[test]
|
||||||
|
fn declared_api_names_match_the_projection() {
|
||||||
|
use prost_reflect::{DescriptorPool, Value};
|
||||||
|
|
||||||
|
let pool = DescriptorPool::decode(easytier_proto::ALL_DESCRIPTOR_BYTES).unwrap();
|
||||||
|
let flags = pool.get_message_by_name("common.Flags").unwrap();
|
||||||
|
let extension = pool.get_extension_by_name("easytier.flag").unwrap();
|
||||||
|
|
||||||
|
// A patch states every field, so the values survive serialization:
|
||||||
|
// protobuf JSON omits a field holding its zero value.
|
||||||
|
let patch: FlagsPatch = Flags::defaults().downgrade();
|
||||||
|
let defaults = serde_json::to_value(&patch).unwrap();
|
||||||
|
|
||||||
|
for field in flags.fields() {
|
||||||
|
let name = field.name();
|
||||||
|
let options = field.options();
|
||||||
|
let annotation = options.get_extension(&extension).into_owned();
|
||||||
|
let Value::Message(meta) = annotation else {
|
||||||
|
panic!("{name}: the annotation is not a message");
|
||||||
|
};
|
||||||
|
if !meta.has_field_by_name("api") {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let Value::Message(spelling) = meta.get_field_by_name("api").unwrap().into_owned()
|
||||||
|
else {
|
||||||
|
panic!("{name}: the api spelling is not a message");
|
||||||
|
};
|
||||||
|
let api_field = match spelling.get_field_by_name("field").as_deref() {
|
||||||
|
Some(Value::String(api_field)) if !api_field.is_empty() => api_field.clone(),
|
||||||
|
_ => continue,
|
||||||
|
};
|
||||||
|
let negate = matches!(
|
||||||
|
spelling.get_field_by_name("negate").as_deref(),
|
||||||
|
Some(Value::Bool(true))
|
||||||
|
);
|
||||||
|
|
||||||
|
let declared = defaults[name]
|
||||||
|
.as_bool()
|
||||||
|
.unwrap_or_else(|| panic!("{name} is not a boolean flag"));
|
||||||
|
|
||||||
|
let mut flipped = defaults.clone();
|
||||||
|
flipped[name] = serde_json::json!(!declared);
|
||||||
|
let patch: FlagsPatch = serde_json::from_value(flipped).unwrap();
|
||||||
|
|
||||||
|
let mut projected = NetworkConfig::default();
|
||||||
|
set_network_flags(&mut projected, patch);
|
||||||
|
|
||||||
|
// Protobuf JSON omits a field holding its zero value.
|
||||||
|
let projected = serde_json::to_value(&projected).unwrap();
|
||||||
|
let value = projected[&api_field].as_bool().unwrap_or(false);
|
||||||
|
assert_eq!(
|
||||||
|
value,
|
||||||
|
if negate { declared } else { !declared },
|
||||||
|
"flipping {name} should leave {api_field} as the annotation declares"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "config-write")]
|
||||||
|
#[test]
|
||||||
|
fn api_flags_round_trip_preserves_missing_default_and_custom_values() {
|
||||||
|
for disabled in [None, Some(false), Some(true)] {
|
||||||
|
for whitelist in [None, Some(false), Some(true)] {
|
||||||
|
let input = NetworkConfig {
|
||||||
|
disable_encryption: disabled,
|
||||||
|
enable_relay_network_whitelist: whitelist,
|
||||||
|
latency_first: Some(false),
|
||||||
|
mtu: Some(0),
|
||||||
|
instance_recv_bps_limit: Some(u64::MAX),
|
||||||
|
..standalone_config()
|
||||||
|
};
|
||||||
|
let config = input.gen_config().unwrap();
|
||||||
|
let config = TomlConfigLoader::new_from_str(&config.dump()).unwrap();
|
||||||
|
let output = NetworkConfig::new_from_config(&config).unwrap();
|
||||||
|
assert_eq!(output.disable_encryption, disabled);
|
||||||
|
assert_eq!(output.enable_relay_network_whitelist, whitelist);
|
||||||
|
assert_eq!(output.latency_first, Some(false));
|
||||||
|
assert_eq!(output.mtu, Some(0));
|
||||||
|
assert_eq!(output.instance_recv_bps_limit, Some(u64::MAX));
|
||||||
|
assert_eq!(output.disable_ipv6, None);
|
||||||
|
assert_eq!(output.encryption_algorithm, None);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert!(
|
||||||
|
NetworkConfig {
|
||||||
|
mtu: Some(-1),
|
||||||
|
..standalone_config()
|
||||||
|
}
|
||||||
|
.gen_config()
|
||||||
|
.is_err()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn magic_dns_zone_round_trips_missing_default_and_custom_values() {
|
||||||
|
for zone in [None, Some("et.net."), Some("nas.internal.")] {
|
||||||
|
let input = NetworkConfig {
|
||||||
|
tld_dns_zone: zone.map(str::to_owned),
|
||||||
|
..standalone_config()
|
||||||
|
};
|
||||||
|
let config = input.gen_config().unwrap();
|
||||||
|
let config = TomlConfigLoader::new_from_str(&config.dump()).unwrap();
|
||||||
|
assert_eq!(config.get_flags().tld_dns_zone, zone.unwrap_or("et.net."));
|
||||||
|
let output = NetworkConfig::new_from_config(&config).unwrap();
|
||||||
|
assert_eq!(output.tld_dns_zone.as_deref(), zone);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn vpn_portal_api_config_round_trips_through_toml_model() {
|
fn vpn_portal_api_config_round_trips_through_toml_model() {
|
||||||
let input = NetworkConfig {
|
let input = NetworkConfig {
|
||||||
@@ -969,6 +1062,62 @@ mod tests {
|
|||||||
assert!(config.get_vpn_portal_config().is_none());
|
assert!(config.get_vpn_portal_config().is_none());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "browser-config")]
|
||||||
|
#[test]
|
||||||
|
fn browser_merge_updates_and_clears_magic_dns_zone() {
|
||||||
|
let original = "[flags]\ntld_dns_zone = \"old.internal.\"\n";
|
||||||
|
for zone in [None, Some("et.net."), Some("nas.internal.")] {
|
||||||
|
let input = NetworkConfig {
|
||||||
|
tld_dns_zone: zone.map(str::to_owned),
|
||||||
|
..standalone_config()
|
||||||
|
};
|
||||||
|
let merged = merge_network_config_toml(original, &input).unwrap();
|
||||||
|
let output = TomlConfigLoader::new_from_str(&merged).unwrap();
|
||||||
|
assert_eq!(output.get_flags().tld_dns_zone, zone.unwrap_or("et.net."));
|
||||||
|
assert_eq!(output.get_flags_patch().tld_dns_zone.as_deref(), zone);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "browser-config")]
|
||||||
|
#[test]
|
||||||
|
fn browser_merge_reconciles_renamed_flags() {
|
||||||
|
let original = r#"
|
||||||
|
[flags]
|
||||||
|
default_protocol = "udp"
|
||||||
|
enable_encryption = false
|
||||||
|
enable_ipv6 = false
|
||||||
|
accept_dns = false
|
||||||
|
private_mode = false
|
||||||
|
"#;
|
||||||
|
|
||||||
|
for value in [Some(false), Some(true), None] {
|
||||||
|
let network_config = NetworkConfig {
|
||||||
|
disable_encryption: value,
|
||||||
|
disable_ipv6: value,
|
||||||
|
enable_magic_dns: value,
|
||||||
|
enable_private_mode: value,
|
||||||
|
..standalone_config()
|
||||||
|
};
|
||||||
|
let merged = merge_network_config_toml(original, &network_config).unwrap();
|
||||||
|
let merged: toml::Table = toml::from_str(&merged).unwrap();
|
||||||
|
let flags = merged["flags"].as_table().unwrap();
|
||||||
|
|
||||||
|
assert_eq!(flags["default_protocol"].as_str(), Some("udp"));
|
||||||
|
for (field, expected) in [
|
||||||
|
("enable_encryption", value.map(|disabled| !disabled)),
|
||||||
|
("enable_ipv6", value.map(|disabled| !disabled)),
|
||||||
|
("accept_dns", value),
|
||||||
|
("private_mode", value),
|
||||||
|
] {
|
||||||
|
assert_eq!(
|
||||||
|
flags.get(field).and_then(toml::Value::as_bool),
|
||||||
|
expected,
|
||||||
|
"merging {field} from API value {value:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(feature = "browser-config")]
|
#[cfg(feature = "browser-config")]
|
||||||
#[test]
|
#[test]
|
||||||
fn browser_merge_preserves_fields_outside_the_shared_form() {
|
fn browser_merge_preserves_fields_outside_the_shared_form() {
|
||||||
@@ -1006,11 +1155,74 @@ disable_p2p = true
|
|||||||
Some("edited-network")
|
Some("edited-network")
|
||||||
);
|
);
|
||||||
assert_eq!(merged["flags"]["default_protocol"].as_str(), Some("udp"));
|
assert_eq!(merged["flags"]["default_protocol"].as_str(), Some("udp"));
|
||||||
assert!(
|
assert_eq!(merged["flags"]["disable_p2p"].as_bool(), Some(false));
|
||||||
!merged["flags"]
|
}
|
||||||
.as_table()
|
|
||||||
.unwrap()
|
#[cfg(feature = "browser-config")]
|
||||||
.contains_key("disable_p2p")
|
#[test]
|
||||||
|
fn browser_merge_normalizes_camel_case_flag_aliases() {
|
||||||
|
let original = r#"
|
||||||
|
[flags]
|
||||||
|
disableP2p = true
|
||||||
|
enableEncryption = false
|
||||||
|
foreignRelayBpsLimit = 1234
|
||||||
|
"#;
|
||||||
|
let parsed_orig = TomlConfigLoader::new_from_str(original).unwrap();
|
||||||
|
let mut network_config = NetworkConfig::new_from_config(&parsed_orig).unwrap();
|
||||||
|
network_config.disable_p2p = Some(false);
|
||||||
|
|
||||||
|
let merged = merge_network_config_toml(original, &network_config).unwrap();
|
||||||
|
let parsed = TomlConfigLoader::new_from_str(&merged)
|
||||||
|
.expect("merged config should be parseable without duplicate-field error");
|
||||||
|
assert!(!parsed.get_flags().disable_p2p);
|
||||||
|
assert!(!parsed.get_flags().enable_encryption);
|
||||||
|
assert_eq!(parsed.get_flags().foreign_relay_bps_limit, 1234);
|
||||||
|
|
||||||
|
let merged_table: toml::Table = toml::from_str(&merged).unwrap();
|
||||||
|
let flags = merged_table["flags"].as_table().unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
flags.get("disable_p2p").and_then(toml::Value::as_bool),
|
||||||
|
Some(false)
|
||||||
);
|
);
|
||||||
|
assert!(flags.get("disableP2p").is_none());
|
||||||
|
assert_eq!(
|
||||||
|
flags
|
||||||
|
.get("enable_encryption")
|
||||||
|
.and_then(toml::Value::as_bool),
|
||||||
|
Some(false)
|
||||||
|
);
|
||||||
|
assert!(flags.get("enableEncryption").is_none());
|
||||||
|
assert_eq!(
|
||||||
|
flags
|
||||||
|
.get("foreign_relay_bps_limit")
|
||||||
|
.and_then(toml::Value::as_integer),
|
||||||
|
Some(1234)
|
||||||
|
);
|
||||||
|
assert!(flags.get("foreignRelayBpsLimit").is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "browser-config")]
|
||||||
|
#[test]
|
||||||
|
fn browser_merge_repro_camel_case_disable_p2p_collision() {
|
||||||
|
let original = r#"
|
||||||
|
[flags]
|
||||||
|
disableP2p = false
|
||||||
|
"#;
|
||||||
|
let mut network_config = standalone_config();
|
||||||
|
network_config.hostname = Some("new-hostname".to_owned());
|
||||||
|
network_config.disable_p2p = Some(false);
|
||||||
|
|
||||||
|
let merged = merge_network_config_toml(original, &network_config).unwrap();
|
||||||
|
let parsed = TomlConfigLoader::new_from_str(&merged)
|
||||||
|
.expect("saving config with camelCase alias must not fail on reload");
|
||||||
|
assert!(!parsed.get_flags().disable_p2p);
|
||||||
|
|
||||||
|
let merged_table: toml::Table = toml::from_str(&merged).unwrap();
|
||||||
|
let flags = merged_table["flags"].as_table().unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
flags.get("disable_p2p").and_then(toml::Value::as_bool),
|
||||||
|
Some(false)
|
||||||
|
);
|
||||||
|
assert!(flags.get("disableP2p").is_none());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,96 @@
|
|||||||
|
use std::{fmt, marker::PhantomData, str::FromStr};
|
||||||
|
|
||||||
|
use anyhow::Context;
|
||||||
|
use cidr::{Cidr, Family, Ipv4Cidr, Ipv6Cidr};
|
||||||
|
use serde::{Deserialize, Deserializer, Serialize, de};
|
||||||
|
|
||||||
|
/// DHCP enablement and an optional subnet used before peers advertise one.
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
|
||||||
|
#[serde(untagged)]
|
||||||
|
pub enum DhcpConfig<C = Ipv4Cidr> {
|
||||||
|
Enabled(bool),
|
||||||
|
Cidr(C),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<C> Default for DhcpConfig<C> {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self::Enabled(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<C: Cidr> DhcpConfig<C> {
|
||||||
|
pub fn enabled(self) -> bool {
|
||||||
|
!matches!(self, Self::Enabled(false))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn cidr(self) -> Option<C> {
|
||||||
|
match self {
|
||||||
|
Self::Enabled(_) => None,
|
||||||
|
Self::Cidr(cidr) => Some(cidr),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn from_cidr(value: &str) -> anyhow::Result<Self> {
|
||||||
|
let cidr: C = value
|
||||||
|
.parse()
|
||||||
|
.with_context(|| format!("invalid DHCP CIDR: {value}"))?;
|
||||||
|
match cidr.family() {
|
||||||
|
Family::Ipv4 => anyhow::ensure!(
|
||||||
|
cidr.network_length() <= 30,
|
||||||
|
"DHCP IPv4 CIDR must have usable host addresses (prefix length 0..=30)"
|
||||||
|
),
|
||||||
|
Family::Ipv6 => {
|
||||||
|
let subnet: Ipv6Cidr = value.parse()?;
|
||||||
|
anyhow::ensure!(
|
||||||
|
valid_ipv6_subnet(subnet),
|
||||||
|
"DHCP IPv6 CIDR must be a unicast subnet within fc00::/7 or 2000::/3, with prefix length at most 126"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(Self::Cidr(cidr))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<C: Cidr> FromStr for DhcpConfig<C> {
|
||||||
|
type Err = anyhow::Error;
|
||||||
|
|
||||||
|
fn from_str(value: &str) -> Result<Self, Self::Err> {
|
||||||
|
match value.parse::<bool>() {
|
||||||
|
Ok(enabled) => Ok(Self::Enabled(enabled)),
|
||||||
|
Err(_) => Self::from_cidr(value),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'de, C: Cidr> Deserialize<'de> for DhcpConfig<C> {
|
||||||
|
fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
|
||||||
|
struct DhcpVisitor<C>(PhantomData<C>);
|
||||||
|
|
||||||
|
impl<C: Cidr> de::Visitor<'_> for DhcpVisitor<C> {
|
||||||
|
type Value = DhcpConfig<C>;
|
||||||
|
|
||||||
|
fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
|
formatter.write_str("a boolean or a usable DHCP CIDR")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn visit_bool<E: de::Error>(self, value: bool) -> Result<Self::Value, E> {
|
||||||
|
Ok(DhcpConfig::Enabled(value))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn visit_str<E: de::Error>(self, value: &str) -> Result<Self::Value, E> {
|
||||||
|
DhcpConfig::from_cidr(value).map_err(E::custom)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
deserializer.deserialize_any(DhcpVisitor(PhantomData))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only overlay unicast subnets are eligible for automatic assignment. Keep at
|
||||||
|
// least two candidates after reserving the subnet endpoints.
|
||||||
|
pub(crate) fn valid_ipv6_subnet(subnet: Ipv6Cidr) -> bool {
|
||||||
|
let first = subnet.first_address().segments()[0];
|
||||||
|
subnet.network_length() <= 126
|
||||||
|
&& ((subnet.network_length() >= 7 && first & 0xfe00 == 0xfc00)
|
||||||
|
|| (subnet.network_length() >= 3 && first & 0xe000 == 0x2000))
|
||||||
|
}
|
||||||
@@ -1,48 +1,59 @@
|
|||||||
use std::{fmt, str::FromStr};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
use strum::VariantArray;
|
use strum::VariantArray;
|
||||||
|
|
||||||
/// Stable configuration vocabulary for every known encryption algorithm.
|
/// Stable configuration vocabulary for every known encryption algorithm.
|
||||||
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, VariantArray)]
|
#[derive(
|
||||||
|
Debug,
|
||||||
|
Default,
|
||||||
|
Clone,
|
||||||
|
Copy,
|
||||||
|
PartialEq,
|
||||||
|
Eq,
|
||||||
|
VariantArray,
|
||||||
|
strum::Display,
|
||||||
|
strum::EnumString,
|
||||||
|
strum::IntoStaticStr,
|
||||||
|
strum::AsRefStr,
|
||||||
|
Serialize,
|
||||||
|
Deserialize,
|
||||||
|
)]
|
||||||
|
#[strum(ascii_case_insensitive)]
|
||||||
pub enum EncryptionAlgorithm {
|
pub enum EncryptionAlgorithm {
|
||||||
|
#[strum(to_string = "xor", serialize = "xor")]
|
||||||
|
#[serde(rename = "xor")]
|
||||||
Xor,
|
Xor,
|
||||||
|
|
||||||
#[default]
|
#[default]
|
||||||
|
#[strum(
|
||||||
|
to_string = "aes-gcm",
|
||||||
|
serialize = "aes-gcm",
|
||||||
|
serialize = "openssl-aes-gcm"
|
||||||
|
)]
|
||||||
|
#[serde(rename = "aes-gcm", alias = "openssl-aes-gcm")]
|
||||||
AesGcm,
|
AesGcm,
|
||||||
|
|
||||||
|
#[strum(
|
||||||
|
to_string = "aes-256-gcm",
|
||||||
|
serialize = "aes-256-gcm",
|
||||||
|
serialize = "openssl-aes-256-gcm"
|
||||||
|
)]
|
||||||
|
#[serde(rename = "aes-256-gcm", alias = "openssl-aes-256-gcm")]
|
||||||
Aes256Gcm,
|
Aes256Gcm,
|
||||||
|
|
||||||
|
#[strum(
|
||||||
|
to_string = "chacha20",
|
||||||
|
serialize = "chacha20",
|
||||||
|
serialize = "chacha20-poly1305",
|
||||||
|
serialize = "openssl-chacha20"
|
||||||
|
)]
|
||||||
|
#[serde(
|
||||||
|
rename = "chacha20",
|
||||||
|
alias = "chacha20-poly1305",
|
||||||
|
alias = "openssl-chacha20"
|
||||||
|
)]
|
||||||
ChaCha20,
|
ChaCha20,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl EncryptionAlgorithm {
|
|
||||||
pub const fn as_str(self) -> &'static str {
|
|
||||||
match self {
|
|
||||||
Self::Xor => "xor",
|
|
||||||
Self::AesGcm => "aes-gcm",
|
|
||||||
Self::Aes256Gcm => "aes-256-gcm",
|
|
||||||
Self::ChaCha20 => "chacha20",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl fmt::Display for EncryptionAlgorithm {
|
|
||||||
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
|
||||||
formatter.write_str(self.as_str())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl FromStr for EncryptionAlgorithm {
|
|
||||||
type Err = ();
|
|
||||||
|
|
||||||
fn from_str(value: &str) -> Result<Self, Self::Err> {
|
|
||||||
match value.to_ascii_lowercase().as_str() {
|
|
||||||
"xor" => Ok(Self::Xor),
|
|
||||||
"aes-gcm" | "openssl-aes-gcm" => Ok(Self::AesGcm),
|
|
||||||
"aes-256-gcm" | "openssl-aes-256-gcm" => Ok(Self::Aes256Gcm),
|
|
||||||
"chacha20" | "chacha20-poly1305" | "openssl-chacha20" => Ok(Self::ChaCha20),
|
|
||||||
_ => Err(()),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|||||||
@@ -10,12 +10,15 @@ pub mod api_input;
|
|||||||
target_os = "unknown"
|
target_os = "unknown"
|
||||||
))]
|
))]
|
||||||
mod browser;
|
mod browser;
|
||||||
|
mod dhcp;
|
||||||
mod encryption;
|
mod encryption;
|
||||||
pub mod gateway;
|
pub mod gateway;
|
||||||
pub mod peers;
|
pub mod peers;
|
||||||
pub mod runtime;
|
pub mod runtime;
|
||||||
pub mod toml;
|
pub mod toml;
|
||||||
|
|
||||||
|
pub use dhcp::DhcpConfig;
|
||||||
|
pub(crate) use dhcp::valid_ipv6_subnet;
|
||||||
pub use encryption::EncryptionAlgorithm;
|
pub use encryption::EncryptionAlgorithm;
|
||||||
|
|
||||||
pub(crate) const DEFAULT_UDP_STUN_SERVERS: &[&str] = &[
|
pub(crate) const DEFAULT_UDP_STUN_SERVERS: &[&str] = &[
|
||||||
@@ -48,6 +51,7 @@ use std::{
|
|||||||
use anyhow::Context as _;
|
use anyhow::Context as _;
|
||||||
use base64::{Engine as _, prelude::BASE64_STANDARD};
|
use base64::{Engine as _, prelude::BASE64_STANDARD};
|
||||||
use easytier_proto::{common as common_pb, core_config as pb};
|
use easytier_proto::{common as common_pb, core_config as pb};
|
||||||
|
use optionize::Optionizable;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use url::Url;
|
use url::Url;
|
||||||
|
|
||||||
@@ -280,6 +284,8 @@ pub struct ForeignNetworkConfig {
|
|||||||
pub cidrs: Vec<IpPrefix>,
|
pub cidrs: Vec<IpPrefix>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[optionize::optionized]
|
||||||
|
#[optionize(object = pb::PeerPolicyConfig)]
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
pub struct PeerPolicyConfig {
|
pub struct PeerPolicyConfig {
|
||||||
pub p2p_enabled: bool,
|
pub p2p_enabled: bool,
|
||||||
@@ -522,28 +528,15 @@ impl From<ForeignNetworkConfig> for pb::ForeignNetworkConfig {
|
|||||||
|
|
||||||
impl From<pb::PeerPolicyConfig> for PeerPolicyConfig {
|
impl From<pb::PeerPolicyConfig> for PeerPolicyConfig {
|
||||||
fn from(value: pb::PeerPolicyConfig) -> Self {
|
fn from(value: pb::PeerPolicyConfig) -> Self {
|
||||||
let default = Self::default();
|
let mut config = Self::default();
|
||||||
Self {
|
config.load(value);
|
||||||
p2p_enabled: value.p2p_enabled.unwrap_or(default.p2p_enabled),
|
config
|
||||||
relay_peer_rpc: value.relay_peer_rpc.unwrap_or(default.relay_peer_rpc),
|
|
||||||
relay_data: value.relay_data.unwrap_or(default.relay_data),
|
|
||||||
latency_first: value.latency_first.unwrap_or(default.latency_first),
|
|
||||||
encryption_required: value
|
|
||||||
.encryption_required
|
|
||||||
.unwrap_or(default.encryption_required),
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl From<PeerPolicyConfig> for pb::PeerPolicyConfig {
|
impl From<PeerPolicyConfig> for pb::PeerPolicyConfig {
|
||||||
fn from(value: PeerPolicyConfig) -> Self {
|
fn from(value: PeerPolicyConfig) -> Self {
|
||||||
Self {
|
value.downgrade()
|
||||||
p2p_enabled: Some(value.p2p_enabled),
|
|
||||||
relay_peer_rpc: Some(value.relay_peer_rpc),
|
|
||||||
relay_data: Some(value.relay_data),
|
|
||||||
latency_first: Some(value.latency_first),
|
|
||||||
encryption_required: Some(value.encryption_required),
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
|
|
||||||
use anyhow::Context as _;
|
use anyhow::Context as _;
|
||||||
use cidr::Ipv6Cidr;
|
use cidr::Ipv6Cidr;
|
||||||
use easytier_proto::common::{FlagsInConfig, PeerFeatureFlag, SecureModeConfig, StunInfo};
|
use easytier_proto::common::{Flags, PeerFeatureFlag, SecureModeConfig, StunInfo};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
use crate::proto::acl::{Acl, AclV1, Action, Chain, ChainType, GroupInfo, Protocol, Rule};
|
use crate::proto::acl::{Acl, AclV1, Action, Chain, ChainType, GroupInfo, Protocol, Rule};
|
||||||
@@ -236,7 +236,7 @@ pub struct PeerRuntimeSnapshot {
|
|||||||
pub runtime: PeerRuntimeConfig,
|
pub runtime: PeerRuntimeConfig,
|
||||||
pub easytier_version: String,
|
pub easytier_version: String,
|
||||||
pub avoid_relay_data_preference: bool,
|
pub avoid_relay_data_preference: bool,
|
||||||
pub flags: FlagsInConfig,
|
pub flags: Flags,
|
||||||
pub pinned_peers: Vec<(url::Url, Option<String>)>,
|
pub pinned_peers: Vec<(url::Url, Option<String>)>,
|
||||||
pub peer_group_memberships: Vec<PeerGroupIdentity>,
|
pub peer_group_memberships: Vec<PeerGroupIdentity>,
|
||||||
pub acl_group_declarations: Vec<PeerGroupIdentity>,
|
pub acl_group_declarations: Vec<PeerGroupIdentity>,
|
||||||
@@ -246,7 +246,7 @@ pub struct PeerRuntimeSnapshot {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl PeerRuntimeSnapshot {
|
impl PeerRuntimeSnapshot {
|
||||||
pub fn new(runtime: PeerRuntimeConfig, flags: FlagsInConfig) -> Self {
|
pub fn new(runtime: PeerRuntimeConfig, flags: Flags) -> Self {
|
||||||
let avoid_relay_data_preference = runtime.feature_flags.avoid_relay_data;
|
let avoid_relay_data_preference = runtime.feature_flags.avoid_relay_data;
|
||||||
Self {
|
Self {
|
||||||
runtime,
|
runtime,
|
||||||
@@ -274,7 +274,7 @@ impl Default for PeerRuntimeSnapshot {
|
|||||||
secure_mode: None,
|
secure_mode: None,
|
||||||
host_routing: HostRoutingPolicy::default(),
|
host_routing: HostRoutingPolicy::default(),
|
||||||
},
|
},
|
||||||
FlagsInConfig::default(),
|
Flags::default(),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
use std::{collections::BTreeSet, sync::Arc};
|
use std::{collections::BTreeSet, sync::Arc};
|
||||||
|
|
||||||
use arc_swap::ArcSwap;
|
use arc_swap::ArcSwap;
|
||||||
use cidr::Ipv4Cidr;
|
use cidr::{Ipv4Cidr, Ipv6Cidr};
|
||||||
use parking_lot::Mutex;
|
use parking_lot::Mutex;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
@@ -16,6 +16,12 @@ use super::{
|
|||||||
pub struct CoreRuntimeConfig {
|
pub struct CoreRuntimeConfig {
|
||||||
pub acl: AclRuleConfig,
|
pub acl: AclRuleConfig,
|
||||||
pub dhcp_ipv4: bool,
|
pub dhcp_ipv4: bool,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub dhcp_ipv4_cidr: Option<Ipv4Cidr>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub dhcp_ipv6: bool,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub dhcp_ipv6_cidr: Option<Ipv6Cidr>,
|
||||||
pub gateway: GatewayRuntimeConfig,
|
pub gateway: GatewayRuntimeConfig,
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
pub manual_routes: Option<BTreeSet<Ipv4Cidr>>,
|
pub manual_routes: Option<BTreeSet<Ipv4Cidr>>,
|
||||||
@@ -30,6 +36,9 @@ impl Default for CoreRuntimeConfig {
|
|||||||
Self {
|
Self {
|
||||||
acl: AclRuleConfig::default(),
|
acl: AclRuleConfig::default(),
|
||||||
dhcp_ipv4: false,
|
dhcp_ipv4: false,
|
||||||
|
dhcp_ipv4_cidr: None,
|
||||||
|
dhcp_ipv6: false,
|
||||||
|
dhcp_ipv6_cidr: None,
|
||||||
gateway: GatewayRuntimeConfig::default(),
|
gateway: GatewayRuntimeConfig::default(),
|
||||||
manual_routes: None,
|
manual_routes: None,
|
||||||
proxy: ProxyRuntimeConfig::default(),
|
proxy: ProxyRuntimeConfig::default(),
|
||||||
|
|||||||
+273
-236
@@ -6,172 +6,24 @@ use std::{
|
|||||||
sync::{Arc, Mutex},
|
sync::{Arc, Mutex},
|
||||||
};
|
};
|
||||||
|
|
||||||
use super::normalize_secure_mode_config;
|
use super::{DhcpConfig, normalize_secure_mode_config};
|
||||||
pub use super::{EncryptionAlgorithm, gateway::PortForwardConfig};
|
pub use super::{EncryptionAlgorithm, gateway::PortForwardConfig};
|
||||||
use anyhow::Context;
|
use anyhow::Context;
|
||||||
#[cfg(feature = "rich-config-errors")]
|
#[cfg(feature = "rich-config-errors")]
|
||||||
use ariadne::{CharSet, Config as AriadneConfig, IndexType, Label, Report, ReportKind, Source};
|
use ariadne::{CharSet, Config as AriadneConfig, IndexType, Label, Report, ReportKind, Source};
|
||||||
|
use optionize::Optionizable;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
use crate::proto::{
|
use crate::proto::{acl::Acl, common::SecureModeConfig};
|
||||||
acl::Acl,
|
|
||||||
common::{CompressionAlgoPb, SecureModeConfig},
|
|
||||||
};
|
|
||||||
|
|
||||||
pub const DEFAULT_ET_DNS_ZONE: &str = "et.net.";
|
pub const DEFAULT_ET_DNS_ZONE: &str = "et.net.";
|
||||||
|
|
||||||
pub type Flags = crate::proto::common::FlagsInConfig;
|
pub use crate::proto::common::{Flags, FlagsPatch};
|
||||||
|
|
||||||
pub(crate) fn default_instance_name() -> String {
|
pub(crate) fn default_instance_name() -> String {
|
||||||
"default".to_owned()
|
"default".to_owned()
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn gen_default_flags() -> Flags {
|
|
||||||
#[allow(deprecated)]
|
|
||||||
Flags {
|
|
||||||
default_protocol: "tcp".to_string(),
|
|
||||||
dev_name: "".to_string(),
|
|
||||||
enable_encryption: true,
|
|
||||||
enable_ipv6: true,
|
|
||||||
mtu: 1380,
|
|
||||||
latency_first: false,
|
|
||||||
enable_exit_node: false,
|
|
||||||
proxy_forward_by_system: false,
|
|
||||||
no_tun: false,
|
|
||||||
use_smoltcp: false,
|
|
||||||
relay_network_whitelist: "*".to_string(),
|
|
||||||
disable_p2p: false,
|
|
||||||
p2p_only: false,
|
|
||||||
lazy_p2p: false,
|
|
||||||
relay_all_peer_rpc: false,
|
|
||||||
disable_tcp_hole_punching: false,
|
|
||||||
disable_udp_hole_punching: false,
|
|
||||||
multi_thread: true,
|
|
||||||
data_compress_algo: CompressionAlgoPb::None.into(),
|
|
||||||
bind_device: true,
|
|
||||||
enable_kcp_proxy: false,
|
|
||||||
disable_kcp_input: false,
|
|
||||||
disable_relay_kcp: false,
|
|
||||||
enable_relay_foreign_network_kcp: false,
|
|
||||||
accept_dns: false,
|
|
||||||
private_mode: false,
|
|
||||||
enable_quic_proxy: false,
|
|
||||||
disable_quic_input: false,
|
|
||||||
disable_relay_quic: false,
|
|
||||||
enable_relay_foreign_network_quic: false,
|
|
||||||
foreign_relay_bps_limit: u64::MAX,
|
|
||||||
multi_thread_count: 2,
|
|
||||||
encryption_algorithm: EncryptionAlgorithm::default().to_string(),
|
|
||||||
disable_sym_hole_punching: false,
|
|
||||||
tld_dns_zone: DEFAULT_ET_DNS_ZONE.to_string(),
|
|
||||||
|
|
||||||
quic_listen_port: u32::MAX,
|
|
||||||
need_p2p: false,
|
|
||||||
instance_recv_bps_limit: u64::MAX,
|
|
||||||
disable_upnp: false,
|
|
||||||
disable_relay_data: false,
|
|
||||||
prefer_peer_relay: false,
|
|
||||||
enable_udp_broadcast_relay: false,
|
|
||||||
socket_mark: None,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(feature = "config-write")]
|
|
||||||
macro_rules! define_flags_diff {
|
|
||||||
(
|
|
||||||
fields: [$($field:ident),* $(,)?],
|
|
||||||
u64s: [$($u64_field:ident),* $(,)?],
|
|
||||||
enums: [$($enum_field:ident),* $(,)?]
|
|
||||||
) => {
|
|
||||||
#[allow(deprecated)]
|
|
||||||
fn flags_diff_from_default(flags: &Flags) -> serde_json::Map<String, serde_json::Value> {
|
|
||||||
let defaults = gen_default_flags();
|
|
||||||
let mut changed = serde_json::Map::new();
|
|
||||||
$(
|
|
||||||
if flags.$field != defaults.$field {
|
|
||||||
changed.insert(
|
|
||||||
stringify!($field).to_owned(),
|
|
||||||
serde_json::to_value(&flags.$field)
|
|
||||||
.expect("FlagsInConfig field should serialize to JSON"),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
)*
|
|
||||||
$(
|
|
||||||
if flags.$u64_field != defaults.$u64_field {
|
|
||||||
changed.insert(
|
|
||||||
stringify!($u64_field).to_owned(),
|
|
||||||
serde_json::json!(flags.$u64_field.to_string()),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
)*
|
|
||||||
$(
|
|
||||||
if flags.$enum_field != defaults.$enum_field {
|
|
||||||
let value = CompressionAlgoPb::try_from(flags.$enum_field)
|
|
||||||
.map(|value| serde_json::to_value(value).expect("enum should serialize"))
|
|
||||||
.unwrap_or_else(|_| serde_json::json!(flags.$enum_field));
|
|
||||||
changed.insert(stringify!($enum_field).to_owned(), value);
|
|
||||||
}
|
|
||||||
)*
|
|
||||||
changed
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(all(test, feature = "config-write"))]
|
|
||||||
const FLAGS_DIFF_FIELDS: &[&str] = &[
|
|
||||||
$(stringify!($field),)*
|
|
||||||
$(stringify!($u64_field),)*
|
|
||||||
$(stringify!($enum_field),)*
|
|
||||||
];
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(feature = "config-write")]
|
|
||||||
define_flags_diff! {
|
|
||||||
fields: [
|
|
||||||
default_protocol,
|
|
||||||
dev_name,
|
|
||||||
enable_encryption,
|
|
||||||
enable_ipv6,
|
|
||||||
mtu,
|
|
||||||
latency_first,
|
|
||||||
enable_exit_node,
|
|
||||||
no_tun,
|
|
||||||
use_smoltcp,
|
|
||||||
relay_network_whitelist,
|
|
||||||
disable_p2p,
|
|
||||||
relay_all_peer_rpc,
|
|
||||||
disable_udp_hole_punching,
|
|
||||||
multi_thread,
|
|
||||||
bind_device,
|
|
||||||
enable_kcp_proxy,
|
|
||||||
disable_kcp_input,
|
|
||||||
disable_relay_kcp,
|
|
||||||
proxy_forward_by_system,
|
|
||||||
accept_dns,
|
|
||||||
private_mode,
|
|
||||||
enable_quic_proxy,
|
|
||||||
disable_quic_input,
|
|
||||||
disable_relay_quic,
|
|
||||||
quic_listen_port,
|
|
||||||
multi_thread_count,
|
|
||||||
enable_relay_foreign_network_kcp,
|
|
||||||
enable_relay_foreign_network_quic,
|
|
||||||
encryption_algorithm,
|
|
||||||
disable_sym_hole_punching,
|
|
||||||
tld_dns_zone,
|
|
||||||
p2p_only,
|
|
||||||
disable_tcp_hole_punching,
|
|
||||||
lazy_p2p,
|
|
||||||
need_p2p,
|
|
||||||
disable_upnp,
|
|
||||||
disable_relay_data,
|
|
||||||
prefer_peer_relay,
|
|
||||||
enable_udp_broadcast_relay,
|
|
||||||
socket_mark,
|
|
||||||
],
|
|
||||||
u64s: [foreign_relay_bps_limit, instance_recv_bps_limit],
|
|
||||||
enums: [data_compress_algo]
|
|
||||||
}
|
|
||||||
|
|
||||||
#[auto_impl::auto_impl(Box, &)]
|
#[auto_impl::auto_impl(Box, &)]
|
||||||
pub trait ConfigLoader: Send + Sync {
|
pub trait ConfigLoader: Send + Sync {
|
||||||
fn get_id(&self) -> uuid::Uuid;
|
fn get_id(&self) -> uuid::Uuid;
|
||||||
@@ -201,8 +53,27 @@ pub trait ConfigLoader: Send + Sync {
|
|||||||
fn get_ipv6_public_addr_prefix(&self) -> Option<cidr::Ipv6Cidr>;
|
fn get_ipv6_public_addr_prefix(&self) -> Option<cidr::Ipv6Cidr>;
|
||||||
fn set_ipv6_public_addr_prefix(&self, prefix: Option<cidr::Ipv6Cidr>);
|
fn set_ipv6_public_addr_prefix(&self, prefix: Option<cidr::Ipv6Cidr>);
|
||||||
|
|
||||||
fn get_dhcp(&self) -> bool;
|
fn get_dhcp_config(&self) -> DhcpConfig;
|
||||||
fn set_dhcp(&self, dhcp: bool);
|
fn set_dhcp_config(&self, dhcp: DhcpConfig);
|
||||||
|
|
||||||
|
fn get_dhcp(&self) -> bool {
|
||||||
|
self.get_dhcp_config().enabled()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn set_dhcp(&self, dhcp: bool) {
|
||||||
|
self.set_dhcp_config(DhcpConfig::Enabled(dhcp));
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get_dhcpv6_config(&self) -> DhcpConfig<cidr::Ipv6Cidr>;
|
||||||
|
fn set_dhcpv6_config(&self, dhcp: DhcpConfig<cidr::Ipv6Cidr>);
|
||||||
|
|
||||||
|
fn get_dhcpv6(&self) -> bool {
|
||||||
|
self.get_dhcpv6_config().enabled()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn set_dhcpv6(&self, dhcp: bool) {
|
||||||
|
self.set_dhcpv6_config(DhcpConfig::Enabled(dhcp));
|
||||||
|
}
|
||||||
|
|
||||||
fn add_proxy_cidr(
|
fn add_proxy_cidr(
|
||||||
&self,
|
&self,
|
||||||
@@ -231,7 +102,12 @@ pub trait ConfigLoader: Send + Sync {
|
|||||||
fn set_vpn_portal_config(&self, config: VpnPortalConfig);
|
fn set_vpn_portal_config(&self, config: VpnPortalConfig);
|
||||||
|
|
||||||
fn get_flags(&self) -> Flags;
|
fn get_flags(&self) -> Flags;
|
||||||
|
/// Replace all flags with explicitly supplied values.
|
||||||
fn set_flags(&self, flags: Flags);
|
fn set_flags(&self, flags: Flags);
|
||||||
|
/// The user-supplied values, before application defaults are resolved.
|
||||||
|
fn get_flags_patch(&self) -> FlagsPatch;
|
||||||
|
/// Apply only supplied fields, preserving absence and explicit defaults.
|
||||||
|
fn patch_flags(&self, flags: FlagsPatch);
|
||||||
|
|
||||||
fn get_exit_nodes(&self) -> Vec<IpAddr>;
|
fn get_exit_nodes(&self) -> Vec<IpAddr>;
|
||||||
fn set_exit_nodes(&self, nodes: Vec<IpAddr>);
|
fn set_exit_nodes(&self, nodes: Vec<IpAddr>);
|
||||||
@@ -486,19 +362,30 @@ fn default_true() -> bool {
|
|||||||
true
|
true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[optionize::optionized]
|
||||||
|
#[cfg_attr(any(feature = "web-client", feature = "browser-config"), optionize(object = easytier_proto::api::manage::ManagedCredentialConfig))]
|
||||||
#[derive(Clone, Deserialize, Serialize, PartialEq, Eq)]
|
#[derive(Clone, Deserialize, Serialize, PartialEq, Eq)]
|
||||||
#[serde(deny_unknown_fields)]
|
#[serde(deny_unknown_fields)]
|
||||||
pub struct ManagedCredentialConfig {
|
pub struct ManagedCredentialConfig {
|
||||||
|
#[optionize(flatten)]
|
||||||
pub credential_id: String,
|
pub credential_id: String,
|
||||||
|
#[optionize(flatten)]
|
||||||
pub credential_secret: String,
|
pub credential_secret: String,
|
||||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||||
|
#[optionize(flatten)]
|
||||||
pub groups: Vec<String>,
|
pub groups: Vec<String>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
|
#[optionize(flatten)]
|
||||||
pub allow_relay: bool,
|
pub allow_relay: bool,
|
||||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||||
|
#[optionize(flatten)]
|
||||||
pub allowed_proxy_cidrs: Vec<String>,
|
pub allowed_proxy_cidrs: Vec<String>,
|
||||||
|
#[optionize(flatten)]
|
||||||
pub expiry_unix: i64,
|
pub expiry_unix: i64,
|
||||||
#[serde(default = "default_true")]
|
#[serde(default = "default_true")]
|
||||||
|
// The mapped field is `Option<bool>` (the protocol spells it `optional bool`),
|
||||||
|
// so this document default does not describe it.
|
||||||
|
#[optionize(attrs(.., -serde), default = |_| default_true())]
|
||||||
pub reusable: bool,
|
pub reusable: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -529,7 +416,8 @@ struct Config {
|
|||||||
ipv6_public_addr_provider: Option<bool>,
|
ipv6_public_addr_provider: Option<bool>,
|
||||||
ipv6_public_addr_auto: Option<bool>,
|
ipv6_public_addr_auto: Option<bool>,
|
||||||
ipv6_public_addr_prefix: Option<String>,
|
ipv6_public_addr_prefix: Option<String>,
|
||||||
dhcp: Option<bool>,
|
dhcp: Option<DhcpConfig>,
|
||||||
|
dhcpv6: Option<DhcpConfig<cidr::Ipv6Cidr>>,
|
||||||
network_identity: Option<NetworkIdentity>,
|
network_identity: Option<NetworkIdentity>,
|
||||||
listeners: Option<Vec<url::Url>>,
|
listeners: Option<Vec<url::Url>>,
|
||||||
mapped_listeners: Option<Vec<url::Url>>,
|
mapped_listeners: Option<Vec<url::Url>>,
|
||||||
@@ -548,10 +436,8 @@ struct Config {
|
|||||||
|
|
||||||
secure_mode: Option<SecureModeConfig>,
|
secure_mode: Option<SecureModeConfig>,
|
||||||
|
|
||||||
flags: Option<serde_json::Map<String, serde_json::Value>>,
|
#[serde(default)]
|
||||||
|
flags: FlagsPatch,
|
||||||
#[serde(skip)]
|
|
||||||
flags_struct: Option<Flags>,
|
|
||||||
|
|
||||||
acl: Option<Acl>,
|
acl: Option<Acl>,
|
||||||
|
|
||||||
@@ -631,7 +517,6 @@ impl TomlConfig {
|
|||||||
fn config_for_dump(&self) -> Config {
|
fn config_for_dump(&self) -> Config {
|
||||||
let mut config = self.config.lock().unwrap().clone();
|
let mut config = self.config.lock().unwrap().clone();
|
||||||
Self::normalize_config_source(&mut config);
|
Self::normalize_config_source(&mut config);
|
||||||
config.flags = Some(flags_diff_from_default(&self.get_flags()));
|
|
||||||
config
|
config
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -705,10 +590,6 @@ impl TomlConfig {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn new_from_config(mut config: Config) -> Result<Self, anyhow::Error> {
|
fn new_from_config(mut config: Config) -> Result<Self, anyhow::Error> {
|
||||||
config.flags_struct = Some(
|
|
||||||
Self::gen_flags(config.flags.clone().unwrap_or_default())
|
|
||||||
.context("failed to parse flags")?,
|
|
||||||
);
|
|
||||||
config.secure_mode = config
|
config.secure_mode = config
|
||||||
.secure_mode
|
.secure_mode
|
||||||
.take()
|
.take()
|
||||||
@@ -745,17 +626,6 @@ impl TomlConfig {
|
|||||||
|
|
||||||
Ok(config)
|
Ok(config)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn gen_flags(
|
|
||||||
flags_hashmap: serde_json::Map<String, serde_json::Value>,
|
|
||||||
) -> serde_json::Result<Flags> {
|
|
||||||
let mut merged_hashmap = match serde_json::to_value(gen_default_flags()) {
|
|
||||||
Ok(serde_json::Value::Object(map)) => map,
|
|
||||||
_ => serde_json::Map::new(),
|
|
||||||
};
|
|
||||||
merged_hashmap.extend(flags_hashmap);
|
|
||||||
serde_json::from_value(serde_json::Value::Object(merged_hashmap))
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(feature = "web-client")]
|
#[cfg(feature = "web-client")]
|
||||||
@@ -875,14 +745,22 @@ impl ConfigLoader for TomlConfig {
|
|||||||
prefix.map(|prefix| prefix.to_string());
|
prefix.map(|prefix| prefix.to_string());
|
||||||
}
|
}
|
||||||
|
|
||||||
fn get_dhcp(&self) -> bool {
|
fn get_dhcp_config(&self) -> DhcpConfig {
|
||||||
self.config.lock().unwrap().dhcp.unwrap_or_default()
|
self.config.lock().unwrap().dhcp.unwrap_or_default()
|
||||||
}
|
}
|
||||||
|
|
||||||
fn set_dhcp(&self, dhcp: bool) {
|
fn set_dhcp_config(&self, dhcp: DhcpConfig) {
|
||||||
self.config.lock().unwrap().dhcp = Some(dhcp);
|
self.config.lock().unwrap().dhcp = Some(dhcp);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn get_dhcpv6_config(&self) -> DhcpConfig<cidr::Ipv6Cidr> {
|
||||||
|
self.config.lock().unwrap().dhcpv6.unwrap_or_default()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn set_dhcpv6_config(&self, dhcp: DhcpConfig<cidr::Ipv6Cidr>) {
|
||||||
|
self.config.lock().unwrap().dhcpv6 = Some(dhcp);
|
||||||
|
}
|
||||||
|
|
||||||
fn add_proxy_cidr(
|
fn add_proxy_cidr(
|
||||||
&self,
|
&self,
|
||||||
cidr: cidr::Ipv4Cidr,
|
cidr: cidr::Ipv4Cidr,
|
||||||
@@ -1019,16 +897,27 @@ impl ConfigLoader for TomlConfig {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn get_flags(&self) -> Flags {
|
fn get_flags(&self) -> Flags {
|
||||||
self.config
|
Flags::resolve(self.get_flags_patch())
|
||||||
.lock()
|
|
||||||
.unwrap()
|
|
||||||
.flags_struct
|
|
||||||
.clone()
|
|
||||||
.unwrap_or_default()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn set_flags(&self, flags: Flags) {
|
fn set_flags(&self, flags: Flags) {
|
||||||
self.config.lock().unwrap().flags_struct = Some(flags);
|
self.config.lock().unwrap().flags = flags.downgrade();
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get_flags_patch(&self) -> FlagsPatch {
|
||||||
|
self.config.lock().unwrap().flags.clone()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn patch_flags(&self, flags: FlagsPatch) {
|
||||||
|
// Protobuf merge: an update overwrites exactly the fields it carries,
|
||||||
|
// and presence is the encoding, so a flag explicitly set to its default
|
||||||
|
// value is applied like any other. Optionize's merge cannot express that
|
||||||
|
// here, because the one flattened nullable field would have to assign
|
||||||
|
// unconditionally.
|
||||||
|
let mut config = self.config.lock().unwrap();
|
||||||
|
let update = prost::Message::encode_to_vec(&flags);
|
||||||
|
prost::Message::merge(&mut config.flags, update.as_slice())
|
||||||
|
.expect("decoding the bytes just encoded cannot fail");
|
||||||
}
|
}
|
||||||
|
|
||||||
fn get_exit_nodes(&self) -> Vec<IpAddr> {
|
fn get_exit_nodes(&self) -> Vec<IpAddr> {
|
||||||
@@ -1205,19 +1094,113 @@ mod tests {
|
|||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn parse_error_preserves_source_and_location() {
|
fn dhcp_accepts_booleans_and_ipv4_subnets() {
|
||||||
let error =
|
assert!(!TomlConfig::default().get_dhcp());
|
||||||
TomlConfig::new_from_str_with_source("fixture.toml", "dhcp = \"yes\"").unwrap_err();
|
for (value, expected) in [
|
||||||
let display = error.to_string();
|
("false", DhcpConfig::Enabled(false)),
|
||||||
|
("true", DhcpConfig::Enabled(true)),
|
||||||
|
(
|
||||||
|
"'192.168.100.0/24'",
|
||||||
|
DhcpConfig::Cidr("192.168.100.0/24".parse().unwrap()),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"'192.168.100.0/30'",
|
||||||
|
DhcpConfig::Cidr("192.168.100.0/30".parse().unwrap()),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"'0.0.0.0/0'",
|
||||||
|
DhcpConfig::Cidr("0.0.0.0/0".parse().unwrap()),
|
||||||
|
),
|
||||||
|
] {
|
||||||
|
let config = TomlConfig::new_from_str(&format!("dhcp = {value}")).unwrap();
|
||||||
|
assert_eq!(config.get_dhcp_config(), expected);
|
||||||
|
assert_eq!(config.get_dhcp(), expected.enabled());
|
||||||
|
#[cfg(feature = "config-write")]
|
||||||
|
assert_eq!(
|
||||||
|
TomlConfig::new_from_str(&config.dump())
|
||||||
|
.unwrap()
|
||||||
|
.get_dhcp_config(),
|
||||||
|
expected
|
||||||
|
);
|
||||||
|
config.set_dhcp(false);
|
||||||
|
assert_eq!(config.get_dhcp_config(), DhcpConfig::Enabled(false));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
assert!(display.contains("fixture.toml"));
|
#[test]
|
||||||
assert!(display.contains("dhcp = \"yes\""));
|
fn dhcpv6_config_round_trip_and_validation() {
|
||||||
assert!(display.contains("invalid type: string"));
|
assert!(!TomlConfig::default().get_dhcpv6());
|
||||||
assert!(
|
for value in [
|
||||||
error
|
"true",
|
||||||
.chain()
|
"false",
|
||||||
.any(|cause| cause.downcast_ref::<toml::de::Error>().is_some())
|
"'fd12:3456::/64'",
|
||||||
);
|
"'fd12::/126'",
|
||||||
|
"'2001:db8::/48'",
|
||||||
|
] {
|
||||||
|
let config =
|
||||||
|
TomlConfig::new_from_str(&format!("dhcp = true\ndhcpv6 = {value}")).unwrap();
|
||||||
|
let exported = config.dump();
|
||||||
|
let restored = TomlConfig::new_from_str(&exported).unwrap();
|
||||||
|
assert_eq!(restored.get_dhcpv6_config(), config.get_dhcpv6_config());
|
||||||
|
assert!(restored.get_dhcp());
|
||||||
|
assert!(exported.contains("dhcp = true"));
|
||||||
|
}
|
||||||
|
for value in [
|
||||||
|
"yes",
|
||||||
|
"10.0.0.0/24",
|
||||||
|
"fd12::1/64",
|
||||||
|
"fd12::/127",
|
||||||
|
"fd12::/128",
|
||||||
|
"::/0",
|
||||||
|
"::/64",
|
||||||
|
"fe80::/64",
|
||||||
|
"ff00::/8",
|
||||||
|
] {
|
||||||
|
assert!(
|
||||||
|
TomlConfig::new_from_str(&format!("dhcpv6 = '{value}'")).is_err(),
|
||||||
|
"accepted {value}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn dhcp_rejects_invalid_or_unusable_subnets() {
|
||||||
|
for value in [
|
||||||
|
"'yes'",
|
||||||
|
"'true'",
|
||||||
|
"'::/64'",
|
||||||
|
"'192.168.100.0/31'",
|
||||||
|
"'192.168.100.1/32'",
|
||||||
|
"'192.168.100.1/24'",
|
||||||
|
"'192.168.100.0/33'",
|
||||||
|
"'192.168.100.1'",
|
||||||
|
"123",
|
||||||
|
"{}",
|
||||||
|
"[]",
|
||||||
|
] {
|
||||||
|
assert!(
|
||||||
|
TomlConfig::new_from_str(&format!("dhcp = {value}")).is_err(),
|
||||||
|
"accepted {value}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_error_preserves_source_and_location() {
|
||||||
|
for field in ["dhcp", "dhcpv6"] {
|
||||||
|
let input = format!("{field} = \"yes\"");
|
||||||
|
let error = TomlConfig::new_from_str_with_source("fixture.toml", &input).unwrap_err();
|
||||||
|
let display = error.to_string();
|
||||||
|
|
||||||
|
assert!(display.contains("fixture.toml"));
|
||||||
|
assert!(display.contains(&input));
|
||||||
|
assert!(display.contains("invalid DHCP CIDR"));
|
||||||
|
assert!(
|
||||||
|
error
|
||||||
|
.chain()
|
||||||
|
.any(|cause| cause.downcast_ref::<toml::de::Error>().is_some())
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(feature = "config-write")]
|
#[cfg(feature = "config-write")]
|
||||||
@@ -1406,37 +1389,9 @@ source = "web"
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod compatibility_tests {
|
mod compatibility_tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
use crate::proto::common::CompressionAlgoPb;
|
||||||
use base64::{Engine as _, prelude::BASE64_STANDARD};
|
use base64::{Engine as _, prelude::BASE64_STANDARD};
|
||||||
|
|
||||||
#[cfg(feature = "config-write")]
|
|
||||||
#[test]
|
|
||||||
fn flags_diff_covers_every_protobuf_field() {
|
|
||||||
use prost::Message as _;
|
|
||||||
|
|
||||||
let descriptor_set =
|
|
||||||
prost_types::FileDescriptorSet::decode(crate::proto::DESCRIPTOR_POOL_BYTES).unwrap();
|
|
||||||
let proto_fields = descriptor_set
|
|
||||||
.file
|
|
||||||
.iter()
|
|
||||||
.find(|file| file.package.as_deref() == Some("common"))
|
|
||||||
.and_then(|file| {
|
|
||||||
file.message_type
|
|
||||||
.iter()
|
|
||||||
.find(|message| message.name.as_deref() == Some("FlagsInConfig"))
|
|
||||||
})
|
|
||||||
.unwrap()
|
|
||||||
.field
|
|
||||||
.iter()
|
|
||||||
.map(|field| field.name.as_deref().unwrap())
|
|
||||||
.collect::<std::collections::BTreeSet<_>>();
|
|
||||||
let diff_fields = FLAGS_DIFF_FIELDS
|
|
||||||
.iter()
|
|
||||||
.copied()
|
|
||||||
.collect::<std::collections::BTreeSet<_>>();
|
|
||||||
|
|
||||||
assert_eq!(diff_fields, proto_fields);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn socket_mark_config_file_roundtrip_none_some_and_zero() {
|
fn socket_mark_config_file_roundtrip_none_some_and_zero() {
|
||||||
// Omitting the flag leaves socket_mark unset (None) -> SO_MARK untouched.
|
// Omitting the flag leaves socket_mark unset (None) -> SO_MARK untouched.
|
||||||
@@ -1486,13 +1441,91 @@ socket_mark = 66
|
|||||||
..cfg.get_flags()
|
..cfg.get_flags()
|
||||||
});
|
});
|
||||||
assert_eq!(cfg.get_flags().socket_mark, None);
|
assert_eq!(cfg.get_flags().socket_mark, None);
|
||||||
|
#[cfg(feature = "config-write")]
|
||||||
|
assert_eq!(
|
||||||
|
TomlConfigLoader::new_from_str(&cfg.dump())
|
||||||
|
.unwrap()
|
||||||
|
.get_flags()
|
||||||
|
.socket_mark,
|
||||||
|
None
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "config-write")]
|
||||||
|
#[test]
|
||||||
|
fn flags_presence_survives_parse_patch_and_dump() {
|
||||||
|
for (input, expected) in [
|
||||||
|
("", None),
|
||||||
|
("enable_encryption = true", Some(true)),
|
||||||
|
("enable_encryption = false", Some(false)),
|
||||||
|
] {
|
||||||
|
let config = TomlConfig::new_from_str(&format!("[flags]\n{input}")).unwrap();
|
||||||
|
assert_eq!(config.get_flags_patch().enable_encryption, expected);
|
||||||
|
config.patch_flags(FlagsPatch {
|
||||||
|
latency_first: Some(false),
|
||||||
|
..Default::default()
|
||||||
|
});
|
||||||
|
let restored = TomlConfig::new_from_str(&config.dump()).unwrap();
|
||||||
|
assert_eq!(restored.get_flags_patch().enable_encryption, expected);
|
||||||
|
assert_eq!(restored.get_flags_patch().latency_first, Some(false));
|
||||||
|
assert_eq!(restored.get_flags_patch().mtu, None);
|
||||||
|
assert_eq!(
|
||||||
|
restored.get_flags().enable_encryption,
|
||||||
|
expected.unwrap_or(true)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn flags_accept_protobuf_aliases_and_numbers_without_default_collisions() {
|
||||||
|
let config = TomlConfig::new_from_str(
|
||||||
|
r#"[flags]
|
||||||
|
enableEncryption = false
|
||||||
|
mtu = "1420"
|
||||||
|
foreignRelayBpsLimit = "18446744073709551615"
|
||||||
|
dataCompressAlgo = "Zstd"
|
||||||
|
socketMark = "0"
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let flags = config.get_flags();
|
||||||
|
assert!(!flags.enable_encryption);
|
||||||
|
assert_eq!(flags.mtu, 1420);
|
||||||
|
assert_eq!(flags.foreign_relay_bps_limit, u64::MAX);
|
||||||
|
assert_eq!(flags.data_compress_algo, CompressionAlgoPb::Zstd as i32);
|
||||||
|
assert_eq!(flags.socket_mark, Some(0));
|
||||||
|
for fields in [
|
||||||
|
"enableEncryption = true\nenable_encryption = false",
|
||||||
|
"mtu = -1",
|
||||||
|
"mtu = 4294967296",
|
||||||
|
"unknown_flag = true",
|
||||||
|
"data_compress_algo = 99",
|
||||||
|
] {
|
||||||
|
assert!(
|
||||||
|
TomlConfig::new_from_str(&format!("[flags]\n{fields}")).is_err(),
|
||||||
|
"{fields}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "config-write")]
|
||||||
|
#[test]
|
||||||
|
fn dump_omits_all_default_flags() {
|
||||||
|
let config = TomlConfigLoader::default();
|
||||||
|
let dumped = config.dump();
|
||||||
|
let document: toml::Table = toml::from_str(&dumped).unwrap();
|
||||||
|
assert!(document["flags"].as_table().unwrap().is_empty());
|
||||||
|
assert_eq!(
|
||||||
|
TomlConfigLoader::new_from_str(&dumped).unwrap().get_flags(),
|
||||||
|
Flags::resolve(FlagsPatch::default())
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(feature = "config-write")]
|
#[cfg(feature = "config-write")]
|
||||||
#[test]
|
#[test]
|
||||||
fn dump_preserves_flags_that_differ_from_easytier_defaults() {
|
fn dump_preserves_flags_that_differ_from_easytier_defaults() {
|
||||||
let cfg = TomlConfigLoader::default();
|
let cfg = TomlConfigLoader::default();
|
||||||
let mut flags = gen_default_flags();
|
let mut flags = Flags::resolve(FlagsPatch::default());
|
||||||
flags.dev_name = "et_test".to_string();
|
flags.dev_name = "et_test".to_string();
|
||||||
flags.enable_quic_proxy = true;
|
flags.enable_quic_proxy = true;
|
||||||
flags.disable_tcp_hole_punching = true;
|
flags.disable_tcp_hole_punching = true;
|
||||||
@@ -1973,13 +2006,16 @@ mod diagnostic_compatibility_tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn non_ascii_before_typed_error_keeps_byte_location() {
|
fn non_ascii_before_typed_error_keeps_byte_location() {
|
||||||
let error = TomlConfig::new_from_str("hostname = \"节点\"\ndhcp = \"yes\"")
|
for field in ["dhcp", "dhcpv6"] {
|
||||||
.unwrap_err()
|
let invalid_field = format!("{field} = \"yes\"");
|
||||||
.to_string();
|
let error = TomlConfig::new_from_str(&format!("hostname = \"节点\"\n{invalid_field}"))
|
||||||
|
.unwrap_err()
|
||||||
|
.to_string();
|
||||||
|
|
||||||
assert!(error.contains("dhcp = \"yes\""));
|
assert!(error.contains(&invalid_field));
|
||||||
assert!(error.contains('^'));
|
assert!(error.contains('^'));
|
||||||
assert!(error.contains("invalid type: string"));
|
assert!(error.contains("invalid DHCP CIDR"));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(feature = "rich-config-errors")]
|
#[cfg(feature = "rich-config-errors")]
|
||||||
@@ -1996,7 +2032,7 @@ mod diagnostic_compatibility_tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn flags_conversion_error_keeps_source_and_cause_chain() {
|
fn flags_parse_error_keeps_source_span_and_cause_chain() {
|
||||||
let error = TomlConfig::new_from_str_with_source(
|
let error = TomlConfig::new_from_str_with_source(
|
||||||
"flags-fixture.toml",
|
"flags-fixture.toml",
|
||||||
"[flags]\nsocket_mark = \"bad\"",
|
"[flags]\nsocket_mark = \"bad\"",
|
||||||
@@ -2005,12 +2041,13 @@ mod diagnostic_compatibility_tests {
|
|||||||
let display = error.to_string();
|
let display = error.to_string();
|
||||||
|
|
||||||
assert!(display.contains("flags-fixture.toml"));
|
assert!(display.contains("flags-fixture.toml"));
|
||||||
assert!(display.contains("failed to load config"));
|
assert!(display.contains("failed to parse config TOML"));
|
||||||
assert!(display.contains("failed to parse flags"));
|
assert!(display.contains("socket_mark = \"bad\""));
|
||||||
|
assert!(display.contains('^'));
|
||||||
assert!(
|
assert!(
|
||||||
error
|
error
|
||||||
.chain()
|
.chain()
|
||||||
.any(|cause| cause.to_string().contains("failed to parse flags"))
|
.any(|cause| cause.downcast_ref::<toml::de::Error>().is_some())
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -723,6 +723,7 @@ mod tests {
|
|||||||
));
|
));
|
||||||
let handler = DirectConnectorRpcHandler::new_for_foreign_network_with_stun(
|
let handler = DirectConnectorRpcHandler::new_for_foreign_network_with_stun(
|
||||||
host,
|
host,
|
||||||
|
Arc::new(crate::connectivity::NoLocalListeners),
|
||||||
SocketContext::default().with_socket_mark(Some(7)),
|
SocketContext::default().with_socket_mark(Some(7)),
|
||||||
Some(Arc::new(FixedStunProvider)),
|
Some(Arc::new(FixedStunProvider)),
|
||||||
);
|
);
|
||||||
@@ -744,6 +745,14 @@ mod tests {
|
|||||||
response.public_ipv4,
|
response.public_ipv4,
|
||||||
Some("198.51.100.7".parse::<std::net::Ipv4Addr>().unwrap().into())
|
Some("198.51.100.7".parse::<std::net::Ipv4Addr>().unwrap().into())
|
||||||
);
|
);
|
||||||
|
assert_eq!(
|
||||||
|
response
|
||||||
|
.listeners
|
||||||
|
.into_iter()
|
||||||
|
.map(Url::from)
|
||||||
|
.collect::<Vec<_>>(),
|
||||||
|
vec!["tcp://192.0.2.1:11010".parse::<Url>().unwrap()]
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
fn snapshot() -> HostConnectorEnvironmentSnapshot {
|
fn snapshot() -> HostConnectorEnvironmentSnapshot {
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ use crate::{
|
|||||||
connectivity::hole_punch::policy::{should_background_p2p_with_peer, should_try_p2p_with_peer},
|
connectivity::hole_punch::policy::{should_background_p2p_with_peer, should_try_p2p_with_peer},
|
||||||
connectivity::stun::{StunInfoProvider, StunSocketMapper},
|
connectivity::stun::{StunInfoProvider, StunSocketMapper},
|
||||||
connectivity::{
|
connectivity::{
|
||||||
LocalListenerUrls, NoLocalListeners,
|
LocalListenerUrls,
|
||||||
protocol::{
|
protocol::{
|
||||||
ClientProtocolUpgrader, ProtocolTransport, protocol_transport, protocol_uses_udp,
|
ClientProtocolUpgrader, ProtocolTransport, protocol_transport, protocol_uses_udp,
|
||||||
},
|
},
|
||||||
@@ -915,10 +915,10 @@ where
|
|||||||
{
|
{
|
||||||
pub fn new_for_foreign_network_with_stun(
|
pub fn new_for_foreign_network_with_stun(
|
||||||
host: Arc<H>,
|
host: Arc<H>,
|
||||||
|
running_listeners: Arc<dyn LocalListenerUrls>,
|
||||||
socket_context: SocketContext,
|
socket_context: SocketContext,
|
||||||
stun: Option<Arc<dyn StunInfoProvider>>,
|
stun: Option<Arc<dyn StunInfoProvider>>,
|
||||||
) -> Self {
|
) -> Self {
|
||||||
let running_listeners = Arc::new(NoLocalListeners);
|
|
||||||
Self {
|
Self {
|
||||||
host,
|
host,
|
||||||
peer_manager: None,
|
peer_manager: None,
|
||||||
@@ -997,14 +997,23 @@ where
|
|||||||
{
|
{
|
||||||
host: Arc<H>,
|
host: Arc<H>,
|
||||||
stun: Arc<dyn StunInfoProvider>,
|
stun: Arc<dyn StunInfoProvider>,
|
||||||
|
running_listeners: Arc<dyn LocalListenerUrls>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl<H> ForeignDirectConnectorRpcRegistrar<H>
|
impl<H> ForeignDirectConnectorRpcRegistrar<H>
|
||||||
where
|
where
|
||||||
H: DirectConnectorHost,
|
H: DirectConnectorHost,
|
||||||
{
|
{
|
||||||
pub fn new(host: Arc<H>, stun: Arc<dyn StunInfoProvider>) -> Self {
|
pub fn new(
|
||||||
Self { host, stun }
|
host: Arc<H>,
|
||||||
|
stun: Arc<dyn StunInfoProvider>,
|
||||||
|
running_listeners: Arc<dyn LocalListenerUrls>,
|
||||||
|
) -> Self {
|
||||||
|
Self {
|
||||||
|
host,
|
||||||
|
stun,
|
||||||
|
running_listeners,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1022,6 +1031,7 @@ where
|
|||||||
GeneratedDirectConnectorRpcServer::new(
|
GeneratedDirectConnectorRpcServer::new(
|
||||||
DirectConnectorRpcHandler::new_for_foreign_network_with_stun(
|
DirectConnectorRpcHandler::new_for_foreign_network_with_stun(
|
||||||
self.host.clone(),
|
self.host.clone(),
|
||||||
|
self.running_listeners.clone(),
|
||||||
socket_context,
|
socket_context,
|
||||||
Some(self.stun.clone()),
|
Some(self.stun.clone()),
|
||||||
),
|
),
|
||||||
@@ -1201,6 +1211,7 @@ fn is_public_ipv4(ip: Ipv4Addr) -> bool {
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
use crate::connectivity::NoLocalListeners;
|
||||||
|
|
||||||
impl<H> DirectConnectorRpcHandler<H>
|
impl<H> DirectConnectorRpcHandler<H>
|
||||||
where
|
where
|
||||||
|
|||||||
@@ -331,6 +331,7 @@ where
|
|||||||
};
|
};
|
||||||
|
|
||||||
let layer = self.session_layer(socket.clone());
|
let layer = self.session_layer(socket.clone());
|
||||||
|
let hole_punch_observers = layer.hole_punch_observers();
|
||||||
let conn_counter = Arc::new(CoreUdpPunchConnCounter {
|
let conn_counter = Arc::new(CoreUdpPunchConnCounter {
|
||||||
layer: Arc::downgrade(&layer),
|
layer: Arc::downgrade(&layer),
|
||||||
});
|
});
|
||||||
@@ -338,6 +339,7 @@ where
|
|||||||
|
|
||||||
Ok(UdpPunchListener {
|
Ok(UdpPunchListener {
|
||||||
socket,
|
socket,
|
||||||
|
hole_punch_observers,
|
||||||
mapped_addr: resolved.mapped_addr,
|
mapped_addr: resolved.mapped_addr,
|
||||||
conn_counter,
|
conn_counter,
|
||||||
acceptor,
|
acceptor,
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ use crate::{
|
|||||||
config::PeerId,
|
config::PeerId,
|
||||||
connectivity::stun::StunInfoProvider,
|
connectivity::stun::StunInfoProvider,
|
||||||
packet::{HOLE_PUNCH_PACKET_BODY_LEN, new_hole_punch_packet},
|
packet::{HOLE_PUNCH_PACKET_BODY_LEN, new_hole_punch_packet},
|
||||||
socket::udp::{UdpBindOptions, VirtualUdpSocketFactory},
|
socket::udp::{UdpBindOptions, VirtualUdpSocket},
|
||||||
};
|
};
|
||||||
|
|
||||||
use super::{
|
use super::{
|
||||||
@@ -41,12 +41,11 @@ const DST_PORT_OFFSET: u16 = 20;
|
|||||||
const REMOTE_WAIT_TIME_MS: u64 = 5000;
|
const REMOTE_WAIT_TIME_MS: u64 = 5000;
|
||||||
|
|
||||||
pub fn apply_peer_easy_sym_port_offset(base_port: u16, peer_is_incremental: bool) -> u16 {
|
pub fn apply_peer_easy_sym_port_offset(base_port: u16, peer_is_incremental: bool) -> u16 {
|
||||||
let port = if peer_is_incremental {
|
if peer_is_incremental {
|
||||||
(base_port as u32).saturating_add(DST_PORT_OFFSET as u32)
|
base_port.saturating_add(DST_PORT_OFFSET)
|
||||||
} else {
|
} else {
|
||||||
(base_port as u32).saturating_sub(DST_PORT_OFFSET as u32)
|
base_port.saturating_sub(DST_PORT_OFFSET).max(1)
|
||||||
};
|
}
|
||||||
port as u16
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tracing::instrument(skip(runtime, signaling), fields(dst_peer_id), err)]
|
#[tracing::instrument(skip(runtime, signaling), fields(dst_peer_id), err)]
|
||||||
@@ -62,17 +61,29 @@ where
|
|||||||
tracing::info!(?dst_peer_id, "start hole punching");
|
tracing::info!(?dst_peer_id, "start hole punching");
|
||||||
let tid = rand::random();
|
let tid = rand::random();
|
||||||
|
|
||||||
let udp_array = UdpSocketArray::new_with_context(1, runtime.clone(), runtime.socket_context());
|
|
||||||
|
|
||||||
let resp = signaling
|
let resp = signaling
|
||||||
.select_punch_listener(
|
.select_punch_listener(
|
||||||
dst_peer_id,
|
dst_peer_id,
|
||||||
SelectPunchListener {
|
SelectPunchListener {
|
||||||
force_new: false,
|
force_new: false,
|
||||||
prefer_port_mapping: true,
|
prefer_port_mapping: true,
|
||||||
|
transaction_id: Some(tid),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
|
if resp
|
||||||
|
.transaction_id
|
||||||
|
.is_some_and(|registered| registered != tid)
|
||||||
|
{
|
||||||
|
return Err(UdpHolePunchSignalError::RemoteRejected(
|
||||||
|
"listener registered a different hole-punch transaction".into(),
|
||||||
|
)
|
||||||
|
.into());
|
||||||
|
}
|
||||||
|
tracing::debug!(
|
||||||
|
source_observation_registered = resp.transaction_id == Some(tid),
|
||||||
|
"selected hole-punch listener"
|
||||||
|
);
|
||||||
let remote_mapped_addr = resp.listener_mapped_addr;
|
let remote_mapped_addr = resp.listener_mapped_addr;
|
||||||
|
|
||||||
let local_socket = UdpHolePunchRuntime::bind_udp(
|
let local_socket = UdpHolePunchRuntime::bind_udp(
|
||||||
@@ -96,14 +107,12 @@ where
|
|||||||
"hole punch got remote listener"
|
"hole punch got remote listener"
|
||||||
);
|
);
|
||||||
|
|
||||||
udp_array.add_new_socket(local_socket).await?;
|
|
||||||
udp_array.add_intreast_tid(tid);
|
|
||||||
let punch_packet = new_hole_punch_packet(tid, HOLE_PUNCH_PACKET_BODY_LEN).into_bytes();
|
let punch_packet = new_hole_punch_packet(tid, HOLE_PUNCH_PACKET_BODY_LEN).into_bytes();
|
||||||
|
|
||||||
send_from_local(&udp_array, &punch_packet, remote_mapped_addr).await?;
|
send_from_local(local_socket.as_ref(), &punch_packet, remote_mapped_addr).await?;
|
||||||
|
|
||||||
let signaling_for_task = signaling.clone();
|
let signaling_for_task = signaling.clone();
|
||||||
let punch_task = AbortOnDropHandle::new(tokio::spawn(async move {
|
let _punch_task = AbortOnDropHandle::new(tokio::spawn(async move {
|
||||||
if let Err(e) = signaling_for_task
|
if let Err(e) = signaling_for_task
|
||||||
.send_punch_packet_cone(
|
.send_punch_packet_cone(
|
||||||
dst_peer_id,
|
dst_peer_id,
|
||||||
@@ -122,33 +131,27 @@ where
|
|||||||
}
|
}
|
||||||
}));
|
}));
|
||||||
|
|
||||||
let mut finish_time: Option<Instant> = None;
|
// The session layer owns receive while connecting. A reachable listener can
|
||||||
while finish_time.is_none() || finish_time.as_ref().unwrap().elapsed().as_millis() < 1000 {
|
// reply to SYN's actual source even when the STUN candidate differs.
|
||||||
crate::foundation::time::sleep(std::time::Duration::from_millis(200)).await;
|
for _ in 0..2 {
|
||||||
|
let connect = runtime.connect_with_socket(local_socket.clone(), remote_mapped_addr);
|
||||||
if finish_time.is_none() && punch_task.is_finished() {
|
tokio::pin!(connect);
|
||||||
finish_time = Some(Instant::now());
|
loop {
|
||||||
}
|
tokio::select! {
|
||||||
|
result = &mut connect => {
|
||||||
let Some(socket) = udp_array.try_fetch_punched_socket(tid) else {
|
match result {
|
||||||
tracing::debug!("no punched socket found, send some more hole punch packets");
|
Ok(socket) => {
|
||||||
send_from_local(&udp_array, &punch_packet, remote_mapped_addr).await?;
|
tracing::info!(?socket, "hole punched");
|
||||||
continue;
|
return Ok(Some(socket));
|
||||||
};
|
}
|
||||||
|
Err(e) => {
|
||||||
tracing::debug!(?socket, ?tid, "punched socket found, try connect with it");
|
tracing::error!(?e, "failed to connect with socket");
|
||||||
|
break;
|
||||||
for _ in 0..2 {
|
}
|
||||||
match runtime
|
}
|
||||||
.connect_with_socket(socket.socket.clone(), remote_mapped_addr)
|
|
||||||
.await
|
|
||||||
{
|
|
||||||
Ok(socket) => {
|
|
||||||
tracing::info!(?socket, "hole punched");
|
|
||||||
return Ok(Some(socket));
|
|
||||||
}
|
}
|
||||||
Err(e) => {
|
_ = crate::foundation::time::sleep(std::time::Duration::from_millis(200)) => {
|
||||||
tracing::error!(?e, "failed to connect with socket");
|
send_from_local(local_socket.as_ref(), &punch_packet, remote_mapped_addr).await?;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -157,17 +160,20 @@ where
|
|||||||
Ok(None)
|
Ok(None)
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn send_from_local<R>(
|
async fn send_from_local<S>(
|
||||||
udp_array: &UdpSocketArray<R>,
|
socket: &S,
|
||||||
punch_packet: &[u8],
|
punch_packet: &[u8],
|
||||||
remote_mapped_addr: SocketAddr,
|
remote_mapped_addr: SocketAddr,
|
||||||
) -> UdpHolePunchClientResult<()>
|
) -> UdpHolePunchClientResult<()>
|
||||||
where
|
where
|
||||||
R: VirtualUdpSocketFactory,
|
S: VirtualUdpSocket,
|
||||||
{
|
{
|
||||||
udp_array
|
for _ in 0..3 {
|
||||||
.send_with_all(punch_packet, remote_mapped_addr)
|
socket
|
||||||
.await?;
|
.send_to(punch_packet, remote_mapped_addr)
|
||||||
|
.await
|
||||||
|
.map_err(anyhow::Error::from)?;
|
||||||
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -206,24 +212,19 @@ where
|
|||||||
}
|
}
|
||||||
|
|
||||||
async fn prepare_udp_array(&self) -> anyhow::Result<Arc<UdpSocketArray<R>>> {
|
async fn prepare_udp_array(&self) -> anyhow::Result<Arc<UdpSocketArray<R>>> {
|
||||||
let rlocked = self.udp_array.read().await;
|
|
||||||
if let Some(udp_array) = rlocked.clone() {
|
|
||||||
return Ok(udp_array);
|
|
||||||
}
|
|
||||||
|
|
||||||
drop(rlocked);
|
|
||||||
let mut wlocked = self.udp_array.write().await;
|
let mut wlocked = self.udp_array.write().await;
|
||||||
if let Some(udp_array) = wlocked.clone() {
|
let udp_array = wlocked
|
||||||
return Ok(udp_array);
|
.get_or_insert_with(|| {
|
||||||
}
|
Arc::new(UdpSocketArray::new_with_context(
|
||||||
|
UDP_ARRAY_SIZE_FOR_HARD_SYM,
|
||||||
let udp_array = Arc::new(UdpSocketArray::new_with_context(
|
self.runtime.clone(),
|
||||||
UDP_ARRAY_SIZE_FOR_HARD_SYM,
|
self.runtime.socket_context(),
|
||||||
self.runtime.clone(),
|
))
|
||||||
self.runtime.socket_context(),
|
})
|
||||||
));
|
.clone();
|
||||||
|
// Successful candidates leave the array even if transport admission fails.
|
||||||
|
// Refill only those slots so other candidates retain their NAT mappings.
|
||||||
udp_array.start().await?;
|
udp_array.start().await?;
|
||||||
wlocked.replace(udp_array.clone());
|
|
||||||
Ok(udp_array)
|
Ok(udp_array)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -353,6 +354,7 @@ where
|
|||||||
SelectPunchListener {
|
SelectPunchListener {
|
||||||
force_new: false,
|
force_new: false,
|
||||||
prefer_port_mapping: true,
|
prefer_port_mapping: true,
|
||||||
|
transaction_id: None,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
@@ -505,11 +507,10 @@ where
|
|||||||
SendPunchPacketBothEasySym {
|
SendPunchPacketBothEasySym {
|
||||||
transaction_id: tid,
|
transaction_id: tid,
|
||||||
public_ip: my_public_ip,
|
public_ip: my_public_ip,
|
||||||
dst_port_num: if me_is_incremental {
|
dst_port_num: apply_peer_easy_sym_port_offset(
|
||||||
cur_mapped_addr.port().saturating_add(DST_PORT_OFFSET)
|
cur_mapped_addr.port(),
|
||||||
} else {
|
me_is_incremental,
|
||||||
cur_mapped_addr.port().saturating_sub(DST_PORT_OFFSET)
|
) as u32,
|
||||||
} as u32,
|
|
||||||
udp_socket_count: UDP_ARRAY_SIZE_FOR_BOTH_EASY_SYM as u32,
|
udp_socket_count: UDP_ARRAY_SIZE_FOR_BOTH_EASY_SYM as u32,
|
||||||
wait_time_ms: REMOTE_WAIT_TIME_MS as u32,
|
wait_time_ms: REMOTE_WAIT_TIME_MS as u32,
|
||||||
},
|
},
|
||||||
@@ -526,6 +527,7 @@ where
|
|||||||
.ok_or(anyhow::anyhow!("remote_mapped_addr is required"))?;
|
.ok_or(anyhow::anyhow!("remote_mapped_addr is required"))?;
|
||||||
|
|
||||||
let now = Instant::now();
|
let now = Instant::now();
|
||||||
|
let punch_packet = new_hole_punch_packet(tid, HOLE_PUNCH_PACKET_BODY_LEN).into_bytes();
|
||||||
remote_mapped_addr.set_port(apply_peer_easy_sym_port_offset(
|
remote_mapped_addr.set_port(apply_peer_easy_sym_port_offset(
|
||||||
remote_mapped_addr.port(),
|
remote_mapped_addr.port(),
|
||||||
peer_is_incremental,
|
peer_is_incremental,
|
||||||
@@ -538,10 +540,7 @@ where
|
|||||||
|
|
||||||
while now.elapsed().as_millis() < (REMOTE_WAIT_TIME_MS + 1000).into() {
|
while now.elapsed().as_millis() < (REMOTE_WAIT_TIME_MS + 1000).into() {
|
||||||
udp_array
|
udp_array
|
||||||
.send_with_all(
|
.send_with_all(&punch_packet, remote_mapped_addr)
|
||||||
&new_hole_punch_packet(tid, HOLE_PUNCH_PACKET_BODY_LEN).into_bytes(),
|
|
||||||
remote_mapped_addr,
|
|
||||||
)
|
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
crate::foundation::time::sleep(std::time::Duration::from_millis(100)).await;
|
crate::foundation::time::sleep(std::time::Duration::from_millis(100)).await;
|
||||||
@@ -563,17 +562,25 @@ where
|
|||||||
);
|
);
|
||||||
|
|
||||||
for _ in 0..2 {
|
for _ in 0..2 {
|
||||||
match self
|
// The peer may still be receiving raw HolePunch packets. Keep
|
||||||
|
// probing this candidate until it switches to a session listener.
|
||||||
|
send_from_local(socket.socket.as_ref(), &punch_packet, remote_mapped_addr).await?;
|
||||||
|
let connect = self
|
||||||
.runtime
|
.runtime
|
||||||
.connect_with_socket(socket.socket.clone(), remote_mapped_addr)
|
.connect_with_socket(socket.socket.clone(), remote_mapped_addr);
|
||||||
.await
|
tokio::pin!(connect);
|
||||||
{
|
loop {
|
||||||
Ok(socket) => {
|
tokio::select! {
|
||||||
return Ok(Some(socket));
|
result = &mut connect => {
|
||||||
}
|
match result {
|
||||||
Err(e) => {
|
Ok(socket) => return Ok(Some(socket)),
|
||||||
tracing::error!(?e, "failed to connect with socket");
|
Err(e) => tracing::error!(?e, "failed to connect with socket"),
|
||||||
continue;
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
_ = crate::foundation::time::sleep(std::time::Duration::from_millis(200)) => {
|
||||||
|
send_from_local(socket.socket.as_ref(), &punch_packet, remote_mapped_addr).await?;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -614,6 +621,11 @@ mod tests {
|
|||||||
|
|
||||||
struct MockSocket {
|
struct MockSocket {
|
||||||
local_addr: SocketAddr,
|
local_addr: SocketAddr,
|
||||||
|
incoming: std::sync::Mutex<std::collections::VecDeque<(Vec<u8>, SocketAddr)>>,
|
||||||
|
incoming_notify: tokio::sync::Notify,
|
||||||
|
sent: std::sync::Mutex<Vec<(Vec<u8>, SocketAddr)>>,
|
||||||
|
drop_syn_packets: AtomicUsize,
|
||||||
|
required_punch_packets: AtomicUsize,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
@@ -622,12 +634,48 @@ mod tests {
|
|||||||
Ok(self.local_addr)
|
Ok(self.local_addr)
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn send_to(&self, _data: &[u8], _addr: SocketAddr) -> io::Result<usize> {
|
async fn send_to(&self, data: &[u8], addr: SocketAddr) -> io::Result<usize> {
|
||||||
Ok(0)
|
self.sent.lock().unwrap().push((data.to_vec(), addr));
|
||||||
|
if crate::packet::hole_punch_packet_tid(data, HOLE_PUNCH_PACKET_BODY_LEN).is_some()
|
||||||
|
&& self.required_punch_packets.load(Ordering::Relaxed) != 0
|
||||||
|
{
|
||||||
|
self.required_punch_packets.fetch_sub(1, Ordering::Relaxed);
|
||||||
|
self.incoming
|
||||||
|
.lock()
|
||||||
|
.unwrap()
|
||||||
|
.push_back((data.to_vec(), addr));
|
||||||
|
self.incoming_notify.notify_one();
|
||||||
|
}
|
||||||
|
if data.len() == 16 && data[4] == crate::packet::UdpPacketType::Syn as u8 {
|
||||||
|
if self.required_punch_packets.load(Ordering::Relaxed) != 0 {
|
||||||
|
return Ok(data.len());
|
||||||
|
}
|
||||||
|
if self.drop_syn_packets.load(Ordering::Relaxed) != 0 {
|
||||||
|
self.drop_syn_packets.fetch_sub(1, Ordering::Relaxed);
|
||||||
|
return Ok(data.len());
|
||||||
|
}
|
||||||
|
let conn_id = u32::from_le_bytes(data[..4].try_into().unwrap());
|
||||||
|
let magic = u64::from_le_bytes(data[8..].try_into().unwrap());
|
||||||
|
self.incoming.lock().unwrap().push_back((
|
||||||
|
crate::socket::udp::new_sack_packet(conn_id, magic)
|
||||||
|
.into_bytes()
|
||||||
|
.to_vec(),
|
||||||
|
addr,
|
||||||
|
));
|
||||||
|
self.incoming_notify.notify_one();
|
||||||
|
}
|
||||||
|
Ok(data.len())
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn recv_from(&self, _buf: &mut [u8]) -> io::Result<(usize, SocketAddr)> {
|
async fn recv_from(&self, buf: &mut [u8]) -> io::Result<(usize, SocketAddr)> {
|
||||||
std::future::pending().await
|
loop {
|
||||||
|
if let Some((data, addr)) = self.incoming.lock().unwrap().pop_front() {
|
||||||
|
let len = data.len().min(buf.len());
|
||||||
|
buf[..len].copy_from_slice(&data[..len]);
|
||||||
|
return Ok((len, addr));
|
||||||
|
}
|
||||||
|
self.incoming_notify.notified().await;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -635,6 +683,9 @@ mod tests {
|
|||||||
bind_count: AtomicUsize,
|
bind_count: AtomicUsize,
|
||||||
resolve_count: AtomicUsize,
|
resolve_count: AtomicUsize,
|
||||||
bind_options: tokio::sync::Mutex<Vec<UdpBindOptions>>,
|
bind_options: tokio::sync::Mutex<Vec<UdpBindOptions>>,
|
||||||
|
sockets: std::sync::Mutex<Vec<std::sync::Weak<MockSocket>>>,
|
||||||
|
drop_syn_packets: usize,
|
||||||
|
required_punch_packets: usize,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl MockRuntime {
|
impl MockRuntime {
|
||||||
@@ -643,6 +694,9 @@ mod tests {
|
|||||||
bind_count: AtomicUsize::new(0),
|
bind_count: AtomicUsize::new(0),
|
||||||
resolve_count: AtomicUsize::new(0),
|
resolve_count: AtomicUsize::new(0),
|
||||||
bind_options: tokio::sync::Mutex::new(Vec::new()),
|
bind_options: tokio::sync::Mutex::new(Vec::new()),
|
||||||
|
sockets: std::sync::Mutex::new(Vec::new()),
|
||||||
|
drop_syn_packets: 0,
|
||||||
|
required_punch_packets: 0,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -654,18 +708,26 @@ mod tests {
|
|||||||
async fn bind_udp(&self, options: UdpBindOptions) -> anyhow::Result<Arc<Self::Socket>> {
|
async fn bind_udp(&self, options: UdpBindOptions) -> anyhow::Result<Arc<Self::Socket>> {
|
||||||
self.bind_options.lock().await.push(options);
|
self.bind_options.lock().await.push(options);
|
||||||
let bind_idx = self.bind_count.fetch_add(1, Ordering::Relaxed);
|
let bind_idx = self.bind_count.fetch_add(1, Ordering::Relaxed);
|
||||||
Ok(Arc::new(MockSocket {
|
let socket = Arc::new(MockSocket {
|
||||||
local_addr: SocketAddr::from(([127, 0, 0, 1], 10000 + bind_idx as u16)),
|
local_addr: SocketAddr::from(([127, 0, 0, 1], 10000 + bind_idx as u16)),
|
||||||
}))
|
incoming: std::sync::Mutex::new(std::collections::VecDeque::new()),
|
||||||
|
incoming_notify: tokio::sync::Notify::new(),
|
||||||
|
sent: std::sync::Mutex::new(Vec::new()),
|
||||||
|
drop_syn_packets: AtomicUsize::new(self.drop_syn_packets),
|
||||||
|
required_punch_packets: AtomicUsize::new(self.required_punch_packets),
|
||||||
|
});
|
||||||
|
self.sockets.lock().unwrap().push(Arc::downgrade(&socket));
|
||||||
|
Ok(socket)
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn resolve_udp_public_addr(
|
async fn resolve_udp_public_addr(
|
||||||
&self,
|
&self,
|
||||||
_socket: Arc<Self::Socket>,
|
socket: Arc<Self::Socket>,
|
||||||
) -> anyhow::Result<super::super::UdpResolvedPublicAddr> {
|
) -> anyhow::Result<super::super::UdpResolvedPublicAddr> {
|
||||||
|
assert_eq!(socket.local_addr()?.port(), 10000);
|
||||||
self.resolve_count.fetch_add(1, Ordering::Relaxed);
|
self.resolve_count.fetch_add(1, Ordering::Relaxed);
|
||||||
Ok(super::super::UdpResolvedPublicAddr {
|
Ok(super::super::UdpResolvedPublicAddr {
|
||||||
mapped_addr: SocketAddr::from(([203, 0, 113, 1], 10000)),
|
mapped_addr: SocketAddr::from(([203, 0, 113, 1], 40000)),
|
||||||
port_mapping_lease: None,
|
port_mapping_lease: None,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -686,10 +748,12 @@ mod tests {
|
|||||||
|
|
||||||
async fn connect_with_socket(
|
async fn connect_with_socket(
|
||||||
&self,
|
&self,
|
||||||
_socket: Arc<Self::Socket>,
|
socket: Arc<Self::Socket>,
|
||||||
_remote: SocketAddr,
|
remote: SocketAddr,
|
||||||
) -> anyhow::Result<UdpPunchSocket> {
|
) -> anyhow::Result<UdpPunchSocket> {
|
||||||
unimplemented!("not used by cone client tests")
|
let layer = Arc::new(crate::socket::udp::UdpSessionLayer::new(socket));
|
||||||
|
let session = layer.connect(remote).await?;
|
||||||
|
Ok(UdpPunchSocket::new(session, remote, layer))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -797,6 +861,8 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
struct RecordingSignaling {
|
struct RecordingSignaling {
|
||||||
|
listener_requests: tokio::sync::Mutex<Vec<SelectPunchListener>>,
|
||||||
|
confirm_transaction_id: bool,
|
||||||
easy_requests: tokio::sync::Mutex<Vec<SendPunchPacketEasySym>>,
|
easy_requests: tokio::sync::Mutex<Vec<SendPunchPacketEasySym>>,
|
||||||
hard_requests: tokio::sync::Mutex<Vec<SendPunchPacketHardSym>>,
|
hard_requests: tokio::sync::Mutex<Vec<SendPunchPacketHardSym>>,
|
||||||
both_requests: tokio::sync::Mutex<Vec<SendPunchPacketBothEasySym>>,
|
both_requests: tokio::sync::Mutex<Vec<SendPunchPacketBothEasySym>>,
|
||||||
@@ -804,9 +870,100 @@ mod tests {
|
|||||||
next_port_index: u32,
|
next_port_index: u32,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn cone_connects_without_reverse_hole_punch_and_retries_dropped_syn() {
|
||||||
|
for (drop_syn_packets, confirm_transaction_id) in
|
||||||
|
[(0, true), (1, true), (0, false), (1, false)]
|
||||||
|
{
|
||||||
|
let mut runtime = MockRuntime::new();
|
||||||
|
runtime.drop_syn_packets = drop_syn_packets;
|
||||||
|
let runtime = Arc::new(runtime);
|
||||||
|
let mut signaling = RecordingSignaling::new(0);
|
||||||
|
signaling.confirm_transaction_id = confirm_transaction_id;
|
||||||
|
let signaling = Arc::new(signaling);
|
||||||
|
let result = tokio::time::timeout(
|
||||||
|
std::time::Duration::from_secs(1),
|
||||||
|
punch_cone_to_cone(runtime.clone(), signaling.clone(), 2),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.unwrap()
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(runtime.bind_count.load(Ordering::Relaxed), 1);
|
||||||
|
assert_eq!(runtime.resolve_count.load(Ordering::Relaxed), 1);
|
||||||
|
let tid = signaling.listener_requests.lock().await[0]
|
||||||
|
.transaction_id
|
||||||
|
.unwrap();
|
||||||
|
let socket = runtime.sockets.lock().unwrap()[0].upgrade().unwrap();
|
||||||
|
let sent = socket.sent.lock().unwrap();
|
||||||
|
let syns = sent
|
||||||
|
.iter()
|
||||||
|
.filter(|(packet, _)| packet[4] == crate::packet::UdpPacketType::Syn as u8)
|
||||||
|
.count();
|
||||||
|
assert_eq!(syns, drop_syn_packets + 1);
|
||||||
|
assert!(sent.iter().any(|(packet, _)| {
|
||||||
|
crate::packet::hole_punch_packet_tid(packet, HOLE_PUNCH_PACKET_BODY_LEN)
|
||||||
|
== Some(tid)
|
||||||
|
}));
|
||||||
|
assert!(
|
||||||
|
sent.iter()
|
||||||
|
.all(|(_, addr)| *addr == SocketAddr::from(([127, 0, 0, 1], 30000)))
|
||||||
|
);
|
||||||
|
drop(sent);
|
||||||
|
drop(result);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn cancelling_cone_connect_releases_socket_and_receive_task() {
|
||||||
|
let mut runtime = MockRuntime::new();
|
||||||
|
runtime.drop_syn_packets = usize::MAX;
|
||||||
|
let runtime = Arc::new(runtime);
|
||||||
|
let task_runtime = runtime.clone();
|
||||||
|
let task = tokio::spawn(async move {
|
||||||
|
punch_cone_to_cone(task_runtime, Arc::new(RecordingSignaling::new(0)), 2).await
|
||||||
|
});
|
||||||
|
let socket = tokio::time::timeout(std::time::Duration::from_secs(1), async {
|
||||||
|
loop {
|
||||||
|
let socket = runtime
|
||||||
|
.sockets
|
||||||
|
.lock()
|
||||||
|
.unwrap()
|
||||||
|
.first()
|
||||||
|
.and_then(std::sync::Weak::upgrade);
|
||||||
|
if let Some(socket) = socket
|
||||||
|
&& socket
|
||||||
|
.sent
|
||||||
|
.lock()
|
||||||
|
.unwrap()
|
||||||
|
.iter()
|
||||||
|
.any(|(packet, _)| packet[4] == crate::packet::UdpPacketType::Syn as u8)
|
||||||
|
{
|
||||||
|
break socket;
|
||||||
|
}
|
||||||
|
tokio::task::yield_now().await;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let weak = Arc::downgrade(&socket);
|
||||||
|
drop(socket);
|
||||||
|
task.abort();
|
||||||
|
assert!(task.await.unwrap_err().is_cancelled());
|
||||||
|
tokio::time::timeout(std::time::Duration::from_secs(1), async {
|
||||||
|
while weak.upgrade().is_some() {
|
||||||
|
tokio::task::yield_now().await;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
impl RecordingSignaling {
|
impl RecordingSignaling {
|
||||||
fn new(next_port_index: u32) -> Self {
|
fn new(next_port_index: u32) -> Self {
|
||||||
Self {
|
Self {
|
||||||
|
listener_requests: tokio::sync::Mutex::new(Vec::new()),
|
||||||
|
confirm_transaction_id: true,
|
||||||
easy_requests: tokio::sync::Mutex::new(Vec::new()),
|
easy_requests: tokio::sync::Mutex::new(Vec::new()),
|
||||||
hard_requests: tokio::sync::Mutex::new(Vec::new()),
|
hard_requests: tokio::sync::Mutex::new(Vec::new()),
|
||||||
both_requests: tokio::sync::Mutex::new(Vec::new()),
|
both_requests: tokio::sync::Mutex::new(Vec::new()),
|
||||||
@@ -832,10 +989,16 @@ mod tests {
|
|||||||
async fn select_punch_listener(
|
async fn select_punch_listener(
|
||||||
&self,
|
&self,
|
||||||
_dst_peer_id: PeerId,
|
_dst_peer_id: PeerId,
|
||||||
_request: SelectPunchListener,
|
request: SelectPunchListener,
|
||||||
) -> Result<super::super::SelectPunchListenerResponse, UdpHolePunchSignalError> {
|
) -> Result<super::super::SelectPunchListenerResponse, UdpHolePunchSignalError> {
|
||||||
|
let transaction_id = self
|
||||||
|
.confirm_transaction_id
|
||||||
|
.then_some(request.transaction_id)
|
||||||
|
.flatten();
|
||||||
|
self.listener_requests.lock().await.push(request);
|
||||||
Ok(super::super::SelectPunchListenerResponse {
|
Ok(super::super::SelectPunchListenerResponse {
|
||||||
listener_mapped_addr: SocketAddr::from(([127, 0, 0, 1], 30000)),
|
listener_mapped_addr: SocketAddr::from(([127, 0, 0, 1], 30000)),
|
||||||
|
transaction_id,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -910,6 +1073,18 @@ mod tests {
|
|||||||
let hard_requests = signaling.hard_requests.lock().await;
|
let hard_requests = signaling.hard_requests.lock().await;
|
||||||
assert_eq!(hard_requests.len(), 1);
|
assert_eq!(hard_requests.len(), 1);
|
||||||
assert_eq!(last_port_idx, 42);
|
assert_eq!(last_port_idx, 42);
|
||||||
|
drop(hard_requests);
|
||||||
|
drop(easy_requests);
|
||||||
|
|
||||||
|
client
|
||||||
|
.do_hole_punching(2, 4, &mut last_port_idx, NatType::SymmetricEasyInc.into())
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(stun.port_mapping_count.load(Ordering::Relaxed), 2);
|
||||||
|
assert_eq!(
|
||||||
|
runtime.bind_count.load(Ordering::Relaxed),
|
||||||
|
UDP_ARRAY_SIZE_FOR_HARD_SYM
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
@@ -944,9 +1119,104 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn both_easy_sym_port_offset_preserves_old_proto_cast_semantics() {
|
fn both_easy_sym_port_offset_stays_in_valid_port_range() {
|
||||||
assert_eq!(apply_peer_easy_sym_port_offset(65530, true), 14);
|
for (base, incremental, expected) in [
|
||||||
assert_eq!(apply_peer_easy_sym_port_offset(10, false), 0);
|
(10000, true, 10020),
|
||||||
|
(10000, false, 9980),
|
||||||
|
(65516, true, 65535),
|
||||||
|
(65530, true, 65535),
|
||||||
|
(65535, true, 65535),
|
||||||
|
(20, false, 1),
|
||||||
|
(10, false, 1),
|
||||||
|
(1, false, 1),
|
||||||
|
] {
|
||||||
|
assert_eq!(apply_peer_easy_sym_port_offset(base, incremental), expected);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn sym_to_cone_replenishes_candidates_after_connected_socket_is_dropped() {
|
||||||
|
let runtime = Arc::new(MockRuntime::new());
|
||||||
|
let client = UdpSymToConePunchClient::new(
|
||||||
|
runtime.clone(),
|
||||||
|
Arc::new(RecordingSignaling::new(0)),
|
||||||
|
Arc::new(MockStunInfoProvider::default()),
|
||||||
|
);
|
||||||
|
let array = client.prepare_udp_array().await.unwrap();
|
||||||
|
let remote = SocketAddr::from(([203, 0, 113, 1], 30000));
|
||||||
|
|
||||||
|
for tid in 0..=UDP_ARRAY_SIZE_FOR_HARD_SYM as u32 {
|
||||||
|
array.add_interest_tid(tid);
|
||||||
|
let socket = runtime
|
||||||
|
.sockets
|
||||||
|
.lock()
|
||||||
|
.unwrap()
|
||||||
|
.last()
|
||||||
|
.unwrap()
|
||||||
|
.upgrade()
|
||||||
|
.unwrap();
|
||||||
|
socket.incoming.lock().unwrap().push_back((
|
||||||
|
new_hole_punch_packet(tid, HOLE_PUNCH_PACKET_BODY_LEN)
|
||||||
|
.into_bytes()
|
||||||
|
.to_vec(),
|
||||||
|
remote,
|
||||||
|
));
|
||||||
|
socket.incoming_notify.notify_one();
|
||||||
|
drop(socket);
|
||||||
|
let punched = tokio::time::timeout(std::time::Duration::from_secs(1), async {
|
||||||
|
loop {
|
||||||
|
if let Some(socket) = array.try_fetch_punched_socket(tid) {
|
||||||
|
break socket;
|
||||||
|
}
|
||||||
|
tokio::task::yield_now().await;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
array.remove_interest_tid(tid);
|
||||||
|
let connected = runtime
|
||||||
|
.connect_with_socket(punched.socket, remote)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
// Model a successful UDP handshake followed by failed peer admission.
|
||||||
|
drop(connected);
|
||||||
|
|
||||||
|
assert!(Arc::ptr_eq(
|
||||||
|
&array,
|
||||||
|
&client.prepare_udp_array().await.unwrap()
|
||||||
|
));
|
||||||
|
assert_eq!(
|
||||||
|
runtime.bind_count.load(Ordering::Relaxed),
|
||||||
|
UDP_ARRAY_SIZE_FOR_HARD_SYM + tid as usize + 1,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn both_easy_sym_keeps_probing_hit_until_remote_listener_is_ready() {
|
||||||
|
let mut runtime = MockRuntime::new();
|
||||||
|
// Initial probes produce a reverse hit, but the remote raw receiver
|
||||||
|
// needs more probes before it can accept SYN as a session listener.
|
||||||
|
runtime.required_punch_packets = 6;
|
||||||
|
let runtime = Arc::new(runtime);
|
||||||
|
let client = UdpBothEasySymPunchClient::new(
|
||||||
|
runtime,
|
||||||
|
Arc::new(RecordingSignaling::new(0)),
|
||||||
|
Arc::new(MockStunInfoProvider::default()),
|
||||||
|
);
|
||||||
|
let result = tokio::time::timeout(
|
||||||
|
std::time::Duration::from_secs(2),
|
||||||
|
client.do_hole_punching(
|
||||||
|
2,
|
||||||
|
NatType::SymmetricEasyInc.into(),
|
||||||
|
NatType::SymmetricEasyInc.into(),
|
||||||
|
&mut false,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.unwrap();
|
||||||
|
assert!(result.is_some());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
|
|||||||
@@ -56,6 +56,7 @@ fn select_listener_request_to_rpc(request: SelectPunchListener) -> SelectPunchLi
|
|||||||
SelectPunchListenerRequest {
|
SelectPunchListenerRequest {
|
||||||
force_new: request.force_new,
|
force_new: request.force_new,
|
||||||
prefer_port_mapping: request.prefer_port_mapping,
|
prefer_port_mapping: request.prefer_port_mapping,
|
||||||
|
transaction_id: request.transaction_id,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -63,6 +64,7 @@ fn select_listener_request_from_rpc(input: SelectPunchListenerRequest) -> Select
|
|||||||
SelectPunchListener {
|
SelectPunchListener {
|
||||||
force_new: input.force_new,
|
force_new: input.force_new,
|
||||||
prefer_port_mapping: input.prefer_port_mapping,
|
prefer_port_mapping: input.prefer_port_mapping,
|
||||||
|
transaction_id: input.transaction_id,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -70,6 +72,7 @@ fn select_listener_response_from_rpc(
|
|||||||
response: SelectPunchListenerResponse,
|
response: SelectPunchListenerResponse,
|
||||||
) -> Result<CoreSelectPunchListenerResponse, UdpHolePunchSignalError> {
|
) -> Result<CoreSelectPunchListenerResponse, UdpHolePunchSignalError> {
|
||||||
Ok(CoreSelectPunchListenerResponse {
|
Ok(CoreSelectPunchListenerResponse {
|
||||||
|
transaction_id: response.transaction_id,
|
||||||
listener_mapped_addr: SocketAddr::from(
|
listener_mapped_addr: SocketAddr::from(
|
||||||
response
|
response
|
||||||
.listener_mapped_addr
|
.listener_mapped_addr
|
||||||
@@ -83,6 +86,7 @@ fn select_listener_response_to_rpc(
|
|||||||
) -> SelectPunchListenerResponse {
|
) -> SelectPunchListenerResponse {
|
||||||
SelectPunchListenerResponse {
|
SelectPunchListenerResponse {
|
||||||
listener_mapped_addr: Some(response.listener_mapped_addr.into()),
|
listener_mapped_addr: Some(response.listener_mapped_addr.into()),
|
||||||
|
transaction_id: response.transaction_id,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -553,8 +557,62 @@ where
|
|||||||
mod tests {
|
mod tests {
|
||||||
use std::{future, net::Ipv4Addr, time::Duration};
|
use std::{future, net::Ipv4Addr, time::Duration};
|
||||||
|
|
||||||
|
use prost::Message;
|
||||||
|
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
|
#[derive(Clone, PartialEq, Message)]
|
||||||
|
struct LegacySelectPunchListenerRequest {
|
||||||
|
#[prost(bool, tag = "1")]
|
||||||
|
force_new: bool,
|
||||||
|
#[prost(bool, tag = "2")]
|
||||||
|
prefer_port_mapping: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, PartialEq, Message)]
|
||||||
|
struct LegacySelectPunchListenerResponse {
|
||||||
|
#[prost(message, optional, tag = "1")]
|
||||||
|
listener_mapped_addr: Option<crate::proto::common::SocketAddr>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn listener_registration_fields_are_compatible_with_legacy_wire_messages() {
|
||||||
|
let request = select_listener_request_to_rpc(SelectPunchListener {
|
||||||
|
force_new: true,
|
||||||
|
prefer_port_mapping: false,
|
||||||
|
transaction_id: Some(0),
|
||||||
|
});
|
||||||
|
assert_eq!(
|
||||||
|
SelectPunchListenerRequest::decode(request.encode_to_vec().as_slice())
|
||||||
|
.unwrap()
|
||||||
|
.transaction_id,
|
||||||
|
Some(0)
|
||||||
|
);
|
||||||
|
let legacy_request =
|
||||||
|
LegacySelectPunchListenerRequest::decode(request.encode_to_vec().as_slice()).unwrap();
|
||||||
|
assert!(legacy_request.force_new);
|
||||||
|
let request =
|
||||||
|
SelectPunchListenerRequest::decode(legacy_request.encode_to_vec().as_slice()).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
select_listener_request_from_rpc(request).transaction_id,
|
||||||
|
None
|
||||||
|
);
|
||||||
|
|
||||||
|
let mapped_addr = "198.51.100.1:31001".parse().unwrap();
|
||||||
|
let response = select_listener_response_to_rpc(CoreSelectPunchListenerResponse {
|
||||||
|
listener_mapped_addr: mapped_addr,
|
||||||
|
transaction_id: Some(0),
|
||||||
|
});
|
||||||
|
let legacy_response =
|
||||||
|
LegacySelectPunchListenerResponse::decode(response.encode_to_vec().as_slice()).unwrap();
|
||||||
|
let response =
|
||||||
|
SelectPunchListenerResponse::decode(legacy_response.encode_to_vec().as_slice())
|
||||||
|
.unwrap();
|
||||||
|
let response = select_listener_response_from_rpc(response).unwrap();
|
||||||
|
assert_eq!(response.listener_mapped_addr, mapped_addr);
|
||||||
|
assert_eq!(response.transaction_id, None);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn outbound_rpc_controllers_preserve_timeouts() {
|
fn outbound_rpc_controllers_preserve_timeouts() {
|
||||||
assert_eq!(cone_controller().timeout_ms, 4000);
|
assert_eq!(cone_controller().timeout_ms, 4000);
|
||||||
@@ -568,14 +626,17 @@ mod tests {
|
|||||||
let domain_request = SelectPunchListener {
|
let domain_request = SelectPunchListener {
|
||||||
force_new: true,
|
force_new: true,
|
||||||
prefer_port_mapping: false,
|
prefer_port_mapping: false,
|
||||||
|
transaction_id: Some(0),
|
||||||
};
|
};
|
||||||
let rpc_request = select_listener_request_to_rpc(domain_request.clone());
|
let rpc_request = select_listener_request_to_rpc(domain_request.clone());
|
||||||
assert!(rpc_request.force_new);
|
assert!(rpc_request.force_new);
|
||||||
assert!(!rpc_request.prefer_port_mapping);
|
assert!(!rpc_request.prefer_port_mapping);
|
||||||
|
assert_eq!(rpc_request.transaction_id, Some(0));
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
select_listener_request_from_rpc(SelectPunchListenerRequest {
|
select_listener_request_from_rpc(SelectPunchListenerRequest {
|
||||||
force_new: true,
|
force_new: true,
|
||||||
prefer_port_mapping: false,
|
prefer_port_mapping: false,
|
||||||
|
transaction_id: Some(0),
|
||||||
}),
|
}),
|
||||||
domain_request
|
domain_request
|
||||||
);
|
);
|
||||||
@@ -583,6 +644,7 @@ mod tests {
|
|||||||
let mapped_addr: SocketAddr = "198.51.100.1:31001".parse().unwrap();
|
let mapped_addr: SocketAddr = "198.51.100.1:31001".parse().unwrap();
|
||||||
let core_response = CoreSelectPunchListenerResponse {
|
let core_response = CoreSelectPunchListenerResponse {
|
||||||
listener_mapped_addr: mapped_addr,
|
listener_mapped_addr: mapped_addr,
|
||||||
|
transaction_id: Some(0),
|
||||||
};
|
};
|
||||||
let rpc_response = select_listener_response_to_rpc(core_response.clone());
|
let rpc_response = select_listener_response_to_rpc(core_response.clone());
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
|
|||||||
@@ -17,7 +17,10 @@ use crate::{
|
|||||||
peers::PeerConnectionOrigin,
|
peers::PeerConnectionOrigin,
|
||||||
socket::{
|
socket::{
|
||||||
ListenerConnectionCounter, SocketContext,
|
ListenerConnectionCounter, SocketContext,
|
||||||
udp::{UdpBindOptions, UdpSession, VirtualUdpSocket, VirtualUdpSocketFactory},
|
udp::{
|
||||||
|
UdpBindOptions, UdpHolePunchObserverRegistry, UdpSession, VirtualUdpSocket,
|
||||||
|
VirtualUdpSocketFactory,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
tunnel::Tunnel,
|
tunnel::Tunnel,
|
||||||
};
|
};
|
||||||
@@ -73,6 +76,7 @@ fn udp_url(addr: SocketAddr) -> url::Url {
|
|||||||
|
|
||||||
pub struct UdpPunchListener<S> {
|
pub struct UdpPunchListener<S> {
|
||||||
pub socket: Arc<S>,
|
pub socket: Arc<S>,
|
||||||
|
pub hole_punch_observers: UdpHolePunchObserverRegistry,
|
||||||
pub mapped_addr: SocketAddr,
|
pub mapped_addr: SocketAddr,
|
||||||
pub conn_counter: Arc<dyn ListenerConnectionCounter>,
|
pub conn_counter: Arc<dyn ListenerConnectionCounter>,
|
||||||
pub acceptor: Box<dyn UdpPunchAcceptor>,
|
pub acceptor: Box<dyn UdpPunchAcceptor>,
|
||||||
@@ -88,11 +92,13 @@ pub struct UdpResolvedPublicAddr {
|
|||||||
pub struct SelectPunchListener {
|
pub struct SelectPunchListener {
|
||||||
pub force_new: bool,
|
pub force_new: bool,
|
||||||
pub prefer_port_mapping: bool,
|
pub prefer_port_mapping: bool,
|
||||||
|
pub transaction_id: Option<u32>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
pub struct SelectPunchListenerResponse {
|
pub struct SelectPunchListenerResponse {
|
||||||
pub listener_mapped_addr: SocketAddr,
|
pub listener_mapped_addr: SocketAddr,
|
||||||
|
pub transaction_id: Option<u32>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ use std::{
|
|||||||
};
|
};
|
||||||
|
|
||||||
use anyhow::Context;
|
use anyhow::Context;
|
||||||
use crossbeam::atomic::AtomicCell;
|
use crossbeam_utils::atomic::AtomicCell;
|
||||||
use quanta::Instant;
|
use quanta::Instant;
|
||||||
use rand::{Rng, seq::SliceRandom as _};
|
use rand::{Rng, seq::SliceRandom as _};
|
||||||
use tokio::{
|
use tokio::{
|
||||||
@@ -35,6 +35,9 @@ use super::{
|
|||||||
};
|
};
|
||||||
|
|
||||||
const MAX_K1_FOR_RANDOM_HARD_SYM: u32 = 180;
|
const MAX_K1_FOR_RANDOM_HARD_SYM: u32 = 180;
|
||||||
|
// Selection precedes local socket/STUN preparation. Match the listener's
|
||||||
|
// selection lifetime so abandoned preparations expire without per-RPC tasks.
|
||||||
|
const PREPARED_CONE_PUNCH_TIMEOUT: Duration = Duration::from_secs(30);
|
||||||
|
|
||||||
pub struct SelectedUdpPunchListener<S> {
|
pub struct SelectedUdpPunchListener<S> {
|
||||||
/// Kept for listener-selection side effects and tests; production callers
|
/// Kept for listener-selection side effects and tests; production callers
|
||||||
@@ -160,19 +163,25 @@ where
|
|||||||
}
|
}
|
||||||
|
|
||||||
let base_port_num = request.base_port_num;
|
let base_port_num = request.base_port_num;
|
||||||
|
anyhow::ensure!(
|
||||||
|
(1..=u16::MAX as u32).contains(&base_port_num),
|
||||||
|
"send_punch_packet_easy_sym invalid base port"
|
||||||
|
);
|
||||||
let max_port_num = request.max_port_num.max(1);
|
let max_port_num = request.max_port_num.max(1);
|
||||||
let port_start = if request.is_incremental {
|
let port_start = if request.is_incremental {
|
||||||
base_port_num.saturating_add(1)
|
base_port_num.saturating_add(1)
|
||||||
} else {
|
} else {
|
||||||
base_port_num.saturating_sub(max_port_num)
|
base_port_num.saturating_sub(max_port_num).max(1)
|
||||||
};
|
};
|
||||||
let port_end = if request.is_incremental {
|
let port_end = if request.is_incremental {
|
||||||
base_port_num.saturating_add(max_port_num)
|
base_port_num
|
||||||
|
.saturating_add(max_port_num)
|
||||||
|
.min(u16::MAX as u32)
|
||||||
} else {
|
} else {
|
||||||
base_port_num.saturating_sub(1)
|
base_port_num.saturating_sub(1)
|
||||||
};
|
};
|
||||||
|
|
||||||
if port_end <= port_start {
|
if port_end < port_start {
|
||||||
anyhow::bail!("send_punch_packet_easy_sym invalid port range");
|
anyhow::bail!("send_punch_packet_easy_sym invalid port range");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -266,8 +275,16 @@ where
|
|||||||
UdpHolePunchSignalError::RemoteRejected("no listener available".into())
|
UdpHolePunchSignalError::RemoteRejected("no listener available".into())
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
|
if let Some(transaction_id) = request.transaction_id {
|
||||||
|
self.common
|
||||||
|
.prepare_hole_punch(&selected.mapped_addr, transaction_id)
|
||||||
|
.await
|
||||||
|
.map_err(Self::anyhow_to_signal_error)?;
|
||||||
|
}
|
||||||
|
|
||||||
Ok(SelectPunchListenerResponse {
|
Ok(SelectPunchListenerResponse {
|
||||||
listener_mapped_addr: selected.mapped_addr,
|
listener_mapped_addr: selected.mapped_addr,
|
||||||
|
transaction_id: request.transaction_id,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -276,17 +293,12 @@ where
|
|||||||
request: SendPunchPacketCone,
|
request: SendPunchPacketCone,
|
||||||
) -> Result<(), UdpHolePunchSignalError> {
|
) -> Result<(), UdpHolePunchSignalError> {
|
||||||
let _admission = self.admit().await?;
|
let _admission = self.admit().await?;
|
||||||
let listener = self
|
let (listener, observer) = self
|
||||||
.common
|
.common
|
||||||
.find_listener(&request.listener_mapped_addr)
|
.observe_hole_punch(&request.listener_mapped_addr, request.transaction_id)
|
||||||
.await
|
.await
|
||||||
.ok_or_else(|| {
|
.map_err(Self::anyhow_to_signal_error)?;
|
||||||
UdpHolePunchSignalError::RemoteRejected(
|
send_cone_hole_punch_packets(listener, &request, observer)
|
||||||
"send_punch_packet_for_cone failed to find listener".into(),
|
|
||||||
)
|
|
||||||
})?;
|
|
||||||
|
|
||||||
send_cone_hole_punch_packets(listener, &request)
|
|
||||||
.await
|
.await
|
||||||
.map_err(Self::anyhow_to_signal_error)
|
.map_err(Self::anyhow_to_signal_error)
|
||||||
}
|
}
|
||||||
@@ -386,6 +398,7 @@ where
|
|||||||
let mut index = 0;
|
let mut index = 0;
|
||||||
while index < listeners.len() {
|
while index < listeners.len() {
|
||||||
let listener = &listeners[index];
|
let listener = &listeners[index];
|
||||||
|
listener.hole_punch_observers.expire_prepared();
|
||||||
let active = listener.last_active_time.load().elapsed().as_secs() < 40
|
let active = listener.last_active_time.load().elapsed().as_secs() < 40
|
||||||
|| listener.last_select_time.load().elapsed().as_secs() < 30;
|
|| listener.last_select_time.load().elapsed().as_secs() < 30;
|
||||||
if active {
|
if active {
|
||||||
@@ -473,6 +486,36 @@ where
|
|||||||
Some(listener.get_socket())
|
Some(listener.get_socket())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn prepare_hole_punch(
|
||||||
|
&self,
|
||||||
|
addr: &SocketAddr,
|
||||||
|
transaction_id: u32,
|
||||||
|
) -> anyhow::Result<()> {
|
||||||
|
let listeners = self.listeners.lock().await;
|
||||||
|
let listener = listeners
|
||||||
|
.iter()
|
||||||
|
.find(|listener| listener.mapped_addr == *addr && listener.running.load())
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("failed to find hole-punch listener"))?;
|
||||||
|
listener
|
||||||
|
.hole_punch_observers
|
||||||
|
.prepare(transaction_id, PREPARED_CONE_PUNCH_TIMEOUT)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn observe_hole_punch(
|
||||||
|
&self,
|
||||||
|
addr: &SocketAddr,
|
||||||
|
transaction_id: u32,
|
||||||
|
) -> anyhow::Result<(Arc<R::Socket>, crate::socket::udp::UdpHolePunchObserver)> {
|
||||||
|
let listeners = self.listeners.lock().await;
|
||||||
|
let listener = listeners
|
||||||
|
.iter()
|
||||||
|
.find(|listener| listener.mapped_addr == *addr && listener.running.load())
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("send_punch_packet_for_cone failed to find listener"))?;
|
||||||
|
let observer = listener.hole_punch_observers.observe(transaction_id)?;
|
||||||
|
Ok((listener.get_socket(), observer))
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn select_listener(
|
pub async fn select_listener(
|
||||||
&self,
|
&self,
|
||||||
force_new_listener: bool,
|
force_new_listener: bool,
|
||||||
@@ -572,6 +615,7 @@ where
|
|||||||
|
|
||||||
struct UdpPunchListenerRecord<S> {
|
struct UdpPunchListenerRecord<S> {
|
||||||
socket: Arc<S>,
|
socket: Arc<S>,
|
||||||
|
hole_punch_observers: crate::socket::udp::UdpHolePunchObserverRegistry,
|
||||||
tasks: Mutex<JoinSet<()>>,
|
tasks: Mutex<JoinSet<()>>,
|
||||||
connection_tasks: Arc<Mutex<JoinSet<()>>>,
|
connection_tasks: Arc<Mutex<JoinSet<()>>>,
|
||||||
running: Arc<AtomicCell<bool>>,
|
running: Arc<AtomicCell<bool>>,
|
||||||
@@ -595,6 +639,7 @@ where
|
|||||||
{
|
{
|
||||||
let UdpPunchListener {
|
let UdpPunchListener {
|
||||||
socket,
|
socket,
|
||||||
|
hole_punch_observers,
|
||||||
mapped_addr,
|
mapped_addr,
|
||||||
conn_counter,
|
conn_counter,
|
||||||
mut acceptor,
|
mut acceptor,
|
||||||
@@ -646,6 +691,7 @@ where
|
|||||||
|
|
||||||
Self {
|
Self {
|
||||||
socket,
|
socket,
|
||||||
|
hole_punch_observers,
|
||||||
tasks: Mutex::new(tasks),
|
tasks: Mutex::new(tasks),
|
||||||
connection_tasks,
|
connection_tasks,
|
||||||
running,
|
running,
|
||||||
@@ -726,6 +772,7 @@ where
|
|||||||
pub async fn send_cone_hole_punch_packets<S>(
|
pub async fn send_cone_hole_punch_packets<S>(
|
||||||
udp: Arc<S>,
|
udp: Arc<S>,
|
||||||
request: &SendPunchPacketCone,
|
request: &SendPunchPacketCone,
|
||||||
|
mut observer: crate::socket::udp::UdpHolePunchObserver,
|
||||||
) -> anyhow::Result<()>
|
) -> anyhow::Result<()>
|
||||||
where
|
where
|
||||||
S: VirtualUdpSocket + 'static,
|
S: VirtualUdpSocket + 'static,
|
||||||
@@ -738,21 +785,47 @@ where
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
for _ in 0..request.packet_batch_count {
|
let udp = udp.as_ref();
|
||||||
tracing::info!(?request, "sending hole punching packet");
|
let send_batch = |dest_addr| async move {
|
||||||
|
|
||||||
for _ in 0..request.packet_count_per_batch {
|
for _ in 0..request.packet_count_per_batch {
|
||||||
let udp_packet =
|
let udp_packet =
|
||||||
new_hole_punch_packet(request.transaction_id, HOLE_PUNCH_PACKET_BODY_LEN);
|
new_hole_punch_packet(request.transaction_id, HOLE_PUNCH_PACKET_BODY_LEN);
|
||||||
if let Err(err) = udp
|
if let Err(err) = udp.send_to(&udp_packet.into_bytes(), dest_addr).await {
|
||||||
.send_to(&udp_packet.into_bytes(), request.dest_addr)
|
tracing::error!(
|
||||||
.await
|
?err,
|
||||||
{
|
?dest_addr,
|
||||||
tracing::error!(?err, "failed to send hole punch packet to dest addr");
|
"failed to send hole punch packet to dest addr"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
crate::foundation::time::sleep(Duration::from_millis(request.packet_interval_ms as u64))
|
};
|
||||||
.await;
|
let mut observed_addr = observer.source_addr();
|
||||||
|
for _ in 0..request.packet_batch_count {
|
||||||
|
tracing::info!(?request, "sending hole punching packet");
|
||||||
|
|
||||||
|
send_batch(request.dest_addr).await;
|
||||||
|
if let Some(addr) = observed_addr.filter(|addr| *addr != request.dest_addr) {
|
||||||
|
send_batch(addr).await;
|
||||||
|
}
|
||||||
|
let interval = crate::foundation::time::sleep(Duration::from_millis(
|
||||||
|
request.packet_interval_ms as u64,
|
||||||
|
));
|
||||||
|
tokio::pin!(interval);
|
||||||
|
if observed_addr.is_none() {
|
||||||
|
tokio::select! {
|
||||||
|
_ = &mut interval => {}
|
||||||
|
addr = observer.wait_for_source_addr() => {
|
||||||
|
let addr = addr?;
|
||||||
|
observed_addr = Some(addr);
|
||||||
|
if addr != request.dest_addr {
|
||||||
|
send_batch(addr).await;
|
||||||
|
}
|
||||||
|
interval.await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
interval.await;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -883,7 +956,6 @@ where
|
|||||||
|
|
||||||
if let Some(s) = udp_array.try_fetch_punched_socket(transaction_id) {
|
if let Some(s) = udp_array.try_fetch_punched_socket(transaction_id) {
|
||||||
tracing::info!(?s, ?transaction_id, "got punched socket in both easy sym");
|
tracing::info!(?s, ?transaction_id, "got punched socket in both easy sym");
|
||||||
assert!(Arc::strong_count(&s.socket) == 1);
|
|
||||||
let Some(port) = s.socket.local_addr().ok().map(|addr| addr.port()) else {
|
let Some(port) = s.socket.local_addr().ok().map(|addr| addr.port()) else {
|
||||||
tracing::warn!("failed to get local addr from punched socket");
|
tracing::warn!("failed to get local addr from punched socket");
|
||||||
continue;
|
continue;
|
||||||
@@ -971,6 +1043,8 @@ mod tests {
|
|||||||
local_addr: SocketAddr,
|
local_addr: SocketAddr,
|
||||||
sent: tokio::sync::Mutex<Vec<(Vec<u8>, SocketAddr)>>,
|
sent: tokio::sync::Mutex<Vec<(Vec<u8>, SocketAddr)>>,
|
||||||
fail_next_send: AtomicUsize,
|
fail_next_send: AtomicUsize,
|
||||||
|
incoming: StdMutex<VecDeque<(Vec<u8>, SocketAddr)>>,
|
||||||
|
incoming_notify: tokio::sync::Notify,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
@@ -988,8 +1062,15 @@ mod tests {
|
|||||||
Ok(data.len())
|
Ok(data.len())
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn recv_from(&self, _buf: &mut [u8]) -> io::Result<(usize, SocketAddr)> {
|
async fn recv_from(&self, buf: &mut [u8]) -> io::Result<(usize, SocketAddr)> {
|
||||||
std::future::pending().await
|
loop {
|
||||||
|
if let Some((data, addr)) = self.incoming.lock().unwrap().pop_front() {
|
||||||
|
let len = data.len().min(buf.len());
|
||||||
|
buf[..len].copy_from_slice(&data[..len]);
|
||||||
|
return Ok((len, addr));
|
||||||
|
}
|
||||||
|
self.incoming_notify.notified().await;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1006,6 +1087,7 @@ mod tests {
|
|||||||
|
|
||||||
struct MockAcceptor {
|
struct MockAcceptor {
|
||||||
sockets: VecDeque<UdpPunchSocket>,
|
sockets: VecDeque<UdpPunchSocket>,
|
||||||
|
_layer: Arc<crate::socket::udp::UdpSessionLayer<MockSocket>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
@@ -1039,6 +1121,8 @@ mod tests {
|
|||||||
local_addr: SocketAddr::from(([127, 0, 0, 1], 0)),
|
local_addr: SocketAddr::from(([127, 0, 0, 1], 0)),
|
||||||
sent: tokio::sync::Mutex::new(Vec::new()),
|
sent: tokio::sync::Mutex::new(Vec::new()),
|
||||||
fail_next_send: AtomicUsize::new(0),
|
fail_next_send: AtomicUsize::new(0),
|
||||||
|
incoming: StdMutex::new(VecDeque::new()),
|
||||||
|
incoming_notify: tokio::sync::Notify::new(),
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1130,16 +1214,22 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn listener(port: u16, sockets: Vec<UdpPunchSocket>) -> UdpPunchListener<MockSocket> {
|
fn listener(port: u16, sockets: Vec<UdpPunchSocket>) -> UdpPunchListener<MockSocket> {
|
||||||
|
let socket = Arc::new(MockSocket {
|
||||||
|
local_addr: SocketAddr::from(([127, 0, 0, 1], port)),
|
||||||
|
sent: tokio::sync::Mutex::new(Vec::new()),
|
||||||
|
fail_next_send: AtomicUsize::new(0),
|
||||||
|
incoming: StdMutex::new(VecDeque::new()),
|
||||||
|
incoming_notify: tokio::sync::Notify::new(),
|
||||||
|
});
|
||||||
|
let layer = Arc::new(crate::socket::udp::UdpSessionLayer::new(socket.clone()));
|
||||||
UdpPunchListener {
|
UdpPunchListener {
|
||||||
socket: Arc::new(MockSocket {
|
socket,
|
||||||
local_addr: SocketAddr::from(([127, 0, 0, 1], port)),
|
hole_punch_observers: layer.hole_punch_observers(),
|
||||||
sent: tokio::sync::Mutex::new(Vec::new()),
|
|
||||||
fail_next_send: AtomicUsize::new(0),
|
|
||||||
}),
|
|
||||||
mapped_addr: SocketAddr::from(([203, 0, 113, 1], port)),
|
mapped_addr: SocketAddr::from(([203, 0, 113, 1], port)),
|
||||||
conn_counter: Arc::new(MockCounter::default()),
|
conn_counter: Arc::new(MockCounter::default()),
|
||||||
acceptor: Box::new(MockAcceptor {
|
acceptor: Box::new(MockAcceptor {
|
||||||
sockets: sockets.into(),
|
sockets: sockets.into(),
|
||||||
|
_layer: layer,
|
||||||
}),
|
}),
|
||||||
port_mapping_lease: None,
|
port_mapping_lease: None,
|
||||||
}
|
}
|
||||||
@@ -1206,6 +1296,8 @@ mod tests {
|
|||||||
local_addr: SocketAddr::from(([127, 0, 0, 1], 10001)),
|
local_addr: SocketAddr::from(([127, 0, 0, 1], 10001)),
|
||||||
sent: tokio::sync::Mutex::new(Vec::new()),
|
sent: tokio::sync::Mutex::new(Vec::new()),
|
||||||
fail_next_send: AtomicUsize::new(0),
|
fail_next_send: AtomicUsize::new(0),
|
||||||
|
incoming: StdMutex::new(VecDeque::new()),
|
||||||
|
incoming_notify: tokio::sync::Notify::new(),
|
||||||
});
|
});
|
||||||
let remote_addr = SocketAddr::from(([127, 0, 0, 1], 20001));
|
let remote_addr = SocketAddr::from(([127, 0, 0, 1], 20001));
|
||||||
let session =
|
let session =
|
||||||
@@ -1231,12 +1323,248 @@ mod tests {
|
|||||||
assert_eq!(sink.server_tunnels.load(Ordering::Relaxed), 1);
|
assert_eq!(sink.server_tunnels.load(Ordering::Relaxed), 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn selected_listener_observes_first_packet_before_cone_rpc_arrives() {
|
||||||
|
let runtime = Arc::new(MockRuntime::new(vec![listener(10002, Vec::new())]));
|
||||||
|
let server = UdpHolePunchServer::new(
|
||||||
|
runtime,
|
||||||
|
mock_stun(),
|
||||||
|
Arc::new(MockSink::default()),
|
||||||
|
UdpSymPunchLock::default(),
|
||||||
|
);
|
||||||
|
server.start().await;
|
||||||
|
let selected = server
|
||||||
|
.select_punch_listener(SelectPunchListener {
|
||||||
|
force_new: false,
|
||||||
|
prefer_port_mapping: false,
|
||||||
|
transaction_id: Some(0),
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(selected.transaction_id, Some(0));
|
||||||
|
let socket = server
|
||||||
|
.common
|
||||||
|
.find_listener(&selected.listener_mapped_addr)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let source = SocketAddr::from(([198, 51, 100, 1], 33140));
|
||||||
|
socket.incoming.lock().unwrap().push_back((
|
||||||
|
new_hole_punch_packet(0, HOLE_PUNCH_PACKET_BODY_LEN)
|
||||||
|
.into_bytes()
|
||||||
|
.to_vec(),
|
||||||
|
source,
|
||||||
|
));
|
||||||
|
socket.incoming_notify.notify_one();
|
||||||
|
tokio::time::timeout(Duration::from_secs(1), async {
|
||||||
|
while !socket.incoming.lock().unwrap().is_empty() {
|
||||||
|
tokio::task::yield_now().await;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let dest_addr = SocketAddr::from(([198, 51, 100, 1], 33105));
|
||||||
|
server
|
||||||
|
.send_punch_packet_cone(SendPunchPacketCone {
|
||||||
|
listener_mapped_addr: selected.listener_mapped_addr,
|
||||||
|
dest_addr,
|
||||||
|
transaction_id: 0,
|
||||||
|
packet_count_per_batch: 2,
|
||||||
|
packet_batch_count: 1,
|
||||||
|
packet_interval_ms: 0,
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let sent = socket.sent.lock().await;
|
||||||
|
assert_eq!(sent.len(), 4);
|
||||||
|
assert_eq!(
|
||||||
|
sent.iter().filter(|(_, addr)| *addr == dest_addr).count(),
|
||||||
|
2
|
||||||
|
);
|
||||||
|
assert_eq!(sent.iter().filter(|(_, addr)| *addr == source).count(), 2);
|
||||||
|
drop(sent);
|
||||||
|
server.stop().await;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test(start_paused = true)]
|
||||||
|
async fn cleanup_expires_abandoned_listener_registration() {
|
||||||
|
let runtime = Arc::new(MockRuntime::new(vec![listener(10002, Vec::new())]));
|
||||||
|
let server = UdpHolePunchServer::new(
|
||||||
|
runtime,
|
||||||
|
mock_stun(),
|
||||||
|
Arc::new(MockSink::default()),
|
||||||
|
UdpSymPunchLock::default(),
|
||||||
|
);
|
||||||
|
server.start().await;
|
||||||
|
let selected = server
|
||||||
|
.select_punch_listener(SelectPunchListener {
|
||||||
|
force_new: false,
|
||||||
|
prefer_port_mapping: false,
|
||||||
|
transaction_id: Some(9),
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert!(
|
||||||
|
server
|
||||||
|
.common
|
||||||
|
.prepare_hole_punch(&selected.listener_mapped_addr, 9)
|
||||||
|
.await
|
||||||
|
.is_err()
|
||||||
|
);
|
||||||
|
// Let the existing periodic cleanup run as the paused clock advances.
|
||||||
|
crate::foundation::time::sleep(PREPARED_CONE_PUNCH_TIMEOUT + Duration::from_secs(5)).await;
|
||||||
|
server
|
||||||
|
.common
|
||||||
|
.prepare_hole_punch(&selected.listener_mapped_addr, 9)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
server.stop().await;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn cone_sender_adds_observed_source_during_last_batch_and_deduplicates() {
|
||||||
|
let runtime = Arc::new(MockRuntime::new(vec![listener(10002, Vec::new())]));
|
||||||
|
let server = UdpHolePunchServer::new(
|
||||||
|
runtime,
|
||||||
|
mock_stun(),
|
||||||
|
Arc::new(MockSink::default()),
|
||||||
|
UdpSymPunchLock::default(),
|
||||||
|
);
|
||||||
|
server.start().await;
|
||||||
|
let selected = server
|
||||||
|
.select_punch_listener(SelectPunchListener {
|
||||||
|
force_new: false,
|
||||||
|
prefer_port_mapping: false,
|
||||||
|
transaction_id: None,
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let socket = server
|
||||||
|
.common
|
||||||
|
.find_listener(&selected.listener_mapped_addr)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let request = SendPunchPacketCone {
|
||||||
|
listener_mapped_addr: selected.listener_mapped_addr,
|
||||||
|
dest_addr: SocketAddr::from(([198, 51, 100, 1], 33105)),
|
||||||
|
transaction_id: 9,
|
||||||
|
packet_count_per_batch: 2,
|
||||||
|
packet_batch_count: 1,
|
||||||
|
packet_interval_ms: 100,
|
||||||
|
};
|
||||||
|
for observed_addr in [
|
||||||
|
SocketAddr::from(([198, 51, 100, 1], 33140)),
|
||||||
|
request.dest_addr,
|
||||||
|
] {
|
||||||
|
socket.sent.lock().await.clear();
|
||||||
|
let send = server.send_punch_packet_cone(request.clone());
|
||||||
|
let observe = async {
|
||||||
|
loop {
|
||||||
|
if socket.sent.lock().await.len() == 2 {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
tokio::task::yield_now().await;
|
||||||
|
}
|
||||||
|
socket.incoming.lock().unwrap().push_back((
|
||||||
|
new_hole_punch_packet(request.transaction_id, HOLE_PUNCH_PACKET_BODY_LEN)
|
||||||
|
.into_bytes()
|
||||||
|
.to_vec(),
|
||||||
|
observed_addr,
|
||||||
|
));
|
||||||
|
socket.incoming_notify.notify_one();
|
||||||
|
};
|
||||||
|
let (result, _) = tokio::time::timeout(Duration::from_secs(2), async {
|
||||||
|
tokio::join!(send, observe)
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
result.unwrap();
|
||||||
|
let sent = socket.sent.lock().await;
|
||||||
|
let expected_count = if observed_addr == request.dest_addr {
|
||||||
|
2
|
||||||
|
} else {
|
||||||
|
4
|
||||||
|
};
|
||||||
|
assert_eq!(sent.len(), expected_count);
|
||||||
|
assert_eq!(
|
||||||
|
sent.iter()
|
||||||
|
.filter(|(_, addr)| *addr == request.dest_addr)
|
||||||
|
.count(),
|
||||||
|
2
|
||||||
|
);
|
||||||
|
for (packet, _) in sent.iter() {
|
||||||
|
assert_eq!(
|
||||||
|
crate::packet::hole_punch_packet_tid(packet, HOLE_PUNCH_PACKET_BODY_LEN),
|
||||||
|
Some(request.transaction_id)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if observed_addr != request.dest_addr {
|
||||||
|
assert_eq!(
|
||||||
|
sent.iter()
|
||||||
|
.filter(|(_, addr)| *addr == observed_addr)
|
||||||
|
.count(),
|
||||||
|
2
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let (_, observer) = server
|
||||||
|
.common
|
||||||
|
.observe_hole_punch(&request.listener_mapped_addr, request.transaction_id)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(observer.source_addr(), None);
|
||||||
|
drop(observer);
|
||||||
|
server.stop().await;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn cancelling_cone_sender_unregisters_observed_source() {
|
||||||
|
let socket = Arc::new(MockSocket {
|
||||||
|
local_addr: SocketAddr::from(([127, 0, 0, 1], 10002)),
|
||||||
|
sent: tokio::sync::Mutex::new(Vec::new()),
|
||||||
|
fail_next_send: AtomicUsize::new(0),
|
||||||
|
incoming: StdMutex::new(VecDeque::new()),
|
||||||
|
incoming_notify: tokio::sync::Notify::new(),
|
||||||
|
});
|
||||||
|
let layer = crate::socket::udp::UdpSessionLayer::new(socket.clone());
|
||||||
|
let request = SendPunchPacketCone {
|
||||||
|
listener_mapped_addr: SocketAddr::from(([203, 0, 113, 1], 10002)),
|
||||||
|
dest_addr: SocketAddr::from(([198, 51, 100, 1], 33105)),
|
||||||
|
transaction_id: 9,
|
||||||
|
packet_count_per_batch: 2,
|
||||||
|
packet_batch_count: 5,
|
||||||
|
packet_interval_ms: 400,
|
||||||
|
};
|
||||||
|
let observer = layer
|
||||||
|
.hole_punch_observers()
|
||||||
|
.observe(request.transaction_id)
|
||||||
|
.unwrap();
|
||||||
|
let send_socket = socket.clone();
|
||||||
|
let task = tokio::spawn(async move {
|
||||||
|
send_cone_hole_punch_packets(send_socket, &request, observer).await
|
||||||
|
});
|
||||||
|
while socket.sent.lock().await.is_empty() {
|
||||||
|
tokio::task::yield_now().await;
|
||||||
|
}
|
||||||
|
task.abort();
|
||||||
|
assert!(task.await.unwrap_err().is_cancelled());
|
||||||
|
assert_eq!(
|
||||||
|
layer
|
||||||
|
.hole_punch_observers()
|
||||||
|
.observe(9)
|
||||||
|
.unwrap()
|
||||||
|
.source_addr(),
|
||||||
|
None
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn cone_packet_sender_keeps_old_batch_shape() {
|
async fn cone_packet_sender_keeps_old_batch_shape() {
|
||||||
let socket = Arc::new(MockSocket {
|
let socket = Arc::new(MockSocket {
|
||||||
local_addr: SocketAddr::from(([127, 0, 0, 1], 10002)),
|
local_addr: SocketAddr::from(([127, 0, 0, 1], 10002)),
|
||||||
sent: tokio::sync::Mutex::new(Vec::new()),
|
sent: tokio::sync::Mutex::new(Vec::new()),
|
||||||
fail_next_send: AtomicUsize::new(0),
|
fail_next_send: AtomicUsize::new(0),
|
||||||
|
incoming: StdMutex::new(VecDeque::new()),
|
||||||
|
incoming_notify: tokio::sync::Notify::new(),
|
||||||
});
|
});
|
||||||
let request = SendPunchPacketCone {
|
let request = SendPunchPacketCone {
|
||||||
listener_mapped_addr: SocketAddr::from(([203, 0, 113, 1], 10002)),
|
listener_mapped_addr: SocketAddr::from(([203, 0, 113, 1], 10002)),
|
||||||
@@ -1247,7 +1575,12 @@ mod tests {
|
|||||||
packet_interval_ms: 0,
|
packet_interval_ms: 0,
|
||||||
};
|
};
|
||||||
|
|
||||||
send_cone_hole_punch_packets(socket.clone(), &request)
|
let layer = crate::socket::udp::UdpSessionLayer::new(socket.clone());
|
||||||
|
let observer = layer
|
||||||
|
.hole_punch_observers()
|
||||||
|
.observe(request.transaction_id)
|
||||||
|
.unwrap();
|
||||||
|
send_cone_hole_punch_packets(socket.clone(), &request, observer)
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
@@ -1262,6 +1595,8 @@ mod tests {
|
|||||||
local_addr: SocketAddr::from(([127, 0, 0, 1], 10004)),
|
local_addr: SocketAddr::from(([127, 0, 0, 1], 10004)),
|
||||||
sent: tokio::sync::Mutex::new(Vec::new()),
|
sent: tokio::sync::Mutex::new(Vec::new()),
|
||||||
fail_next_send: AtomicUsize::new(0),
|
fail_next_send: AtomicUsize::new(0),
|
||||||
|
incoming: StdMutex::new(VecDeque::new()),
|
||||||
|
incoming_notify: tokio::sync::Notify::new(),
|
||||||
});
|
});
|
||||||
let request = SendPunchPacketCone {
|
let request = SendPunchPacketCone {
|
||||||
listener_mapped_addr: SocketAddr::from(([203, 0, 113, 1], 10004)),
|
listener_mapped_addr: SocketAddr::from(([203, 0, 113, 1], 10004)),
|
||||||
@@ -1272,7 +1607,12 @@ mod tests {
|
|||||||
packet_interval_ms: 0,
|
packet_interval_ms: 0,
|
||||||
};
|
};
|
||||||
|
|
||||||
let err = send_cone_hole_punch_packets(socket.clone(), &request)
|
let layer = crate::socket::udp::UdpSessionLayer::new(socket.clone());
|
||||||
|
let observer = layer
|
||||||
|
.hole_punch_observers()
|
||||||
|
.observe(request.transaction_id)
|
||||||
|
.unwrap();
|
||||||
|
let err = send_cone_hole_punch_packets(socket.clone(), &request, observer)
|
||||||
.await
|
.await
|
||||||
.unwrap_err();
|
.unwrap_err();
|
||||||
|
|
||||||
@@ -1286,6 +1626,8 @@ mod tests {
|
|||||||
local_addr: SocketAddr::from(([127, 0, 0, 1], 10005)),
|
local_addr: SocketAddr::from(([127, 0, 0, 1], 10005)),
|
||||||
sent: tokio::sync::Mutex::new(Vec::new()),
|
sent: tokio::sync::Mutex::new(Vec::new()),
|
||||||
fail_next_send: AtomicUsize::new(1),
|
fail_next_send: AtomicUsize::new(1),
|
||||||
|
incoming: StdMutex::new(VecDeque::new()),
|
||||||
|
incoming_notify: tokio::sync::Notify::new(),
|
||||||
});
|
});
|
||||||
let request = SendPunchPacketCone {
|
let request = SendPunchPacketCone {
|
||||||
listener_mapped_addr: SocketAddr::from(([203, 0, 113, 1], 10005)),
|
listener_mapped_addr: SocketAddr::from(([203, 0, 113, 1], 10005)),
|
||||||
@@ -1296,7 +1638,12 @@ mod tests {
|
|||||||
packet_interval_ms: 0,
|
packet_interval_ms: 0,
|
||||||
};
|
};
|
||||||
|
|
||||||
send_cone_hole_punch_packets(socket.clone(), &request)
|
let layer = crate::socket::udp::UdpSessionLayer::new(socket.clone());
|
||||||
|
let observer = layer
|
||||||
|
.hole_punch_observers()
|
||||||
|
.observe(request.transaction_id)
|
||||||
|
.unwrap();
|
||||||
|
send_cone_hole_punch_packets(socket.clone(), &request, observer)
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
@@ -1304,12 +1651,96 @@ mod tests {
|
|||||||
assert_eq!(sent.len(), 1);
|
assert_eq!(sent.len(), 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn predictable_sender_uses_valid_boundary_ports() {
|
||||||
|
for (base_port_num, max_port_num, is_incremental, expected_ports) in [
|
||||||
|
(10000, 4, true, vec![10001, 10002, 10003, 10004]),
|
||||||
|
(10000, 4, false, vec![9996, 9997, 9998, 9999]),
|
||||||
|
(65534, 50, true, vec![65535]),
|
||||||
|
(2, 50, false, vec![1]),
|
||||||
|
(65535, 50, true, vec![]),
|
||||||
|
(1, 50, false, vec![]),
|
||||||
|
] {
|
||||||
|
let public_ips = vec![Ipv4Addr::new(198, 51, 100, 1)];
|
||||||
|
let listener = listener(10007, Vec::new());
|
||||||
|
let socket = listener.socket.clone();
|
||||||
|
let listener_mapped_addr = listener.mapped_addr;
|
||||||
|
let server = UdpHolePunchServer::new(
|
||||||
|
Arc::new(MockRuntime::new(vec![listener])),
|
||||||
|
mock_stun(),
|
||||||
|
Arc::new(MockSink::default()),
|
||||||
|
UdpSymPunchLock::default(),
|
||||||
|
);
|
||||||
|
server.common.select_listener(false, false).await.unwrap();
|
||||||
|
let result = server
|
||||||
|
.send_punch_packet_easy_sym_inner(SendPunchPacketEasySym {
|
||||||
|
listener_mapped_addr,
|
||||||
|
public_ips: public_ips.clone(),
|
||||||
|
transaction_id: 9,
|
||||||
|
base_port_num,
|
||||||
|
max_port_num,
|
||||||
|
is_incremental,
|
||||||
|
})
|
||||||
|
.await;
|
||||||
|
assert_eq!(result.is_ok(), !expected_ports.is_empty());
|
||||||
|
let sent = socket.sent.lock().await;
|
||||||
|
assert_eq!(sent.len(), expected_ports.len() * public_ips.len() * 3 * 2);
|
||||||
|
for ip in &public_ips {
|
||||||
|
for port in &expected_ports {
|
||||||
|
assert_eq!(
|
||||||
|
sent.iter()
|
||||||
|
.filter(|(_, addr)| *addr == SocketAddr::from((*ip, *port)))
|
||||||
|
.count(),
|
||||||
|
6
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn predictable_sender_keeps_packet_budget_with_multiple_public_ips() {
|
||||||
|
let listener = listener(10007, Vec::new());
|
||||||
|
let socket = listener.socket.clone();
|
||||||
|
let listener_mapped_addr = listener.mapped_addr;
|
||||||
|
let server = UdpHolePunchServer::new(
|
||||||
|
Arc::new(MockRuntime::new(vec![listener])),
|
||||||
|
mock_stun(),
|
||||||
|
Arc::new(MockSink::default()),
|
||||||
|
UdpSymPunchLock::default(),
|
||||||
|
);
|
||||||
|
server.common.select_listener(false, false).await.unwrap();
|
||||||
|
let public_ips = vec![
|
||||||
|
Ipv4Addr::new(198, 51, 100, 1),
|
||||||
|
Ipv4Addr::new(198, 51, 100, 2),
|
||||||
|
];
|
||||||
|
server
|
||||||
|
.send_punch_packet_easy_sym_inner(SendPunchPacketEasySym {
|
||||||
|
listener_mapped_addr,
|
||||||
|
public_ips: public_ips.clone(),
|
||||||
|
transaction_id: 9,
|
||||||
|
base_port_num: 10000,
|
||||||
|
max_port_num: 50,
|
||||||
|
is_incremental: true,
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let sent = socket.sent.lock().await;
|
||||||
|
assert_eq!(sent.len(), 300);
|
||||||
|
for ip in public_ips {
|
||||||
|
assert!(sent.iter().any(|(_, addr)| addr.ip() == ip));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn symmetric_packet_sender_returns_next_port_index() {
|
async fn symmetric_packet_sender_returns_next_port_index() {
|
||||||
let socket = Arc::new(MockSocket {
|
let socket = Arc::new(MockSocket {
|
||||||
local_addr: SocketAddr::from(([127, 0, 0, 1], 10003)),
|
local_addr: SocketAddr::from(([127, 0, 0, 1], 10003)),
|
||||||
sent: tokio::sync::Mutex::new(Vec::new()),
|
sent: tokio::sync::Mutex::new(Vec::new()),
|
||||||
fail_next_send: AtomicUsize::new(0),
|
fail_next_send: AtomicUsize::new(0),
|
||||||
|
incoming: StdMutex::new(VecDeque::new()),
|
||||||
|
incoming_notify: tokio::sync::Notify::new(),
|
||||||
});
|
});
|
||||||
let next_idx = send_symmetric_hole_punch_packet(
|
let next_idx = send_symmetric_hole_punch_packet(
|
||||||
&[10, 11, 12],
|
&[10, 11, 12],
|
||||||
@@ -1335,6 +1766,8 @@ mod tests {
|
|||||||
local_addr: SocketAddr::from(([127, 0, 0, 1], 10006)),
|
local_addr: SocketAddr::from(([127, 0, 0, 1], 10006)),
|
||||||
sent: tokio::sync::Mutex::new(Vec::new()),
|
sent: tokio::sync::Mutex::new(Vec::new()),
|
||||||
fail_next_send: AtomicUsize::new(1),
|
fail_next_send: AtomicUsize::new(1),
|
||||||
|
incoming: StdMutex::new(VecDeque::new()),
|
||||||
|
incoming_notify: tokio::sync::Notify::new(),
|
||||||
});
|
});
|
||||||
|
|
||||||
let err = send_symmetric_hole_punch_packet(
|
let err = send_symmetric_hole_punch_packet(
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ use std::{
|
|||||||
sync::{Arc, Mutex},
|
sync::{Arc, Mutex},
|
||||||
};
|
};
|
||||||
|
|
||||||
use dashmap::{DashMap, DashSet};
|
use dashmap::DashMap;
|
||||||
use tokio::task::JoinSet;
|
use tokio::task::JoinSet;
|
||||||
use tracing::{Instrument, Level, instrument};
|
use tracing::{Instrument, Level, instrument};
|
||||||
|
|
||||||
@@ -42,7 +42,6 @@ where
|
|||||||
socket_context: SocketContext,
|
socket_context: SocketContext,
|
||||||
tasks: Arc<Mutex<JoinSet<()>>>,
|
tasks: Arc<Mutex<JoinSet<()>>>,
|
||||||
|
|
||||||
interest_tids: Arc<DashSet<u32>>,
|
|
||||||
tid_to_socket: Arc<DashMap<u32, Vec<PunchedUdpSocket<R::Socket>>>>,
|
tid_to_socket: Arc<DashMap<u32, Vec<PunchedUdpSocket<R::Socket>>>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -68,7 +67,6 @@ where
|
|||||||
socket_context,
|
socket_context,
|
||||||
tasks,
|
tasks,
|
||||||
|
|
||||||
interest_tids: Arc::new(DashSet::new()),
|
|
||||||
tid_to_socket: Arc::new(DashMap::new()),
|
tid_to_socket: Arc::new(DashMap::new()),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -80,12 +78,11 @@ where
|
|||||||
pub async fn add_new_socket(&self, socket: Arc<R::Socket>) -> anyhow::Result<()> {
|
pub async fn add_new_socket(&self, socket: Arc<R::Socket>) -> anyhow::Result<()> {
|
||||||
let socket_map = self.sockets.clone();
|
let socket_map = self.sockets.clone();
|
||||||
let local_addr = socket.local_addr()?;
|
let local_addr = socket.local_addr()?;
|
||||||
let interest_tids = self.interest_tids.clone();
|
|
||||||
let tid_to_socket = self.tid_to_socket.clone();
|
let tid_to_socket = self.tid_to_socket.clone();
|
||||||
socket_map.insert(local_addr, socket.clone());
|
socket_map.insert(local_addr, socket.clone());
|
||||||
self.tasks.lock().unwrap().spawn(
|
self.tasks.lock().unwrap().spawn(
|
||||||
async move {
|
async move {
|
||||||
let _socket_map_guard = RemoveSocketOnDrop {
|
let socket_map_guard = RemoveSocketOnDrop {
|
||||||
sockets: socket_map,
|
sockets: socket_map,
|
||||||
local_addr,
|
local_addr,
|
||||||
};
|
};
|
||||||
@@ -106,17 +103,18 @@ where
|
|||||||
|
|
||||||
tracing::debug!(?addr, ?tid, "got udp hole punch packet");
|
tracing::debug!(?addr, ?tid, "got udp hole punch packet");
|
||||||
|
|
||||||
if interest_tids.contains(&tid) {
|
if let Some(mut punched) = tid_to_socket.get_mut(&tid) {
|
||||||
tracing::info!(?addr, ?tid, "got hole punching packet with interest tid");
|
tracing::info!(?addr, ?tid, "got hole punching packet with interest tid");
|
||||||
tid_to_socket
|
// Stop receiving and remove the candidate before handing it off.
|
||||||
.entry(tid)
|
// The consumer may immediately reuse the socket or rebind its port.
|
||||||
.or_default()
|
drop(socket_map_guard);
|
||||||
.push(PunchedUdpSocket {
|
tracing::debug!(?local_addr, "udp socket recv loop end");
|
||||||
socket: socket.clone(),
|
punched.push(PunchedUdpSocket {
|
||||||
tid,
|
socket,
|
||||||
remote_addr: addr,
|
tid,
|
||||||
});
|
remote_addr: addr,
|
||||||
break;
|
});
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
tracing::debug!(?local_addr, "udp socket recv loop end");
|
tracing::debug!(?local_addr, "udp socket recv loop end");
|
||||||
@@ -170,7 +168,7 @@ where
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn add_interest_tid(&self, tid: u32) {
|
pub fn add_interest_tid(&self, tid: u32) {
|
||||||
self.interest_tids.insert(tid);
|
self.tid_to_socket.entry(tid).or_default();
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn add_intreast_tid(&self, tid: u32) {
|
pub fn add_intreast_tid(&self, tid: u32) {
|
||||||
@@ -178,7 +176,6 @@ where
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn remove_interest_tid(&self, tid: u32) {
|
pub fn remove_interest_tid(&self, tid: u32) {
|
||||||
self.interest_tids.remove(&tid);
|
|
||||||
self.tid_to_socket.remove(&tid);
|
self.tid_to_socket.remove(&tid);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -196,7 +193,6 @@ where
|
|||||||
.field("sockets", &self.sockets.len())
|
.field("sockets", &self.sockets.len())
|
||||||
.field("max_socket_count", &self.max_socket_count)
|
.field("max_socket_count", &self.max_socket_count)
|
||||||
.field("started", &self.started())
|
.field("started", &self.started())
|
||||||
.field("interest_tids", &self.interest_tids.len())
|
|
||||||
.field("tid_to_socket", &self.tid_to_socket.len())
|
.field("tid_to_socket", &self.tid_to_socket.len())
|
||||||
.finish()
|
.finish()
|
||||||
}
|
}
|
||||||
@@ -323,6 +319,9 @@ mod tests {
|
|||||||
assert_eq!(punched.tid, tid);
|
assert_eq!(punched.tid, tid);
|
||||||
assert_eq!(punched.remote_addr, remote_addr);
|
assert_eq!(punched.remote_addr, remote_addr);
|
||||||
assert!(Arc::ptr_eq(&punched.socket, &socket));
|
assert!(Arc::ptr_eq(&punched.socket, &socket));
|
||||||
|
assert!(array.sockets.is_empty());
|
||||||
|
drop(socket);
|
||||||
|
assert_eq!(Arc::strong_count(&punched.socket), 1);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
tokio::task::yield_now().await;
|
tokio::task::yield_now().await;
|
||||||
@@ -331,6 +330,27 @@ mod tests {
|
|||||||
panic!("punched socket was not recorded");
|
panic!("punched socket was not recorded");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn removing_interest_drops_hits_and_ignores_late_packets() {
|
||||||
|
let array = UdpSocketArray::new(0, Arc::new(MockFactory::new()));
|
||||||
|
let tid = 7;
|
||||||
|
array.add_interest_tid(tid);
|
||||||
|
array.remove_interest_tid(tid);
|
||||||
|
let socket = Arc::new(MockSocket::new(
|
||||||
|
SocketAddr::from(([127, 0, 0, 1], 20000)),
|
||||||
|
vec![(
|
||||||
|
new_hole_punch_packet(tid, HOLE_PUNCH_PACKET_BODY_LEN)
|
||||||
|
.into_bytes()
|
||||||
|
.to_vec(),
|
||||||
|
SocketAddr::from(([10, 0, 0, 1], 1234)),
|
||||||
|
)],
|
||||||
|
));
|
||||||
|
array.add_new_socket(socket).await.unwrap();
|
||||||
|
tokio::task::yield_now().await;
|
||||||
|
assert!(array.tid_to_socket.is_empty());
|
||||||
|
assert_eq!(array.sockets.len(), 1);
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn send_with_all_sends_three_packets_per_socket() {
|
async fn send_with_all_sends_three_packets_per_socket() {
|
||||||
let runtime = Arc::new(MockFactory::new());
|
let runtime = Arc::new(MockFactory::new());
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ use hyper::{Request, header};
|
|||||||
use hyper_util::rt::TokioIo;
|
use hyper_util::rt::TokioIo;
|
||||||
use rand::{Rng as _, seq::SliceRandom};
|
use rand::{Rng as _, seq::SliceRandom};
|
||||||
use rustls::pki_types::ServerName;
|
use rustls::pki_types::ServerName;
|
||||||
|
#[cfg(feature = "system-certificates")]
|
||||||
|
use rustls_platform_verifier::BuilderVerifierExt as _;
|
||||||
use tokio::io::{AsyncRead, AsyncWrite};
|
use tokio::io::{AsyncRead, AsyncWrite};
|
||||||
use tokio_rustls::TlsConnector;
|
use tokio_rustls::TlsConnector;
|
||||||
use tokio_util::task::AbortOnDropHandle;
|
use tokio_util::task::AbortOnDropHandle;
|
||||||
@@ -184,12 +186,23 @@ where
|
|||||||
|
|
||||||
if request.url.scheme() == "https" {
|
if request.url.scheme() == "https" {
|
||||||
let server_name = tls_server_name(&request.url)?;
|
let server_name = tls_server_name(&request.url)?;
|
||||||
let root_store = rustls::RootCertStore {
|
// A host application can enable another rustls backend through Cargo
|
||||||
|
// feature unification. Select ring explicitly instead of requiring a
|
||||||
|
// process-wide provider to have been initialized first (#2607).
|
||||||
|
let tls_config = rustls::ClientConfig::builder_with_provider(Arc::new(
|
||||||
|
rustls::crypto::ring::default_provider(),
|
||||||
|
))
|
||||||
|
.with_safe_default_protocol_versions()
|
||||||
|
.context("selecting HTTPS protocol versions failed")?;
|
||||||
|
#[cfg(feature = "system-certificates")]
|
||||||
|
let tls_config = tls_config
|
||||||
|
.with_platform_verifier()
|
||||||
|
.context("loading platform TLS verifier failed")?;
|
||||||
|
#[cfg(not(feature = "system-certificates"))]
|
||||||
|
let tls_config = tls_config.with_root_certificates(rustls::RootCertStore {
|
||||||
roots: webpki_roots::TLS_SERVER_ROOTS.to_vec(),
|
roots: webpki_roots::TLS_SERVER_ROOTS.to_vec(),
|
||||||
};
|
});
|
||||||
let tls_config = rustls::ClientConfig::builder()
|
let tls_config = tls_config.with_no_client_auth();
|
||||||
.with_root_certificates(root_store)
|
|
||||||
.with_no_client_auth();
|
|
||||||
let stream = TlsConnector::from(Arc::new(tls_config))
|
let stream = TlsConnector::from(Arc::new(tls_config))
|
||||||
.connect(server_name, socket)
|
.connect(server_name, socket)
|
||||||
.await
|
.await
|
||||||
@@ -631,6 +644,44 @@ mod tests {
|
|||||||
assert_eq!(options.bind.context.socket_mark, Some(9));
|
assert_eq!(options.bind.context.socket_mark, Some(9));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn https_fetch_reaches_tls_handshake_without_global_provider() {
|
||||||
|
// Also run with rustls/aws_lc_rs enabled: feature unification must not
|
||||||
|
// make HTTPS discovery panic before it can send a ClientHello (#2607).
|
||||||
|
let (client, mut server) = tokio::io::duplex(8192);
|
||||||
|
let host = Arc::new(HttpTestHost {
|
||||||
|
stream: Mutex::new(Some(client)),
|
||||||
|
connects: Mutex::new(Vec::new()),
|
||||||
|
});
|
||||||
|
let dns = HttpTestDns {
|
||||||
|
queries: Mutex::new(Vec::new()),
|
||||||
|
};
|
||||||
|
let server_task = tokio::spawn(async move {
|
||||||
|
let mut record_header = [0; 5];
|
||||||
|
server.read_exact(&mut record_header).await.unwrap();
|
||||||
|
assert_eq!(record_header[0], 22, "expected a TLS handshake record");
|
||||||
|
// Close without replying; discovery should report a handshake
|
||||||
|
// error rather than panic while selecting a crypto provider.
|
||||||
|
});
|
||||||
|
|
||||||
|
let error = fetch_http_discovery(
|
||||||
|
host,
|
||||||
|
&dns,
|
||||||
|
HttpDiscoveryRequest {
|
||||||
|
url: "https://discovery.example/lookup".parse().unwrap(),
|
||||||
|
user_agent: "easytier/test".to_owned(),
|
||||||
|
network_name: "test-network".to_owned(),
|
||||||
|
timeout: Duration::from_secs(5),
|
||||||
|
ip_version: IpVersion::V4,
|
||||||
|
tcp_bind: TcpBindOptions::default(),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect_err("server closed during the TLS handshake");
|
||||||
|
assert!(format!("{error:#}").contains("HTTPS handshake failed"));
|
||||||
|
server_task.await.unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn http_discovery_interprets_redirect_and_body_forms() {
|
fn http_discovery_interprets_redirect_and_body_forms() {
|
||||||
let query = resolve_http_endpoint(HttpDiscoveryResponse {
|
let query = resolve_http_endpoint(HttpDiscoveryResponse {
|
||||||
|
|||||||
@@ -1381,13 +1381,14 @@ mod tests {
|
|||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn timeout_budget_reports_the_active_stage() {
|
async fn timeout_budget_reports_the_active_stage() {
|
||||||
let error =
|
let error = with_timeout_budget(
|
||||||
with_timeout_budget("connect", Instant::now(), Duration::from_millis(1), async {
|
"connect",
|
||||||
crate::foundation::time::sleep(Duration::from_millis(20)).await;
|
Instant::now(),
|
||||||
Ok::<(), anyhow::Error>(())
|
Duration::from_millis(1),
|
||||||
})
|
std::future::pending::<anyhow::Result<()>>(),
|
||||||
.await
|
)
|
||||||
.unwrap_err();
|
.await
|
||||||
|
.unwrap_err();
|
||||||
|
|
||||||
assert!(error.to_string().contains("connect timeout after"));
|
assert!(error.to_string().contains("connect timeout after"));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,3 +14,15 @@ pub mod stats;
|
|||||||
pub(crate) mod task;
|
pub(crate) mod task;
|
||||||
pub(crate) mod time;
|
pub(crate) mod time;
|
||||||
pub(crate) mod token_bucket;
|
pub(crate) mod token_bucket;
|
||||||
|
|
||||||
|
// Each network and peer owns several small maps. Cap their shard arrays to
|
||||||
|
// avoid multiplying empty-map overhead on public servers, while retaining
|
||||||
|
// DashMap's smaller default on single-CPU machines.
|
||||||
|
pub(crate) fn small_map_shard_amount() -> usize {
|
||||||
|
static SHARDS: std::sync::LazyLock<usize> = std::sync::LazyLock::new(|| {
|
||||||
|
(std::thread::available_parallelism().map_or(1, usize::from) * 4)
|
||||||
|
.next_power_of_two()
|
||||||
|
.min(8)
|
||||||
|
});
|
||||||
|
*SHARDS
|
||||||
|
}
|
||||||
|
|||||||
@@ -12,6 +12,11 @@ use crate::foundation::time;
|
|||||||
|
|
||||||
pub(crate) trait ByteLimiter: Send + Sync {
|
pub(crate) trait ByteLimiter: Send + Sync {
|
||||||
fn try_consume(&self, bytes: u64) -> bool;
|
fn try_consume(&self, bytes: u64) -> bool;
|
||||||
|
|
||||||
|
/// Bytes charged by this limiter so far.
|
||||||
|
fn metered_bytes(&self) -> u64 {
|
||||||
|
0
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ByteLimiter for () {
|
impl ByteLimiter for () {
|
||||||
@@ -37,6 +42,7 @@ struct BucketState {
|
|||||||
available_tokens: u64,
|
available_tokens: u64,
|
||||||
last_refill: Instant,
|
last_refill: Instant,
|
||||||
refill_remainder: u64,
|
refill_remainder: u64,
|
||||||
|
metered_bytes: u64,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Copy)]
|
#[derive(Clone, Copy)]
|
||||||
@@ -78,6 +84,7 @@ impl TokenBucket {
|
|||||||
available_tokens: config.capacity,
|
available_tokens: config.capacity,
|
||||||
last_refill: Instant::now(),
|
last_refill: Instant::now(),
|
||||||
refill_remainder: 0,
|
refill_remainder: 0,
|
||||||
|
metered_bytes: 0,
|
||||||
}),
|
}),
|
||||||
config,
|
config,
|
||||||
stop_notifier: Notify::new(),
|
stop_notifier: Notify::new(),
|
||||||
@@ -132,15 +139,30 @@ impl TokenBucket {
|
|||||||
let tokens = tokens.min(self.config.capacity);
|
let tokens = tokens.min(self.config.capacity);
|
||||||
|
|
||||||
let mut state = self.state.lock();
|
let mut state = self.state.lock();
|
||||||
self.refill(&mut state, Instant::now());
|
let now = Instant::now();
|
||||||
|
self.try_consume_locked(&mut state, tokens, now)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Consume from an already locked state at an explicit instant.
|
||||||
|
///
|
||||||
|
/// Production takes the lock first and then reads the clock, so tests can
|
||||||
|
/// pass the same instant twice to observe the bucket without any refill.
|
||||||
|
fn try_consume_locked(&self, state: &mut BucketState, tokens: u64, now: Instant) -> bool {
|
||||||
|
self.refill(state, now);
|
||||||
if state.available_tokens < tokens {
|
if state.available_tokens < tokens {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
state.available_tokens -= tokens;
|
state.available_tokens -= tokens;
|
||||||
|
state.metered_bytes = state.metered_bytes.saturating_add(tokens);
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Total bytes charged by this bucket so far.
|
||||||
|
pub fn metered_bytes(&self) -> u64 {
|
||||||
|
self.state.lock().metered_bytes
|
||||||
|
}
|
||||||
|
|
||||||
/// Consume tokens, sleeping until they become available.
|
/// Consume tokens, sleeping until they become available.
|
||||||
pub async fn consume(&self, tokens: u64) {
|
pub async fn consume(&self, tokens: u64) {
|
||||||
let mut remaining = tokens;
|
let mut remaining = tokens;
|
||||||
@@ -166,6 +188,7 @@ impl TokenBucket {
|
|||||||
self.refill(&mut state, Instant::now());
|
self.refill(&mut state, Instant::now());
|
||||||
if state.available_tokens >= tokens {
|
if state.available_tokens >= tokens {
|
||||||
state.available_tokens -= tokens;
|
state.available_tokens -= tokens;
|
||||||
|
state.metered_bytes = state.metered_bytes.saturating_add(tokens);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -195,6 +218,10 @@ impl ByteLimiter for TokenBucket {
|
|||||||
fn try_consume(&self, bytes: u64) -> bool {
|
fn try_consume(&self, bytes: u64) -> bool {
|
||||||
TokenBucket::try_consume(self, bytes)
|
TokenBucket::try_consume(self, bytes)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn metered_bytes(&self) -> u64 {
|
||||||
|
TokenBucket::metered_bytes(self)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub struct TokenBucketManager {
|
pub struct TokenBucketManager {
|
||||||
@@ -295,10 +322,14 @@ mod tests {
|
|||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn test_initial_state() {
|
async fn test_initial_state() {
|
||||||
let bucket = TokenBucket::new(1000, 1000);
|
let bucket = TokenBucket::new(1000, 1000);
|
||||||
|
// Refill is time based; reuse one instant so no token can appear
|
||||||
|
// between the two calls regardless of scheduling.
|
||||||
|
let now = Instant::now();
|
||||||
|
let mut state = bucket.state.lock();
|
||||||
|
|
||||||
// Should have full capacity initially
|
// Should have full capacity initially
|
||||||
assert!(bucket.try_consume(1000));
|
assert!(bucket.try_consume_locked(&mut state, 1000, now));
|
||||||
assert!(!bucket.try_consume(1)); // Should be empty now
|
assert!(!bucket.try_consume_locked(&mut state, 1, now)); // Should be empty now
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Test token consumption behavior
|
/// Test token consumption behavior
|
||||||
@@ -306,14 +337,18 @@ mod tests {
|
|||||||
async fn test_consumption() {
|
async fn test_consumption() {
|
||||||
let bucket = TokenBucket::new(1500, 1000);
|
let bucket = TokenBucket::new(1500, 1000);
|
||||||
|
|
||||||
|
// Same instant for every call: no refill can happen between them.
|
||||||
|
let now = Instant::now();
|
||||||
|
let mut state = bucket.state.lock();
|
||||||
|
|
||||||
// First packet should succeed
|
// First packet should succeed
|
||||||
assert!(bucket.try_consume(1000));
|
assert!(bucket.try_consume_locked(&mut state, 1000, now));
|
||||||
|
|
||||||
// Second packet should fail (only 500 left)
|
// Second packet should fail (only 500 left)
|
||||||
assert!(!bucket.try_consume(600));
|
assert!(!bucket.try_consume_locked(&mut state, 600, now));
|
||||||
|
|
||||||
// Should be able to take remaining tokens
|
// Should be able to take remaining tokens
|
||||||
assert!(bucket.try_consume(500));
|
assert!(bucket.try_consume_locked(&mut state, 500, now));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
//! Shared data-plane flow registration and ownership.
|
//! Shared data-plane flow registration and ownership.
|
||||||
|
|
||||||
use std::{
|
use std::{
|
||||||
net::{IpAddr, SocketAddr},
|
net::SocketAddr,
|
||||||
sync::{
|
sync::{
|
||||||
Arc,
|
Arc,
|
||||||
atomic::{AtomicUsize, Ordering},
|
atomic::{AtomicUsize, Ordering},
|
||||||
@@ -26,6 +26,16 @@ pub(crate) struct FlowKey {
|
|||||||
pub kind: FlowKind,
|
pub kind: FlowKind,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl FlowKey {
|
||||||
|
pub(super) fn udp_bind(local_addr: SocketAddr) -> Self {
|
||||||
|
Self {
|
||||||
|
src: local_addr,
|
||||||
|
dst: SocketAddr::from(([0, 0, 0, 0], 0)),
|
||||||
|
kind: FlowKind::Udp,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||||
pub(crate) struct FlowCountChange {
|
pub(crate) struct FlowCountChange {
|
||||||
pub previous: usize,
|
pub previous: usize,
|
||||||
@@ -136,14 +146,12 @@ impl<V> FlowTable<V> {
|
|||||||
self.entries.is_empty()
|
self.entries.is_empty()
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn contains_key(&self, entry: &FlowKey) -> bool {
|
pub(super) fn registration(&self, entry: &FlowKey) -> Option<u64> {
|
||||||
self.entries.contains_key(entry)
|
self.entries.get(entry).map(|flow| flow.registration)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn contains_destination_ip(&self, destination: IpAddr) -> bool {
|
pub fn contains_key(&self, entry: &FlowKey) -> bool {
|
||||||
self.entries
|
self.entries.contains_key(entry)
|
||||||
.iter()
|
|
||||||
.any(|entry| entry.key().dst.ip() == destination)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
@@ -252,7 +260,7 @@ impl<V> FlowTable<V> {
|
|||||||
fn increment_count(&self) -> FlowCountChange {
|
fn increment_count(&self) -> FlowCountChange {
|
||||||
let previous = self
|
let previous = self
|
||||||
.count
|
.count
|
||||||
.fetch_update(Ordering::AcqRel, Ordering::Acquire, |count| {
|
.try_update(Ordering::AcqRel, Ordering::Acquire, |count| {
|
||||||
count.checked_add(1)
|
count.checked_add(1)
|
||||||
})
|
})
|
||||||
.expect("flow count overflow");
|
.expect("flow count overflow");
|
||||||
@@ -294,7 +302,7 @@ impl<V> FlowTable<V> {
|
|||||||
|
|
||||||
let previous = self
|
let previous = self
|
||||||
.count
|
.count
|
||||||
.fetch_update(Ordering::AcqRel, Ordering::Acquire, |count| {
|
.try_update(Ordering::AcqRel, Ordering::Acquire, |count| {
|
||||||
count.checked_sub(delta)
|
count.checked_sub(delta)
|
||||||
})
|
})
|
||||||
.expect("flow count underflow");
|
.expect("flow count underflow");
|
||||||
@@ -376,7 +384,6 @@ mod tests {
|
|||||||
assert!(!table.try_insert(first.clone(), 2));
|
assert!(!table.try_insert(first.clone(), 2));
|
||||||
assert!(table.try_insert(second.clone(), 3));
|
assert!(table.try_insert(second.clone(), 3));
|
||||||
assert_eq!(table.count(), 2);
|
assert_eq!(table.count(), 2);
|
||||||
assert!(table.contains_destination_ip(first.dst.ip()));
|
|
||||||
|
|
||||||
let retained = table.retain(|entry, _| entry == &second);
|
let retained = table.retain(|entry, _| entry == &second);
|
||||||
assert_eq!(retained.removed, 1);
|
assert_eq!(retained.removed, 1);
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ use crate::{
|
|||||||
},
|
},
|
||||||
packet::{PacketType, ZCPacket},
|
packet::{PacketType, ZCPacket},
|
||||||
peers::{
|
peers::{
|
||||||
PeerPacketFilter,
|
PeerPacketFilter, PeerPacketFilterResult,
|
||||||
peer_manager::{PeerManagerCore, PipelineRegistrationGuard},
|
peer_manager::{PeerManagerCore, PipelineRegistrationGuard},
|
||||||
},
|
},
|
||||||
socket::{
|
socket::{
|
||||||
@@ -52,7 +52,7 @@ use crate::{
|
|||||||
VirtualTcpListener, VirtualTcpListenerFactory, VirtualTcpSocket,
|
VirtualTcpListener, VirtualTcpListenerFactory, VirtualTcpSocket,
|
||||||
VirtualTcpSocketFactory,
|
VirtualTcpSocketFactory,
|
||||||
},
|
},
|
||||||
udp::{UdpBindOptions, VirtualUdpSocket, VirtualUdpSocketFactory},
|
udp::VirtualUdpSocketFactory,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -69,6 +69,9 @@ mod tcp;
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests;
|
mod tests;
|
||||||
mod udp;
|
mod udp;
|
||||||
|
mod udp_fragments;
|
||||||
|
|
||||||
|
use udp_fragments::UdpFragments;
|
||||||
|
|
||||||
use self::{
|
use self::{
|
||||||
deadline::{DataPlaneDeadline, DataPlaneIoDeadline},
|
deadline::{DataPlaneDeadline, DataPlaneIoDeadline},
|
||||||
@@ -169,12 +172,23 @@ pub(super) enum FlowData {
|
|||||||
Udp,
|
Udp,
|
||||||
}
|
}
|
||||||
|
|
||||||
const UDP_ENTRY: FlowKind = FlowKind::Udp;
|
|
||||||
const TCP_ENTRY: FlowKind = FlowKind::Tcp;
|
const TCP_ENTRY: FlowKind = FlowKind::Tcp;
|
||||||
const TCP_LISTEN_ENTRY: FlowKind = FlowKind::TcpListen;
|
const TCP_LISTEN_ENTRY: FlowKind = FlowKind::TcpListen;
|
||||||
|
|
||||||
type FlowSet = Arc<FlowTable<FlowData>>;
|
type FlowSet = Arc<FlowTable<FlowData>>;
|
||||||
type DataPlaneTcpIo = Box<dyn TcpProxyStream>;
|
pub(super) enum DataPlaneTcpIo {
|
||||||
|
Smoltcp(crate::gateway::smoltcp::TcpStream),
|
||||||
|
External(Box<dyn TcpProxyStream>),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl DataPlaneTcpIo {
|
||||||
|
fn as_mut(&mut self) -> &mut dyn TcpProxyStream {
|
||||||
|
match self {
|
||||||
|
Self::Smoltcp(stream) => stream,
|
||||||
|
Self::External(stream) => &mut **stream,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub(crate) struct DataPlaneRuntime<H>
|
pub(crate) struct DataPlaneRuntime<H>
|
||||||
where
|
where
|
||||||
@@ -195,6 +209,7 @@ where
|
|||||||
|
|
||||||
net: Arc<Mutex<Option<SmoltcpPlane>>>,
|
net: Arc<Mutex<Option<SmoltcpPlane>>>,
|
||||||
pub(super) entries: FlowSet,
|
pub(super) entries: FlowSet,
|
||||||
|
udp_fragments: Arc<std::sync::Mutex<UdpFragments>>,
|
||||||
|
|
||||||
data_plane_consumers: Arc<DataPlaneConsumers>,
|
data_plane_consumers: Arc<DataPlaneConsumers>,
|
||||||
// Tracks whether the smoltcp `net` is ready for data-plane callers.
|
// Tracks whether the smoltcp `net` is ready for data-plane callers.
|
||||||
@@ -207,8 +222,8 @@ impl<H> PeerPacketFilter for DataPlaneRuntime<H>
|
|||||||
where
|
where
|
||||||
H: VirtualTcpSocketFactory + VirtualTcpListenerFactory + VirtualUdpSocketFactory,
|
H: VirtualTcpSocketFactory + VirtualTcpListenerFactory + VirtualUdpSocketFactory,
|
||||||
{
|
{
|
||||||
async fn try_process_packet_from_peer(&self, packet: ZCPacket) -> Option<ZCPacket> {
|
async fn try_process_packet_from_peer(&self, packet: ZCPacket) -> PeerPacketFilterResult {
|
||||||
if self.entries.is_idle() {
|
if self.entries.is_idle() && !packet::is_udp_fragment(&packet) {
|
||||||
if tracing::enabled!(tracing::Level::TRACE)
|
if tracing::enabled!(tracing::Level::TRACE)
|
||||||
&& let Some(hdr) = packet.peer_manager_header()
|
&& let Some(hdr) = packet.peer_manager_header()
|
||||||
&& matches!(
|
&& matches!(
|
||||||
@@ -257,11 +272,11 @@ where
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return Some(packet);
|
return PeerPacketFilterResult::Pass(packet);
|
||||||
}
|
}
|
||||||
let route = self.entries.route_peer_packet(&packet, true);
|
let route = self.entries.route_peer_packet(&packet, true);
|
||||||
let (entry_key, tcp_flags) = match route {
|
let (entry_key, tcp_flags) = match route {
|
||||||
PeerPacketRoute::Pass => return Some(packet),
|
PeerPacketRoute::Pass => return PeerPacketFilterResult::Pass(packet),
|
||||||
PeerPacketRoute::Unmatched { entry, tcp_flags } => {
|
PeerPacketRoute::Unmatched { entry, tcp_flags } => {
|
||||||
tracing::trace!(
|
tracing::trace!(
|
||||||
entry_key = ?entry,
|
entry_key = ?entry,
|
||||||
@@ -271,34 +286,20 @@ where
|
|||||||
entry_count = self.entries.count(),
|
entry_count = self.entries.count(),
|
||||||
"data plane has no flow for packet from peer"
|
"data plane has no flow for packet from peer"
|
||||||
);
|
);
|
||||||
return Some(packet);
|
return PeerPacketFilterResult::Pass(packet);
|
||||||
}
|
}
|
||||||
PeerPacketRoute::Deliver { entry, tcp_flags } => (entry, tcp_flags),
|
PeerPacketRoute::Deliver { entry, tcp_flags } => (entry, tcp_flags),
|
||||||
PeerPacketRoute::FragmentedUdp { source, mirror } => {
|
PeerPacketRoute::FragmentedUdp { destination } => {
|
||||||
let source: IpAddr = source.into();
|
let local_ip = Self::runtime_ipv4(&self.runtime_config).map(|ip| ip.address());
|
||||||
tracing::trace!(
|
if Some(destination) != local_ip {
|
||||||
is_in_entries = mirror,
|
return PeerPacketFilterResult::Pass(packet);
|
||||||
"ipv4 src = {:?}, check need send both smoltcp and kernel tun",
|
|
||||||
source
|
|
||||||
);
|
|
||||||
if mirror {
|
|
||||||
// if the packet is fragmented, no matther what the payload is, need send it to both smoltcp and kernel tun. because
|
|
||||||
// we cannot determine the udp port of the packet.
|
|
||||||
match self.packet_sender.try_send(packet.clone()) {
|
|
||||||
Ok(()) => tracing::trace!(
|
|
||||||
?source,
|
|
||||||
entry_count = self.entries.count(),
|
|
||||||
"data plane delivered fragmented packet from peer to smoltcp"
|
|
||||||
),
|
|
||||||
Err(err) => tracing::trace!(
|
|
||||||
?source,
|
|
||||||
?err,
|
|
||||||
entry_count = self.entries.count(),
|
|
||||||
"data plane failed to deliver fragmented packet from peer to smoltcp"
|
|
||||||
),
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return Some(packet);
|
return self.udp_fragments.lock().unwrap().process(
|
||||||
|
packet,
|
||||||
|
local_ip,
|
||||||
|
&self.entries,
|
||||||
|
&self.packet_sender,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -327,7 +328,7 @@ where
|
|||||||
),
|
),
|
||||||
}
|
}
|
||||||
|
|
||||||
None
|
PeerPacketFilterResult::Consumed
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -359,6 +360,7 @@ where
|
|||||||
|
|
||||||
net: Arc::new(Mutex::new(None)),
|
net: Arc::new(Mutex::new(None)),
|
||||||
entries: Arc::new(FlowTable::default()),
|
entries: Arc::new(FlowTable::default()),
|
||||||
|
udp_fragments: Arc::new(std::sync::Mutex::new(UdpFragments::default())),
|
||||||
|
|
||||||
data_plane_consumers: Arc::new(DataPlaneConsumers::new()),
|
data_plane_consumers: Arc::new(DataPlaneConsumers::new()),
|
||||||
data_plane_net_ready: tokio::sync::watch::channel(false).0,
|
data_plane_net_ready: tokio::sync::watch::channel(false).0,
|
||||||
@@ -392,12 +394,21 @@ where
|
|||||||
let peer_manager = self.peer_manager.clone();
|
let peer_manager = self.peer_manager.clone();
|
||||||
let packet_recv = self.packet_recv.clone();
|
let packet_recv = self.packet_recv.clone();
|
||||||
let entries = self.entries.clone();
|
let entries = self.entries.clone();
|
||||||
|
let udp_fragments = self.udp_fragments.clone();
|
||||||
let data_plane_consumers = self.data_plane_consumers.clone();
|
let data_plane_consumers = self.data_plane_consumers.clone();
|
||||||
let data_plane_net_ready = self.data_plane_net_ready.clone();
|
let data_plane_net_ready = self.data_plane_net_ready.clone();
|
||||||
self.runtime_tasks.lock().unwrap().spawn(async move {
|
self.runtime_tasks.lock().unwrap().spawn(async move {
|
||||||
let mut prev_ipv4 = None;
|
let mut prev_ipv4 = None;
|
||||||
|
let mut fragment_gc = tokio::time::interval(Duration::from_secs(1));
|
||||||
let mut peer_changes = runtime_config.subscribe_peer_runtime_changes();
|
let mut peer_changes = runtime_config.subscribe_peer_runtime_changes();
|
||||||
loop {
|
loop {
|
||||||
|
// Run housekeeping regardless of which select branch woke us.
|
||||||
|
let cur_ipv4 = Self::runtime_ipv4(&runtime_config);
|
||||||
|
{
|
||||||
|
let mut fragments = udp_fragments.lock().unwrap();
|
||||||
|
fragments.set_local_ip(cur_ipv4.map(|ip| ip.address()));
|
||||||
|
fragments.remove_expired();
|
||||||
|
}
|
||||||
let data_plane_active = data_plane_consumers.has_consumers();
|
let data_plane_active = data_plane_consumers.has_consumers();
|
||||||
|
|
||||||
if !data_plane_active {
|
if !data_plane_active {
|
||||||
@@ -429,12 +440,12 @@ where
|
|||||||
let _ = data_plane_net_ready.send_replace(false);
|
let _ = data_plane_net_ready.send_replace(false);
|
||||||
select! {
|
select! {
|
||||||
_ = peer_changes.changed() => {}
|
_ = peer_changes.changed() => {}
|
||||||
|
_ = fragment_gc.tick() => {}
|
||||||
_ = data_plane_consumers.changed() => {}
|
_ = data_plane_consumers.changed() => {}
|
||||||
}
|
}
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
let cur_ipv4 = Self::runtime_ipv4(&runtime_config);
|
|
||||||
if prev_ipv4 != cur_ipv4 {
|
if prev_ipv4 != cur_ipv4 {
|
||||||
let old_ipv4 = prev_ipv4;
|
let old_ipv4 = prev_ipv4;
|
||||||
prev_ipv4 = cur_ipv4;
|
prev_ipv4 = cur_ipv4;
|
||||||
@@ -487,6 +498,7 @@ where
|
|||||||
|
|
||||||
select! {
|
select! {
|
||||||
_ = peer_changes.changed() => {}
|
_ = peer_changes.changed() => {}
|
||||||
|
_ = fragment_gc.tick() => {}
|
||||||
_ = data_plane_consumers.changed() => {}
|
_ = data_plane_consumers.changed() => {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -549,6 +561,7 @@ where
|
|||||||
let _ = self.data_plane_net_ready.send_replace(false);
|
let _ = self.data_plane_net_ready.send_replace(false);
|
||||||
self.entries.clear();
|
self.entries.clear();
|
||||||
Self::shutdown_tasks(&self.runtime_tasks).await;
|
Self::shutdown_tasks(&self.runtime_tasks).await;
|
||||||
|
*self.udp_fragments.lock().unwrap() = UdpFragments::default();
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) async fn stop_runtime(&self) {
|
pub(crate) async fn stop_runtime(&self) {
|
||||||
@@ -687,7 +700,7 @@ where
|
|||||||
generation.ensure_open()?;
|
generation.ensure_open()?;
|
||||||
|
|
||||||
Ok(DataPlaneTcpStream::new(
|
Ok(DataPlaneTcpStream::new(
|
||||||
Box::new(stream),
|
DataPlaneTcpIo::Smoltcp(stream),
|
||||||
local_addr,
|
local_addr,
|
||||||
Some(data_plane_ref),
|
Some(data_plane_ref),
|
||||||
DataPlaneTcpStreamRoute::Outbound { _flow: flow },
|
DataPlaneTcpStreamRoute::Outbound { _flow: flow },
|
||||||
@@ -709,7 +722,7 @@ where
|
|||||||
.await?;
|
.await?;
|
||||||
let local_addr = socket.local_addr().map_err(DataPlaneError::from)?;
|
let local_addr = socket.local_addr().map_err(DataPlaneError::from)?;
|
||||||
Ok(DataPlaneTcpStream::new(
|
Ok(DataPlaneTcpStream::new(
|
||||||
Box::new(socket),
|
DataPlaneTcpIo::External(Box::new(socket)),
|
||||||
local_addr,
|
local_addr,
|
||||||
None,
|
None,
|
||||||
DataPlaneTcpStreamRoute::External,
|
DataPlaneTcpStreamRoute::External,
|
||||||
@@ -747,7 +760,7 @@ where
|
|||||||
)))
|
)))
|
||||||
.await?;
|
.await?;
|
||||||
Ok(DataPlaneTcpStream::new(
|
Ok(DataPlaneTcpStream::new(
|
||||||
stream,
|
DataPlaneTcpIo::External(stream),
|
||||||
source_addr,
|
source_addr,
|
||||||
None,
|
None,
|
||||||
DataPlaneTcpStreamRoute::External,
|
DataPlaneTcpStreamRoute::External,
|
||||||
@@ -943,19 +956,44 @@ where
|
|||||||
) -> DataPlaneResult<DataPlaneUdpSocket> {
|
) -> DataPlaneResult<DataPlaneUdpSocket> {
|
||||||
let data_plane_ref = self.acquire_data_plane_ref()?;
|
let data_plane_ref = self.acquire_data_plane_ref()?;
|
||||||
let (ipv4_addr, smoltcp_net, generation) = self.wait_data_plane_net(deadline).await?;
|
let (ipv4_addr, smoltcp_net, generation) = self.wait_data_plane_net(deadline).await?;
|
||||||
let reservation_options = UdpBindOptions::port_lease(SocketAddr::new(
|
|
||||||
IpAddr::V4(Ipv4Addr::UNSPECIFIED),
|
// The bound endpoint is also the port lease. Reserve it before creating
|
||||||
local_port,
|
// the smoltcp socket, which does not check other sockets for duplicates.
|
||||||
))
|
let try_register = |port| {
|
||||||
.with_context(self.socket_context.clone());
|
let local_addr = SocketAddr::new(ipv4_addr.address().into(), port);
|
||||||
let reservation = generation
|
FlowLease::try_register(
|
||||||
.while_open(deadline.run(self.host.bind_udp(reservation_options)))
|
self.entries.clone(),
|
||||||
.await?;
|
FlowKey::udp_bind(local_addr),
|
||||||
let reserved_port = reservation
|
FlowData::Udp,
|
||||||
.local_addr()
|
)
|
||||||
.map_err(DataPlaneError::from)?
|
.map(|flow| (local_addr, flow))
|
||||||
.port();
|
};
|
||||||
let bind_addr = SocketAddr::new(IpAddr::V4(ipv4_addr.address()), reserved_port);
|
let (bind_addr, bind_flow) = if local_port != 0 {
|
||||||
|
generation.ensure_open()?;
|
||||||
|
deadline.remaining()?;
|
||||||
|
try_register(local_port).ok_or_else(|| {
|
||||||
|
DataPlaneError::new(
|
||||||
|
DataPlaneErrorKind::AddressInUse,
|
||||||
|
format!("data-plane UDP port {local_port} is already bound"),
|
||||||
|
)
|
||||||
|
})?
|
||||||
|
} else {
|
||||||
|
let mut binding = None;
|
||||||
|
for port in smoltcp_net.ephemeral_ports() {
|
||||||
|
generation.ensure_open()?;
|
||||||
|
deadline.remaining()?;
|
||||||
|
binding = try_register(port);
|
||||||
|
if binding.is_some() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
binding.ok_or_else(|| {
|
||||||
|
DataPlaneError::new(
|
||||||
|
DataPlaneErrorKind::ResourceLimit,
|
||||||
|
"no free data-plane UDP ports",
|
||||||
|
)
|
||||||
|
})?
|
||||||
|
};
|
||||||
let smol = generation
|
let smol = generation
|
||||||
.while_open(deadline.run(smoltcp_net.udp_bind(bind_addr)))
|
.while_open(deadline.run(smoltcp_net.udp_bind(bind_addr)))
|
||||||
.await?;
|
.await?;
|
||||||
@@ -965,10 +1003,8 @@ where
|
|||||||
|
|
||||||
Ok(DataPlaneUdpSocket {
|
Ok(DataPlaneUdpSocket {
|
||||||
socket,
|
socket,
|
||||||
flows: self.entries.clone(),
|
_bind_flow: bind_flow,
|
||||||
routes: std::sync::Mutex::new(std::collections::HashMap::new()),
|
|
||||||
local_addr,
|
local_addr,
|
||||||
_reservation: reservation,
|
|
||||||
_data_plane_lease: data_plane_ref,
|
_data_plane_lease: data_plane_ref,
|
||||||
generation,
|
generation,
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -4,10 +4,7 @@ use std::net::{IpAddr, Ipv4Addr, SocketAddr};
|
|||||||
|
|
||||||
use smoltcp::wire::{IPV4_HEADER_LEN, IpProtocol, Ipv4Packet, TcpPacket, UdpPacket};
|
use smoltcp::wire::{IPV4_HEADER_LEN, IpProtocol, Ipv4Packet, TcpPacket, UdpPacket};
|
||||||
|
|
||||||
use crate::{
|
use crate::packet::{PacketType, ZCPacket};
|
||||||
gateway::proxy::ip_reassembler::IpReassembler,
|
|
||||||
packet::{PacketType, ZCPacket},
|
|
||||||
};
|
|
||||||
|
|
||||||
use super::flow::{FlowKey, FlowKind, FlowTable};
|
use super::flow::{FlowKey, FlowKind, FlowTable};
|
||||||
|
|
||||||
@@ -22,7 +19,7 @@ enum ClassifiedPeerPacket {
|
|||||||
entry: FlowKey,
|
entry: FlowKey,
|
||||||
},
|
},
|
||||||
FragmentedUdp {
|
FragmentedUdp {
|
||||||
source: Ipv4Addr,
|
destination: Ipv4Addr,
|
||||||
},
|
},
|
||||||
Unsupported,
|
Unsupported,
|
||||||
}
|
}
|
||||||
@@ -38,8 +35,7 @@ pub(crate) enum PeerPacketRoute {
|
|||||||
tcp_flags: Option<u8>,
|
tcp_flags: Option<u8>,
|
||||||
},
|
},
|
||||||
FragmentedUdp {
|
FragmentedUdp {
|
||||||
source: Ipv4Addr,
|
destination: Ipv4Addr,
|
||||||
mirror: bool,
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
fn classify_peer_ipv4_payload(payload: &[u8]) -> ClassifiedPeerPacket {
|
fn classify_peer_ipv4_payload(payload: &[u8]) -> ClassifiedPeerPacket {
|
||||||
@@ -72,9 +68,9 @@ fn classify_peer_ipv4_payload(payload: &[u8]) -> ClassifiedPeerPacket {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
IpProtocol::Udp => {
|
IpProtocol::Udp => {
|
||||||
if IpReassembler::is_packet_fragmented(&ipv4) {
|
if ipv4.frag_offset() != 0 || ipv4.more_frags() {
|
||||||
return ClassifiedPeerPacket::FragmentedUdp {
|
return ClassifiedPeerPacket::FragmentedUdp {
|
||||||
source: ipv4.src_addr(),
|
destination: ipv4.dst_addr(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
let Ok(udp) = UdpPacket::new_checked(ipv4.payload()) else {
|
let Ok(udp) = UdpPacket::new_checked(ipv4.payload()) else {
|
||||||
@@ -92,6 +88,34 @@ fn classify_peer_ipv4_payload(payload: &[u8]) -> ClassifiedPeerPacket {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub(super) fn is_udp_fragment(packet: &ZCPacket) -> bool {
|
||||||
|
is_data_plane_packet(packet)
|
||||||
|
&& Ipv4Packet::new_checked(packet.payload()).is_ok_and(|ip| {
|
||||||
|
ip.version() == 4
|
||||||
|
&& usize::from(ip.header_len()) >= IPV4_HEADER_LEN
|
||||||
|
&& ip.next_header() == IpProtocol::Udp
|
||||||
|
&& (ip.frag_offset() != 0 || ip.more_frags())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_data_plane_packet(packet: &ZCPacket) -> bool {
|
||||||
|
let Some(header) = packet.peer_manager_header() else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
let is_modified_source = matches!(
|
||||||
|
header.packet_type,
|
||||||
|
x if x == PacketType::DataWithKcpSrcModified as u8
|
||||||
|
|| x == PacketType::DataWithQuicSrcModified as u8
|
||||||
|
);
|
||||||
|
if header.packet_type != PacketType::Data as u8 && !is_modified_source {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if is_modified_source && header.from_peer_id != header.to_peer_id {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
pub(super) fn tcp_flags<T: AsRef<[u8]>>(tcp: &TcpPacket<T>) -> u8 {
|
pub(super) fn tcp_flags<T: AsRef<[u8]>>(tcp: &TcpPacket<T>) -> u8 {
|
||||||
u8::from(tcp.fin())
|
u8::from(tcp.fin())
|
||||||
| (u8::from(tcp.syn()) << 1)
|
| (u8::from(tcp.syn()) << 1)
|
||||||
@@ -108,18 +132,7 @@ impl<V> FlowTable<V> {
|
|||||||
packet: &ZCPacket,
|
packet: &ZCPacket,
|
||||||
allow_tcp_listen_fallback: bool,
|
allow_tcp_listen_fallback: bool,
|
||||||
) -> PeerPacketRoute {
|
) -> PeerPacketRoute {
|
||||||
let Some(header) = packet.peer_manager_header() else {
|
if !is_data_plane_packet(packet) {
|
||||||
return PeerPacketRoute::Pass;
|
|
||||||
};
|
|
||||||
let is_modified_source = matches!(
|
|
||||||
header.packet_type,
|
|
||||||
x if x == PacketType::DataWithKcpSrcModified as u8
|
|
||||||
|| x == PacketType::DataWithQuicSrcModified as u8
|
|
||||||
);
|
|
||||||
if header.packet_type != PacketType::Data as u8 && !is_modified_source {
|
|
||||||
return PeerPacketRoute::Pass;
|
|
||||||
}
|
|
||||||
if is_modified_source && header.from_peer_id != header.to_peer_id {
|
|
||||||
return PeerPacketRoute::Pass;
|
return PeerPacketRoute::Pass;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -144,12 +157,9 @@ impl<V> FlowTable<V> {
|
|||||||
};
|
};
|
||||||
(entry, Some(flags))
|
(entry, Some(flags))
|
||||||
}
|
}
|
||||||
ClassifiedPeerPacket::Udp { entry } => (entry, None),
|
ClassifiedPeerPacket::Udp { entry } => (FlowKey::udp_bind(entry.src), None),
|
||||||
ClassifiedPeerPacket::FragmentedUdp { source } => {
|
ClassifiedPeerPacket::FragmentedUdp { destination } => {
|
||||||
return PeerPacketRoute::FragmentedUdp {
|
return PeerPacketRoute::FragmentedUdp { destination };
|
||||||
source,
|
|
||||||
mirror: self.contains_destination_ip(source.into()),
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
ClassifiedPeerPacket::Unsupported => return PeerPacketRoute::Pass,
|
ClassifiedPeerPacket::Unsupported => return PeerPacketRoute::Pass,
|
||||||
};
|
};
|
||||||
@@ -234,7 +244,7 @@ mod tests {
|
|||||||
assert_eq!(
|
assert_eq!(
|
||||||
classify_peer_ipv4_payload(&fragmented),
|
classify_peer_ipv4_payload(&fragmented),
|
||||||
ClassifiedPeerPacket::FragmentedUdp {
|
ClassifiedPeerPacket::FragmentedUdp {
|
||||||
source: Ipv4Addr::new(10, 1, 1, 2),
|
destination: Ipv4Addr::new(10, 2, 2, 3),
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -314,34 +324,51 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
#[test]
|
#[test]
|
||||||
fn flow_table_routes_fragmented_udp_by_source_ip() {
|
fn flow_table_routes_udp_by_bound_port() {
|
||||||
let mut packet = ipv4_packet(IpProtocol::Udp, 8);
|
let mut packet = ipv4_packet(IpProtocol::Udp, 8);
|
||||||
Ipv4Packet::new_unchecked(&mut packet).set_frag_offset(8);
|
let mut ipv4 = Ipv4Packet::new_unchecked(&mut packet);
|
||||||
|
let mut udp = UdpPacket::new_unchecked(ipv4.payload_mut());
|
||||||
|
udp.set_src_port(1234);
|
||||||
|
udp.set_dst_port(4321);
|
||||||
|
udp.set_len(8);
|
||||||
let table = FlowTable::default();
|
let table = FlowTable::default();
|
||||||
|
let entry = FlowKey::udp_bind("10.2.2.3:4321".parse().unwrap());
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
table.route_peer_ipv4_payload(&packet, false),
|
table.route_peer_ipv4_payload(&packet, false),
|
||||||
PeerPacketRoute::FragmentedUdp {
|
PeerPacketRoute::Unmatched {
|
||||||
source: Ipv4Addr::new(10, 1, 1, 2),
|
entry: entry.clone(),
|
||||||
mirror: false,
|
tcp_flags: None
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
table.insert(entry.clone(), ());
|
||||||
table.insert(
|
|
||||||
FlowKey {
|
|
||||||
src: "10.2.2.3:4321".parse().unwrap(),
|
|
||||||
dst: "10.1.1.2:1234".parse().unwrap(),
|
|
||||||
kind: FlowKind::Udp,
|
|
||||||
},
|
|
||||||
(),
|
|
||||||
);
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
table.route_peer_ipv4_payload(&packet, false),
|
table.route_peer_ipv4_payload(&packet, false),
|
||||||
PeerPacketRoute::FragmentedUdp {
|
PeerPacketRoute::Deliver {
|
||||||
source: Ipv4Addr::new(10, 1, 1, 2),
|
entry,
|
||||||
mirror: true,
|
tcp_flags: None
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
for more_frags in [false, true] {
|
||||||
|
let mut ipv4 = Ipv4Packet::new_unchecked(&mut packet);
|
||||||
|
ipv4.set_frag_offset(8);
|
||||||
|
ipv4.set_more_frags(more_frags);
|
||||||
|
assert_eq!(
|
||||||
|
table.route_peer_ipv4_payload(&packet, false),
|
||||||
|
PeerPacketRoute::FragmentedUdp {
|
||||||
|
destination: Ipv4Addr::new(10, 2, 2, 3),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
for len in 0..smoltcp::wire::UDP_HEADER_LEN {
|
||||||
|
let mut short = ipv4_packet(IpProtocol::Udp, len);
|
||||||
|
Ipv4Packet::new_unchecked(&mut short).set_more_frags(true);
|
||||||
|
assert_eq!(
|
||||||
|
table.route_peer_ipv4_payload(&short, false),
|
||||||
|
PeerPacketRoute::FragmentedUdp {
|
||||||
|
destination: Ipv4Addr::new(10, 2, 2, 3)
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
#[test]
|
#[test]
|
||||||
fn flow_table_routes_loopback_modified_source_packets() {
|
fn flow_table_routes_loopback_modified_source_packets() {
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ use std::{
|
|||||||
sync::{Arc, Mutex},
|
sync::{Arc, Mutex},
|
||||||
};
|
};
|
||||||
|
|
||||||
use crossbeam::atomic::AtomicCell;
|
use crossbeam_utils::atomic::AtomicCell;
|
||||||
use tokio::sync::Notify;
|
use tokio::sync::Notify;
|
||||||
use tokio_util::sync::CancellationToken;
|
use tokio_util::sync::CancellationToken;
|
||||||
|
|
||||||
|
|||||||
@@ -77,7 +77,9 @@ impl SmoltcpPlane {
|
|||||||
tracing::debug!("smoltcp-to-peer bridge stopped");
|
tracing::debug!("smoltcp-to-peer bridge stopped");
|
||||||
});
|
});
|
||||||
|
|
||||||
let interface_config = smoltcp::iface::Config::new(smoltcp::wire::HardwareAddress::Ip);
|
// A fixed seed replays IP IDs after the last consumer closes and the
|
||||||
|
// stack is recreated, potentially reusing a remote shallow-cache route.
|
||||||
|
let interface_config = Self::interface_config(rand::random());
|
||||||
let net = Net::new(
|
let net = Net::new(
|
||||||
device,
|
device,
|
||||||
NetConfig::new(
|
NetConfig::new(
|
||||||
@@ -117,6 +119,12 @@ impl SmoltcpPlane {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub(super) fn interface_config(random_seed: u64) -> smoltcp::iface::Config {
|
||||||
|
let mut config = smoltcp::iface::Config::new(smoltcp::wire::HardwareAddress::Ip);
|
||||||
|
config.random_seed = random_seed;
|
||||||
|
config
|
||||||
|
}
|
||||||
|
|
||||||
pub(super) fn lease(&self) -> DataPlaneIoGuard {
|
pub(super) fn lease(&self) -> DataPlaneIoGuard {
|
||||||
self.generation.clone()
|
self.generation.clone()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ pub(super) enum DataPlaneTcpStreamRoute {
|
|||||||
|
|
||||||
/// A TCP stream created by the data-plane API.
|
/// A TCP stream created by the data-plane API.
|
||||||
pub struct DataPlaneTcpStream {
|
pub struct DataPlaneTcpStream {
|
||||||
pub(super) stream: DataPlaneTcpIo,
|
stream: Option<DataPlaneTcpIo>,
|
||||||
pub(super) local_addr: SocketAddr,
|
pub(super) local_addr: SocketAddr,
|
||||||
pub(super) _route: DataPlaneTcpStreamRoute,
|
pub(super) _route: DataPlaneTcpStreamRoute,
|
||||||
pub(super) _data_plane_lease: Option<DataPlaneLease>,
|
pub(super) _data_plane_lease: Option<DataPlaneLease>,
|
||||||
@@ -53,7 +53,7 @@ impl DataPlaneTcpStream {
|
|||||||
generation: DataPlaneIoGuard,
|
generation: DataPlaneIoGuard,
|
||||||
) -> Self {
|
) -> Self {
|
||||||
Self {
|
Self {
|
||||||
stream,
|
stream: Some(stream),
|
||||||
local_addr,
|
local_addr,
|
||||||
_data_plane_lease: data_plane_lease,
|
_data_plane_lease: data_plane_lease,
|
||||||
_route: route,
|
_route: route,
|
||||||
@@ -68,6 +68,21 @@ impl DataPlaneTcpStream {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl Drop for DataPlaneTcpStream {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
if let Some(DataPlaneTcpIo::Smoltcp(stream)) = self.stream.take() {
|
||||||
|
if self.generation.is_closed() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// The application is done, but FIN retransmissions and the peer's
|
||||||
|
// FIN still need this flow and the network stack until TCP closes.
|
||||||
|
let route = std::mem::replace(&mut self._route, DataPlaneTcpStreamRoute::External);
|
||||||
|
let lease = self._data_plane_lease.take();
|
||||||
|
stream.close((route, lease));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
impl DataPlaneTcpListener {
|
impl DataPlaneTcpListener {
|
||||||
pub fn local_addr(&self) -> SocketAddr {
|
pub fn local_addr(&self) -> SocketAddr {
|
||||||
self.local_addr
|
self.local_addr
|
||||||
@@ -99,7 +114,7 @@ impl DataPlaneTcpListener {
|
|||||||
FlowData::DataPlaneRoute,
|
FlowData::DataPlaneRoute,
|
||||||
);
|
);
|
||||||
let accepted = DataPlaneTcpStream::new(
|
let accepted = DataPlaneTcpStream::new(
|
||||||
Box::new(stream),
|
DataPlaneTcpIo::Smoltcp(stream),
|
||||||
local_addr,
|
local_addr,
|
||||||
Some(self.data_plane_lease.clone()),
|
Some(self.data_plane_lease.clone()),
|
||||||
DataPlaneTcpStreamRoute::Accepted { _flow: flow },
|
DataPlaneTcpStreamRoute::Accepted { _flow: flow },
|
||||||
@@ -119,7 +134,7 @@ impl AsyncRead for DataPlaneTcpStream {
|
|||||||
if this.generation.ensure_open().is_err() || this.read_closed.as_mut().poll(cx).is_ready() {
|
if this.generation.ensure_open().is_err() || this.read_closed.as_mut().poll(cx).is_ready() {
|
||||||
return Poll::Ready(Err(this.generation.closed_io_error()));
|
return Poll::Ready(Err(this.generation.closed_io_error()));
|
||||||
}
|
}
|
||||||
Pin::new(&mut this.stream).poll_read(cx, buf)
|
Pin::new(this.stream.as_mut().unwrap().as_mut()).poll_read(cx, buf)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -134,7 +149,7 @@ impl AsyncWrite for DataPlaneTcpStream {
|
|||||||
{
|
{
|
||||||
return Poll::Ready(Err(this.generation.closed_io_error()));
|
return Poll::Ready(Err(this.generation.closed_io_error()));
|
||||||
}
|
}
|
||||||
Pin::new(&mut this.stream).poll_write(cx, buf)
|
Pin::new(this.stream.as_mut().unwrap().as_mut()).poll_write(cx, buf)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn poll_flush(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Result<(), std::io::Error>> {
|
fn poll_flush(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Result<(), std::io::Error>> {
|
||||||
@@ -143,7 +158,7 @@ impl AsyncWrite for DataPlaneTcpStream {
|
|||||||
{
|
{
|
||||||
return Poll::Ready(Err(this.generation.closed_io_error()));
|
return Poll::Ready(Err(this.generation.closed_io_error()));
|
||||||
}
|
}
|
||||||
Pin::new(&mut this.stream).poll_flush(cx)
|
Pin::new(this.stream.as_mut().unwrap().as_mut()).poll_flush(cx)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn poll_shutdown(
|
fn poll_shutdown(
|
||||||
@@ -155,6 +170,6 @@ impl AsyncWrite for DataPlaneTcpStream {
|
|||||||
{
|
{
|
||||||
return Poll::Ready(Err(this.generation.closed_io_error()));
|
return Poll::Ready(Err(this.generation.closed_io_error()));
|
||||||
}
|
}
|
||||||
Pin::new(&mut this.stream).poll_shutdown(cx)
|
Pin::new(this.stream.as_mut().unwrap().as_mut()).poll_shutdown(cx)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
use std::net::{IpAddr, Ipv4Addr, SocketAddr};
|
use std::net::{IpAddr, Ipv4Addr, SocketAddr};
|
||||||
|
|
||||||
use smoltcp::wire::{IpAddress, IpProtocol, Ipv4Packet, TcpPacket};
|
use smoltcp::wire::{IpAddress, IpProtocol, Ipv4Packet, TcpPacket, UdpPacket};
|
||||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||||
|
|
||||||
use super::*;
|
use super::*;
|
||||||
@@ -36,6 +36,7 @@ fn test_gateway() -> Arc<DataPlaneRuntime<TestHost>> {
|
|||||||
packet_recv: Arc::new(Mutex::new(packet_recv)),
|
packet_recv: Arc::new(Mutex::new(packet_recv)),
|
||||||
net: Arc::new(Mutex::new(None)),
|
net: Arc::new(Mutex::new(None)),
|
||||||
entries: Arc::new(FlowTable::default()),
|
entries: Arc::new(FlowTable::default()),
|
||||||
|
udp_fragments: Arc::new(std::sync::Mutex::new(UdpFragments::default())),
|
||||||
data_plane_consumers: Arc::new(DataPlaneConsumers::new()),
|
data_plane_consumers: Arc::new(DataPlaneConsumers::new()),
|
||||||
data_plane_net_ready: tokio::sync::watch::channel(false).0,
|
data_plane_net_ready: tokio::sync::watch::channel(false).0,
|
||||||
pipeline_guard: Mutex::new(None),
|
pipeline_guard: Mutex::new(None),
|
||||||
@@ -196,26 +197,45 @@ fn build_tcp_packet(src: SocketAddr, dst: SocketAddr) -> Vec<u8> {
|
|||||||
buf
|
buf
|
||||||
}
|
}
|
||||||
|
|
||||||
fn build_udp_followup_fragment(src: Ipv4Addr, dst: Ipv4Addr) -> Vec<u8> {
|
pub(super) const UDP_LOCAL_IP: Ipv4Addr = Ipv4Addr::new(10, 144, 144, 1);
|
||||||
let mut buf = vec![0u8; 28];
|
const UDP_LOCAL: SocketAddr = SocketAddr::new(IpAddr::V4(UDP_LOCAL_IP), 40000);
|
||||||
{
|
const UDP_REMOTE: SocketAddr = SocketAddr::new(IpAddr::V4(Ipv4Addr::new(10, 144, 144, 3)), 53);
|
||||||
let mut ip_packet = Ipv4Packet::new_unchecked(&mut buf);
|
|
||||||
ip_packet.set_version(4);
|
|
||||||
ip_packet.set_header_len(20);
|
|
||||||
ip_packet.set_total_len(28);
|
|
||||||
ip_packet.set_hop_limit(64);
|
|
||||||
ip_packet.set_next_header(IpProtocol::Udp);
|
|
||||||
ip_packet.set_frag_offset(8);
|
|
||||||
ip_packet.set_src_addr(src);
|
|
||||||
ip_packet.set_dst_addr(dst);
|
|
||||||
ip_packet
|
|
||||||
.payload_mut()
|
|
||||||
.copy_from_slice(&[0xde, 0xad, 0xbe, 0xef, 0xca, 0xfe, 0xba, 0xbe]);
|
|
||||||
|
|
||||||
ip_packet.fill_checksum();
|
pub(super) fn build_udp_fragments() -> [ZCPacket; 2] {
|
||||||
|
let src_ip = Ipv4Addr::new(10, 144, 144, 3);
|
||||||
|
let dst_ip = UDP_LOCAL_IP;
|
||||||
|
let mut udp = [0; 24];
|
||||||
|
{
|
||||||
|
let mut packet = UdpPacket::new_unchecked(&mut udp[..]);
|
||||||
|
packet.set_src_port(UDP_REMOTE.port());
|
||||||
|
packet.set_dst_port(UDP_LOCAL.port());
|
||||||
|
packet.set_len(24);
|
||||||
|
packet.payload_mut().copy_from_slice(b"fragmented-first");
|
||||||
|
packet.fill_checksum(&IpAddress::Ipv4(src_ip), &IpAddress::Ipv4(dst_ip));
|
||||||
}
|
}
|
||||||
|
|
||||||
buf
|
[(&udp[..16], 0, true), (&udp[16..], 16, false)].map(|(payload, offset, more_fragments)| {
|
||||||
|
let mut buf = vec![0; 20 + payload.len()];
|
||||||
|
let mut ip = Ipv4Packet::new_unchecked(&mut buf);
|
||||||
|
ip.set_version(4);
|
||||||
|
ip.set_header_len(20);
|
||||||
|
ip.set_total_len((20 + payload.len()) as u16);
|
||||||
|
ip.set_ident(42);
|
||||||
|
ip.set_hop_limit(64);
|
||||||
|
ip.set_next_header(IpProtocol::Udp);
|
||||||
|
ip.set_src_addr(src_ip);
|
||||||
|
ip.set_dst_addr(dst_ip);
|
||||||
|
ip.set_frag_offset(offset);
|
||||||
|
ip.set_more_frags(more_fragments);
|
||||||
|
ip.payload_mut().copy_from_slice(payload);
|
||||||
|
ip.fill_checksum();
|
||||||
|
let mut packet = ZCPacket::new_with_payload(&buf);
|
||||||
|
packet.fill_peer_manager_hdr(1, 2, PacketType::Data as u8);
|
||||||
|
let header = packet.mut_peer_manager_header().unwrap();
|
||||||
|
header.set_latency_first(true).set_no_proxy(true);
|
||||||
|
header.forward_counter = 3;
|
||||||
|
packet
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
@@ -440,18 +460,6 @@ async fn data_plane_sessions_report_udp_truncation() {
|
|||||||
.unwrap()
|
.unwrap()
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
let warmup = session_b
|
|
||||||
.submit_udp_send(socket_b, addr_a, b"warmup".to_vec())
|
|
||||||
.unwrap();
|
|
||||||
wait_for_session_completion(&session_b).await;
|
|
||||||
session_b
|
|
||||||
.take_result_with(warmup, |outcome| match outcome {
|
|
||||||
Ok(DataPlaneOperationResult::UdpSent { len }) => Some(*len),
|
|
||||||
_ => None,
|
|
||||||
})
|
|
||||||
.unwrap()
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
let receive = session_b.submit_udp_receive(socket_b, 2).unwrap();
|
let receive = session_b.submit_udp_receive(socket_b, 2).unwrap();
|
||||||
let send = session_a
|
let send = session_a
|
||||||
.submit_udp_send(socket_a, addr_b, b"ping".to_vec())
|
.submit_udp_send(socket_a, addr_b, b"ping".to_vec())
|
||||||
@@ -534,44 +542,66 @@ async fn listener_and_accepted_stream_own_independent_flow_lifetimes() {
|
|||||||
assert_eq!(&buf, b"after-listener-drop");
|
assert_eq!(&buf, b"after-listener-drop");
|
||||||
|
|
||||||
drop(client);
|
drop(client);
|
||||||
assert_eq!(a.gateway.entries.count(), 0);
|
assert_eq!(a.gateway.entries.count(), 1);
|
||||||
|
assert_eq!(server.read(&mut buf).await.unwrap(), 0);
|
||||||
|
// The closed application no longer reads, but TCP must keep draining
|
||||||
|
// incoming data so the peer's queued data and FIN can finish sending.
|
||||||
|
tokio::time::timeout(timeout, async {
|
||||||
|
server.write_all(&vec![0; 1024 * 1024]).await.unwrap();
|
||||||
|
server.shutdown().await.unwrap();
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
drop(server);
|
drop(server);
|
||||||
assert_eq!(b.gateway.entries.count(), 0);
|
|
||||||
|
tokio::time::timeout(Duration::from_secs(15), async {
|
||||||
|
while a.gateway.entries.count() != 0
|
||||||
|
|| b.gateway.entries.count() != 0
|
||||||
|
|| a.gateway.net.lock().await.is_some()
|
||||||
|
|| b.gateway.net.lock().await.is_some()
|
||||||
|
{
|
||||||
|
tokio::time::sleep(Duration::from_millis(10)).await;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.expect("TCP teardown must release the last flow and stack leases");
|
||||||
|
|
||||||
stop_data_plane_pair(&a, &b).await;
|
stop_data_plane_pair(&a, &b).await;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn send_udp(sender: &DataPlaneUdpSocket, receiver: &DataPlaneUdpSocket, data: &[u8]) {
|
||||||
|
sender.send_to(data, receiver.local_addr()).await.unwrap();
|
||||||
|
let mut buf = vec![0; data.len()];
|
||||||
|
let (len, from) = tokio::time::timeout(Duration::from_secs(10), receiver.recv_from(&mut buf))
|
||||||
|
.await
|
||||||
|
.expect("UDP receive timed out")
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(&buf[..len], data);
|
||||||
|
assert_eq!(from, sender.local_addr());
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn data_plane_udp_pingpong() {
|
async fn data_plane_udp_pingpong() {
|
||||||
let (a, b) = setup_data_plane_pair().await;
|
let (a, b) = setup_data_plane_pair().await;
|
||||||
let timeout = Duration::from_secs(10);
|
let timeout = Duration::from_secs(10);
|
||||||
let socket_a = a.gateway.data_plane_udp_bind(0, timeout).await.unwrap();
|
let first = a.gateway.data_plane_udp_bind(0, timeout).await.unwrap();
|
||||||
let socket_b = b.gateway.data_plane_udp_bind(0, timeout).await.unwrap();
|
let second = a.gateway.data_plane_udp_bind(0, timeout).await.unwrap();
|
||||||
let addr_a = SocketAddr::new(a.ip.address().into(), socket_a.local_addr().port());
|
let receiver = b.gateway.data_plane_udp_bind(0, timeout).await.unwrap();
|
||||||
let addr_b = SocketAddr::new(b.ip.address().into(), socket_b.local_addr().port());
|
assert_ne!(first.local_addr(), second.local_addr());
|
||||||
|
for sender in [&first, &second] {
|
||||||
socket_b.send_to(b"warmup", addr_a).await.unwrap();
|
assert_ne!(sender.local_addr().port(), 0);
|
||||||
socket_a.send_to(b"ping", addr_b).await.unwrap();
|
assert_eq!(sender.local_addr().ip(), IpAddr::V4(a.ip.address()));
|
||||||
let mut buf = [0u8; 16];
|
send_udp(sender, &receiver, b"ping").await;
|
||||||
let (len, from) = tokio::time::timeout(timeout, socket_b.recv_from(&mut buf))
|
send_udp(&receiver, sender, b"pong").await;
|
||||||
.await
|
|
||||||
.expect("receive ping timed out")
|
|
||||||
.unwrap();
|
|
||||||
assert_eq!(&buf[..len], b"ping");
|
|
||||||
assert_eq!(from, addr_a);
|
|
||||||
|
|
||||||
socket_b.send_to(b"pong", addr_a).await.unwrap();
|
|
||||||
loop {
|
|
||||||
let (len, from) = tokio::time::timeout(timeout, socket_a.recv_from(&mut buf))
|
|
||||||
.await
|
|
||||||
.expect("receive pong timed out")
|
|
||||||
.unwrap();
|
|
||||||
if &buf[..len] == b"pong" {
|
|
||||||
assert_eq!(from, addr_b);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
// Sending to different destinations must not create per-remote flows.
|
||||||
|
assert_eq!(a.gateway.entries.count(), 2);
|
||||||
|
assert_eq!(b.gateway.entries.count(), 1);
|
||||||
|
assert_eq!(a.gateway.host.udp_binds.load(Ordering::Relaxed), 0);
|
||||||
|
assert_eq!(b.gateway.host.udp_binds.load(Ordering::Relaxed), 0);
|
||||||
|
drop((first, second, receiver));
|
||||||
|
assert_eq!(a.gateway.entries.count(), 0);
|
||||||
|
assert_eq!(b.gateway.entries.count(), 0);
|
||||||
stop_data_plane_pair(&a, &b).await;
|
stop_data_plane_pair(&a, &b).await;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -581,60 +611,125 @@ async fn data_plane_udp_carries_maximum_ipv4_payload() {
|
|||||||
let timeout = Duration::from_secs(10);
|
let timeout = Duration::from_secs(10);
|
||||||
let socket_a = a.gateway.data_plane_udp_bind(0, timeout).await.unwrap();
|
let socket_a = a.gateway.data_plane_udp_bind(0, timeout).await.unwrap();
|
||||||
let socket_b = b.gateway.data_plane_udp_bind(0, timeout).await.unwrap();
|
let socket_b = b.gateway.data_plane_udp_bind(0, timeout).await.unwrap();
|
||||||
let addr_a = SocketAddr::new(a.ip.address().into(), socket_a.local_addr().port());
|
send_udp(&socket_a, &socket_b, &vec![0x5a; 65_507]).await;
|
||||||
let addr_b = SocketAddr::new(b.ip.address().into(), socket_b.local_addr().port());
|
|
||||||
|
|
||||||
socket_b.send_to(b"warmup", addr_a).await.unwrap();
|
|
||||||
let payload = vec![0x5a; 65_507];
|
|
||||||
socket_a.send_to(&payload, addr_b).await.unwrap();
|
|
||||||
let mut received = vec![0; payload.len()];
|
|
||||||
let (len, from) = tokio::time::timeout(timeout, socket_b.recv_from(&mut received))
|
|
||||||
.await
|
|
||||||
.expect("receive maximum UDP payload timed out")
|
|
||||||
.unwrap();
|
|
||||||
assert_eq!(from, addr_a);
|
|
||||||
assert_eq!(len, payload.len());
|
|
||||||
assert_eq!(received, payload);
|
|
||||||
|
|
||||||
stop_data_plane_pair(&a, &b).await;
|
stop_data_plane_pair(&a, &b).await;
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn udp_socket_drop_releases_every_destination_flow() {
|
async fn udp_fixed_ports_are_owned_by_each_instance_until_socket_drop() {
|
||||||
let (a, b) = setup_data_plane_pair().await;
|
let (a, b) = setup_data_plane_pair().await;
|
||||||
let timeout = Duration::from_secs(10);
|
let timeout = Duration::from_secs(10);
|
||||||
let socket = a.gateway.data_plane_udp_bind(0, timeout).await.unwrap();
|
let first = a.gateway.data_plane_udp_bind(48000, timeout).await.unwrap();
|
||||||
let first = SocketAddr::new(b.ip.address().into(), 31001);
|
let second = b.gateway.data_plane_udp_bind(48000, timeout).await.unwrap();
|
||||||
let second = SocketAddr::new(b.ip.address().into(), 31002);
|
let error = a
|
||||||
|
.gateway
|
||||||
socket.send_to(b"one", first).await.unwrap();
|
.data_plane_udp_bind(48000, timeout)
|
||||||
socket.send_to(b"two", second).await.unwrap();
|
.await
|
||||||
assert_eq!(a.gateway.entries.count(), 2);
|
.err()
|
||||||
|
.unwrap();
|
||||||
drop(socket);
|
assert_eq!(error.kind(), DataPlaneErrorKind::AddressInUse);
|
||||||
|
assert_eq!(a.gateway.entries.count(), 1);
|
||||||
|
send_udp(&first, &second, b"same-port").await;
|
||||||
|
let addr = first.local_addr();
|
||||||
|
drop(first);
|
||||||
assert_eq!(a.gateway.entries.count(), 0);
|
assert_eq!(a.gateway.entries.count(), 0);
|
||||||
|
let rebound = a.gateway.data_plane_udp_bind(48000, timeout).await.unwrap();
|
||||||
|
assert_eq!(rebound.local_addr(), addr);
|
||||||
|
send_udp(&second, &rebound, b"rebound").await;
|
||||||
stop_data_plane_pair(&a, &b).await;
|
stop_data_plane_pair(&a, &b).await;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||||
|
async fn concurrent_udp_binds_have_exactly_one_owner() {
|
||||||
|
let host = Arc::new(TestHost::default());
|
||||||
|
let endpoint = data_plane_endpoint(host, "10.126.132.1/24".parse().unwrap());
|
||||||
|
endpoint.peer_manager.run().await.unwrap();
|
||||||
|
endpoint.gateway.start_runtime().await.unwrap();
|
||||||
|
let barrier = Arc::new(tokio::sync::Barrier::new(8));
|
||||||
|
let mut tasks = JoinSet::new();
|
||||||
|
for _ in 0..8 {
|
||||||
|
let gateway = endpoint.gateway.clone();
|
||||||
|
let barrier = barrier.clone();
|
||||||
|
tasks.spawn(async move {
|
||||||
|
barrier.wait().await;
|
||||||
|
gateway
|
||||||
|
.data_plane_udp_bind(48000, Duration::from_secs(10))
|
||||||
|
.await
|
||||||
|
});
|
||||||
|
}
|
||||||
|
let mut sockets = Vec::new();
|
||||||
|
while let Some(result) = tasks.join_next().await {
|
||||||
|
match result.unwrap() {
|
||||||
|
Ok(socket) => sockets.push(socket),
|
||||||
|
Err(error) => assert_eq!(error.kind(), DataPlaneErrorKind::AddressInUse),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert_eq!(sockets.len(), 1);
|
||||||
|
assert_eq!(endpoint.gateway.entries.count(), 1);
|
||||||
|
assert_eq!(endpoint.gateway.host.udp_binds.load(Ordering::Relaxed), 0);
|
||||||
|
drop(sockets);
|
||||||
|
assert_eq!(endpoint.gateway.entries.count(), 0);
|
||||||
|
endpoint.gateway.stop_runtime().await;
|
||||||
|
endpoint.peer_manager.clear_resources().await;
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn udp_socket_owns_a_host_port_reservation() {
|
async fn udp_ephemeral_allocation_advances_past_collisions() {
|
||||||
|
let endpoint = data_plane_endpoint(
|
||||||
|
Arc::new(TestHost::default()),
|
||||||
|
"10.126.132.1/24".parse().unwrap(),
|
||||||
|
);
|
||||||
|
endpoint.peer_manager.run().await.unwrap();
|
||||||
|
endpoint.gateway.start_runtime().await.unwrap();
|
||||||
|
let gateway = &endpoint.gateway;
|
||||||
|
let timeout = Duration::from_secs(10);
|
||||||
|
let fixed = gateway.data_plane_udp_bind(11000, timeout).await.unwrap();
|
||||||
|
for port in 10001..11000 {
|
||||||
|
let addr = SocketAddr::new(endpoint.ip.address().into(), port);
|
||||||
|
gateway
|
||||||
|
.entries
|
||||||
|
.insert(FlowKey::udp_bind(addr), FlowData::Udp);
|
||||||
|
}
|
||||||
|
let socket = gateway.data_plane_udp_bind(0, timeout).await.unwrap();
|
||||||
|
assert_eq!(socket.local_addr().port(), 11001);
|
||||||
|
let net = gateway.net.lock().await.as_ref().unwrap().net.clone();
|
||||||
|
assert_eq!(net.get_port(), 11002);
|
||||||
|
let next = gateway.data_plane_udp_bind(0, timeout).await.unwrap();
|
||||||
|
assert_eq!(next.local_addr().port(), 11003);
|
||||||
|
|
||||||
|
let mut slow = net.ephemeral_ports();
|
||||||
|
let last = net.ephemeral_ports().nth(10).unwrap();
|
||||||
|
slow.nth(2);
|
||||||
|
assert_eq!(net.get_port(), last + 1);
|
||||||
|
drop((fixed, socket, next));
|
||||||
|
gateway.stop_runtime().await;
|
||||||
|
endpoint.peer_manager.clear_resources().await;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn udp_ephemeral_bind_wraps_and_reports_exhaustion() {
|
||||||
let host = Arc::new(TestHost::default());
|
let host = Arc::new(TestHost::default());
|
||||||
let endpoint = data_plane_endpoint(host.clone(), "10.126.132.1/24".parse().unwrap());
|
let endpoint = data_plane_endpoint(host.clone(), "10.126.132.1/24".parse().unwrap());
|
||||||
endpoint.peer_manager.run().await.unwrap();
|
endpoint.peer_manager.run().await.unwrap();
|
||||||
endpoint.gateway.start_runtime().await.unwrap();
|
endpoint.gateway.start_runtime().await.unwrap();
|
||||||
|
let gateway = &endpoint.gateway;
|
||||||
|
let timeout = Duration::from_secs(10);
|
||||||
|
let fixed = gateway.data_plane_udp_bind(60001, timeout).await.unwrap();
|
||||||
|
// Leave only the port reached after wrapping past the end of the range.
|
||||||
|
for port in 10001..60001 {
|
||||||
|
let addr = SocketAddr::new(endpoint.ip.address().into(), port);
|
||||||
|
gateway
|
||||||
|
.entries
|
||||||
|
.insert(FlowKey::udp_bind(addr), FlowData::Udp);
|
||||||
|
}
|
||||||
|
let socket = gateway.data_plane_udp_bind(0, timeout).await.unwrap();
|
||||||
|
assert_eq!(socket.local_addr().port(), 10000);
|
||||||
|
let error = gateway.data_plane_udp_bind(0, timeout).await.err().unwrap();
|
||||||
|
assert_eq!(error.kind(), DataPlaneErrorKind::ResourceLimit);
|
||||||
|
assert_eq!(host.udp_binds.load(Ordering::Relaxed), 0);
|
||||||
|
|
||||||
let socket = endpoint
|
drop((fixed, socket));
|
||||||
.gateway
|
gateway.stop_runtime().await;
|
||||||
.data_plane_udp_bind(0, Duration::from_secs(1))
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
assert_eq!(socket.local_addr().port(), 20002);
|
|
||||||
assert_eq!(host.udp_binds.load(Ordering::Relaxed), 1);
|
|
||||||
|
|
||||||
drop(socket);
|
|
||||||
endpoint.gateway.stop_runtime().await;
|
|
||||||
endpoint.peer_manager.clear_resources().await;
|
endpoint.peer_manager.clear_resources().await;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -833,7 +928,7 @@ async fn data_plane_consumes_modified_data_when_entry_matches() {
|
|||||||
packet.fill_peer_manager_hdr(1, 1, packet_type as u8);
|
packet.fill_peer_manager_hdr(1, 1, packet_type as u8);
|
||||||
|
|
||||||
let result = gateway.try_process_packet_from_peer(packet).await;
|
let result = gateway.try_process_packet_from_peer(packet).await;
|
||||||
assert!(result.is_none());
|
assert!(matches!(result, PeerPacketFilterResult::Consumed));
|
||||||
|
|
||||||
let mut receiver = gateway.packet_recv.lock().await;
|
let mut receiver = gateway.packet_recv.lock().await;
|
||||||
let received = receiver.try_recv().unwrap();
|
let received = receiver.try_recv().unwrap();
|
||||||
@@ -864,12 +959,12 @@ async fn data_plane_passes_through_unmatched_or_malformed_modified_data() {
|
|||||||
ZCPacket::new_with_payload(&build_tcp_packet(remote, unmatched_local));
|
ZCPacket::new_with_payload(&build_tcp_packet(remote, unmatched_local));
|
||||||
unmatched_packet.fill_peer_manager_hdr(1, 2, PacketType::DataWithKcpSrcModified as u8);
|
unmatched_packet.fill_peer_manager_hdr(1, 2, PacketType::DataWithKcpSrcModified as u8);
|
||||||
let result = gateway.try_process_packet_from_peer(unmatched_packet).await;
|
let result = gateway.try_process_packet_from_peer(unmatched_packet).await;
|
||||||
assert!(result.is_some());
|
assert!(matches!(result, PeerPacketFilterResult::Pass(_)));
|
||||||
|
|
||||||
let mut malformed_packet = ZCPacket::new_with_payload(&[0u8; 8]);
|
let mut malformed_packet = ZCPacket::new_with_payload(&[0u8; 8]);
|
||||||
malformed_packet.fill_peer_manager_hdr(1, 2, PacketType::DataWithQuicSrcModified as u8);
|
malformed_packet.fill_peer_manager_hdr(1, 2, PacketType::DataWithQuicSrcModified as u8);
|
||||||
let result = gateway.try_process_packet_from_peer(malformed_packet).await;
|
let result = gateway.try_process_packet_from_peer(malformed_packet).await;
|
||||||
assert!(result.is_some());
|
assert!(matches!(result, PeerPacketFilterResult::Pass(_)));
|
||||||
|
|
||||||
let mut receiver = gateway.packet_recv.lock().await;
|
let mut receiver = gateway.packet_recv.lock().await;
|
||||||
assert!(receiver.try_recv().is_err());
|
assert!(receiver.try_recv().is_err());
|
||||||
@@ -897,47 +992,204 @@ async fn data_plane_passes_through_non_loopback_modified_data_when_entry_matches
|
|||||||
packet.fill_peer_manager_hdr(1, 2, PacketType::DataWithKcpSrcModified as u8);
|
packet.fill_peer_manager_hdr(1, 2, PacketType::DataWithKcpSrcModified as u8);
|
||||||
|
|
||||||
let result = gateway.try_process_packet_from_peer(packet).await;
|
let result = gateway.try_process_packet_from_peer(packet).await;
|
||||||
assert!(result.is_some());
|
assert!(matches!(result, PeerPacketFilterResult::Pass(_)));
|
||||||
|
|
||||||
let mut receiver = gateway.packet_recv.lock().await;
|
let mut receiver = gateway.packet_recv.lock().await;
|
||||||
assert!(receiver.try_recv().is_err());
|
assert!(receiver.try_recv().is_err());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn data_plane_mirrors_fragmented_udp_when_entry_matches() {
|
async fn data_plane_routes_udp_fragments_together() {
|
||||||
|
for port in [None, Some(UDP_LOCAL.port() + 1), Some(UDP_LOCAL.port())] {
|
||||||
|
for reverse in [false, true] {
|
||||||
|
let gateway = test_gateway();
|
||||||
|
gateway.runtime_config.update_peer_with(|peer| {
|
||||||
|
peer.runtime.core.routes.ipv4 =
|
||||||
|
Some(IpPrefix::new(UDP_LOCAL_IP.into(), 24).unwrap());
|
||||||
|
});
|
||||||
|
if let Some(port) = port {
|
||||||
|
gateway.entries.insert(
|
||||||
|
FlowKey::udp_bind(SocketAddr::new(UDP_LOCAL_IP.into(), port)),
|
||||||
|
FlowData::Udp,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let mut downstream = Vec::new();
|
||||||
|
let mut delivered = Vec::new();
|
||||||
|
let mut expected = Vec::new();
|
||||||
|
// More than smoltcp's 16 slots, with every tail arriving before any first.
|
||||||
|
for index in if reverse { [1, 0] } else { [0, 1] } {
|
||||||
|
for id in 0..32 {
|
||||||
|
let mut packet = build_udp_fragments().into_iter().nth(index).unwrap();
|
||||||
|
let mut ip = Ipv4Packet::new_unchecked(packet.mut_payload());
|
||||||
|
ip.set_ident(id);
|
||||||
|
ip.fill_checksum();
|
||||||
|
expected.push(packet.clone().into_bytes());
|
||||||
|
match gateway.try_process_packet_from_peer(packet).await {
|
||||||
|
PeerPacketFilterResult::Consumed => {}
|
||||||
|
PeerPacketFilterResult::Pass(packet) => {
|
||||||
|
downstream.push(packet.into_bytes())
|
||||||
|
}
|
||||||
|
PeerPacketFilterResult::PassBatch(packets) => {
|
||||||
|
downstream.extend(packets.into_iter().map(ZCPacket::into_bytes))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let mut receiver = gateway.packet_recv.lock().await;
|
||||||
|
while let Ok(packet) = receiver.try_recv() {
|
||||||
|
delivered.push(packet.into_bytes());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
expected.sort();
|
||||||
|
downstream.sort();
|
||||||
|
delivered.sort();
|
||||||
|
if port == Some(UDP_LOCAL.port()) {
|
||||||
|
assert_eq!(delivered, expected);
|
||||||
|
assert!(downstream.is_empty());
|
||||||
|
} else {
|
||||||
|
assert_eq!(downstream, expected);
|
||||||
|
assert!(delivered.is_empty());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn data_plane_only_buffers_eligible_local_udp_fragments() {
|
||||||
let gateway = test_gateway();
|
let gateway = test_gateway();
|
||||||
|
gateway.runtime_config.update_peer_with(|peer| {
|
||||||
let local = SocketAddr::new(IpAddr::V4(Ipv4Addr::new(10, 144, 144, 1)), 40000);
|
peer.runtime.core.routes.ipv4 = Some(IpPrefix::new(UDP_LOCAL_IP.into(), 24).unwrap());
|
||||||
let remote = SocketAddr::new(IpAddr::V4(Ipv4Addr::new(10, 144, 144, 3)), 53);
|
});
|
||||||
gateway.entries.insert(
|
gateway
|
||||||
FlowKey {
|
.entries
|
||||||
src: local,
|
.insert(FlowKey::udp_bind(UDP_LOCAL), FlowData::Udp);
|
||||||
dst: remote,
|
for (destination, packet_type, to_peer, buffered) in [
|
||||||
kind: UDP_ENTRY,
|
(UDP_LOCAL_IP, PacketType::Data, 2, true),
|
||||||
},
|
(Ipv4Addr::new(10, 144, 144, 9), PacketType::Data, 2, false),
|
||||||
FlowData::Udp,
|
(UDP_LOCAL_IP, PacketType::DataWithKcpSrcModified, 1, true),
|
||||||
);
|
(UDP_LOCAL_IP, PacketType::DataWithKcpSrcModified, 2, false),
|
||||||
assert_eq!(gateway.entries.count(), 1);
|
(UDP_LOCAL_IP, PacketType::DataWithQuicSrcModified, 1, true),
|
||||||
|
(UDP_LOCAL_IP, PacketType::DataWithQuicSrcModified, 2, false),
|
||||||
let mut packet = ZCPacket::new_with_payload(&build_udp_followup_fragment(
|
] {
|
||||||
match remote.ip() {
|
let [_, mut tail] = build_udp_fragments();
|
||||||
IpAddr::V4(ip) => ip,
|
let mut ip = Ipv4Packet::new_unchecked(tail.mut_payload());
|
||||||
IpAddr::V6(_) => unreachable!(),
|
ip.set_dst_addr(destination);
|
||||||
},
|
ip.fill_checksum();
|
||||||
match local.ip() {
|
tail.fill_peer_manager_hdr(1, to_peer, packet_type as u8);
|
||||||
IpAddr::V4(ip) => ip,
|
let expected = tail.clone().into_bytes();
|
||||||
IpAddr::V6(_) => unreachable!(),
|
let result = gateway.try_process_packet_from_peer(tail).await;
|
||||||
},
|
if buffered {
|
||||||
));
|
assert!(matches!(result, PeerPacketFilterResult::Consumed));
|
||||||
packet.fill_peer_manager_hdr(1, 2, PacketType::Data as u8);
|
} else {
|
||||||
|
let PeerPacketFilterResult::Pass(packet) = result else {
|
||||||
let result = gateway.try_process_packet_from_peer(packet).await;
|
panic!("unrelated fragment buffered")
|
||||||
assert!(result.is_some());
|
};
|
||||||
|
assert_eq!(packet.into_bytes(), expected);
|
||||||
|
}
|
||||||
|
assert!(gateway.packet_recv.lock().await.try_recv().is_err());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let mut receiver = gateway.packet_recv.lock().await;
|
#[tokio::test]
|
||||||
let received = receiver.try_recv().unwrap();
|
async fn data_plane_udp_receives_fragments_after_unbound_datagrams() {
|
||||||
assert_eq!(
|
let endpoint = data_plane_endpoint(
|
||||||
received.peer_manager_header().unwrap().packet_type,
|
Arc::new(TestHost::default()),
|
||||||
PacketType::Data as u8
|
"10.144.144.1/24".parse().unwrap(),
|
||||||
);
|
);
|
||||||
|
endpoint.peer_manager.run().await.unwrap();
|
||||||
|
endpoint.gateway.start_runtime().await.unwrap();
|
||||||
|
let timeout = Duration::from_secs(10);
|
||||||
|
let socket = endpoint
|
||||||
|
.gateway
|
||||||
|
.data_plane_udp_bind(UDP_LOCAL.port(), timeout)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
// Unrelated complete datagrams must never leave orphan tails in smoltcp.
|
||||||
|
for id in 0..32 {
|
||||||
|
for mut packet in build_udp_fragments() {
|
||||||
|
let mut ip = Ipv4Packet::new_unchecked(packet.mut_payload());
|
||||||
|
ip.set_ident(id);
|
||||||
|
if ip.frag_offset() == 0 {
|
||||||
|
let mut udp = UdpPacket::new_unchecked(ip.payload_mut());
|
||||||
|
udp.set_dst_port(UDP_LOCAL.port() + 1);
|
||||||
|
udp.set_checksum(0);
|
||||||
|
}
|
||||||
|
ip.fill_checksum();
|
||||||
|
assert!(matches!(
|
||||||
|
endpoint.gateway.try_process_packet_from_peer(packet).await,
|
||||||
|
PeerPacketFilterResult::Pass(_)
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let [first, tail] = build_udp_fragments();
|
||||||
|
for packet in [tail, first] {
|
||||||
|
assert!(matches!(
|
||||||
|
endpoint.gateway.try_process_packet_from_peer(packet).await,
|
||||||
|
PeerPacketFilterResult::Consumed
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let mut data = [0; 32];
|
||||||
|
let (len, from) = tokio::time::timeout(timeout, socket.recv_from(&mut data))
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(&data[..len], b"fragmented-first");
|
||||||
|
assert_eq!(from, UDP_REMOTE);
|
||||||
|
// A datagram larger than either MTU remains a set of original small packets.
|
||||||
|
for port in [UDP_LOCAL.port(), UDP_LOCAL.port() + 1] {
|
||||||
|
let mut udp = vec![0; 8 + 4096];
|
||||||
|
let mut udp_packet = UdpPacket::new_unchecked(&mut udp);
|
||||||
|
udp_packet.set_src_port(UDP_REMOTE.port());
|
||||||
|
udp_packet.set_dst_port(port);
|
||||||
|
udp_packet.set_len(4104);
|
||||||
|
udp_packet.payload_mut().fill(0xab);
|
||||||
|
udp_packet.fill_checksum(&UDP_REMOTE.ip().into(), &UDP_LOCAL.ip().into());
|
||||||
|
let [template, _] = build_udp_fragments();
|
||||||
|
let mut fragments = Vec::new();
|
||||||
|
for (index, payload) in udp.chunks(1200).enumerate() {
|
||||||
|
let mut bytes = template.payload()[..20].to_vec();
|
||||||
|
bytes.extend_from_slice(payload);
|
||||||
|
let mut ip = Ipv4Packet::new_unchecked(&mut bytes);
|
||||||
|
ip.set_ident(port);
|
||||||
|
ip.set_total_len((20 + payload.len()) as u16);
|
||||||
|
ip.set_frag_offset((index * 1200) as u16);
|
||||||
|
ip.set_more_frags((index + 1) * 1200 < udp.len());
|
||||||
|
ip.fill_checksum();
|
||||||
|
let mut packet = ZCPacket::new_with_payload(&bytes);
|
||||||
|
packet.fill_peer_manager_hdr(1, 2, PacketType::Data as u8);
|
||||||
|
fragments.push(packet);
|
||||||
|
}
|
||||||
|
let mut expected = fragments
|
||||||
|
.iter()
|
||||||
|
.map(|p| p.clone().into_bytes())
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
let mut passed = Vec::new();
|
||||||
|
for packet in fragments.into_iter().rev() {
|
||||||
|
match endpoint.gateway.try_process_packet_from_peer(packet).await {
|
||||||
|
PeerPacketFilterResult::Consumed => {}
|
||||||
|
PeerPacketFilterResult::Pass(_) => panic!("tails should wait for the first"),
|
||||||
|
PeerPacketFilterResult::PassBatch(packets) => {
|
||||||
|
assert!(packets.iter().all(|packet| packet.payload_len() <= 1280));
|
||||||
|
passed.extend(packets.into_iter().map(ZCPacket::into_bytes));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if port == UDP_LOCAL.port() {
|
||||||
|
assert!(passed.is_empty());
|
||||||
|
let mut data = vec![0; 4096];
|
||||||
|
let (len, from) = tokio::time::timeout(timeout, socket.recv_from(&mut data))
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(len, 4096);
|
||||||
|
assert_eq!(data, vec![0xab; 4096]);
|
||||||
|
assert_eq!(from, UDP_REMOTE);
|
||||||
|
} else {
|
||||||
|
expected.sort();
|
||||||
|
passed.sort();
|
||||||
|
assert_eq!(passed, expected);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
drop(socket);
|
||||||
|
endpoint.gateway.stop_runtime().await;
|
||||||
|
endpoint.peer_manager.clear_resources().await;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,23 +1,13 @@
|
|||||||
//! UDP socket resources exposed by the data plane.
|
//! UDP socket resources exposed by the data plane.
|
||||||
|
|
||||||
use std::{
|
use std::{net::SocketAddr, sync::Arc};
|
||||||
any::Any,
|
|
||||||
collections::{HashMap, hash_map::Entry},
|
|
||||||
net::SocketAddr,
|
|
||||||
sync::{Arc, Mutex},
|
|
||||||
};
|
|
||||||
|
|
||||||
use super::{
|
use super::{DataPlaneIoGuard, DataPlaneLease, DataPlaneUdpIo, FlowData, FlowLease};
|
||||||
DataPlaneIoGuard, DataPlaneLease, DataPlaneUdpIo, FlowData, FlowKey, FlowLease, FlowSet,
|
|
||||||
UDP_ENTRY,
|
|
||||||
};
|
|
||||||
|
|
||||||
pub struct DataPlaneUdpSocket {
|
pub struct DataPlaneUdpSocket {
|
||||||
pub(super) socket: Arc<DataPlaneUdpIo>,
|
pub(super) socket: Arc<DataPlaneUdpIo>,
|
||||||
pub(super) flows: FlowSet,
|
pub(super) _bind_flow: FlowLease<FlowData>,
|
||||||
pub(super) routes: Mutex<HashMap<SocketAddr, FlowLease<FlowData>>>,
|
|
||||||
pub(super) local_addr: SocketAddr,
|
pub(super) local_addr: SocketAddr,
|
||||||
pub(super) _reservation: Arc<dyn Any + Send + Sync>,
|
|
||||||
pub(super) _data_plane_lease: DataPlaneLease,
|
pub(super) _data_plane_lease: DataPlaneLease,
|
||||||
pub(super) generation: DataPlaneIoGuard,
|
pub(super) generation: DataPlaneIoGuard,
|
||||||
}
|
}
|
||||||
@@ -31,21 +21,6 @@ impl DataPlaneUdpSocket {
|
|||||||
self.generation
|
self.generation
|
||||||
.ensure_open()
|
.ensure_open()
|
||||||
.map_err(|error| error.into_io_error())?;
|
.map_err(|error| error.into_io_error())?;
|
||||||
let key = FlowKey {
|
|
||||||
src: self.local_addr,
|
|
||||||
dst: addr,
|
|
||||||
kind: UDP_ENTRY,
|
|
||||||
};
|
|
||||||
if let Entry::Vacant(route) = self.routes.lock().unwrap().entry(addr) {
|
|
||||||
let lease = FlowLease::try_register(self.flows.clone(), key, FlowData::Udp)
|
|
||||||
.ok_or_else(|| {
|
|
||||||
std::io::Error::new(
|
|
||||||
std::io::ErrorKind::AddrInUse,
|
|
||||||
"data-plane UDP flow already exists",
|
|
||||||
)
|
|
||||||
})?;
|
|
||||||
route.insert(lease);
|
|
||||||
}
|
|
||||||
tokio::select! {
|
tokio::select! {
|
||||||
biased;
|
biased;
|
||||||
_ = self.generation.closed() => Err(self.generation.closed_io_error()),
|
_ = self.generation.closed() => Err(self.generation.closed_io_error()),
|
||||||
|
|||||||
@@ -0,0 +1,381 @@
|
|||||||
|
//! Route original UDP fragments together without assembling their payloads.
|
||||||
|
|
||||||
|
use std::{
|
||||||
|
net::{Ipv4Addr, SocketAddr},
|
||||||
|
time::Duration,
|
||||||
|
};
|
||||||
|
|
||||||
|
use ordered_hash_map::OrderedHashMap;
|
||||||
|
use smoltcp::wire::{Ipv4Packet, UDP_HEADER_LEN, UdpPacket};
|
||||||
|
use tokio::{sync::mpsc, time::Instant};
|
||||||
|
|
||||||
|
use super::{FlowKey, FlowSet, PeerPacketFilterResult};
|
||||||
|
use crate::packet::ZCPacket;
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Hash, PartialEq, Eq)]
|
||||||
|
struct UdpFragmentKey {
|
||||||
|
source: Ipv4Addr,
|
||||||
|
destination: Ipv4Addr,
|
||||||
|
id: u16,
|
||||||
|
from_peer: u32,
|
||||||
|
to_peer: u32,
|
||||||
|
packet_type: u8,
|
||||||
|
exit_node: bool,
|
||||||
|
no_proxy: bool,
|
||||||
|
not_send_to_tun: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
struct UdpFragmentRoute {
|
||||||
|
entry: FlowKey,
|
||||||
|
registration: Option<u64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
struct UdpFragment {
|
||||||
|
last_seen: Instant,
|
||||||
|
route: Option<UdpFragmentRoute>,
|
||||||
|
pending: Vec<ZCPacket>,
|
||||||
|
bytes: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl UdpFragment {
|
||||||
|
fn new(now: Instant) -> Self {
|
||||||
|
Self {
|
||||||
|
last_seen: now,
|
||||||
|
route: None,
|
||||||
|
pending: Vec::new(),
|
||||||
|
bytes: 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Default)]
|
||||||
|
pub(super) struct UdpFragments {
|
||||||
|
local_ip: Option<Ipv4Addr>,
|
||||||
|
packets: OrderedHashMap<UdpFragmentKey, UdpFragment>,
|
||||||
|
bytes: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl UdpFragments {
|
||||||
|
const CAPACITY: usize = 1024;
|
||||||
|
// Budget retained packet bytes, not allocator overhead.
|
||||||
|
const MAX_BYTES: usize = 1024 * 1024;
|
||||||
|
// A shallow cache cannot detect completion. Randomizing each sending stack's
|
||||||
|
// seed avoids deterministic IP ID reuse after a restart, but 16-bit IDs can
|
||||||
|
// still collide. We accept occasional UDP loss or delivery to the cached
|
||||||
|
// downstream route instead of tracking fragment ranges to detect completion.
|
||||||
|
// Hits refresh this idle timeout, so repeated collisions can prolong it.
|
||||||
|
// Expired/evicted unknown tails wait for a first fragment.
|
||||||
|
const TIMEOUT: Duration = Duration::from_secs(1);
|
||||||
|
|
||||||
|
pub(super) fn set_local_ip(&mut self, local_ip: Option<Ipv4Addr>) {
|
||||||
|
if self.local_ip != local_ip {
|
||||||
|
*self = Self {
|
||||||
|
local_ip,
|
||||||
|
..Self::default()
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn evict_oldest(&mut self) {
|
||||||
|
if let Some(packet) = self.packets.pop_front() {
|
||||||
|
self.bytes -= packet.bytes;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn remove_expired(&mut self) {
|
||||||
|
let now = Instant::now();
|
||||||
|
while self
|
||||||
|
.packets
|
||||||
|
.front()
|
||||||
|
.is_some_and(|packet| now.duration_since(packet.last_seen) >= Self::TIMEOUT)
|
||||||
|
{
|
||||||
|
self.evict_oldest();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn process(
|
||||||
|
&mut self,
|
||||||
|
packet: ZCPacket,
|
||||||
|
local_ip: Option<Ipv4Addr>,
|
||||||
|
flows: &FlowSet,
|
||||||
|
sender: &mpsc::Sender<ZCPacket>,
|
||||||
|
) -> PeerPacketFilterResult {
|
||||||
|
self.set_local_ip(local_ip);
|
||||||
|
// The caller has already classified a valid IPv4 UDP fragment.
|
||||||
|
let ip = Ipv4Packet::new_checked(packet.payload()).unwrap();
|
||||||
|
if Some(ip.dst_addr()) != local_ip {
|
||||||
|
return PeerPacketFilterResult::Pass(packet);
|
||||||
|
}
|
||||||
|
let payload = ip.payload();
|
||||||
|
if !ip.verify_checksum()
|
||||||
|
|| payload.is_empty()
|
||||||
|
|| (ip.more_frags() && !payload.len().is_multiple_of(8))
|
||||||
|
|| usize::from(ip.frag_offset()) + payload.len() > usize::from(u16::MAX) - 20
|
||||||
|
{
|
||||||
|
return PeerPacketFilterResult::Consumed;
|
||||||
|
}
|
||||||
|
let port = if ip.frag_offset() == 0 {
|
||||||
|
if payload.len() < UDP_HEADER_LEN {
|
||||||
|
return PeerPacketFilterResult::Consumed;
|
||||||
|
}
|
||||||
|
// Its length covers the whole datagram, not just this fragment.
|
||||||
|
let udp = UdpPacket::new_unchecked(payload);
|
||||||
|
if usize::from(udp.len()) < UDP_HEADER_LEN {
|
||||||
|
return PeerPacketFilterResult::Consumed;
|
||||||
|
}
|
||||||
|
Some(udp.dst_port())
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
let header = packet.peer_manager_header().unwrap();
|
||||||
|
let key = UdpFragmentKey {
|
||||||
|
source: ip.src_addr(),
|
||||||
|
destination: ip.dst_addr(),
|
||||||
|
id: ip.ident(),
|
||||||
|
from_peer: header.from_peer_id.get(),
|
||||||
|
to_peer: header.to_peer_id.get(),
|
||||||
|
packet_type: header.packet_type,
|
||||||
|
exit_node: header.is_exit_node(),
|
||||||
|
no_proxy: header.is_no_proxy(),
|
||||||
|
not_send_to_tun: header.is_not_send_to_tun(),
|
||||||
|
};
|
||||||
|
self.remove_expired();
|
||||||
|
let now = Instant::now();
|
||||||
|
let mut context = self
|
||||||
|
.packets
|
||||||
|
.get_mut(&key)
|
||||||
|
.map(|context| std::mem::replace(context, UdpFragment::new(now)))
|
||||||
|
.unwrap_or_else(|| UdpFragment::new(now));
|
||||||
|
self.bytes -= context.bytes;
|
||||||
|
context.last_seen = now;
|
||||||
|
if context.route.is_none()
|
||||||
|
&& let Some(port) = port
|
||||||
|
{
|
||||||
|
let entry = FlowKey::udp_bind(SocketAddr::new(key.destination.into(), port));
|
||||||
|
let registration = flows.registration(&entry);
|
||||||
|
context.route = Some(UdpFragmentRoute {
|
||||||
|
entry,
|
||||||
|
registration,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
let result = if let Some(route) = &context.route {
|
||||||
|
context.bytes = 0;
|
||||||
|
// Preserve ownership across close/rebind, including downstream routes.
|
||||||
|
if let Some(registration) = route.registration {
|
||||||
|
if flows.registration(&route.entry) == Some(registration) {
|
||||||
|
for packet in std::iter::once(packet).chain(context.pending.drain(..)) {
|
||||||
|
if let Err(error) = sender.try_send(packet) {
|
||||||
|
tracing::trace!(?error, "data plane fragment queue full or closed");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
context.pending = Vec::new();
|
||||||
|
PeerPacketFilterResult::Consumed
|
||||||
|
} else if context.pending.is_empty() {
|
||||||
|
PeerPacketFilterResult::Pass(packet)
|
||||||
|
} else {
|
||||||
|
context.pending.push(packet);
|
||||||
|
PeerPacketFilterResult::PassBatch(std::mem::take(&mut context.pending))
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
context.bytes += packet.buf_len();
|
||||||
|
context.pending.push(packet);
|
||||||
|
PeerPacketFilterResult::Consumed
|
||||||
|
};
|
||||||
|
self.bytes += context.bytes;
|
||||||
|
// Replacing an entry moves it to the back, keeping expiry and LRU order.
|
||||||
|
self.packets.insert(key, context);
|
||||||
|
while self.packets.len() > Self::CAPACITY || self.bytes > Self::MAX_BYTES {
|
||||||
|
self.evict_oldest();
|
||||||
|
}
|
||||||
|
result
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use super::super::{
|
||||||
|
FlowData, FlowTable,
|
||||||
|
tests::{UDP_LOCAL_IP, build_udp_fragments},
|
||||||
|
};
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn recreated_sending_stack_does_not_replay_fragment_routes() {
|
||||||
|
use super::super::stack::SmoltcpPlane;
|
||||||
|
use crate::gateway::smoltcp::{Net, NetConfig, channel_device};
|
||||||
|
|
||||||
|
let flows = Arc::new(FlowTable::default());
|
||||||
|
let local = SocketAddr::new(UDP_LOCAL_IP.into(), 40000);
|
||||||
|
flows.insert(FlowKey::udp_bind(local), FlowData::Udp);
|
||||||
|
let (sender, mut receiver) = mpsc::channel(16);
|
||||||
|
let mut cache = UdpFragments::default();
|
||||||
|
// Use controlled seeds: random 16-bit IDs may legitimately collide.
|
||||||
|
// Each iteration drops the old sender and creates a real new stack.
|
||||||
|
for (seed, port) in [(1, 40001), (2, 40000)] {
|
||||||
|
let mut capabilities = smoltcp::phy::DeviceCapabilities::default();
|
||||||
|
capabilities.medium = smoltcp::phy::Medium::Ip;
|
||||||
|
capabilities.max_transmission_unit = 1284;
|
||||||
|
let (device, _input, mut output) = channel_device::ChannelDevice::new(capabilities);
|
||||||
|
let net = Net::new(
|
||||||
|
device,
|
||||||
|
NetConfig::new(
|
||||||
|
SmoltcpPlane::interface_config(seed),
|
||||||
|
"10.144.144.3/24".parse().unwrap(),
|
||||||
|
Vec::new(),
|
||||||
|
None,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
let socket = net
|
||||||
|
.udp_bind("10.144.144.3:53".parse().unwrap())
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
socket
|
||||||
|
.send_to(&[0xab; 4096], SocketAddr::new(UDP_LOCAL_IP.into(), port))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let mut count = 0;
|
||||||
|
loop {
|
||||||
|
let bytes = tokio::time::timeout(Duration::from_secs(1), output.recv())
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.unwrap();
|
||||||
|
let ip = Ipv4Packet::new_checked(bytes.as_slice()).unwrap();
|
||||||
|
assert!(ip.more_frags() || ip.frag_offset() != 0);
|
||||||
|
let last = !ip.more_frags();
|
||||||
|
// Keep the old route live regardless of test-machine scheduling.
|
||||||
|
for context in cache.packets.values_mut() {
|
||||||
|
context.last_seen = Instant::now();
|
||||||
|
}
|
||||||
|
let mut packet = ZCPacket::new_with_payload(&bytes);
|
||||||
|
packet.fill_peer_manager_hdr(1, 2, crate::packet::PacketType::Data as u8);
|
||||||
|
let result = cache.process(packet, Some(UDP_LOCAL_IP), &flows, &sender);
|
||||||
|
if port == local.port() {
|
||||||
|
assert!(matches!(result, PeerPacketFilterResult::Consumed));
|
||||||
|
assert_eq!(receiver.try_recv().unwrap().payload(), bytes);
|
||||||
|
} else {
|
||||||
|
assert!(matches!(result, PeerPacketFilterResult::Pass(_)));
|
||||||
|
assert!(receiver.try_recv().is_err());
|
||||||
|
}
|
||||||
|
count += 1;
|
||||||
|
if last {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert_eq!(count, 4);
|
||||||
|
}
|
||||||
|
assert_eq!(cache.packets.len(), 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn routes_survive_binding_changes() {
|
||||||
|
for bound in [false, true] {
|
||||||
|
let flows = Arc::new(FlowTable::default());
|
||||||
|
let entry = FlowKey::udp_bind(SocketAddr::new(UDP_LOCAL_IP.into(), 40000));
|
||||||
|
if bound {
|
||||||
|
flows.insert(entry.clone(), FlowData::Udp);
|
||||||
|
}
|
||||||
|
let (sender, mut receiver) = mpsc::channel(4);
|
||||||
|
let mut cache = UdpFragments::default();
|
||||||
|
let [first, tail] = build_udp_fragments();
|
||||||
|
let result = cache.process(first, Some(UDP_LOCAL_IP), &flows, &sender);
|
||||||
|
assert_eq!(matches!(result, PeerPacketFilterResult::Consumed), bound);
|
||||||
|
if bound {
|
||||||
|
receiver.try_recv().unwrap();
|
||||||
|
}
|
||||||
|
flows.clear();
|
||||||
|
flows.insert(entry, FlowData::Udp);
|
||||||
|
let result = cache.process(tail, Some(UDP_LOCAL_IP), &flows, &sender);
|
||||||
|
assert_eq!(matches!(result, PeerPacketFilterResult::Consumed), bound);
|
||||||
|
assert!(
|
||||||
|
receiver.try_recv().is_err(),
|
||||||
|
"old fragments reached a new binding"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn cache_expires_and_resets_on_address_change() {
|
||||||
|
let flows = Arc::new(FlowTable::default());
|
||||||
|
let (sender, _receiver) = mpsc::channel(4);
|
||||||
|
let mut cache = UdpFragments::default();
|
||||||
|
let [first, tail] = build_udp_fragments();
|
||||||
|
cache.process(tail.clone(), Some(UDP_LOCAL_IP), &flows, &sender);
|
||||||
|
assert!(cache.bytes > 0);
|
||||||
|
for context in cache.packets.values_mut() {
|
||||||
|
context.last_seen -= UdpFragments::TIMEOUT;
|
||||||
|
}
|
||||||
|
cache.remove_expired();
|
||||||
|
assert!(cache.packets.is_empty());
|
||||||
|
assert_eq!(cache.bytes, 0);
|
||||||
|
assert!(matches!(
|
||||||
|
cache.process(first, Some(UDP_LOCAL_IP), &flows, &sender),
|
||||||
|
PeerPacketFilterResult::Pass(_)
|
||||||
|
));
|
||||||
|
cache.process(tail, Some(UDP_LOCAL_IP), &flows, &sender);
|
||||||
|
cache.set_local_ip(None);
|
||||||
|
assert!(cache.packets.is_empty());
|
||||||
|
assert_eq!(cache.bytes, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn cache_limits_pending_bytes_and_evicts_least_recent_entry() {
|
||||||
|
let flows = Arc::new(FlowTable::default());
|
||||||
|
let (sender, _receiver) = mpsc::channel(4);
|
||||||
|
let mut cache = UdpFragments::default();
|
||||||
|
for id in 0..=UdpFragments::CAPACITY as u16 {
|
||||||
|
let [_, mut tail] = build_udp_fragments();
|
||||||
|
let mut ip = Ipv4Packet::new_unchecked(tail.mut_payload());
|
||||||
|
ip.set_ident(id);
|
||||||
|
ip.fill_checksum();
|
||||||
|
cache.process(tail, Some(UDP_LOCAL_IP), &flows, &sender);
|
||||||
|
}
|
||||||
|
assert_eq!(cache.packets.len(), UdpFragments::CAPACITY);
|
||||||
|
assert!(!cache.packets.keys().any(|key| key.id == 0));
|
||||||
|
let [_, tail] = build_udp_fragments();
|
||||||
|
cache.process(tail.clone(), Some(UDP_LOCAL_IP), &flows, &sender);
|
||||||
|
let refreshed = *cache.packets.keys().find(|key| key.id == 42).unwrap();
|
||||||
|
cache.evict_oldest();
|
||||||
|
assert!(cache.packets.contains_key(&refreshed));
|
||||||
|
cache.set_local_ip(None);
|
||||||
|
let mut payload = tail.payload().to_vec();
|
||||||
|
payload.resize(65516, 0);
|
||||||
|
let mut ip = Ipv4Packet::new_unchecked(&mut payload);
|
||||||
|
ip.set_total_len(65516);
|
||||||
|
ip.fill_checksum();
|
||||||
|
let mut large = ZCPacket::new_with_payload(&payload);
|
||||||
|
large.fill_peer_manager_hdr(1, 2, crate::packet::PacketType::Data as u8);
|
||||||
|
for _ in 0..=UdpFragments::MAX_BYTES / large.buf_len() {
|
||||||
|
cache.process(large.clone(), Some(UDP_LOCAL_IP), &flows, &sender);
|
||||||
|
assert!(cache.bytes <= UdpFragments::MAX_BYTES);
|
||||||
|
}
|
||||||
|
assert_eq!(
|
||||||
|
cache.bytes, 0,
|
||||||
|
"over-budget pending datagram was not evicted"
|
||||||
|
);
|
||||||
|
assert!(cache.packets.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pending_fragments_do_not_cross_peer_or_delivery_flags() {
|
||||||
|
let flows = Arc::new(FlowTable::default());
|
||||||
|
let (sender, _receiver) = mpsc::channel(4);
|
||||||
|
for change_peer in [true, false] {
|
||||||
|
let mut cache = UdpFragments::default();
|
||||||
|
let [first, mut tail] = build_udp_fragments();
|
||||||
|
if change_peer {
|
||||||
|
tail.mut_peer_manager_header().unwrap().from_peer_id.set(9);
|
||||||
|
} else {
|
||||||
|
tail.mut_peer_manager_header().unwrap().set_no_proxy(false);
|
||||||
|
}
|
||||||
|
cache.process(tail, Some(UDP_LOCAL_IP), &flows, &sender);
|
||||||
|
assert!(matches!(
|
||||||
|
cache.process(first, Some(UDP_LOCAL_IP), &flows, &sender),
|
||||||
|
PeerPacketFilterResult::Pass(_)
|
||||||
|
));
|
||||||
|
assert!(cache.bytes > 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+717
-237
File diff suppressed because it is too large
Load Diff
@@ -27,6 +27,6 @@ pub use dataplane::{
|
|||||||
DataPlaneTcpListener, DataPlaneTcpStream, DataPlaneUdpSocket,
|
DataPlaneTcpListener, DataPlaneTcpStream, DataPlaneUdpSocket,
|
||||||
};
|
};
|
||||||
#[cfg(feature = "proxy-smoltcp-stack")]
|
#[cfg(feature = "proxy-smoltcp-stack")]
|
||||||
pub(crate) use port_forward::PortForwardAdapter;
|
pub(crate) use port_forward::{PortForwardAdapter, PreparedPortForwards};
|
||||||
#[cfg(feature = "proxy-smoltcp-stack")]
|
#[cfg(feature = "proxy-smoltcp-stack")]
|
||||||
pub(crate) use socks5::Socks5GatewayAdapter;
|
pub(crate) use socks5::Socks5GatewayAdapter;
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
//! Host port-forward adapter backed by the data-plane runtime.
|
//! Host port-forward adapter backed by the data-plane runtime.
|
||||||
|
|
||||||
use std::{
|
use std::{
|
||||||
|
collections::HashMap,
|
||||||
net::SocketAddr,
|
net::SocketAddr,
|
||||||
sync::{
|
sync::{
|
||||||
Arc,
|
Arc,
|
||||||
@@ -9,17 +10,17 @@ use std::{
|
|||||||
time::Duration,
|
time::Duration,
|
||||||
};
|
};
|
||||||
|
|
||||||
use crossbeam::atomic::AtomicCell;
|
use crossbeam_utils::atomic::AtomicCell;
|
||||||
use dashmap::DashMap;
|
use dashmap::DashMap;
|
||||||
use quanta::Instant;
|
use quanta::Instant;
|
||||||
use tokio::{
|
use tokio::{
|
||||||
select,
|
select,
|
||||||
sync::{Mutex, OwnedMutexGuard, OwnedSemaphorePermit, Semaphore},
|
sync::{Mutex, OwnedMutexGuard, OwnedSemaphorePermit, Semaphore},
|
||||||
task::JoinSet,
|
task::{JoinHandle, JoinSet},
|
||||||
};
|
};
|
||||||
use tokio_util::{
|
use tokio_util::{
|
||||||
sync::{CancellationToken, DropGuard},
|
sync::CancellationToken,
|
||||||
task::AbortOnDropHandle,
|
task::{AbortOnDropHandle, TaskTracker},
|
||||||
};
|
};
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
@@ -94,24 +95,133 @@ struct UdpClientReservation {
|
|||||||
admission: OwnedMutexGuard<()>,
|
admission: OwnedMutexGuard<()>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
struct PortForwardListener {
|
||||||
|
config: Arc<std::sync::Mutex<PortForwardConfig>>,
|
||||||
|
cancel: CancellationToken,
|
||||||
|
task: AbortOnDropHandle<()>,
|
||||||
|
udp_tasks: TaskTracker,
|
||||||
|
}
|
||||||
|
|
||||||
|
enum PortForwardSocket<H: VirtualTcpListenerFactory + VirtualUdpSocketFactory> {
|
||||||
|
Tcp(Arc<H::Listener>),
|
||||||
|
Udp(Arc<<H as VirtualUdpSocketFactory>::Socket>),
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Bound additions stay private until persistence and validation have succeeded.
|
||||||
|
/// Dropping a prepared update leaves every existing listener and target intact.
|
||||||
|
pub(crate) struct PreparedPortForwards<H>
|
||||||
|
where
|
||||||
|
H: VirtualTcpSocketFactory + VirtualTcpListenerFactory + VirtualUdpSocketFactory,
|
||||||
|
{
|
||||||
|
adapter: Arc<PortForwardAdapter<H>>,
|
||||||
|
configs: HashMap<(String, SocketAddr), PortForwardConfig>,
|
||||||
|
sockets: Vec<(PortForwardConfig, PortForwardSocket<H>)>,
|
||||||
|
consumer_lease: Option<DataPlaneConsumerLease>,
|
||||||
|
// Release prepared sockets before allowing another update to bind them.
|
||||||
|
operation: OwnedMutexGuard<()>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<H> PreparedPortForwards<H>
|
||||||
|
where
|
||||||
|
H: VirtualTcpSocketFactory + VirtualTcpListenerFactory + VirtualUdpSocketFactory,
|
||||||
|
{
|
||||||
|
/// All fallible work is complete. Publish targets and start the bound sockets
|
||||||
|
/// without yielding, then retain the operation lock until removals release
|
||||||
|
/// their sockets, even if the caller stops waiting for this cleanup.
|
||||||
|
pub(crate) fn commit(self) -> JoinHandle<()> {
|
||||||
|
let Self {
|
||||||
|
adapter,
|
||||||
|
operation,
|
||||||
|
configs,
|
||||||
|
sockets,
|
||||||
|
consumer_lease,
|
||||||
|
} = self;
|
||||||
|
let _runtime = adapter.runtime_handle.as_ref().map(|handle| handle.enter());
|
||||||
|
let mut listeners = adapter.listeners.lock().unwrap();
|
||||||
|
let mut removed = Vec::new();
|
||||||
|
let mut response_tasks = Vec::new();
|
||||||
|
let removed_keys = listeners
|
||||||
|
.keys()
|
||||||
|
.filter(|key| !configs.contains_key(*key))
|
||||||
|
.cloned()
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
for key in removed_keys {
|
||||||
|
let listener = listeners.remove(&key).unwrap();
|
||||||
|
listener.cancel.cancel();
|
||||||
|
listener.task.abort();
|
||||||
|
{
|
||||||
|
let current = listener.config.lock().unwrap();
|
||||||
|
tracing::info!(?current, "port-forward removed by runtime config reload");
|
||||||
|
adapter.remove_udp_clients(¤t, &mut response_tasks);
|
||||||
|
}
|
||||||
|
removed.push(listener);
|
||||||
|
}
|
||||||
|
for (key, listener) in listeners.iter() {
|
||||||
|
let mut current = listener.config.lock().unwrap();
|
||||||
|
if let Some(next) = configs.get(key)
|
||||||
|
&& *current != *next
|
||||||
|
{
|
||||||
|
adapter.remove_udp_clients(¤t, &mut response_tasks);
|
||||||
|
*current = next.clone();
|
||||||
|
adapter
|
||||||
|
.events
|
||||||
|
.emit(CoreEvent::GatewayPortForwardAdded(next.clone()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (cfg, socket) in sockets {
|
||||||
|
let key = (cfg.proto.clone(), cfg.bind_addr);
|
||||||
|
let listener = match socket {
|
||||||
|
PortForwardSocket::Tcp(socket) => adapter.add_tcp_port_forward(&cfg, socket),
|
||||||
|
PortForwardSocket::Udp(socket) => adapter.add_udp_port_forward(&cfg, socket),
|
||||||
|
};
|
||||||
|
listeners.insert(key, listener);
|
||||||
|
adapter.events.emit(CoreEvent::GatewayPortForwardAdded(cfg));
|
||||||
|
}
|
||||||
|
drop(listeners);
|
||||||
|
let mut lease = adapter.consumer_lease.lock().unwrap();
|
||||||
|
if let Some(consumer_lease) = consumer_lease {
|
||||||
|
lease.replace(consumer_lease);
|
||||||
|
}
|
||||||
|
let released_lease = if configs.is_empty() {
|
||||||
|
lease.take()
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
drop(lease);
|
||||||
|
tokio::spawn(async move {
|
||||||
|
let _operation = operation;
|
||||||
|
for listener in removed {
|
||||||
|
let _ = listener.task.await;
|
||||||
|
listener.udp_tasks.close();
|
||||||
|
listener.udp_tasks.wait().await;
|
||||||
|
}
|
||||||
|
for task in response_tasks {
|
||||||
|
let _ = task.await;
|
||||||
|
}
|
||||||
|
drop(released_lease);
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub(crate) struct PortForwardAdapter<H>
|
pub(crate) struct PortForwardAdapter<H>
|
||||||
where
|
where
|
||||||
H: VirtualTcpSocketFactory + VirtualTcpListenerFactory + VirtualUdpSocketFactory,
|
H: VirtualTcpSocketFactory + VirtualTcpListenerFactory + VirtualUdpSocketFactory,
|
||||||
{
|
{
|
||||||
operation: Mutex<()>,
|
operation: Arc<Mutex<()>>,
|
||||||
started: AtomicBool,
|
started: AtomicBool,
|
||||||
|
runtime_handle: Option<tokio::runtime::Handle>,
|
||||||
runtime_config: CoreRuntimeConfigStore,
|
runtime_config: CoreRuntimeConfigStore,
|
||||||
data_plane: Arc<DataPlaneRuntime<H>>,
|
data_plane: Arc<DataPlaneRuntime<H>>,
|
||||||
host: Arc<H>,
|
host: Arc<H>,
|
||||||
socket_context: SocketContext,
|
socket_context: SocketContext,
|
||||||
events: Arc<dyn CoreEventSink>,
|
events: Arc<dyn CoreEventSink>,
|
||||||
tasks: Arc<std::sync::Mutex<JoinSet<()>>>,
|
tasks: Arc<std::sync::Mutex<JoinSet<()>>>,
|
||||||
cancel_tokens: Arc<DashMap<PortForwardConfig, DropGuard>>,
|
listeners: std::sync::Mutex<HashMap<(String, SocketAddr), PortForwardListener>>,
|
||||||
udp_clients: Arc<DashMap<UdpClientKey, Arc<UdpClientInfo<H>>>>,
|
udp_clients: Arc<DashMap<UdpClientKey, Arc<UdpClientInfo<H>>>>,
|
||||||
udp_response_tasks: Arc<DashMap<UdpClientKey, AbortOnDropHandle<()>>>,
|
udp_response_tasks: Arc<DashMap<UdpClientKey, AbortOnDropHandle<()>>>,
|
||||||
udp_client_admission: Arc<Mutex<()>>,
|
udp_client_admission: Arc<Mutex<()>>,
|
||||||
udp_client_slots: Arc<Semaphore>,
|
udp_client_slots: Arc<Semaphore>,
|
||||||
consumer_lease: Mutex<Option<DataPlaneConsumerLease>>,
|
consumer_lease: std::sync::Mutex<Option<DataPlaneConsumerLease>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl<H> PortForwardAdapter<H>
|
impl<H> PortForwardAdapter<H>
|
||||||
@@ -124,27 +234,29 @@ where
|
|||||||
host: Arc<H>,
|
host: Arc<H>,
|
||||||
socket_context: SocketContext,
|
socket_context: SocketContext,
|
||||||
events: Arc<dyn CoreEventSink>,
|
events: Arc<dyn CoreEventSink>,
|
||||||
|
runtime_handle: Option<tokio::runtime::Handle>,
|
||||||
) -> Arc<Self> {
|
) -> Arc<Self> {
|
||||||
Arc::new(Self {
|
Arc::new(Self {
|
||||||
operation: Mutex::new(()),
|
operation: Arc::new(Mutex::new(())),
|
||||||
started: AtomicBool::new(false),
|
started: AtomicBool::new(false),
|
||||||
|
runtime_handle,
|
||||||
runtime_config,
|
runtime_config,
|
||||||
data_plane,
|
data_plane,
|
||||||
host,
|
host,
|
||||||
socket_context,
|
socket_context,
|
||||||
events,
|
events,
|
||||||
tasks: Arc::new(std::sync::Mutex::new(JoinSet::new())),
|
tasks: Arc::new(std::sync::Mutex::new(JoinSet::new())),
|
||||||
cancel_tokens: Arc::new(DashMap::new()),
|
listeners: std::sync::Mutex::new(HashMap::new()),
|
||||||
udp_clients: Arc::new(DashMap::new()),
|
udp_clients: Arc::new(DashMap::new()),
|
||||||
udp_response_tasks: Arc::new(DashMap::new()),
|
udp_response_tasks: Arc::new(DashMap::new()),
|
||||||
udp_client_admission: Arc::new(Mutex::new(())),
|
udp_client_admission: Arc::new(Mutex::new(())),
|
||||||
udp_client_slots: Arc::new(Semaphore::new(MAX_ACTIVE_UDP_CLIENTS)),
|
udp_client_slots: Arc::new(Semaphore::new(MAX_ACTIVE_UDP_CLIENTS)),
|
||||||
consumer_lease: Mutex::new(None),
|
consumer_lease: std::sync::Mutex::new(None),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) async fn start(&self) -> anyhow::Result<()> {
|
pub(crate) async fn start(self: &Arc<Self>) -> anyhow::Result<()> {
|
||||||
let _operation = self.operation.lock().await;
|
let operation = self.operation.clone().lock_owned().await;
|
||||||
if self.started.load(Ordering::Acquire) {
|
if self.started.load(Ordering::Acquire) {
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
@@ -160,95 +272,157 @@ where
|
|||||||
.gateway
|
.gateway
|
||||||
.port_forwards
|
.port_forwards
|
||||||
.clone();
|
.clone();
|
||||||
if let Err(error) = self.apply_port_forwards(&cfgs).await {
|
let prepared = match self.prepare_inner(&cfgs, operation).await {
|
||||||
self.stop_inner().await;
|
Ok(prepared) => prepared,
|
||||||
return Err(error);
|
Err(error) => {
|
||||||
}
|
self.stop_inner().await;
|
||||||
|
return Err(error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let cleanup = prepared.commit();
|
||||||
self.started.store(true, Ordering::Release);
|
self.started.store(true, Ordering::Release);
|
||||||
|
let _ = cleanup.await;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) async fn reload(&self, cfgs: &[PortForwardConfig]) -> anyhow::Result<()> {
|
pub(crate) async fn prepare(
|
||||||
let _operation = self.operation.lock().await;
|
self: &Arc<Self>,
|
||||||
if !self.started.load(Ordering::Acquire) {
|
cfgs: &[PortForwardConfig],
|
||||||
return Ok(());
|
) -> anyhow::Result<Option<PreparedPortForwards<H>>> {
|
||||||
|
if let Some(runtime) = &self.runtime_handle {
|
||||||
|
let adapter = self.clone();
|
||||||
|
let cfgs = cfgs.to_vec();
|
||||||
|
// Binding must happen on the instance executor, including reloads
|
||||||
|
// reached through management config patches.
|
||||||
|
return AbortOnDropHandle::new(
|
||||||
|
runtime.spawn(async move { adapter.prepare_on_runtime(&cfgs).await }),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map_err(|error| anyhow::anyhow!("port-forward prepare task failed: {error}"))?;
|
||||||
}
|
}
|
||||||
self.apply_port_forwards(cfgs).await
|
self.prepare_on_runtime(cfgs).await
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn apply_port_forwards(&self, cfgs: &[PortForwardConfig]) -> anyhow::Result<()> {
|
async fn prepare_on_runtime(
|
||||||
|
self: &Arc<Self>,
|
||||||
|
cfgs: &[PortForwardConfig],
|
||||||
|
) -> anyhow::Result<Option<PreparedPortForwards<H>>> {
|
||||||
|
let operation = self.operation.clone().lock_owned().await;
|
||||||
|
if !self.started.load(Ordering::Acquire) {
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
self.prepare_inner(cfgs, operation).await.map(Some)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn prepare_inner(
|
||||||
|
self: &Arc<Self>,
|
||||||
|
cfgs: &[PortForwardConfig],
|
||||||
|
operation: OwnedMutexGuard<()>,
|
||||||
|
) -> anyhow::Result<PreparedPortForwards<H>> {
|
||||||
|
let mut configs = HashMap::new();
|
||||||
for cfg in cfgs {
|
for cfg in cfgs {
|
||||||
if !matches!(cfg.proto.to_lowercase().as_str(), "tcp" | "udp") {
|
let mut cfg = cfg.clone();
|
||||||
|
cfg.proto.make_ascii_lowercase();
|
||||||
|
if !matches!(cfg.proto.as_str(), "tcp" | "udp") {
|
||||||
anyhow::bail!(
|
anyhow::bail!(
|
||||||
"unsupported protocol: {}, only support udp / tcp",
|
"unsupported protocol: {}, only support udp / tcp",
|
||||||
cfg.proto
|
cfg.proto
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
let key = (cfg.proto.clone(), cfg.bind_addr);
|
||||||
|
if let Some(previous) = configs.insert(key, cfg.clone()) {
|
||||||
if !cfgs.is_empty() {
|
anyhow::ensure!(
|
||||||
let mut consumer_lease = self.consumer_lease.lock().await;
|
previous == cfg,
|
||||||
if consumer_lease.is_none() {
|
"conflicting port-forward targets for {} {}",
|
||||||
consumer_lease.replace(self.data_plane.acquire_consumer_lease()?);
|
cfg.proto,
|
||||||
|
cfg.bind_addr
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
let consumer_lease = if !configs.is_empty() && self.consumer_lease.lock().unwrap().is_none()
|
||||||
self.cancel_tokens.retain(|current, _| {
|
{
|
||||||
let keep = cfgs.iter().any(|next| {
|
Some(self.data_plane.acquire_consumer_lease()?)
|
||||||
if next.dst_addr.ip().is_unspecified() {
|
} else {
|
||||||
current.bind_addr == next.bind_addr && current.proto == next.proto
|
None
|
||||||
} else {
|
};
|
||||||
current == next
|
let mut sockets = Vec::new();
|
||||||
}
|
|
||||||
});
|
|
||||||
if !keep {
|
|
||||||
tracing::info!(?current, "port-forward removed by runtime config reload");
|
|
||||||
}
|
|
||||||
keep
|
|
||||||
});
|
|
||||||
self.udp_clients
|
|
||||||
.retain(|key, _| self.cancel_tokens.contains_key(&key.forward));
|
|
||||||
self.udp_response_tasks
|
|
||||||
.retain(|key, _| self.udp_clients.contains_key(key));
|
|
||||||
for cfg in cfgs {
|
for cfg in cfgs {
|
||||||
if !self.cancel_tokens.contains_key(cfg) {
|
let mut cfg = cfg.clone();
|
||||||
self.add_port_forward(cfg.clone()).await?;
|
cfg.proto.make_ascii_lowercase();
|
||||||
|
let key = (cfg.proto.clone(), cfg.bind_addr);
|
||||||
|
if self.listeners.lock().unwrap().contains_key(&key)
|
||||||
|
|| sockets
|
||||||
|
.iter()
|
||||||
|
.any(|(bound, _): &(PortForwardConfig, PortForwardSocket<H>)| {
|
||||||
|
bound.proto == cfg.proto && bound.bind_addr == cfg.bind_addr
|
||||||
|
})
|
||||||
|
{
|
||||||
|
continue;
|
||||||
}
|
}
|
||||||
}
|
let socket = if cfg.proto == "tcp" {
|
||||||
if cfgs.is_empty() {
|
PortForwardSocket::Tcp(
|
||||||
self.consumer_lease.lock().await.take();
|
self.host
|
||||||
}
|
.bind_tcp(self.tcp_listen_options(cfg.bind_addr))
|
||||||
Ok(())
|
.await?,
|
||||||
}
|
|
||||||
|
|
||||||
async fn add_port_forward(&self, cfg: PortForwardConfig) -> anyhow::Result<()> {
|
|
||||||
match cfg.proto.to_lowercase().as_str() {
|
|
||||||
"tcp" => self.add_tcp_port_forward(&cfg).await?,
|
|
||||||
"udp" => self.add_udp_port_forward(&cfg).await?,
|
|
||||||
_ => {
|
|
||||||
anyhow::bail!(
|
|
||||||
"unsupported protocol: {}, only support udp / tcp",
|
|
||||||
cfg.proto
|
|
||||||
)
|
)
|
||||||
}
|
} else {
|
||||||
|
PortForwardSocket::Udp(
|
||||||
|
self.host
|
||||||
|
.bind_udp(
|
||||||
|
UdpBindOptions::port_forward(cfg.bind_addr)
|
||||||
|
.with_context(self.socket_context.clone()),
|
||||||
|
)
|
||||||
|
.await?,
|
||||||
|
)
|
||||||
|
};
|
||||||
|
sockets.push((cfg, socket));
|
||||||
}
|
}
|
||||||
self.events.emit(CoreEvent::GatewayPortForwardAdded(cfg));
|
Ok(PreparedPortForwards {
|
||||||
Ok(())
|
adapter: self.clone(),
|
||||||
|
operation,
|
||||||
|
configs,
|
||||||
|
sockets,
|
||||||
|
consumer_lease,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn add_tcp_port_forward(&self, cfg: &PortForwardConfig) -> anyhow::Result<()> {
|
fn tcp_listen_options(&self, bind_addr: SocketAddr) -> TcpListenOptions {
|
||||||
let (bind_addr, dst_addr) = (cfg.bind_addr, cfg.dst_addr);
|
|
||||||
let options = TcpListenOptions::port_forward(bind_addr);
|
let options = TcpListenOptions::port_forward(bind_addr);
|
||||||
let bind = options
|
let bind = options
|
||||||
.bind
|
.bind
|
||||||
.clone()
|
.clone()
|
||||||
.with_context(self.socket_context.clone());
|
.with_context(self.socket_context.clone());
|
||||||
let options = options.with_bind(bind);
|
options.with_bind(bind)
|
||||||
let listener = self.host.bind_tcp(options.clone()).await?;
|
}
|
||||||
|
|
||||||
|
fn remove_udp_clients(&self, cfg: &PortForwardConfig, tasks: &mut Vec<AbortOnDropHandle<()>>) {
|
||||||
|
self.udp_clients.retain(|key, _| key.forward != *cfg);
|
||||||
|
let keys = self
|
||||||
|
.udp_response_tasks
|
||||||
|
.iter()
|
||||||
|
.filter(|entry| entry.key().forward == *cfg)
|
||||||
|
.map(|entry| entry.key().clone())
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
for key in keys {
|
||||||
|
if let Some((_, task)) = self.udp_response_tasks.remove(&key) {
|
||||||
|
task.abort();
|
||||||
|
tasks.push(task);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn add_tcp_port_forward(
|
||||||
|
&self,
|
||||||
|
cfg: &PortForwardConfig,
|
||||||
|
listener: Arc<H::Listener>,
|
||||||
|
) -> PortForwardListener {
|
||||||
|
let (bind_addr, dst_addr) = (cfg.bind_addr, cfg.dst_addr);
|
||||||
|
let options = self.tcp_listen_options(bind_addr);
|
||||||
tracing::info!(?bind_addr, ?dst_addr, "TCP port-forward listener bound");
|
tracing::info!(?bind_addr, ?dst_addr, "TCP port-forward listener bound");
|
||||||
let cancel = CancellationToken::new();
|
let cancel = CancellationToken::new();
|
||||||
self.cancel_tokens
|
let config = Arc::new(std::sync::Mutex::new(cfg.clone()));
|
||||||
.insert(cfg.clone(), cancel.clone().drop_guard());
|
let task_cancel = cancel.clone();
|
||||||
|
let task_config = config.clone();
|
||||||
|
|
||||||
let data_plane = self.data_plane.clone();
|
let data_plane = self.data_plane.clone();
|
||||||
let connections = Arc::new(std::sync::Mutex::new(JoinSet::new()));
|
let connections = Arc::new(std::sync::Mutex::new(JoinSet::new()));
|
||||||
@@ -257,7 +431,8 @@ where
|
|||||||
"TCP port-forward connections",
|
"TCP port-forward connections",
|
||||||
));
|
));
|
||||||
let host = self.host.clone();
|
let host = self.host.clone();
|
||||||
self.tasks.lock().unwrap().spawn(async move {
|
let task = AbortOnDropHandle::new(tokio::spawn(async move {
|
||||||
|
let cancel = task_cancel;
|
||||||
let mut listener = Some(listener);
|
let mut listener = Some(listener);
|
||||||
loop {
|
loop {
|
||||||
let Some((incoming, source_addr)) =
|
let Some((incoming, source_addr)) =
|
||||||
@@ -265,6 +440,7 @@ where
|
|||||||
else {
|
else {
|
||||||
break;
|
break;
|
||||||
};
|
};
|
||||||
|
let dst_addr = task_config.lock().unwrap().dst_addr;
|
||||||
tracing::info!(
|
tracing::info!(
|
||||||
?bind_addr,
|
?bind_addr,
|
||||||
?source_addr,
|
?source_addr,
|
||||||
@@ -299,22 +475,27 @@ where
|
|||||||
?dst_addr,
|
?dst_addr,
|
||||||
"TCP port-forward listener task stopped"
|
"TCP port-forward listener task stopped"
|
||||||
);
|
);
|
||||||
});
|
}));
|
||||||
Ok(())
|
PortForwardListener {
|
||||||
|
config,
|
||||||
|
cancel,
|
||||||
|
task,
|
||||||
|
udp_tasks: TaskTracker::new(),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn add_udp_port_forward(&self, cfg: &PortForwardConfig) -> anyhow::Result<()> {
|
fn add_udp_port_forward(
|
||||||
let (bind_addr, dst_addr) = (cfg.bind_addr, cfg.dst_addr);
|
&self,
|
||||||
let forward = cfg.clone();
|
cfg: &PortForwardConfig,
|
||||||
let socket = self
|
socket: Arc<<H as VirtualUdpSocketFactory>::Socket>,
|
||||||
.host
|
) -> PortForwardListener {
|
||||||
.bind_udp(
|
let bind_addr = cfg.bind_addr;
|
||||||
UdpBindOptions::port_forward(bind_addr).with_context(self.socket_context.clone()),
|
|
||||||
)
|
|
||||||
.await?;
|
|
||||||
let cancel = CancellationToken::new();
|
let cancel = CancellationToken::new();
|
||||||
self.cancel_tokens
|
let config = Arc::new(std::sync::Mutex::new(cfg.clone()));
|
||||||
.insert(cfg.clone(), cancel.clone().drop_guard());
|
let task_cancel = cancel.clone();
|
||||||
|
let task_config = config.clone();
|
||||||
|
let udp_tasks = TaskTracker::new();
|
||||||
|
let response_tracker = udp_tasks.clone();
|
||||||
|
|
||||||
let data_plane = self.data_plane.clone();
|
let data_plane = self.data_plane.clone();
|
||||||
let host = self.host.clone();
|
let host = self.host.clone();
|
||||||
@@ -323,7 +504,8 @@ where
|
|||||||
let response_tasks = self.udp_response_tasks.clone();
|
let response_tasks = self.udp_response_tasks.clone();
|
||||||
let client_admission = self.udp_client_admission.clone();
|
let client_admission = self.udp_client_admission.clone();
|
||||||
let client_slots = self.udp_client_slots.clone();
|
let client_slots = self.udp_client_slots.clone();
|
||||||
self.tasks.lock().unwrap().spawn(async move {
|
let task = AbortOnDropHandle::new(tokio::spawn(async move {
|
||||||
|
let cancel = task_cancel;
|
||||||
let adapter = UdpFlowFactory {
|
let adapter = UdpFlowFactory {
|
||||||
data_plane,
|
data_plane,
|
||||||
host,
|
host,
|
||||||
@@ -342,6 +524,8 @@ where
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
let forward = task_config.lock().unwrap().clone();
|
||||||
|
let dst_addr = forward.dst_addr;
|
||||||
let key = UdpClientKey {
|
let key = UdpClientKey {
|
||||||
client_addr,
|
client_addr,
|
||||||
forward: forward.clone(),
|
forward: forward.clone(),
|
||||||
@@ -376,6 +560,13 @@ where
|
|||||||
continue;
|
continue;
|
||||||
};
|
};
|
||||||
let UdpClientReservation { slot, admission } = reservation;
|
let UdpClientReservation { slot, admission } = reservation;
|
||||||
|
// Commit uses this same lock when replacing the target
|
||||||
|
// and removing its flows. Do not publish an old flow
|
||||||
|
// after either operation, including after an async open.
|
||||||
|
let current = task_config.lock().unwrap();
|
||||||
|
if *current != forward || cancel.is_cancelled() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
let slot = Arc::new(slot);
|
let slot = Arc::new(slot);
|
||||||
let client = Arc::new(UdpClientInfo {
|
let client = Arc::new(UdpClientInfo {
|
||||||
flow: flow.clone(),
|
flow: flow.clone(),
|
||||||
@@ -390,7 +581,7 @@ where
|
|||||||
let response_slot = slot;
|
let response_slot = slot;
|
||||||
response_tasks.insert(
|
response_tasks.insert(
|
||||||
key.clone(),
|
key.clone(),
|
||||||
AbortOnDropHandle::new(tokio::spawn(async move {
|
AbortOnDropHandle::new(response_tracker.spawn(async move {
|
||||||
let _slot = response_slot;
|
let _slot = response_slot;
|
||||||
let mut buf = vec![0u8; MAX_UDP_PAYLOAD_SIZE];
|
let mut buf = vec![0u8; MAX_UDP_PAYLOAD_SIZE];
|
||||||
loop {
|
loop {
|
||||||
@@ -417,6 +608,7 @@ where
|
|||||||
}
|
}
|
||||||
})),
|
})),
|
||||||
);
|
);
|
||||||
|
drop(current);
|
||||||
drop(admission);
|
drop(admission);
|
||||||
client
|
client
|
||||||
}
|
}
|
||||||
@@ -426,9 +618,14 @@ where
|
|||||||
tracing::error!(?error, ?dst_addr, "send UDP data-plane packet failed");
|
tracing::error!(?error, ?dst_addr, "send UDP data-plane packet failed");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
}));
|
||||||
|
|
||||||
Ok(())
|
PortForwardListener {
|
||||||
|
config,
|
||||||
|
cancel,
|
||||||
|
task,
|
||||||
|
udp_tasks,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn start_udp_reaper(&self) {
|
fn start_udp_reaper(&self) {
|
||||||
@@ -450,14 +647,29 @@ where
|
|||||||
|
|
||||||
async fn stop_inner(&self) {
|
async fn stop_inner(&self) {
|
||||||
self.started.store(false, Ordering::Release);
|
self.started.store(false, Ordering::Release);
|
||||||
|
let listeners = std::mem::take(&mut *self.listeners.lock().unwrap());
|
||||||
tracing::info!(
|
tracing::info!(
|
||||||
forward_count = self.cancel_tokens.len(),
|
forward_count = listeners.len(),
|
||||||
"port-forward adapter stopping"
|
"port-forward adapter stopping"
|
||||||
);
|
);
|
||||||
self.cancel_tokens.clear();
|
for listener in listeners.values() {
|
||||||
|
listener.cancel.cancel();
|
||||||
|
listener.task.abort();
|
||||||
|
}
|
||||||
|
// First stop publishers, then remove response handles. The trackers
|
||||||
|
// include responses whose handles the reaper/eviction already dropped.
|
||||||
|
let mut trackers = Vec::new();
|
||||||
|
for (_, listener) in listeners {
|
||||||
|
let _ = listener.task.await;
|
||||||
|
trackers.push(listener.udp_tasks);
|
||||||
|
}
|
||||||
self.udp_response_tasks.clear();
|
self.udp_response_tasks.clear();
|
||||||
self.udp_clients.clear();
|
self.udp_clients.clear();
|
||||||
self.consumer_lease.lock().await.take();
|
for tracker in trackers {
|
||||||
|
tracker.close();
|
||||||
|
tracker.wait().await;
|
||||||
|
}
|
||||||
|
self.consumer_lease.lock().unwrap().take();
|
||||||
let mut tasks = {
|
let mut tasks = {
|
||||||
let mut tasks = self.tasks.lock().unwrap();
|
let mut tasks = self.tasks.lock().unwrap();
|
||||||
std::mem::replace(&mut *tasks, JoinSet::new())
|
std::mem::replace(&mut *tasks, JoinSet::new())
|
||||||
@@ -621,6 +833,9 @@ where
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod reload_tests;
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use std::{io, sync::atomic::AtomicUsize};
|
use std::{io, sync::atomic::AtomicUsize};
|
||||||
@@ -892,15 +1107,8 @@ mod tests {
|
|||||||
response_tasks.insert(replacement.clone(), pending_response_task(replacement_slot));
|
response_tasks.insert(replacement.clone(), pending_response_task(replacement_slot));
|
||||||
drop(admission_guard);
|
drop(admission_guard);
|
||||||
|
|
||||||
tokio::time::timeout(Duration::from_secs(1), async {
|
// Release the test's permit reference and wait for the full eviction;
|
||||||
while clients.contains_key(&replacement) {
|
// the client and response-task entries are removed separately.
|
||||||
tokio::task::yield_now().await;
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
assert!(!response_tasks.contains_key(&replacement));
|
|
||||||
|
|
||||||
drop(replacement_client);
|
drop(replacement_client);
|
||||||
let second_slot = tokio::time::timeout(Duration::from_secs(1), second)
|
let second_slot = tokio::time::timeout(Duration::from_secs(1), second)
|
||||||
.await
|
.await
|
||||||
@@ -908,6 +1116,9 @@ mod tests {
|
|||||||
.unwrap()
|
.unwrap()
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
|
assert!(!clients.contains_key(&replacement));
|
||||||
|
assert!(!response_tasks.contains_key(&replacement));
|
||||||
|
|
||||||
drop(second_slot);
|
drop(second_slot);
|
||||||
tokio::task::yield_now().await;
|
tokio::task::yield_now().await;
|
||||||
assert_eq!(slots.available_permits(), 1);
|
assert_eq!(slots.available_permits(), 1);
|
||||||
|
|||||||
@@ -0,0 +1,290 @@
|
|||||||
|
use std::{io, sync::Weak};
|
||||||
|
|
||||||
|
use tokio::sync::{Notify, mpsc, oneshot};
|
||||||
|
|
||||||
|
use super::*;
|
||||||
|
use crate::{
|
||||||
|
config::{IpPrefix, peers::PeerRuntimeSnapshot, runtime::CoreRuntimeConfig},
|
||||||
|
host::{
|
||||||
|
packet::{HostPacketReceiver, host_packet_channel},
|
||||||
|
testkit::TestHost,
|
||||||
|
},
|
||||||
|
peers::peer_manager::{PeerManagerCore, PortablePeerManagerConfig},
|
||||||
|
socket::tcp::TcpConnectOptions,
|
||||||
|
};
|
||||||
|
|
||||||
|
type Datagram = (Vec<u8>, SocketAddr);
|
||||||
|
|
||||||
|
struct ReloadUdpSocket {
|
||||||
|
address: SocketAddr,
|
||||||
|
incoming: Option<Mutex<mpsc::UnboundedReceiver<Datagram>>>,
|
||||||
|
sent: mpsc::UnboundedSender<Datagram>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait::async_trait]
|
||||||
|
impl VirtualUdpSocket for ReloadUdpSocket {
|
||||||
|
fn local_addr(&self) -> io::Result<SocketAddr> {
|
||||||
|
Ok(self.address)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn send_to(&self, data: &[u8], address: SocketAddr) -> io::Result<usize> {
|
||||||
|
self.sent.send((data.to_vec(), address)).unwrap();
|
||||||
|
Ok(data.len())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn recv_from(&self, buf: &mut [u8]) -> io::Result<(usize, SocketAddr)> {
|
||||||
|
let Some(incoming) = &self.incoming else {
|
||||||
|
return std::future::pending().await;
|
||||||
|
};
|
||||||
|
let (data, address) = incoming.lock().await.recv().await.unwrap();
|
||||||
|
buf[..data.len()].copy_from_slice(&data);
|
||||||
|
Ok((data.len(), address))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct ReloadUdpHost {
|
||||||
|
incoming: std::sync::Mutex<Option<mpsc::UnboundedReceiver<Datagram>>>,
|
||||||
|
sent: mpsc::UnboundedSender<Datagram>,
|
||||||
|
sockets: std::sync::Mutex<Vec<Weak<ReloadUdpSocket>>>,
|
||||||
|
open_started: Notify,
|
||||||
|
open_release: std::sync::Mutex<Option<oneshot::Receiver<()>>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait::async_trait]
|
||||||
|
impl VirtualTcpSocketFactory for ReloadUdpHost {
|
||||||
|
type Socket = <TestHost as VirtualTcpSocketFactory>::Socket;
|
||||||
|
|
||||||
|
async fn connect_tcp(&self, options: TcpConnectOptions) -> anyhow::Result<Self::Socket> {
|
||||||
|
TestHost::default().connect_tcp(options).await
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait::async_trait]
|
||||||
|
impl VirtualTcpListenerFactory for ReloadUdpHost {
|
||||||
|
type Listener = <TestHost as VirtualTcpListenerFactory>::Listener;
|
||||||
|
|
||||||
|
async fn bind_tcp(&self, options: TcpListenOptions) -> anyhow::Result<Arc<Self::Listener>> {
|
||||||
|
TestHost::default().bind_tcp(options).await
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait::async_trait]
|
||||||
|
impl VirtualUdpSocketFactory for ReloadUdpHost {
|
||||||
|
type Socket = ReloadUdpSocket;
|
||||||
|
|
||||||
|
async fn bind_udp(&self, options: UdpBindOptions) -> anyhow::Result<Arc<Self::Socket>> {
|
||||||
|
let address = options.local_addr.unwrap();
|
||||||
|
let incoming = if address.port() != 0 {
|
||||||
|
self.incoming.lock().unwrap().take().map(Mutex::new)
|
||||||
|
} else {
|
||||||
|
let release = self.open_release.lock().unwrap().take();
|
||||||
|
if let Some(release) = release {
|
||||||
|
self.open_started.notify_one();
|
||||||
|
release.await.unwrap();
|
||||||
|
}
|
||||||
|
None
|
||||||
|
};
|
||||||
|
let socket = Arc::new(ReloadUdpSocket {
|
||||||
|
address,
|
||||||
|
incoming,
|
||||||
|
sent: self.sent.clone(),
|
||||||
|
});
|
||||||
|
self.sockets.lock().unwrap().push(Arc::downgrade(&socket));
|
||||||
|
Ok(socket)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct UdpReloadTest {
|
||||||
|
adapter: Arc<PortForwardAdapter<ReloadUdpHost>>,
|
||||||
|
host: Arc<ReloadUdpHost>,
|
||||||
|
incoming: mpsc::UnboundedSender<Datagram>,
|
||||||
|
sent: mpsc::UnboundedReceiver<Datagram>,
|
||||||
|
_peer_manager: Arc<PeerManagerCore>,
|
||||||
|
_packets: HostPacketReceiver,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl UdpReloadTest {
|
||||||
|
async fn new() -> Self {
|
||||||
|
let mut runtime = PeerRuntimeSnapshot::default().runtime;
|
||||||
|
runtime.core.routes.ipv4 = Some(IpPrefix::new("10.144.0.2".parse().unwrap(), 24).unwrap());
|
||||||
|
let peer_config = PortablePeerManagerConfig::new(runtime);
|
||||||
|
let runtime_config = CoreRuntimeConfigStore::new(
|
||||||
|
CoreRuntimeConfig::default(),
|
||||||
|
Arc::new(peer_config.snapshot.clone()),
|
||||||
|
);
|
||||||
|
let (packet_sender, packets) = host_packet_channel();
|
||||||
|
let peer_manager =
|
||||||
|
Arc::new(PeerManagerCore::new_portable_for_test(peer_config, packet_sender).unwrap());
|
||||||
|
let (incoming, receiver) = mpsc::unbounded_channel();
|
||||||
|
let (sent, sent_receiver) = mpsc::unbounded_channel();
|
||||||
|
let host = Arc::new(ReloadUdpHost {
|
||||||
|
incoming: std::sync::Mutex::new(Some(receiver)),
|
||||||
|
sent,
|
||||||
|
sockets: std::sync::Mutex::new(Vec::new()),
|
||||||
|
open_started: Notify::new(),
|
||||||
|
open_release: std::sync::Mutex::new(None),
|
||||||
|
});
|
||||||
|
let data_plane = DataPlaneRuntime::new(
|
||||||
|
runtime_config.clone(),
|
||||||
|
peer_manager.clone(),
|
||||||
|
None,
|
||||||
|
host.clone(),
|
||||||
|
SocketContext::default(),
|
||||||
|
);
|
||||||
|
data_plane.start_runtime().await.unwrap();
|
||||||
|
let adapter = PortForwardAdapter::new(
|
||||||
|
runtime_config,
|
||||||
|
data_plane,
|
||||||
|
host.clone(),
|
||||||
|
SocketContext::default(),
|
||||||
|
Arc::new(()),
|
||||||
|
None,
|
||||||
|
);
|
||||||
|
adapter.start().await.unwrap();
|
||||||
|
Self {
|
||||||
|
adapter,
|
||||||
|
host,
|
||||||
|
incoming,
|
||||||
|
sent: sent_receiver,
|
||||||
|
_peer_manager: peer_manager,
|
||||||
|
_packets: packets,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn apply(&self, port: u16) -> PortForwardConfig {
|
||||||
|
let config = PortForwardConfig {
|
||||||
|
proto: "udp".to_owned(),
|
||||||
|
bind_addr: "127.0.0.1:19491".parse().unwrap(),
|
||||||
|
dst_addr: SocketAddr::from(([10, 144, 0, 2], port)),
|
||||||
|
};
|
||||||
|
self.adapter
|
||||||
|
.prepare(std::slice::from_ref(&config))
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.unwrap()
|
||||||
|
.commit()
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
config
|
||||||
|
}
|
||||||
|
|
||||||
|
fn send(&self, payload: &[u8]) {
|
||||||
|
self.incoming
|
||||||
|
.send((payload.to_vec(), "127.0.0.1:40000".parse().unwrap()))
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn receive(&mut self) -> Datagram {
|
||||||
|
tokio::time::timeout(Duration::from_secs(2), self.sent.recv())
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn stop(&self) {
|
||||||
|
self.adapter.stop().await;
|
||||||
|
self.adapter.data_plane.stop_runtime().await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn udp_target_update_reuses_listener_and_releases_old_flow() {
|
||||||
|
let mut test = UdpReloadTest::new().await;
|
||||||
|
let first = test.apply(19381).await;
|
||||||
|
test.send(b"first");
|
||||||
|
assert_eq!(test.receive().await, (b"first".to_vec(), first.dst_addr));
|
||||||
|
let second = test.apply(19382).await;
|
||||||
|
assert!(test.adapter.udp_clients.is_empty());
|
||||||
|
{
|
||||||
|
let sockets = test.host.sockets.lock().unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
sockets.len(),
|
||||||
|
2,
|
||||||
|
"target changes must not bind a new listener"
|
||||||
|
);
|
||||||
|
assert!(sockets[0].upgrade().is_some());
|
||||||
|
assert!(sockets[1].upgrade().is_none(), "old flow must be released");
|
||||||
|
}
|
||||||
|
test.send(b"second");
|
||||||
|
assert_eq!(test.receive().await, (b"second".to_vec(), second.dst_addr));
|
||||||
|
assert_eq!(test.adapter.udp_clients.len(), 1);
|
||||||
|
test.stop().await;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn udp_target_update_during_open_discards_old_flow() {
|
||||||
|
let mut test = UdpReloadTest::new().await;
|
||||||
|
test.apply(19381).await;
|
||||||
|
let (release, waiting) = oneshot::channel();
|
||||||
|
*test.host.open_release.lock().unwrap() = Some(waiting);
|
||||||
|
test.send(b"old");
|
||||||
|
tokio::time::timeout(Duration::from_secs(2), test.host.open_started.notified())
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let next = test.apply(19382).await;
|
||||||
|
release.send(()).unwrap();
|
||||||
|
test.send(b"new");
|
||||||
|
assert_eq!(test.receive().await, (b"new".to_vec(), next.dst_addr));
|
||||||
|
assert!(
|
||||||
|
test.adapter
|
||||||
|
.udp_clients
|
||||||
|
.iter()
|
||||||
|
.all(|entry| entry.key().forward == next)
|
||||||
|
);
|
||||||
|
assert!(test.host.sockets.lock().unwrap()[1].upgrade().is_none());
|
||||||
|
test.stop().await;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(any(target_arch = "wasm32", target_os = "wasi")))]
|
||||||
|
#[tokio::test]
|
||||||
|
async fn udp_removal_waits_for_response_tasks_already_removed_from_map() {
|
||||||
|
let test = UdpReloadTest::new().await;
|
||||||
|
let forward = test.apply(19381).await;
|
||||||
|
let socket = test.host.sockets.lock().unwrap()[0].upgrade().unwrap();
|
||||||
|
let tracker = test
|
||||||
|
.adapter
|
||||||
|
.listeners
|
||||||
|
.lock()
|
||||||
|
.unwrap()
|
||||||
|
.get(&(forward.proto.clone(), forward.bind_addr))
|
||||||
|
.unwrap()
|
||||||
|
.udp_tasks
|
||||||
|
.clone();
|
||||||
|
let (started, ready) = oneshot::channel();
|
||||||
|
let (release, waiting) = std::sync::mpsc::channel();
|
||||||
|
// A running blocking task makes the delay between abort and socket release
|
||||||
|
// deterministic, as can happen when a response task is currently executing.
|
||||||
|
let task = tracker.spawn_blocking(move || {
|
||||||
|
started.send(()).unwrap();
|
||||||
|
waiting.recv().unwrap();
|
||||||
|
drop(socket);
|
||||||
|
});
|
||||||
|
let key = UdpClientKey {
|
||||||
|
client_addr: "127.0.0.1:40000".parse().unwrap(),
|
||||||
|
forward,
|
||||||
|
};
|
||||||
|
test.adapter
|
||||||
|
.udp_response_tasks
|
||||||
|
.insert(key, AbortOnDropHandle::new(task));
|
||||||
|
ready.await.unwrap();
|
||||||
|
test.adapter.udp_response_tasks.clear();
|
||||||
|
let cleanup = test.adapter.prepare(&[]).await.unwrap().unwrap().commit();
|
||||||
|
drop(cleanup);
|
||||||
|
let mut next = std::pin::pin!(test.adapter.prepare(&[]));
|
||||||
|
let result = tokio::time::timeout(Duration::from_millis(20), &mut next).await;
|
||||||
|
let pending = result.is_err();
|
||||||
|
release.send(()).unwrap();
|
||||||
|
let prepared = match result {
|
||||||
|
Ok(prepared) => prepared,
|
||||||
|
Err(_) => tokio::time::timeout(Duration::from_secs(2), next)
|
||||||
|
.await
|
||||||
|
.unwrap(),
|
||||||
|
};
|
||||||
|
prepared.unwrap().unwrap().commit().await.unwrap();
|
||||||
|
assert!(
|
||||||
|
pending,
|
||||||
|
"dropping the cleanup waiter released the operation lock before the socket"
|
||||||
|
);
|
||||||
|
assert!(test.host.sockets.lock().unwrap()[0].upgrade().is_none());
|
||||||
|
test.stop().await;
|
||||||
|
}
|
||||||
@@ -11,11 +11,12 @@ use tokio::{
|
|||||||
sync::mpsc::{UnboundedReceiver, UnboundedSender, unbounded_channel},
|
sync::mpsc::{UnboundedReceiver, UnboundedSender, unbounded_channel},
|
||||||
task::JoinSet,
|
task::JoinSet,
|
||||||
};
|
};
|
||||||
|
use tokio_util::sync::CancellationToken;
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
packet::ZCPacket,
|
packet::ZCPacket,
|
||||||
peers::{
|
peers::{
|
||||||
PeerPacketFilter,
|
PeerPacketFilter, PeerPacketFilterResult,
|
||||||
peer_manager::{PeerManagerCore, PipelineRegistrationGuard},
|
peer_manager::{PeerManagerCore, PipelineRegistrationGuard},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@@ -26,23 +27,6 @@ use super::{
|
|||||||
traits::{IcmpProxyRuntime, IcmpProxySocket, ProxyRuntimeError},
|
traits::{IcmpProxyRuntime, IcmpProxySocket, ProxyRuntimeError},
|
||||||
};
|
};
|
||||||
|
|
||||||
async fn start_icmp_runtime<R: IcmpProxyRuntime + 'static>(
|
|
||||||
runtime: &R,
|
|
||||||
no_tun: bool,
|
|
||||||
) -> Result<Option<Arc<R::Socket>>, ProxyRuntimeError> {
|
|
||||||
match runtime.start_icmp().await {
|
|
||||||
Ok(socket) => Ok(Some(socket)),
|
|
||||||
Err(err) => {
|
|
||||||
runtime.stop_icmp();
|
|
||||||
if !no_tun {
|
|
||||||
return Err(err);
|
|
||||||
}
|
|
||||||
tracing::warn!(?err, "start ICMP runtime failed without TUN");
|
|
||||||
Ok(None)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub struct IcmpProxyService<R: IcmpProxyRuntime + 'static> {
|
pub struct IcmpProxyService<R: IcmpProxyRuntime + 'static> {
|
||||||
peer_manager: Arc<PeerManagerCore>,
|
peer_manager: Arc<PeerManagerCore>,
|
||||||
runtime: Arc<R>,
|
runtime: Arc<R>,
|
||||||
@@ -52,6 +36,8 @@ pub struct IcmpProxyService<R: IcmpProxyRuntime + 'static> {
|
|||||||
pipeline_guard: std::sync::Mutex<Option<PipelineRegistrationGuard>>,
|
pipeline_guard: std::sync::Mutex<Option<PipelineRegistrationGuard>>,
|
||||||
tasks: std::sync::Mutex<JoinSet<()>>,
|
tasks: std::sync::Mutex<JoinSet<()>>,
|
||||||
socket: std::sync::Mutex<Option<Arc<R::Socket>>>,
|
socket: std::sync::Mutex<Option<Arc<R::Socket>>>,
|
||||||
|
socket_init: tokio::sync::Mutex<()>,
|
||||||
|
stopped: CancellationToken,
|
||||||
runtime_started: AtomicBool,
|
runtime_started: AtomicBool,
|
||||||
started: AtomicBool,
|
started: AtomicBool,
|
||||||
}
|
}
|
||||||
@@ -73,6 +59,8 @@ impl<R: IcmpProxyRuntime + 'static> IcmpProxyService<R> {
|
|||||||
pipeline_guard: std::sync::Mutex::new(None),
|
pipeline_guard: std::sync::Mutex::new(None),
|
||||||
tasks: std::sync::Mutex::new(JoinSet::new()),
|
tasks: std::sync::Mutex::new(JoinSet::new()),
|
||||||
socket: std::sync::Mutex::new(None),
|
socket: std::sync::Mutex::new(None),
|
||||||
|
socket_init: tokio::sync::Mutex::new(()),
|
||||||
|
stopped: CancellationToken::new(),
|
||||||
runtime_started: AtomicBool::new(false),
|
runtime_started: AtomicBool::new(false),
|
||||||
started: AtomicBool::new(false),
|
started: AtomicBool::new(false),
|
||||||
})
|
})
|
||||||
@@ -84,17 +72,6 @@ impl<R: IcmpProxyRuntime + 'static> IcmpProxyService<R> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let snapshot = self.runtime.proxy_runtime_snapshot();
|
let snapshot = self.runtime.proxy_runtime_snapshot();
|
||||||
let socket = match start_icmp_runtime(self.runtime.as_ref(), snapshot.no_tun).await {
|
|
||||||
Ok(socket) => socket,
|
|
||||||
Err(err) => {
|
|
||||||
self.started.store(false, Ordering::Release);
|
|
||||||
return Err(err);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
let runtime_started = socket.is_some();
|
|
||||||
self.runtime_started
|
|
||||||
.store(runtime_started, Ordering::Release);
|
|
||||||
self.socket.lock().unwrap().clone_from(&socket);
|
|
||||||
|
|
||||||
if let Some(mut response_rx) = self.response_rx.lock().unwrap().take() {
|
if let Some(mut response_rx) = self.response_rx.lock().unwrap().take() {
|
||||||
let peer_manager = self.peer_manager.clone();
|
let peer_manager = self.peer_manager.clone();
|
||||||
@@ -113,33 +90,6 @@ impl<R: IcmpProxyRuntime + 'static> IcmpProxyService<R> {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Some(socket) = socket {
|
|
||||||
let service = Arc::downgrade(self);
|
|
||||||
self.tasks.lock().unwrap().spawn(async move {
|
|
||||||
loop {
|
|
||||||
let recv_result = socket.recv().await;
|
|
||||||
let (peer_ip, mut packet) = match recv_result {
|
|
||||||
Ok(packet) => packet,
|
|
||||||
Err(err) => {
|
|
||||||
tracing::error!(?err, "receive ICMP packet failed");
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
if packet.is_empty() {
|
|
||||||
tracing::error!("received empty ICMP packet");
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
let IpAddr::V4(peer_ip) = peer_ip else {
|
|
||||||
continue;
|
|
||||||
};
|
|
||||||
let Some(service) = service.upgrade() else {
|
|
||||||
break;
|
|
||||||
};
|
|
||||||
service.handle_socket_response(peer_ip, &mut packet);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
let service = Arc::downgrade(self);
|
let service = Arc::downgrade(self);
|
||||||
self.tasks.lock().unwrap().spawn(async move {
|
self.tasks.lock().unwrap().spawn(async move {
|
||||||
loop {
|
loop {
|
||||||
@@ -176,9 +126,13 @@ impl<R: IcmpProxyRuntime + 'static> IcmpProxyService<R> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn stop(&self) {
|
pub fn stop(&self) {
|
||||||
|
// Serialize shutdown with publication of a newly opened socket and its
|
||||||
|
// receive task. Opening itself must not hold this synchronous lock.
|
||||||
|
let mut socket = self.socket.lock().unwrap();
|
||||||
if !self.started.swap(false, Ordering::AcqRel) {
|
if !self.started.swap(false, Ordering::AcqRel) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
self.stopped.cancel();
|
||||||
if let Some(guard) = self.pipeline_guard.lock().unwrap().take() {
|
if let Some(guard) = self.pipeline_guard.lock().unwrap().take() {
|
||||||
guard.close();
|
guard.close();
|
||||||
}
|
}
|
||||||
@@ -186,7 +140,61 @@ impl<R: IcmpProxyRuntime + 'static> IcmpProxyService<R> {
|
|||||||
if self.runtime_started.swap(false, Ordering::AcqRel) {
|
if self.runtime_started.swap(false, Ordering::AcqRel) {
|
||||||
self.runtime.stop_icmp();
|
self.runtime.stop_icmp();
|
||||||
}
|
}
|
||||||
self.socket.lock().unwrap().take();
|
socket.take();
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn socket(self: &Arc<Self>) -> Result<Arc<R::Socket>, ProxyRuntimeError> {
|
||||||
|
let _init = self.socket_init.lock().await;
|
||||||
|
if !self.started.load(Ordering::Acquire) || self.stopped.is_cancelled() {
|
||||||
|
return Err(std::io::Error::from(std::io::ErrorKind::NotConnected).into());
|
||||||
|
}
|
||||||
|
if let Some(socket) = self.socket.lock().unwrap().clone() {
|
||||||
|
return Ok(socket);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Roll back partial resources on errors, caller cancellation or stop.
|
||||||
|
let rollback = guarden::guard! { self.runtime.stop_icmp(); };
|
||||||
|
let socket = tokio::select! {
|
||||||
|
biased;
|
||||||
|
_ = self.stopped.cancelled() => {
|
||||||
|
return Err(std::io::Error::from(std::io::ErrorKind::NotConnected).into());
|
||||||
|
}
|
||||||
|
socket = self.runtime.start_icmp() => socket?,
|
||||||
|
};
|
||||||
|
let mut current = self.socket.lock().unwrap();
|
||||||
|
if !self.started.load(Ordering::Acquire) {
|
||||||
|
return Err(std::io::Error::from(std::io::ErrorKind::NotConnected).into());
|
||||||
|
}
|
||||||
|
|
||||||
|
let service = Arc::downgrade(self);
|
||||||
|
let receiver = socket.clone();
|
||||||
|
self.tasks.lock().unwrap().spawn(async move {
|
||||||
|
loop {
|
||||||
|
let recv_result = receiver.recv().await;
|
||||||
|
let (peer_ip, mut packet) = match recv_result {
|
||||||
|
Ok(packet) => packet,
|
||||||
|
Err(err) => {
|
||||||
|
tracing::error!(?err, "receive ICMP packet failed");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if packet.is_empty() {
|
||||||
|
tracing::error!("received empty ICMP packet");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
let IpAddr::V4(peer_ip) = peer_ip else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let Some(service) = service.upgrade() else {
|
||||||
|
break;
|
||||||
|
};
|
||||||
|
service.handle_socket_response(peer_ip, &mut packet);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
*current = Some(socket.clone());
|
||||||
|
self.runtime_started.store(true, Ordering::Release);
|
||||||
|
rollback.defuse();
|
||||||
|
Ok(socket)
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn handle_peer_packet(self: Arc<Self>, packet: ZCPacket) -> Option<ZCPacket> {
|
async fn handle_peer_packet(self: Arc<Self>, packet: ZCPacket) -> Option<ZCPacket> {
|
||||||
@@ -204,14 +212,13 @@ impl<R: IcmpProxyRuntime + 'static> IcmpProxyService<R> {
|
|||||||
destination,
|
destination,
|
||||||
packet: request,
|
packet: request,
|
||||||
} => {
|
} => {
|
||||||
let socket = self.socket.lock().unwrap().clone();
|
match self.socket().await {
|
||||||
match socket {
|
Ok(socket) => {
|
||||||
Some(socket) => {
|
|
||||||
if let Err(err) = socket.send(destination, &request).await {
|
if let Err(err) = socket.send(destination, &request).await {
|
||||||
tracing::error!(?err, "send ICMP packet through runtime failed");
|
tracing::error!(?err, "send ICMP packet through runtime failed");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
None => tracing::error!("send ICMP packet without a runtime socket"),
|
Err(err) => tracing::error!(?err, "start ICMP runtime failed"),
|
||||||
}
|
}
|
||||||
None
|
None
|
||||||
}
|
}
|
||||||
@@ -249,35 +256,69 @@ struct IcmpProxyServiceFilter<R: IcmpProxyRuntime + 'static> {
|
|||||||
|
|
||||||
#[async_trait::async_trait]
|
#[async_trait::async_trait]
|
||||||
impl<R: IcmpProxyRuntime + 'static> PeerPacketFilter for IcmpProxyServiceFilter<R> {
|
impl<R: IcmpProxyRuntime + 'static> PeerPacketFilter for IcmpProxyServiceFilter<R> {
|
||||||
async fn try_process_packet_from_peer(&self, packet: ZCPacket) -> Option<ZCPacket> {
|
async fn try_process_packet_from_peer(&self, packet: ZCPacket) -> PeerPacketFilterResult {
|
||||||
let Some(service) = self.service.upgrade() else {
|
let Some(service) = self.service.upgrade() else {
|
||||||
return Some(packet);
|
return PeerPacketFilterResult::Pass(packet);
|
||||||
};
|
};
|
||||||
service.handle_peer_packet(packet).await
|
service.handle_peer_packet(packet).await.into()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use std::{
|
use std::sync::atomic::AtomicUsize;
|
||||||
net::{IpAddr, Ipv4Addr},
|
|
||||||
sync::{
|
use smoltcp::wire::{Icmpv4Message, Icmpv4Packet, IpProtocol, Ipv4Packet};
|
||||||
Arc,
|
use tokio::sync::Semaphore;
|
||||||
atomic::{AtomicUsize, Ordering},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
use super::*;
|
use super::*;
|
||||||
use crate::gateway::proxy::traits::{IcmpProxySocket, ProxyRuntimeInfo, ProxyRuntimeSnapshot};
|
use crate::{
|
||||||
|
config::peers::PeerRuntimeSnapshot,
|
||||||
|
gateway::proxy::{
|
||||||
|
cidr_table::{ProxyCidrRule, ProxyCidrSnapshot},
|
||||||
|
traits::{ProxyRuntimeInfo, ProxyRuntimeSnapshot},
|
||||||
|
},
|
||||||
|
packet::PacketType,
|
||||||
|
peers::peer_manager::PortablePeerManagerConfig,
|
||||||
|
};
|
||||||
|
|
||||||
#[derive(Default)]
|
#[derive(Default)]
|
||||||
struct PartialStartRuntime {
|
struct TestSocket {
|
||||||
stop_count: AtomicUsize,
|
sent: std::sync::Mutex<Vec<Ipv4Addr>>,
|
||||||
|
receives: AtomicUsize,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ProxyRuntimeInfo for PartialStartRuntime {
|
#[async_trait::async_trait]
|
||||||
|
impl IcmpProxySocket for TestSocket {
|
||||||
|
async fn send(
|
||||||
|
&self,
|
||||||
|
destination: Ipv4Addr,
|
||||||
|
_packet: &[u8],
|
||||||
|
) -> Result<(), ProxyRuntimeError> {
|
||||||
|
self.sent.lock().unwrap().push(destination);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn recv(&self) -> Result<(IpAddr, Vec<u8>), ProxyRuntimeError> {
|
||||||
|
self.receives.fetch_add(1, Ordering::AcqRel);
|
||||||
|
std::future::pending().await
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Default)]
|
||||||
|
struct TestRuntime {
|
||||||
|
snapshot: ProxyRuntimeSnapshot,
|
||||||
|
socket: Arc<TestSocket>,
|
||||||
|
starts: AtomicUsize,
|
||||||
|
stops: AtomicUsize,
|
||||||
|
active: AtomicBool,
|
||||||
|
fail: AtomicBool,
|
||||||
|
start_gate: Option<Semaphore>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ProxyRuntimeInfo for TestRuntime {
|
||||||
fn proxy_runtime_snapshot(&self) -> ProxyRuntimeSnapshot {
|
fn proxy_runtime_snapshot(&self) -> ProxyRuntimeSnapshot {
|
||||||
ProxyRuntimeSnapshot::default()
|
self.snapshot
|
||||||
}
|
}
|
||||||
|
|
||||||
fn is_ip_local_virtual_ip(&self, _ip: &IpAddr) -> bool {
|
fn is_ip_local_virtual_ip(&self, _ip: &IpAddr) -> bool {
|
||||||
@@ -285,53 +326,252 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
struct NoopIcmpSocket;
|
|
||||||
|
|
||||||
#[async_trait::async_trait]
|
#[async_trait::async_trait]
|
||||||
impl IcmpProxySocket for NoopIcmpSocket {
|
impl IcmpProxyRuntime for TestRuntime {
|
||||||
async fn send(
|
type Socket = TestSocket;
|
||||||
&self,
|
|
||||||
_destination: Ipv4Addr,
|
|
||||||
_packet: &[u8],
|
|
||||||
) -> Result<(), ProxyRuntimeError> {
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn recv(&self) -> Result<(IpAddr, Vec<u8>), ProxyRuntimeError> {
|
|
||||||
Err(std::io::Error::other("unused test socket").into())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[async_trait::async_trait]
|
|
||||||
impl IcmpProxyRuntime for PartialStartRuntime {
|
|
||||||
type Socket = NoopIcmpSocket;
|
|
||||||
|
|
||||||
async fn start_icmp(&self) -> Result<Arc<Self::Socket>, ProxyRuntimeError> {
|
async fn start_icmp(&self) -> Result<Arc<Self::Socket>, ProxyRuntimeError> {
|
||||||
Err(std::io::Error::other("partial start").into())
|
self.starts.fetch_add(1, Ordering::AcqRel);
|
||||||
|
self.active.store(true, Ordering::Release);
|
||||||
|
if let Some(gate) = &self.start_gate {
|
||||||
|
gate.acquire().await.unwrap().forget();
|
||||||
|
}
|
||||||
|
tokio::task::yield_now().await;
|
||||||
|
if self.fail.load(Ordering::Acquire) {
|
||||||
|
return Err(std::io::Error::other("partial start").into());
|
||||||
|
}
|
||||||
|
Ok(self.socket.clone())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn stop_icmp(&self) {
|
fn stop_icmp(&self) {
|
||||||
self.stop_count.fetch_add(1, Ordering::AcqRel);
|
self.stops.fetch_add(1, Ordering::AcqRel);
|
||||||
|
self.active.store(false, Ordering::Release);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
fn service(
|
||||||
async fn failed_runtime_start_rolls_back_partial_resources() {
|
runtime: Arc<TestRuntime>,
|
||||||
let runtime = PartialStartRuntime::default();
|
table: Arc<ProxyCidrTable>,
|
||||||
|
) -> Arc<IcmpProxyService<TestRuntime>> {
|
||||||
|
let (packet_tx, _packet_rx) = crate::host::packet::host_packet_channel();
|
||||||
|
let peer_manager = Arc::new(
|
||||||
|
PeerManagerCore::new_portable_for_test(
|
||||||
|
PortablePeerManagerConfig::new(PeerRuntimeSnapshot::default().runtime),
|
||||||
|
packet_tx,
|
||||||
|
)
|
||||||
|
.unwrap(),
|
||||||
|
);
|
||||||
|
IcmpProxyService::new(peer_manager, runtime, table, Duration::from_secs(10))
|
||||||
|
}
|
||||||
|
|
||||||
assert!(start_icmp_runtime(&runtime, false).await.is_err());
|
fn runtime(no_tun: bool, enable_exit_node: bool) -> Arc<TestRuntime> {
|
||||||
assert_eq!(runtime.stop_count.load(Ordering::Acquire), 1);
|
Arc::new(TestRuntime {
|
||||||
|
snapshot: ProxyRuntimeSnapshot {
|
||||||
|
virtual_ipv4: Some("10.0.0.1".parse().unwrap()),
|
||||||
|
no_tun,
|
||||||
|
enable_exit_node,
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
..Default::default()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn echo_request(destination: &str) -> ZCPacket {
|
||||||
|
let mut bytes = [0_u8; 28];
|
||||||
|
let mut request = Icmpv4Packet::new_unchecked(&mut bytes[20..]);
|
||||||
|
request.set_msg_type(Icmpv4Message::EchoRequest);
|
||||||
|
request.set_echo_ident(7);
|
||||||
|
request.set_echo_seq_no(11);
|
||||||
|
request.fill_checksum();
|
||||||
|
let mut ipv4 = Ipv4Packet::new_unchecked(&mut bytes[..]);
|
||||||
|
ipv4.set_version(4);
|
||||||
|
ipv4.set_header_len(20);
|
||||||
|
ipv4.set_total_len(28);
|
||||||
|
ipv4.set_hop_limit(64);
|
||||||
|
ipv4.set_next_header(IpProtocol::Icmp);
|
||||||
|
ipv4.set_src_addr("10.0.0.2".parse().unwrap());
|
||||||
|
ipv4.set_dst_addr(destination.parse().unwrap());
|
||||||
|
ipv4.fill_checksum();
|
||||||
|
let mut packet = ZCPacket::new_with_payload(&bytes);
|
||||||
|
packet.fill_peer_manager_hdr(101, 202, PacketType::Data as u8);
|
||||||
|
packet
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn no_tun_suppresses_start_error_after_rollback() {
|
async fn no_tun_local_echo_does_not_open_socket() {
|
||||||
let runtime = PartialStartRuntime::default();
|
let runtime = runtime(true, false);
|
||||||
|
let service = service(runtime.clone(), Arc::new(ProxyCidrTable::new()));
|
||||||
|
let mut responses = service.response_rx.lock().unwrap().take().unwrap();
|
||||||
|
service.start().await.unwrap();
|
||||||
|
assert!(
|
||||||
|
service
|
||||||
|
.clone()
|
||||||
|
.handle_peer_packet(echo_request("10.0.0.1"))
|
||||||
|
.await
|
||||||
|
.is_none()
|
||||||
|
);
|
||||||
|
let response = responses.try_recv().unwrap();
|
||||||
|
let ipv4 = Ipv4Packet::new_checked(response.payload()).unwrap();
|
||||||
|
let icmp = Icmpv4Packet::new_checked(ipv4.payload()).unwrap();
|
||||||
|
assert_eq!(icmp.msg_type(), Icmpv4Message::EchoReply);
|
||||||
|
assert_eq!(runtime.starts.load(Ordering::Acquire), 0);
|
||||||
|
assert_eq!(runtime.socket.receives.load(Ordering::Acquire), 0);
|
||||||
|
service.stop();
|
||||||
|
assert_eq!(runtime.stops.load(Ordering::Acquire), 0);
|
||||||
|
}
|
||||||
|
|
||||||
let socket = start_icmp_runtime(&runtime, true).await.unwrap();
|
#[tokio::test]
|
||||||
assert!(socket.is_none());
|
async fn dynamic_proxy_networks_open_once_and_follow_updated_rules() {
|
||||||
if socket.is_some() {
|
let runtime = runtime(false, false);
|
||||||
runtime.stop_icmp();
|
let table = Arc::new(ProxyCidrTable::new());
|
||||||
}
|
let service = service(runtime.clone(), table.clone());
|
||||||
assert_eq!(runtime.stop_count.load(Ordering::Acquire), 1);
|
service.start().await.unwrap();
|
||||||
|
assert!(
|
||||||
|
service
|
||||||
|
.clone()
|
||||||
|
.handle_peer_packet(echo_request("192.0.2.42"))
|
||||||
|
.await
|
||||||
|
.is_some()
|
||||||
|
);
|
||||||
|
assert_eq!(runtime.starts.load(Ordering::Acquire), 0);
|
||||||
|
|
||||||
|
table.update_snapshot(ProxyCidrSnapshot {
|
||||||
|
rules: vec![ProxyCidrRule {
|
||||||
|
cidr: "127.0.0.0/24".parse().unwrap(),
|
||||||
|
mapped_cidr: Some("192.0.2.0/24".parse().unwrap()),
|
||||||
|
}],
|
||||||
|
});
|
||||||
|
// The first concurrent requests must share both the socket and receiver.
|
||||||
|
futures::future::join_all((0..16).map(|_| {
|
||||||
|
service
|
||||||
|
.clone()
|
||||||
|
.handle_peer_packet(echo_request("192.0.2.42"))
|
||||||
|
}))
|
||||||
|
.await;
|
||||||
|
tokio::task::yield_now().await;
|
||||||
|
assert_eq!(runtime.starts.load(Ordering::Acquire), 1);
|
||||||
|
assert_eq!(runtime.socket.receives.load(Ordering::Acquire), 1);
|
||||||
|
assert_eq!(
|
||||||
|
*runtime.socket.sent.lock().unwrap(),
|
||||||
|
vec!["127.0.0.42".parse::<Ipv4Addr>().unwrap(); 16]
|
||||||
|
);
|
||||||
|
|
||||||
|
table.update_snapshot(ProxyCidrSnapshot::default());
|
||||||
|
assert!(
|
||||||
|
service
|
||||||
|
.clone()
|
||||||
|
.handle_peer_packet(echo_request("192.0.2.42"))
|
||||||
|
.await
|
||||||
|
.is_some()
|
||||||
|
);
|
||||||
|
assert_eq!(runtime.socket.sent.lock().unwrap().len(), 16);
|
||||||
|
// Keep the socket for in-flight replies and subsequent network additions.
|
||||||
|
assert_eq!(runtime.stops.load(Ordering::Acquire), 0);
|
||||||
|
table.update_snapshot(ProxyCidrSnapshot {
|
||||||
|
rules: vec![ProxyCidrRule {
|
||||||
|
cidr: "192.0.2.0/24".parse().unwrap(),
|
||||||
|
mapped_cidr: None,
|
||||||
|
}],
|
||||||
|
});
|
||||||
|
assert!(
|
||||||
|
service
|
||||||
|
.clone()
|
||||||
|
.handle_peer_packet(echo_request("192.0.2.42"))
|
||||||
|
.await
|
||||||
|
.is_none()
|
||||||
|
);
|
||||||
|
assert_eq!(runtime.starts.load(Ordering::Acquire), 1);
|
||||||
|
assert_eq!(
|
||||||
|
runtime.socket.sent.lock().unwrap().last(),
|
||||||
|
Some(&"192.0.2.42".parse().unwrap())
|
||||||
|
);
|
||||||
|
service.stop();
|
||||||
|
service.stop();
|
||||||
|
assert_eq!(runtime.stops.load(Ordering::Acquire), 1);
|
||||||
|
assert!(!runtime.active.load(Ordering::Acquire));
|
||||||
|
assert!(service.socket().await.is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn exit_node_opens_socket_without_proxy_networks() {
|
||||||
|
let runtime = runtime(false, true);
|
||||||
|
let service = service(runtime.clone(), Arc::new(ProxyCidrTable::new()));
|
||||||
|
service.start().await.unwrap();
|
||||||
|
assert_eq!(runtime.starts.load(Ordering::Acquire), 0);
|
||||||
|
let mut packet = echo_request("192.0.2.42");
|
||||||
|
packet
|
||||||
|
.mut_peer_manager_header()
|
||||||
|
.unwrap()
|
||||||
|
.set_exit_node(true);
|
||||||
|
assert!(service.clone().handle_peer_packet(packet).await.is_none());
|
||||||
|
assert_eq!(runtime.starts.load(Ordering::Acquire), 1);
|
||||||
|
assert_eq!(
|
||||||
|
*runtime.socket.sent.lock().unwrap(),
|
||||||
|
["192.0.2.42".parse::<Ipv4Addr>().unwrap()]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn failed_runtime_start_rolls_back_and_can_retry() {
|
||||||
|
let runtime = runtime(true, false);
|
||||||
|
runtime.fail.store(true, Ordering::Release);
|
||||||
|
let service = service(runtime.clone(), Arc::new(ProxyCidrTable::new()));
|
||||||
|
let mut responses = service.response_rx.lock().unwrap().take().unwrap();
|
||||||
|
service.start().await.unwrap();
|
||||||
|
assert!(service.socket().await.is_err());
|
||||||
|
assert_eq!(runtime.stops.load(Ordering::Acquire), 1);
|
||||||
|
assert!(!runtime.active.load(Ordering::Acquire));
|
||||||
|
assert!(
|
||||||
|
service
|
||||||
|
.clone()
|
||||||
|
.handle_peer_packet(echo_request("10.0.0.1"))
|
||||||
|
.await
|
||||||
|
.is_none()
|
||||||
|
);
|
||||||
|
assert!(responses.try_recv().is_ok());
|
||||||
|
assert_eq!(runtime.starts.load(Ordering::Acquire), 1);
|
||||||
|
runtime.fail.store(false, Ordering::Release);
|
||||||
|
assert!(service.socket().await.is_ok());
|
||||||
|
service.stop();
|
||||||
|
assert_eq!(runtime.stops.load(Ordering::Acquire), 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn stop_cancels_pending_socket_start() {
|
||||||
|
let runtime = Arc::new(TestRuntime {
|
||||||
|
start_gate: Some(Semaphore::new(0)),
|
||||||
|
..Default::default()
|
||||||
|
});
|
||||||
|
let service = service(runtime.clone(), Arc::new(ProxyCidrTable::new()));
|
||||||
|
service.start().await.unwrap();
|
||||||
|
let mut opening = Box::pin(service.socket());
|
||||||
|
assert!(futures::poll!(&mut opening).is_pending());
|
||||||
|
assert!(runtime.active.load(Ordering::Acquire));
|
||||||
|
service.stop();
|
||||||
|
assert!(opening.await.is_err());
|
||||||
|
assert!(!runtime.active.load(Ordering::Acquire));
|
||||||
|
assert_eq!(runtime.stops.load(Ordering::Acquire), 1);
|
||||||
|
assert_eq!(runtime.socket.receives.load(Ordering::Acquire), 0);
|
||||||
|
assert!(service.socket().await.is_err());
|
||||||
|
assert_eq!(runtime.starts.load(Ordering::Acquire), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn cancelled_caller_rolls_back_socket_start() {
|
||||||
|
let runtime = Arc::new(TestRuntime {
|
||||||
|
start_gate: Some(Semaphore::new(0)),
|
||||||
|
..Default::default()
|
||||||
|
});
|
||||||
|
let service = service(runtime.clone(), Arc::new(ProxyCidrTable::new()));
|
||||||
|
service.start().await.unwrap();
|
||||||
|
let mut opening = Box::pin(service.socket());
|
||||||
|
assert!(futures::poll!(&mut opening).is_pending());
|
||||||
|
drop(opening);
|
||||||
|
assert!(!runtime.active.load(Ordering::Acquire));
|
||||||
|
assert_eq!(runtime.stops.load(Ordering::Acquire), 1);
|
||||||
|
runtime.start_gate.as_ref().unwrap().add_permits(1);
|
||||||
|
assert!(service.socket().await.is_ok());
|
||||||
|
service.stop();
|
||||||
|
assert_eq!(runtime.stops.load(Ordering::Acquire), 2);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -379,12 +379,12 @@ where
|
|||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Runtime updates can be cancelled while a host operation is pending.
|
||||||
|
// Roll back the startup flags and resources so a later update can retry.
|
||||||
|
let rollback = guarden::guard! { self.stop_started(); };
|
||||||
self.runtime.latch_smoltcp();
|
self.runtime.latch_smoltcp();
|
||||||
self.tcp_started.store(true, Ordering::Release);
|
self.tcp_started.store(true, Ordering::Release);
|
||||||
if let Err(error) = self.tcp.start(true).await {
|
self.tcp.start(true).await?;
|
||||||
self.stop_started();
|
|
||||||
return Err(error);
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Some(icmp) = &self.icmp {
|
if let Some(icmp) = &self.icmp {
|
||||||
self.icmp_started.store(true, Ordering::Release);
|
self.icmp_started.store(true, Ordering::Release);
|
||||||
@@ -398,7 +398,6 @@ where
|
|||||||
.proxy
|
.proxy
|
||||||
.icmp_failure_is_fatal
|
.icmp_failure_is_fatal
|
||||||
{
|
{
|
||||||
self.stop_started();
|
|
||||||
return Err(error);
|
return Err(error);
|
||||||
}
|
}
|
||||||
tracing::warn!(?error, "optional ICMP proxy runtime failed to start");
|
tracing::warn!(?error, "optional ICMP proxy runtime failed to start");
|
||||||
@@ -407,6 +406,7 @@ where
|
|||||||
|
|
||||||
self.udp_started.store(true, Ordering::Release);
|
self.udp_started.store(true, Ordering::Release);
|
||||||
self.udp.start().await;
|
self.udp.start().await;
|
||||||
|
rollback.defuse();
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ use std::{
|
|||||||
};
|
};
|
||||||
|
|
||||||
use cidr::Ipv4Inet;
|
use cidr::Ipv4Inet;
|
||||||
use crossbeam::atomic::AtomicCell;
|
use crossbeam_utils::atomic::AtomicCell;
|
||||||
use dashmap::{DashMap, mapref::entry::Entry};
|
use dashmap::{DashMap, mapref::entry::Entry};
|
||||||
use smoltcp::wire::{IpAddress, IpProtocol, Ipv4Packet, TcpPacket};
|
use smoltcp::wire::{IpAddress, IpProtocol, Ipv4Packet, TcpPacket};
|
||||||
|
|
||||||
@@ -186,7 +186,7 @@ impl TcpProxyEngine {
|
|||||||
for _ in 0..u16::MAX {
|
for _ in 0..u16::MAX {
|
||||||
let translated_port = self
|
let translated_port = self
|
||||||
.next_translated_port
|
.next_translated_port
|
||||||
.fetch_update(Ordering::Relaxed, Ordering::Relaxed, |port| {
|
.try_update(Ordering::Relaxed, Ordering::Relaxed, |port| {
|
||||||
Some(if port == u16::MAX { 1 } else { port + 1 })
|
Some(if port == u16::MAX { 1 } else { port + 1 })
|
||||||
})
|
})
|
||||||
.expect("translated port counter update cannot fail");
|
.expect("translated port counter update cannot fail");
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ use crate::{
|
|||||||
foundation::time::timeout,
|
foundation::time::timeout,
|
||||||
packet::ZCPacket,
|
packet::ZCPacket,
|
||||||
peers::{
|
peers::{
|
||||||
NicPacketFilter, PeerPacketFilter,
|
NicPacketFilter, PeerPacketFilter, PeerPacketFilterResult,
|
||||||
peer_manager::{PeerManagerCore, PipelineRegistrationGuard},
|
peer_manager::{PeerManagerCore, PipelineRegistrationGuard},
|
||||||
},
|
},
|
||||||
socket::{
|
socket::{
|
||||||
@@ -544,11 +544,11 @@ struct TcpProxyServiceFilter<
|
|||||||
impl<R: TcpProxyRuntime + 'static, F: VirtualTcpListenerFactory, C: TcpProxyDestinationConnector>
|
impl<R: TcpProxyRuntime + 'static, F: VirtualTcpListenerFactory, C: TcpProxyDestinationConnector>
|
||||||
PeerPacketFilter for TcpProxyServiceFilter<R, F, C>
|
PeerPacketFilter for TcpProxyServiceFilter<R, F, C>
|
||||||
{
|
{
|
||||||
async fn try_process_packet_from_peer(&self, packet: ZCPacket) -> Option<ZCPacket> {
|
async fn try_process_packet_from_peer(&self, packet: ZCPacket) -> PeerPacketFilterResult {
|
||||||
let Some(service) = self.service.upgrade() else {
|
let Some(service) = self.service.upgrade() else {
|
||||||
return Some(packet);
|
return PeerPacketFilterResult::Pass(packet);
|
||||||
};
|
};
|
||||||
service.handle_peer_packet(packet).await
|
service.handle_peer_packet(packet).await.into()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -9,8 +9,8 @@ use tokio::sync::mpsc::{self, Receiver, Sender};
|
|||||||
use tokio::task::JoinSet;
|
use tokio::task::JoinSet;
|
||||||
|
|
||||||
use crate::packet::ZCPacket;
|
use crate::packet::ZCPacket;
|
||||||
use crate::peers::PeerPacketFilter;
|
|
||||||
use crate::peers::peer_manager::{PeerManagerCore, PipelineRegistrationGuard};
|
use crate::peers::peer_manager::{PeerManagerCore, PipelineRegistrationGuard};
|
||||||
|
use crate::peers::{PeerPacketFilter, PeerPacketFilterResult};
|
||||||
|
|
||||||
use super::cidr_table::ProxyCidrTable;
|
use super::cidr_table::ProxyCidrTable;
|
||||||
use super::traits::{UdpProxyResponseSink, UdpProxyRuntime};
|
use super::traits::{UdpProxyResponseSink, UdpProxyRuntime};
|
||||||
@@ -208,10 +208,10 @@ struct UdpProxyServiceFilter<R: UdpProxyRuntime + 'static> {
|
|||||||
|
|
||||||
#[async_trait::async_trait]
|
#[async_trait::async_trait]
|
||||||
impl<R: UdpProxyRuntime + 'static> PeerPacketFilter for UdpProxyServiceFilter<R> {
|
impl<R: UdpProxyRuntime + 'static> PeerPacketFilter for UdpProxyServiceFilter<R> {
|
||||||
async fn try_process_packet_from_peer(&self, packet: ZCPacket) -> Option<ZCPacket> {
|
async fn try_process_packet_from_peer(&self, packet: ZCPacket) -> PeerPacketFilterResult {
|
||||||
let Some(service) = self.service.upgrade() else {
|
let Some(service) = self.service.upgrade() else {
|
||||||
return Some(packet);
|
return PeerPacketFilterResult::Pass(packet);
|
||||||
};
|
};
|
||||||
service.handle_peer_packet(packet).await
|
service.handle_peer_packet(packet).await.into()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ use crate::{
|
|||||||
listener::RunningListenerRegistry,
|
listener::RunningListenerRegistry,
|
||||||
packet::{PacketType, ZCPacket, ZCPacketType},
|
packet::{PacketType, ZCPacket, ZCPacketType},
|
||||||
peers::{
|
peers::{
|
||||||
PeerPacketFilter,
|
PeerPacketFilter, PeerPacketFilterResult,
|
||||||
peer_manager::{PeerManagerCore, PipelineRegistrationGuard},
|
peer_manager::{PeerManagerCore, PipelineRegistrationGuard},
|
||||||
},
|
},
|
||||||
process_runtime::ProtectedTcpPortRegistry,
|
process_runtime::ProtectedTcpPortRegistry,
|
||||||
@@ -147,15 +147,15 @@ struct WrappedTransportPeerFilter {
|
|||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
impl PeerPacketFilter for WrappedTransportPeerFilter {
|
impl PeerPacketFilter for WrappedTransportPeerFilter {
|
||||||
async fn try_process_packet_from_peer(&self, packet: ZCPacket) -> Option<ZCPacket> {
|
async fn try_process_packet_from_peer(&self, packet: ZCPacket) -> PeerPacketFilterResult {
|
||||||
let Some(header) = packet.peer_manager_header() else {
|
let Some(header) = packet.peer_manager_header() else {
|
||||||
return Some(packet);
|
return PeerPacketFilterResult::Pass(packet);
|
||||||
};
|
};
|
||||||
if header.packet_type != self.transport.incoming_packet_type(self.role) as u8 {
|
if header.packet_type != self.transport.incoming_packet_type(self.role) as u8 {
|
||||||
return Some(packet);
|
return PeerPacketFilterResult::Pass(packet);
|
||||||
}
|
}
|
||||||
let Some(engine) = self.engine.upgrade() else {
|
let Some(engine) = self.engine.upgrade() else {
|
||||||
return Some(packet);
|
return PeerPacketFilterResult::Pass(packet);
|
||||||
};
|
};
|
||||||
let from_peer_id = header.from_peer_id.get();
|
let from_peer_id = header.from_peer_id.get();
|
||||||
if let Err(error) = engine
|
if let Err(error) = engine
|
||||||
@@ -169,7 +169,7 @@ impl PeerPacketFilter for WrappedTransportPeerFilter {
|
|||||||
"failed to inject wrapped transport packet"
|
"failed to inject wrapped transport packet"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
None
|
PeerPacketFilterResult::Consumed
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -797,7 +797,10 @@ mod tests {
|
|||||||
let mut packet = ZCPacket::new_with_payload(b"payload");
|
let mut packet = ZCPacket::new_with_payload(b"payload");
|
||||||
packet.fill_peer_manager_hdr(7, 1, PacketType::KcpDst as u8);
|
packet.fill_peer_manager_hdr(7, 1, PacketType::KcpDst as u8);
|
||||||
|
|
||||||
assert!(filter.try_process_packet_from_peer(packet).await.is_none());
|
assert!(matches!(
|
||||||
|
filter.try_process_packet_from_peer(packet).await,
|
||||||
|
PeerPacketFilterResult::Consumed
|
||||||
|
));
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
*engine.injections.lock().unwrap(),
|
*engine.injections.lock().unwrap(),
|
||||||
[(
|
[(
|
||||||
@@ -811,7 +814,10 @@ mod tests {
|
|||||||
drop(engine);
|
drop(engine);
|
||||||
let mut packet = ZCPacket::new_with_payload(b"stale");
|
let mut packet = ZCPacket::new_with_payload(b"stale");
|
||||||
packet.fill_peer_manager_hdr(7, 1, PacketType::KcpDst as u8);
|
packet.fill_peer_manager_hdr(7, 1, PacketType::KcpDst as u8);
|
||||||
assert!(filter.try_process_packet_from_peer(packet).await.is_some());
|
assert!(matches!(
|
||||||
|
filter.try_process_packet_from_peer(packet).await,
|
||||||
|
PeerPacketFilterResult::Pass(_)
|
||||||
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -3,5 +3,5 @@ mod tokio_smoltcp;
|
|||||||
|
|
||||||
pub(super) use stack::{SmolTcpStack, output_dst_ip};
|
pub(super) use stack::{SmolTcpStack, output_dst_ip};
|
||||||
pub(super) use tokio_smoltcp::{
|
pub(super) use tokio_smoltcp::{
|
||||||
BufferSize, Net, NetConfig, TcpListener, UdpSocket, channel_device,
|
BufferSize, Net, NetConfig, TcpListener, TcpStream, UdpSocket, channel_device,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -82,6 +82,9 @@ impl std::fmt::Debug for Net {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl Net {
|
impl Net {
|
||||||
|
const EPHEMERAL_PORT_START: u16 = 10000;
|
||||||
|
const EPHEMERAL_PORT_END: u16 = 60001;
|
||||||
|
|
||||||
/// Creates a new `Net` instance. It panics if the medium is not supported.
|
/// Creates a new `Net` instance. It panics if the medium is not supported.
|
||||||
pub fn new<D: device::AsyncDevice + 'static>(device: D, config: NetConfig) -> Net {
|
pub fn new<D: device::AsyncDevice + 'static>(device: D, config: NetConfig) -> Net {
|
||||||
Self::new2(device, config)
|
Self::new2(device, config)
|
||||||
@@ -119,18 +122,44 @@ impl Net {
|
|||||||
Net {
|
Net {
|
||||||
reactor: Arc::new(reactor),
|
reactor: Arc::new(reactor),
|
||||||
ip_addr: config.ip_addr,
|
ip_addr: config.ip_addr,
|
||||||
from_port: AtomicU16::new(10001),
|
from_port: AtomicU16::new(Self::EPHEMERAL_PORT_START + 1),
|
||||||
stopper,
|
stopper,
|
||||||
_fut: AbortOnDropHandle::new(tokio::spawn(fut)),
|
_fut: AbortOnDropHandle::new(tokio::spawn(fut)),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
fn next_port(port: u16) -> u16 {
|
||||||
|
if port >= Self::EPHEMERAL_PORT_END {
|
||||||
|
Self::EPHEMERAL_PORT_START
|
||||||
|
} else {
|
||||||
|
port + 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub fn get_port(&self) -> u16 {
|
pub fn get_port(&self) -> u16 {
|
||||||
self.from_port
|
self.from_port
|
||||||
.fetch_update(Ordering::SeqCst, Ordering::SeqCst, |x| {
|
.try_update(Ordering::SeqCst, Ordering::SeqCst, |port| {
|
||||||
Some(if x > 60000 { 10000 } else { x + 1 })
|
Some(Self::next_port(port))
|
||||||
})
|
})
|
||||||
.unwrap()
|
.unwrap()
|
||||||
}
|
}
|
||||||
|
/// Visit each ephemeral port once, independently of concurrent allocations.
|
||||||
|
pub(crate) fn ephemeral_ports(&self) -> impl Iterator<Item = u16> {
|
||||||
|
let start = self.get_port();
|
||||||
|
std::iter::once(start).chain(
|
||||||
|
(start + 1..=Self::EPHEMERAL_PORT_END)
|
||||||
|
.chain(Self::EPHEMERAL_PORT_START..start)
|
||||||
|
.inspect(|&port| {
|
||||||
|
// Follow the scan, but never overwrite another allocator's progress.
|
||||||
|
let _ = self.from_port.compare_exchange(
|
||||||
|
port,
|
||||||
|
Self::next_port(port),
|
||||||
|
Ordering::SeqCst,
|
||||||
|
Ordering::SeqCst,
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
/// Creates a new TcpListener, which will be bound to the specified address.
|
/// Creates a new TcpListener, which will be bound to the specified address.
|
||||||
pub async fn tcp_bind(&self, addr: SocketAddr) -> io::Result<TcpListener> {
|
pub async fn tcp_bind(&self, addr: SocketAddr) -> io::Result<TcpListener> {
|
||||||
let addr = self.set_address(addr);
|
let addr = self.set_address(addr);
|
||||||
@@ -188,3 +217,36 @@ impl Drop for Net {
|
|||||||
self.stopper.notify_waiters()
|
self.stopper.notify_waiters()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use smoltcp::{phy::Medium, wire::HardwareAddress};
|
||||||
|
|
||||||
|
#[allow(clippy::unused_async)]
|
||||||
|
#[tokio::test]
|
||||||
|
async fn port_allocation_returns_previous_value_and_wraps() {
|
||||||
|
let mut capabilities = device::DeviceCapabilities::default();
|
||||||
|
capabilities.medium = Medium::Ip;
|
||||||
|
capabilities.max_transmission_unit = 1280;
|
||||||
|
let (device, _ingress, _egress) = channel_device::ChannelDevice::new(capabilities);
|
||||||
|
let net = Net::new(
|
||||||
|
device,
|
||||||
|
NetConfig::new(
|
||||||
|
Config::new(HardwareAddress::Ip),
|
||||||
|
"10.126.126.1/24".parse().unwrap(),
|
||||||
|
Vec::new(),
|
||||||
|
None,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
assert_eq!(net.get_port(), 10001);
|
||||||
|
assert_eq!(net.get_port(), 10002);
|
||||||
|
|
||||||
|
net.from_port.store(60000, Ordering::SeqCst);
|
||||||
|
assert_eq!(net.get_port(), 60000);
|
||||||
|
assert_eq!(net.get_port(), 60001);
|
||||||
|
assert_eq!(net.get_port(), 10000);
|
||||||
|
assert_eq!(net.get_port(), 10001);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -24,7 +24,10 @@ use std::{
|
|||||||
sync::Arc,
|
sync::Arc,
|
||||||
task::{Context as TaskContext, Poll, Waker},
|
task::{Context as TaskContext, Poll, Waker},
|
||||||
};
|
};
|
||||||
use tokio::{pin, select, sync::Notify};
|
use tokio::{
|
||||||
|
pin, select,
|
||||||
|
sync::{Notify, mpsc},
|
||||||
|
};
|
||||||
|
|
||||||
use crate::foundation::time::sleep;
|
use crate::foundation::time::sleep;
|
||||||
|
|
||||||
@@ -50,11 +53,18 @@ struct TcpListenerRegistry {
|
|||||||
|
|
||||||
type SharedTcpListenerRegistry = Arc<Mutex<TcpListenerRegistry>>;
|
type SharedTcpListenerRegistry = Arc<Mutex<TcpListenerRegistry>>;
|
||||||
|
|
||||||
|
struct ClosingTcpSocket {
|
||||||
|
handle: OwnedSocketHandle,
|
||||||
|
deadline: Instant,
|
||||||
|
_keep_alive: Box<dyn Send>,
|
||||||
|
}
|
||||||
|
|
||||||
pub(crate) struct Reactor {
|
pub(crate) struct Reactor {
|
||||||
notify: Arc<Notify>,
|
notify: Arc<Notify>,
|
||||||
iface: BufferInterface,
|
iface: BufferInterface,
|
||||||
socket_allocator: SocketAlloctor,
|
socket_allocator: SocketAlloctor,
|
||||||
tcp_listeners: SharedTcpListenerRegistry,
|
tcp_listeners: SharedTcpListenerRegistry,
|
||||||
|
closing_tcp: mpsc::UnboundedSender<ClosingTcpSocket>,
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn receive(
|
async fn receive(
|
||||||
@@ -203,12 +213,14 @@ fn update_tcp_listeners(
|
|||||||
wakers
|
wakers
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
async fn run(
|
async fn run(
|
||||||
mut async_iface: impl super::device::AsyncDevice,
|
mut async_iface: impl super::device::AsyncDevice,
|
||||||
iface: BufferInterface,
|
iface: BufferInterface,
|
||||||
mut device: BufferDevice,
|
mut device: BufferDevice,
|
||||||
socket_allocator: SocketAlloctor,
|
socket_allocator: SocketAlloctor,
|
||||||
tcp_listeners: SharedTcpListenerRegistry,
|
tcp_listeners: SharedTcpListenerRegistry,
|
||||||
|
mut closing_tcp: mpsc::UnboundedReceiver<ClosingTcpSocket>,
|
||||||
notify: Arc<Notify>,
|
notify: Arc<Notify>,
|
||||||
stopper: Arc<Notify>,
|
stopper: Arc<Notify>,
|
||||||
) -> io::Result<()> {
|
) -> io::Result<()> {
|
||||||
@@ -219,21 +231,36 @@ async fn run(
|
|||||||
.max_burst_size
|
.max_burst_size
|
||||||
.unwrap_or(MAX_BURST_SIZE);
|
.unwrap_or(MAX_BURST_SIZE);
|
||||||
let mut recv_buf = VecDeque::with_capacity(max_burst_size);
|
let mut recv_buf = VecDeque::with_capacity(max_burst_size);
|
||||||
|
let mut closing = Vec::<ClosingTcpSocket>::new();
|
||||||
|
let mut closed = Vec::new();
|
||||||
pin!(timer);
|
pin!(timer);
|
||||||
|
|
||||||
loop {
|
loop {
|
||||||
let packets = device.take_send_queue();
|
let packets = device.take_send_queue();
|
||||||
|
|
||||||
async_iface.send_all(&mut iter(packets).map(Ok)).await?;
|
async_iface.send_all(&mut iter(packets).map(Ok)).await?;
|
||||||
|
// Keep routing and stack leases until the last protocol output has
|
||||||
|
// left the device queue. Drop handles outside the socket-set lock.
|
||||||
|
closed.clear();
|
||||||
|
while let Ok(socket) = closing_tcp.try_recv() {
|
||||||
|
closing.push(socket);
|
||||||
|
}
|
||||||
|
|
||||||
if recv_buf.is_empty() && device.need_wait() {
|
if recv_buf.is_empty() && device.need_wait() {
|
||||||
let start = Instant::now();
|
let start = Instant::now();
|
||||||
let deadline = {
|
let mut deadline = {
|
||||||
iface
|
iface
|
||||||
.lock()
|
.lock()
|
||||||
.poll_delay(start, &socket_allocator.sockets().lock())
|
.poll_delay(start, &socket_allocator.sockets().lock())
|
||||||
.unwrap_or(default_timeout)
|
.unwrap_or(default_timeout)
|
||||||
};
|
};
|
||||||
|
for socket in &closing {
|
||||||
|
deadline = deadline.min(if socket.deadline <= start {
|
||||||
|
Duration::ZERO
|
||||||
|
} else {
|
||||||
|
socket.deadline - start
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
if deadline != Duration::ZERO {
|
if deadline != Duration::ZERO {
|
||||||
timer
|
timer
|
||||||
@@ -243,6 +270,7 @@ async fn run(
|
|||||||
_ = &mut timer => {},
|
_ = &mut timer => {},
|
||||||
_ = receive(&mut async_iface,&mut recv_buf) => {}
|
_ = receive(&mut async_iface,&mut recv_buf) => {}
|
||||||
_ = notify.notified() => {}
|
_ = notify.notified() => {}
|
||||||
|
Some(socket) = closing_tcp.recv() => closing.push(socket),
|
||||||
_ = stopper.notified() => break,
|
_ = stopper.notified() => break,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -290,6 +318,21 @@ async fn run(
|
|||||||
iface.poll(now, &mut device, &mut sockets);
|
iface.poll(now, &mut device, &mut sockets);
|
||||||
listener_wakers.extend(update_tcp_listeners(&mut listeners, &mut sockets));
|
listener_wakers.extend(update_tcp_listeners(&mut listeners, &mut sockets));
|
||||||
|
|
||||||
|
if prepare_tcp_close(now, &closing, &mut sockets) {
|
||||||
|
iface.poll(now, &mut device, &mut sockets);
|
||||||
|
}
|
||||||
|
let mut index = 0;
|
||||||
|
while index < closing.len() {
|
||||||
|
let socket = sockets.get::<tcp::Socket>(*closing[index].handle);
|
||||||
|
// Closed can still have an RST to dispatch. TIME-WAIT must also
|
||||||
|
// remain available to acknowledge a retransmitted peer FIN.
|
||||||
|
if socket.state() == tcp::State::Closed && socket.remote_endpoint().is_none() {
|
||||||
|
closed.push(closing.swap_remove(index));
|
||||||
|
} else {
|
||||||
|
index += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// wake up all closed sockets (smoltcp seems have a bug that it doesn't wake up closed sockets)
|
// wake up all closed sockets (smoltcp seems have a bug that it doesn't wake up closed sockets)
|
||||||
for (_, socket) in sockets.iter_mut() {
|
for (_, socket) in sockets.iter_mut() {
|
||||||
if let Socket::Tcp(tcp) = socket
|
if let Socket::Tcp(tcp) = socket
|
||||||
@@ -309,6 +352,30 @@ async fn run(
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn prepare_tcp_close(
|
||||||
|
now: Instant,
|
||||||
|
closing: &[ClosingTcpSocket],
|
||||||
|
sockets: &mut SocketSet<'static>,
|
||||||
|
) -> bool {
|
||||||
|
let mut changed = false;
|
||||||
|
for entry in closing {
|
||||||
|
let socket = sockets.get_mut::<tcp::Socket>(*entry.handle);
|
||||||
|
// There is no application reader anymore. Keep the receive window
|
||||||
|
// open so queued peer data cannot prevent its FIN from reaching us.
|
||||||
|
if socket.can_recv() {
|
||||||
|
let _ = socket.recv(|data| (data.len(), ()));
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
// The normal TCP timeout measures inactivity, so ACKs can extend it.
|
||||||
|
// A released application resource has an absolute teardown deadline.
|
||||||
|
if now >= entry.deadline && socket.state() != tcp::State::Closed {
|
||||||
|
socket.abort();
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
changed
|
||||||
|
}
|
||||||
|
|
||||||
impl Reactor {
|
impl Reactor {
|
||||||
pub fn new(
|
pub fn new(
|
||||||
async_device: impl super::device::AsyncDevice,
|
async_device: impl super::device::AsyncDevice,
|
||||||
@@ -321,12 +388,14 @@ impl Reactor {
|
|||||||
let notify = Arc::new(Notify::new());
|
let notify = Arc::new(Notify::new());
|
||||||
let socket_allocator = SocketAlloctor::new(buffer_size);
|
let socket_allocator = SocketAlloctor::new(buffer_size);
|
||||||
let tcp_listeners = Arc::new(Mutex::new(TcpListenerRegistry::default()));
|
let tcp_listeners = Arc::new(Mutex::new(TcpListenerRegistry::default()));
|
||||||
|
let (closing_tx, closing_rx) = mpsc::unbounded_channel();
|
||||||
let fut = run(
|
let fut = run(
|
||||||
async_device,
|
async_device,
|
||||||
iface.clone(),
|
iface.clone(),
|
||||||
device,
|
device,
|
||||||
socket_allocator.clone(),
|
socket_allocator.clone(),
|
||||||
tcp_listeners.clone(),
|
tcp_listeners.clone(),
|
||||||
|
closing_rx,
|
||||||
notify.clone(),
|
notify.clone(),
|
||||||
stopper,
|
stopper,
|
||||||
);
|
);
|
||||||
@@ -337,6 +406,7 @@ impl Reactor {
|
|||||||
iface,
|
iface,
|
||||||
socket_allocator,
|
socket_allocator,
|
||||||
tcp_listeners,
|
tcp_listeners,
|
||||||
|
closing_tcp: closing_tx,
|
||||||
},
|
},
|
||||||
fut,
|
fut,
|
||||||
)
|
)
|
||||||
@@ -350,6 +420,21 @@ impl Reactor {
|
|||||||
|sockets: &mut smoltcp::iface::SocketSet<'_>| sockets.get_mut::<T>(handle),
|
|sockets: &mut smoltcp::iface::SocketSet<'_>| sockets.get_mut::<T>(handle),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
pub(super) fn close_tcp(
|
||||||
|
&self,
|
||||||
|
handle: OwnedSocketHandle,
|
||||||
|
keep_alive: Box<dyn Send>,
|
||||||
|
deadline: Instant,
|
||||||
|
) {
|
||||||
|
self.get_socket::<tcp::Socket>(*handle).close();
|
||||||
|
// The receiver and all closing sockets belong to run(), so stopping
|
||||||
|
// the reactor also releases them without a detached cleanup task.
|
||||||
|
let _ = self.closing_tcp.send(ClosingTcpSocket {
|
||||||
|
handle,
|
||||||
|
deadline,
|
||||||
|
_keep_alive: keep_alive,
|
||||||
|
});
|
||||||
|
}
|
||||||
pub fn context(&self) -> MappedMutexGuard<'_, Context> {
|
pub fn context(&self) -> MappedMutexGuard<'_, Context> {
|
||||||
MutexGuard::map(self.iface.lock(), |iface| iface.context())
|
MutexGuard::map(self.iface.lock(), |iface| iface.context())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -97,6 +97,15 @@ pub struct TcpStream {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl TcpStream {
|
impl TcpStream {
|
||||||
|
/// Release the application stream while the reactor finishes TCP teardown.
|
||||||
|
pub(crate) fn close(self, keep_alive: impl Send + 'static) {
|
||||||
|
self.reactor.close_tcp(
|
||||||
|
self.handle,
|
||||||
|
Box::new(keep_alive),
|
||||||
|
smoltcp::time::Instant::now() + super::socket_allocator::TCP_TIMEOUT,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
pub(super) async fn connect(
|
pub(super) async fn connect(
|
||||||
reactor: Arc<Reactor>,
|
reactor: Arc<Reactor>,
|
||||||
local_endpoint: IpEndpoint,
|
local_endpoint: IpEndpoint,
|
||||||
@@ -331,21 +340,21 @@ impl UdpSocket {
|
|||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use std::time::Duration as StdDuration;
|
use std::{sync::Arc, time::Duration as StdDuration};
|
||||||
|
|
||||||
use smoltcp::{
|
use smoltcp::{
|
||||||
iface::Config,
|
iface::Config,
|
||||||
phy::{DeviceCapabilities, Medium},
|
phy::{DeviceCapabilities, Medium},
|
||||||
socket::tcp,
|
socket::tcp,
|
||||||
time::Duration,
|
time::Duration,
|
||||||
wire::{HardwareAddress, Ipv4Address, TcpControl, TcpSeqNumber},
|
wire::{HardwareAddress, Ipv4Address, Ipv4Packet, TcpControl, TcpPacket, TcpSeqNumber},
|
||||||
};
|
};
|
||||||
|
|
||||||
use super::super::{
|
use super::super::{
|
||||||
Net, NetConfig, channel_device,
|
Net, NetConfig, channel_device,
|
||||||
test_utils::{TcpPackets, recv_tcp, recv_tcp_for_port},
|
test_utils::{TcpPackets, recv_tcp, recv_tcp_for_port},
|
||||||
};
|
};
|
||||||
use super::{TCP_LISTENER_MAX_PENDING, TcpListener};
|
use super::{TCP_LISTENER_MAX_PENDING, TcpListener, TcpStream};
|
||||||
|
|
||||||
const LISTEN_ADDR: Ipv4Address = Ipv4Address::new(10, 126, 126, 1);
|
const LISTEN_ADDR: Ipv4Address = Ipv4Address::new(10, 126, 126, 1);
|
||||||
const LISTEN_PORT: u16 = 34569;
|
const LISTEN_PORT: u16 = 34569;
|
||||||
@@ -417,6 +426,136 @@ mod tests {
|
|||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn connected_stream(
|
||||||
|
net: &Net,
|
||||||
|
ingress: &tokio::sync::mpsc::Sender<std::io::Result<Vec<u8>>>,
|
||||||
|
egress: &mut tokio::sync::mpsc::Receiver<Vec<u8>>,
|
||||||
|
) -> TcpStream {
|
||||||
|
let mut listener = net
|
||||||
|
.tcp_bind("10.126.126.1:34569".parse().unwrap())
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let client_addr = Ipv4Address::new(10, 126, 126, 2);
|
||||||
|
let sequence = begin_handshake(ingress, egress, client_addr, 40000, 1000).await;
|
||||||
|
finish_handshake(ingress, client_addr, 40000, 1000, sequence).await;
|
||||||
|
tokio::time::timeout(StdDuration::from_secs(1), listener.accept())
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.unwrap()
|
||||||
|
.0
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn closing_stream_retransmits_fin_and_retains_time_wait() {
|
||||||
|
let (net, ingress, mut egress) = test_net();
|
||||||
|
let stream = connected_stream(&net, &ingress, &mut egress).await;
|
||||||
|
let reactor = stream.reactor.clone();
|
||||||
|
let keep_alive = Arc::new(());
|
||||||
|
let retained = Arc::downgrade(&keep_alive);
|
||||||
|
stream.close(keep_alive);
|
||||||
|
|
||||||
|
let fin = recv_tcp(&mut egress).await;
|
||||||
|
assert_eq!(fin.control, TcpControl::Fin);
|
||||||
|
// Lose the first FIN. The application handle is gone, but the reactor
|
||||||
|
// must still retransmit the same sequence number.
|
||||||
|
let retransmit = tokio::time::timeout(StdDuration::from_secs(3), egress.recv())
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.unwrap();
|
||||||
|
let ipv4 = Ipv4Packet::new_checked(&retransmit).unwrap();
|
||||||
|
let tcp = TcpPacket::new_checked(ipv4.payload()).unwrap();
|
||||||
|
assert!(tcp.fin());
|
||||||
|
assert_eq!(tcp.seq_number(), fin.sequence);
|
||||||
|
|
||||||
|
let client_addr = Ipv4Address::new(10, 126, 126, 2);
|
||||||
|
let peer_fin = PACKETS.fin(client_addr, 40000, TcpSeqNumber(1001), fin.sequence + 1);
|
||||||
|
ingress.send(Ok(peer_fin.clone())).await.unwrap();
|
||||||
|
let ack = recv_tcp(&mut egress).await;
|
||||||
|
assert_eq!(ack.control, TcpControl::None);
|
||||||
|
assert_eq!(ack.ack_number, Some(TcpSeqNumber(1002)));
|
||||||
|
assert!(retained.upgrade().is_some());
|
||||||
|
|
||||||
|
// Lose our final ACK. TIME-WAIT must acknowledge the repeated FIN.
|
||||||
|
ingress.send(Ok(peer_fin)).await.unwrap();
|
||||||
|
let ack = recv_tcp(&mut egress).await;
|
||||||
|
assert_eq!(ack.control, TcpControl::None);
|
||||||
|
assert_eq!(ack.ack_number, Some(TcpSeqNumber(1002)));
|
||||||
|
tokio::time::timeout(StdDuration::from_secs(15), async {
|
||||||
|
while retained.upgrade().is_some() {
|
||||||
|
tokio::time::sleep(StdDuration::from_millis(10)).await;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.expect("TIME-WAIT must eventually release the socket and its leases");
|
||||||
|
assert_eq!(
|
||||||
|
reactor.socket_allocator().sockets().lock().iter().count(),
|
||||||
|
0
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn closing_stream_deadline_sends_reset_and_releases_socket() {
|
||||||
|
let (net, ingress, mut egress) = test_net();
|
||||||
|
let stream = connected_stream(&net, &ingress, &mut egress).await;
|
||||||
|
let reactor = stream.reactor.clone();
|
||||||
|
let keep_alive = Arc::new(());
|
||||||
|
let retained = Arc::downgrade(&keep_alive);
|
||||||
|
// Exercise the same reactor deadline with a short test budget.
|
||||||
|
reactor.close_tcp(
|
||||||
|
stream.handle,
|
||||||
|
Box::new(keep_alive),
|
||||||
|
smoltcp::time::Instant::now() + Duration::from_millis(150),
|
||||||
|
);
|
||||||
|
let fin = recv_tcp(&mut egress).await;
|
||||||
|
assert_eq!(fin.control, TcpControl::Fin);
|
||||||
|
ingress
|
||||||
|
.send(Ok(PACKETS.ack(
|
||||||
|
Ipv4Address::new(10, 126, 126, 2),
|
||||||
|
40000,
|
||||||
|
TcpSeqNumber(1001),
|
||||||
|
fin.sequence + 1,
|
||||||
|
)))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
// The peer ACKs our FIN but never closes its own half (FIN-WAIT-2).
|
||||||
|
let reset = recv_tcp(&mut egress).await;
|
||||||
|
assert_eq!(reset.control, TcpControl::Rst);
|
||||||
|
tokio::time::timeout(StdDuration::from_secs(1), async {
|
||||||
|
while retained.upgrade().is_some() {
|
||||||
|
tokio::task::yield_now().await;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
reactor.socket_allocator().sockets().lock().iter().count(),
|
||||||
|
0
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn stopping_reactor_releases_closing_streams() {
|
||||||
|
let (net, ingress, mut egress) = test_net();
|
||||||
|
let stream = connected_stream(&net, &ingress, &mut egress).await;
|
||||||
|
let reactor = stream.reactor.clone();
|
||||||
|
let keep_alive = Arc::new(());
|
||||||
|
let retained = Arc::downgrade(&keep_alive);
|
||||||
|
stream.close(keep_alive);
|
||||||
|
assert_eq!(recv_tcp(&mut egress).await.control, TcpControl::Fin);
|
||||||
|
drop(net);
|
||||||
|
tokio::time::timeout(StdDuration::from_secs(1), async {
|
||||||
|
while retained.upgrade().is_some() {
|
||||||
|
tokio::task::yield_now().await;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.expect("stopping the reactor must release unfinished TCP teardown");
|
||||||
|
assert_eq!(
|
||||||
|
reactor.socket_allocator().sockets().lock().iter().count(),
|
||||||
|
0
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn duplicate_syn_does_not_consume_another_backlog_slot() {
|
async fn duplicate_syn_does_not_consume_another_backlog_slot() {
|
||||||
let (net, ingress, mut egress) = test_net();
|
let (net, ingress, mut egress) = test_net();
|
||||||
|
|||||||
@@ -114,6 +114,7 @@ pub(crate) struct TcpPacketSummary {
|
|||||||
pub(crate) control: TcpControl,
|
pub(crate) control: TcpControl,
|
||||||
pub(crate) sequence: TcpSeqNumber,
|
pub(crate) sequence: TcpSeqNumber,
|
||||||
pub(crate) dst_port: u16,
|
pub(crate) dst_port: u16,
|
||||||
|
pub(crate) ack_number: Option<TcpSeqNumber>,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) async fn recv_tcp(receiver: &mut mpsc::Receiver<Vec<u8>>) -> TcpPacketSummary {
|
pub(crate) async fn recv_tcp(receiver: &mut mpsc::Receiver<Vec<u8>>) -> TcpPacketSummary {
|
||||||
@@ -133,6 +134,7 @@ pub(crate) async fn recv_tcp(receiver: &mut mpsc::Receiver<Vec<u8>>) -> TcpPacke
|
|||||||
control: repr.control,
|
control: repr.control,
|
||||||
sequence: repr.seq_number,
|
sequence: repr.seq_number,
|
||||||
dst_port: repr.dst_port,
|
dst_port: repr.dst_port,
|
||||||
|
ack_number: repr.ack_number,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,11 +6,12 @@
|
|||||||
//! accept, UDP sockets never receive, and DNS answers only literal IP hosts.
|
//! accept, UDP sockets never receive, and DNS answers only literal IP hosts.
|
||||||
|
|
||||||
use std::{
|
use std::{
|
||||||
|
collections::HashMap,
|
||||||
io,
|
io,
|
||||||
net::{IpAddr, SocketAddr},
|
net::{IpAddr, SocketAddr},
|
||||||
pin::Pin,
|
pin::Pin,
|
||||||
sync::{
|
sync::{
|
||||||
Arc,
|
Arc, Mutex,
|
||||||
atomic::{AtomicUsize, Ordering},
|
atomic::{AtomicUsize, Ordering},
|
||||||
},
|
},
|
||||||
task::{Context, Poll},
|
task::{Context, Poll},
|
||||||
@@ -70,11 +71,16 @@ impl VirtualTcpSocket for TestTcpSocket {
|
|||||||
pub struct TestTcpListener {
|
pub struct TestTcpListener {
|
||||||
address: SocketAddr,
|
address: SocketAddr,
|
||||||
active_listeners: Arc<AtomicUsize>,
|
active_listeners: Arc<AtomicUsize>,
|
||||||
|
listeners: Option<Arc<Mutex<TestTcpListeners>>>,
|
||||||
|
incoming: Option<tokio::sync::Mutex<tokio::sync::mpsc::UnboundedReceiver<TestTcpSocket>>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Drop for TestTcpListener {
|
impl Drop for TestTcpListener {
|
||||||
fn drop(&mut self) {
|
fn drop(&mut self) {
|
||||||
self.active_listeners.fetch_sub(1, Ordering::Relaxed);
|
self.active_listeners.fetch_sub(1, Ordering::Relaxed);
|
||||||
|
if let Some(listeners) = &self.listeners {
|
||||||
|
listeners.lock().unwrap().remove(&self.address);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -87,6 +93,12 @@ impl VirtualTcpListener for TestTcpListener {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async fn accept(&self) -> io::Result<(Self::Socket, SocketAddr)> {
|
async fn accept(&self) -> io::Result<(Self::Socket, SocketAddr)> {
|
||||||
|
if let Some(incoming) = &self.incoming {
|
||||||
|
let socket = incoming.lock().await.recv().await.ok_or_else(|| {
|
||||||
|
io::Error::new(io::ErrorKind::ConnectionAborted, "test listener closed")
|
||||||
|
})?;
|
||||||
|
return Ok((socket, "127.0.0.1:20001".parse().unwrap()));
|
||||||
|
}
|
||||||
std::future::pending().await
|
std::future::pending().await
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -108,6 +120,8 @@ impl VirtualUdpSocket for TestUdpSocket {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub type TestTcpListeners = HashMap<SocketAddr, tokio::sync::mpsc::UnboundedSender<TestTcpSocket>>;
|
||||||
|
|
||||||
#[derive(Default)]
|
#[derive(Default)]
|
||||||
pub struct TestHost {
|
pub struct TestHost {
|
||||||
pub tcp_binds: AtomicUsize,
|
pub tcp_binds: AtomicUsize,
|
||||||
@@ -115,21 +129,44 @@ pub struct TestHost {
|
|||||||
pub udp_binds: AtomicUsize,
|
pub udp_binds: AtomicUsize,
|
||||||
pub proxy_nat_connections:
|
pub proxy_nat_connections:
|
||||||
Option<tokio::sync::mpsc::UnboundedSender<(SocketAddr, tokio::io::DuplexStream)>>,
|
Option<tokio::sync::mpsc::UnboundedSender<(SocketAddr, tokio::io::DuplexStream)>>,
|
||||||
|
pub port_forward_connections:
|
||||||
|
Option<tokio::sync::mpsc::UnboundedSender<(SocketAddr, tokio::io::DuplexStream)>>,
|
||||||
|
pub tcp_listeners: Option<Arc<Mutex<TestTcpListeners>>>,
|
||||||
|
pub reject_tcp_bind: Mutex<Option<SocketAddr>>,
|
||||||
|
pub tcp_bind_gate: Option<Arc<tokio::sync::Semaphore>>,
|
||||||
|
pub tcp_bind_runtime: Mutex<Option<tokio::runtime::Id>>,
|
||||||
pub reject_socks5_listener: bool,
|
pub reject_socks5_listener: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl TestHost {
|
||||||
|
#[cfg(all(feature = "management", feature = "proxy-smoltcp-stack"))]
|
||||||
|
pub fn connect_to_listener(&self, address: SocketAddr) -> tokio::io::DuplexStream {
|
||||||
|
let (socket, peer) = tokio::io::duplex(1024);
|
||||||
|
self.tcp_listeners
|
||||||
|
.as_ref()
|
||||||
|
.unwrap()
|
||||||
|
.lock()
|
||||||
|
.unwrap()
|
||||||
|
.get(&address)
|
||||||
|
.expect("test listener must be bound")
|
||||||
|
.send(TestTcpSocket(socket))
|
||||||
|
.unwrap();
|
||||||
|
peer
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[async_trait::async_trait]
|
#[async_trait::async_trait]
|
||||||
impl VirtualTcpSocketFactory for TestHost {
|
impl VirtualTcpSocketFactory for TestHost {
|
||||||
type Socket = TestTcpSocket;
|
type Socket = TestTcpSocket;
|
||||||
|
|
||||||
async fn connect_tcp(&self, options: TcpConnectOptions) -> anyhow::Result<Self::Socket> {
|
async fn connect_tcp(&self, options: TcpConnectOptions) -> anyhow::Result<Self::Socket> {
|
||||||
if options.purpose != TcpSocketPurpose::ProxyNat {
|
let connections = match options.purpose {
|
||||||
anyhow::bail!("test host does not connect non-proxy TCP sockets");
|
TcpSocketPurpose::ProxyNat => &self.proxy_nat_connections,
|
||||||
|
TcpSocketPurpose::PortForward => &self.port_forward_connections,
|
||||||
|
_ => anyhow::bail!("test host does not connect non-proxy TCP sockets"),
|
||||||
}
|
}
|
||||||
let connections = self
|
.as_ref()
|
||||||
.proxy_nat_connections
|
.ok_or_else(|| anyhow::anyhow!("test host TCP connections are disabled"))?;
|
||||||
.as_ref()
|
|
||||||
.ok_or_else(|| anyhow::anyhow!("test host proxy NAT is disabled"))?;
|
|
||||||
let (socket, peer) = tokio::io::duplex(1024);
|
let (socket, peer) = tokio::io::duplex(1024);
|
||||||
connections
|
connections
|
||||||
.send((options.remote_addr, peer))
|
.send((options.remote_addr, peer))
|
||||||
@@ -144,6 +181,10 @@ impl VirtualTcpListenerFactory for TestHost {
|
|||||||
|
|
||||||
async fn bind_tcp(&self, options: TcpListenOptions) -> anyhow::Result<Arc<Self::Listener>> {
|
async fn bind_tcp(&self, options: TcpListenOptions) -> anyhow::Result<Arc<Self::Listener>> {
|
||||||
self.tcp_binds.fetch_add(1, Ordering::Relaxed);
|
self.tcp_binds.fetch_add(1, Ordering::Relaxed);
|
||||||
|
*self.tcp_bind_runtime.lock().unwrap() = Some(tokio::runtime::Handle::current().id());
|
||||||
|
if let Some(gate) = &self.tcp_bind_gate {
|
||||||
|
gate.acquire().await.unwrap().forget();
|
||||||
|
}
|
||||||
if self.reject_socks5_listener && options.purpose == TcpListenPurpose::Socks5 {
|
if self.reject_socks5_listener && options.purpose == TcpListenPurpose::Socks5 {
|
||||||
anyhow::bail!("test host rejected SOCKS5 listener");
|
anyhow::bail!("test host rejected SOCKS5 listener");
|
||||||
}
|
}
|
||||||
@@ -159,10 +200,26 @@ impl VirtualTcpListenerFactory for TestHost {
|
|||||||
} else {
|
} else {
|
||||||
address
|
address
|
||||||
};
|
};
|
||||||
|
if *self.reject_tcp_bind.lock().unwrap() == Some(address) {
|
||||||
|
anyhow::bail!("test host rejected TCP bind to {address}");
|
||||||
|
}
|
||||||
|
let incoming = if let Some(listeners) = &self.tcp_listeners {
|
||||||
|
let mut listeners = listeners.lock().unwrap();
|
||||||
|
if listeners.contains_key(&address) {
|
||||||
|
return Err(io::Error::new(io::ErrorKind::AddrInUse, address.to_string()).into());
|
||||||
|
}
|
||||||
|
let (sender, receiver) = tokio::sync::mpsc::unbounded_channel();
|
||||||
|
listeners.insert(address, sender);
|
||||||
|
Some(tokio::sync::Mutex::new(receiver))
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
self.active_tcp_listeners.fetch_add(1, Ordering::Relaxed);
|
self.active_tcp_listeners.fetch_add(1, Ordering::Relaxed);
|
||||||
Ok(Arc::new(TestTcpListener {
|
Ok(Arc::new(TestTcpListener {
|
||||||
address,
|
address,
|
||||||
active_listeners: self.active_tcp_listeners.clone(),
|
active_listeners: self.active_tcp_listeners.clone(),
|
||||||
|
listeners: self.tcp_listeners.clone(),
|
||||||
|
incoming,
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ use crate::config::{peers::PeerRuntimeSnapshot, runtime::CoreRuntimeConfig};
|
|||||||
use super::CoreInstanceConfig;
|
use super::CoreInstanceConfig;
|
||||||
|
|
||||||
const DHCP_IPV4_AVAILABLE: bool = cfg!(feature = "dhcp-ipv4");
|
const DHCP_IPV4_AVAILABLE: bool = cfg!(feature = "dhcp-ipv4");
|
||||||
|
const DHCP_IPV6_AVAILABLE: bool = cfg!(feature = "dhcp-ipv6");
|
||||||
const SMOLTCP_GATEWAY_AVAILABLE: bool = cfg!(feature = "proxy-smoltcp-stack");
|
const SMOLTCP_GATEWAY_AVAILABLE: bool = cfg!(feature = "proxy-smoltcp-stack");
|
||||||
const PACKET_PROXY_AVAILABLE: bool = cfg!(feature = "proxy-packet");
|
const PACKET_PROXY_AVAILABLE: bool = cfg!(feature = "proxy-packet");
|
||||||
const PROXY_CIDR_MONITOR_AVAILABLE: bool = cfg!(feature = "proxy-cidr-monitor");
|
const PROXY_CIDR_MONITOR_AVAILABLE: bool = cfg!(feature = "proxy-cidr-monitor");
|
||||||
@@ -28,6 +29,7 @@ fn validate_snapshot(
|
|||||||
) -> anyhow::Result<()> {
|
) -> anyhow::Result<()> {
|
||||||
let core = &peer.runtime.core;
|
let core = &peer.runtime.core;
|
||||||
require(DHCP_IPV4_AVAILABLE, runtime.dhcp_ipv4, "DHCP IPv4")?;
|
require(DHCP_IPV4_AVAILABLE, runtime.dhcp_ipv4, "DHCP IPv4")?;
|
||||||
|
require(DHCP_IPV6_AVAILABLE, runtime.dhcp_ipv6, "DHCP IPv6")?;
|
||||||
require(
|
require(
|
||||||
SMOLTCP_GATEWAY_AVAILABLE,
|
SMOLTCP_GATEWAY_AVAILABLE,
|
||||||
runtime.gateway.socks5_bind.is_some() || !runtime.gateway.port_forwards.is_empty(),
|
runtime.gateway.socks5_bind.is_some() || !runtime.gateway.port_forwards.is_empty(),
|
||||||
|
|||||||
@@ -92,6 +92,16 @@ impl Default for CoreInstanceHostConfig {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl CoreInstanceHostConfig {
|
impl CoreInstanceHostConfig {
|
||||||
|
/// Record whether this host can serve the TCP subnet proxy from a user-space TCP stack.
|
||||||
|
///
|
||||||
|
/// Hosts without an OS TUN device (WASI guests, sandboxes) can only forward proxied TCP
|
||||||
|
/// through such a stack, so they must advertise it for the proxy runtime to pick that path.
|
||||||
|
/// `requires_smoltcp` is sticky: once the host needs the stack it keeps needing it.
|
||||||
|
pub fn set_smoltcp_stack(&mut self, available: bool) {
|
||||||
|
self.smoltcp_available = available;
|
||||||
|
self.requires_smoltcp |= available;
|
||||||
|
}
|
||||||
|
|
||||||
pub(crate) fn accepts_runtime_url(&self, url: &url::Url) -> bool {
|
pub(crate) fn accepts_runtime_url(&self, url: &url::Url) -> bool {
|
||||||
!self.ignore_unsupported_config
|
!self.ignore_unsupported_config
|
||||||
|| self
|
|| self
|
||||||
@@ -210,14 +220,20 @@ impl CoreInstanceConfig {
|
|||||||
network_name: network_name.clone(),
|
network_name: network_name.clone(),
|
||||||
},
|
},
|
||||||
routes: RouteConfig {
|
routes: RouteConfig {
|
||||||
ipv4: config.get_ipv4().map(|value| IpPrefix {
|
ipv4: config
|
||||||
address: value.address().into(),
|
.get_ipv4()
|
||||||
prefix_len: value.network_length(),
|
.filter(|_| !config.get_dhcp())
|
||||||
}),
|
.map(|value| IpPrefix {
|
||||||
ipv6: config.get_ipv6().map(|value| IpPrefix {
|
address: value.address().into(),
|
||||||
address: value.address().into(),
|
prefix_len: value.network_length(),
|
||||||
prefix_len: value.network_length(),
|
}),
|
||||||
}),
|
ipv6: config
|
||||||
|
.get_ipv6()
|
||||||
|
.filter(|_| !config.get_dhcpv6())
|
||||||
|
.map(|value| IpPrefix {
|
||||||
|
address: value.address().into(),
|
||||||
|
prefix_len: value.network_length(),
|
||||||
|
}),
|
||||||
proxy_networks: proxy_networks
|
proxy_networks: proxy_networks
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.map(|proxy| ProxyNetworkConfig {
|
.map(|proxy| ProxyNetworkConfig {
|
||||||
@@ -296,6 +312,9 @@ impl CoreInstanceConfig {
|
|||||||
whitelist_priority: None,
|
whitelist_priority: None,
|
||||||
},
|
},
|
||||||
dhcp_ipv4: config.get_dhcp(),
|
dhcp_ipv4: config.get_dhcp(),
|
||||||
|
dhcp_ipv4_cidr: config.get_dhcp_config().cidr(),
|
||||||
|
dhcp_ipv6: config.get_dhcpv6(),
|
||||||
|
dhcp_ipv6_cidr: config.get_dhcpv6_config().cidr(),
|
||||||
gateway: GatewayRuntimeConfig {
|
gateway: GatewayRuntimeConfig {
|
||||||
socks5_bind,
|
socks5_bind,
|
||||||
port_forwards: if host.ignore_unsupported_config && !host.gateway_enabled {
|
port_forwards: if host.ignore_unsupported_config && !host.gateway_enabled {
|
||||||
@@ -407,6 +426,24 @@ mod tests {
|
|||||||
|
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn dhcp_initial_routes_ignore_static_addresses_of_managed_families() {
|
||||||
|
for dhcp_ipv4 in [false, true] {
|
||||||
|
for dhcp_ipv6 in [false, true] {
|
||||||
|
let config =
|
||||||
|
TomlConfig::new_from_str("ipv4 = '10.20.0.7/24'\nipv6 = 'fd12::7/64'").unwrap();
|
||||||
|
config.set_dhcp(dhcp_ipv4);
|
||||||
|
config.set_dhcpv6(dhcp_ipv6);
|
||||||
|
let normalized = CoreInstanceConfig::from_toml(&config).unwrap();
|
||||||
|
let routes = &normalized.peer.snapshot.runtime.core.routes;
|
||||||
|
assert_eq!(routes.ipv4.is_some(), !dhcp_ipv4);
|
||||||
|
assert_eq!(routes.ipv6.is_some(), !dhcp_ipv6);
|
||||||
|
assert_eq!(config.get_ipv4().unwrap(), "10.20.0.7/24".parse().unwrap());
|
||||||
|
assert_eq!(config.get_ipv6().unwrap(), "fd12::7/64".parse().unwrap());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn tcp_stun_servers_follow_toml_override_rules() {
|
fn tcp_stun_servers_follow_toml_override_rules() {
|
||||||
let fallback = TomlConfig::new_from_str(
|
let fallback = TomlConfig::new_from_str(
|
||||||
@@ -738,3 +775,24 @@ data_compress_algo = "Zstd"
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod host_capability_tests {
|
||||||
|
use super::CoreInstanceHostConfig;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn set_smoltcp_stack_marks_the_host_as_capable_and_dependent() {
|
||||||
|
let mut config = CoreInstanceHostConfig::default();
|
||||||
|
config.set_smoltcp_stack(true);
|
||||||
|
assert!(config.smoltcp_available);
|
||||||
|
assert!(config.requires_smoltcp);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn set_smoltcp_stack_does_not_claim_a_missing_stack() {
|
||||||
|
let mut config = CoreInstanceHostConfig::default();
|
||||||
|
config.set_smoltcp_stack(false);
|
||||||
|
assert!(!config.smoltcp_available);
|
||||||
|
assert!(!config.requires_smoltcp);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -6,8 +6,8 @@
|
|||||||
|
|
||||||
use std::sync::{Arc, Weak};
|
use std::sync::{Arc, Weak};
|
||||||
|
|
||||||
#[cfg(feature = "dhcp-ipv4")]
|
#[cfg(any(feature = "dhcp-ipv4", feature = "dhcp-ipv6"))]
|
||||||
use crate::gateway::dhcp::{DhcpIpv4Host, DhcpIpv4RouteSource};
|
use crate::gateway::dhcp::{DhcpHost, DhcpRouteSource};
|
||||||
|
|
||||||
use super::{CoreInstance, CoreInstanceHost, CoreInstanceState};
|
use super::{CoreInstance, CoreInstanceHost, CoreInstanceState};
|
||||||
|
|
||||||
@@ -66,30 +66,39 @@ where
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(feature = "dhcp-ipv4")]
|
#[cfg(any(feature = "dhcp-ipv4", feature = "dhcp-ipv6"))]
|
||||||
async fn start_dhcp_ipv4(&self, host: Option<Arc<dyn DhcpIpv4Host>>) -> anyhow::Result<()> {
|
async fn start_dhcp(&self, host: Option<Arc<dyn DhcpHost>>) -> anyhow::Result<()> {
|
||||||
if !self.runtime_config.snapshot().services.dhcp_ipv4 {
|
let config = self.runtime_config.snapshot();
|
||||||
|
if !config.services.dhcp_ipv4 && !config.services.dhcp_ipv6 {
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
let host = host.ok_or_else(|| {
|
let host = host
|
||||||
anyhow::anyhow!("DHCP IPv4 is enabled but no host adapter was provided")
|
.ok_or_else(|| anyhow::anyhow!("DHCP is enabled but no host adapter was provided"))?;
|
||||||
})?;
|
let route_source: Arc<dyn DhcpRouteSource> = self.peer_manager.clone();
|
||||||
let route_source: Arc<dyn DhcpIpv4RouteSource> = self.peer_manager.clone();
|
self.dhcp
|
||||||
self.dhcp_ipv4
|
|
||||||
.start(route_source, self.runtime_config.clone(), host)
|
.start(route_source, self.runtime_config.clone(), host)
|
||||||
.await;
|
.await;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(feature = "proxy-packet")]
|
#[cfg(feature = "proxy-packet")]
|
||||||
async fn start_packet_proxy(&self) -> anyhow::Result<()> {
|
pub(super) async fn start_packet_proxy(&self) -> anyhow::Result<()> {
|
||||||
let config = self.runtime_config.snapshot();
|
let config = self.runtime_config.snapshot();
|
||||||
let has_proxy_networks = !config.peer.runtime.core.routes.proxy_networks.is_empty();
|
let has_proxy_networks = !config.peer.runtime.core.routes.proxy_networks.is_empty();
|
||||||
if config.services.proxy.should_start(has_proxy_networks) {
|
if config.services.proxy.should_start(has_proxy_networks) {
|
||||||
self.packet_proxy
|
if let Some(runtime) = self.runtime_handle() {
|
||||||
.start()
|
let proxy = self.packet_proxy.clone();
|
||||||
|
tokio_util::task::AbortOnDropHandle::new(
|
||||||
|
runtime.spawn(async move { proxy.start().await }),
|
||||||
|
)
|
||||||
.await
|
.await
|
||||||
.map_err(anyhow::Error::new)?;
|
.map_err(|error| anyhow::anyhow!("packet proxy start task failed: {error}"))??;
|
||||||
|
} else {
|
||||||
|
self.packet_proxy
|
||||||
|
.start()
|
||||||
|
.await
|
||||||
|
.map_err(anyhow::Error::new)?;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -125,9 +134,9 @@ where
|
|||||||
self.public_ipv6_provider.start().await;
|
self.public_ipv6_provider.start().await;
|
||||||
|
|
||||||
let dhcp_host = self.instance_runtime.prepare(self.packet_plane()).await?;
|
let dhcp_host = self.instance_runtime.prepare(self.packet_plane()).await?;
|
||||||
#[cfg(feature = "dhcp-ipv4")]
|
#[cfg(any(feature = "dhcp-ipv4", feature = "dhcp-ipv6"))]
|
||||||
self.start_dhcp_ipv4(dhcp_host).await?;
|
self.start_dhcp(dhcp_host).await?;
|
||||||
#[cfg(not(feature = "dhcp-ipv4"))]
|
#[cfg(not(any(feature = "dhcp-ipv4", feature = "dhcp-ipv6")))]
|
||||||
let _ = dhcp_host;
|
let _ = dhcp_host;
|
||||||
#[cfg(feature = "wrapped-transport")]
|
#[cfg(feature = "wrapped-transport")]
|
||||||
if let Some(wrapped_transport) = &self.wrapped_transport {
|
if let Some(wrapped_transport) = &self.wrapped_transport {
|
||||||
@@ -173,8 +182,8 @@ where
|
|||||||
self.vpn_portal.stop().await;
|
self.vpn_portal.stop().await;
|
||||||
#[cfg(feature = "public-ipv6-provider")]
|
#[cfg(feature = "public-ipv6-provider")]
|
||||||
self.public_ipv6_provider.stop().await;
|
self.public_ipv6_provider.stop().await;
|
||||||
#[cfg(feature = "dhcp-ipv4")]
|
#[cfg(any(feature = "dhcp-ipv4", feature = "dhcp-ipv6"))]
|
||||||
self.dhcp_ipv4.stop().await;
|
self.dhcp.stop().await;
|
||||||
#[cfg(feature = "proxy-cidr-monitor")]
|
#[cfg(feature = "proxy-cidr-monitor")]
|
||||||
self.proxy_cidr_monitor.stop().await;
|
self.proxy_cidr_monitor.stop().await;
|
||||||
if let Some(listener) = &self.listener {
|
if let Some(listener) = &self.listener {
|
||||||
@@ -216,8 +225,25 @@ where
|
|||||||
self.packet_egress.stop().await;
|
self.packet_egress.stop().await;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Returns the optional Host-owned executor for this instance.
|
||||||
|
pub fn runtime_handle(&self) -> Option<tokio::runtime::Handle> {
|
||||||
|
self.instance_runtime.runtime_handle()
|
||||||
|
}
|
||||||
|
|
||||||
/// Starts the complete instance through one serial composition path.
|
/// Starts the complete instance through one serial composition path.
|
||||||
pub async fn start(self: &Arc<Self>) -> anyhow::Result<()> {
|
pub async fn start(self: &Arc<Self>) -> anyhow::Result<()> {
|
||||||
|
if let Some(runtime) = self.runtime_handle() {
|
||||||
|
let instance = self.clone();
|
||||||
|
return tokio_util::task::AbortOnDropHandle::new(
|
||||||
|
runtime.spawn(async move { instance.start_on_runtime().await }),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map_err(|error| anyhow::anyhow!("instance start task failed: {error}"))?;
|
||||||
|
}
|
||||||
|
self.start_on_runtime().await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn start_on_runtime(self: &Arc<Self>) -> anyhow::Result<()> {
|
||||||
let _operation = self.operation.lock().await;
|
let _operation = self.operation.lock().await;
|
||||||
let state = self.state();
|
let state = self.state();
|
||||||
if state != CoreInstanceState::Created {
|
if state != CoreInstanceState::Created {
|
||||||
@@ -260,6 +286,21 @@ where
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub async fn stop(self: &Arc<Self>) {
|
pub async fn stop(self: &Arc<Self>) {
|
||||||
|
self.cancel.cancel();
|
||||||
|
if let Some(runtime) = self.runtime_handle() {
|
||||||
|
let instance = self.clone();
|
||||||
|
// Cleanup must survive cancellation even before its first poll.
|
||||||
|
// Dropping a plain JoinHandle detaches rather than aborts the task.
|
||||||
|
runtime
|
||||||
|
.spawn(async move { instance.stop_on_runtime().await })
|
||||||
|
.await
|
||||||
|
.expect("instance stop task failed");
|
||||||
|
} else {
|
||||||
|
self.stop_on_runtime().await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn stop_on_runtime(self: &Arc<Self>) {
|
||||||
self.cancel.cancel();
|
self.cancel.cancel();
|
||||||
let mut recovery = self.recovery_guard();
|
let mut recovery = self.recovery_guard();
|
||||||
let _operation = self.operation.lock().await;
|
let _operation = self.operation.lock().await;
|
||||||
|
|||||||
@@ -387,7 +387,11 @@ where
|
|||||||
let instance_state_changes = self.instance_state_changes.clone();
|
let instance_state_changes = self.instance_state_changes.clone();
|
||||||
runtime.spawn(async move {
|
runtime.spawn(async move {
|
||||||
if let Err(error) = instance.start().await {
|
if let Err(error) = instance.start().await {
|
||||||
tracing::error!(%error, %instance_id, "instance failed to start");
|
tracing::error!(
|
||||||
|
error = %format_args!("{error:#}"),
|
||||||
|
%instance_id,
|
||||||
|
"instance failed to start"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
instance_state_changes.mark_changed();
|
instance_state_changes.mark_changed();
|
||||||
notifier.notify_one();
|
notifier.notify_one();
|
||||||
@@ -479,9 +483,9 @@ where
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn data_plane_runtime_handle(&self, instance_id: &Uuid) -> Option<tokio::runtime::Handle> {
|
pub fn data_plane_runtime_handle(&self, instance_id: &Uuid) -> Option<tokio::runtime::Handle> {
|
||||||
self.instance(*instance_id)?;
|
self.instance(*instance_id)?
|
||||||
self.runtime_handle
|
.runtime_handle()
|
||||||
.clone()
|
.or_else(|| self.runtime_handle.clone())
|
||||||
.or_else(|| tokio::runtime::Handle::try_current().ok())
|
.or_else(|| tokio::runtime::Handle::try_current().ok())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ mod management;
|
|||||||
mod management_extension;
|
mod management_extension;
|
||||||
mod management_state;
|
mod management_state;
|
||||||
pub mod manager;
|
pub mod manager;
|
||||||
mod packet_io;
|
pub(crate) mod packet_io;
|
||||||
mod packet_plane;
|
mod packet_plane;
|
||||||
#[cfg(feature = "proxy-packet")]
|
#[cfg(feature = "proxy-packet")]
|
||||||
mod packet_proxy_extension;
|
mod packet_proxy_extension;
|
||||||
@@ -58,7 +58,7 @@ use crate::{
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
events::CoreEventSink,
|
events::CoreEventSink,
|
||||||
gateway::dhcp::DhcpIpv4Host,
|
gateway::dhcp::DhcpHost,
|
||||||
host::{
|
host::{
|
||||||
dns::{DnsRecordResolver, DnsResolver},
|
dns::{DnsRecordResolver, DnsResolver},
|
||||||
packet::{HostPacketReceiver, PacketSink, host_packet_channel},
|
packet::{HostPacketReceiver, PacketSink, host_packet_channel},
|
||||||
@@ -96,8 +96,8 @@ use crate::gateway::vpn_portal::PortalRuntimeConfig;
|
|||||||
#[cfg(feature = "public-ipv6-provider")]
|
#[cfg(feature = "public-ipv6-provider")]
|
||||||
use crate::peers::public_ipv6::provider::PublicIpv6ProviderPlatform;
|
use crate::peers::public_ipv6::provider::PublicIpv6ProviderPlatform;
|
||||||
|
|
||||||
#[cfg(feature = "dhcp-ipv4")]
|
#[cfg(any(feature = "dhcp-ipv4", feature = "dhcp-ipv6"))]
|
||||||
use crate::gateway::dhcp::DhcpIpv4Runtime;
|
use crate::gateway::dhcp::DhcpRuntime;
|
||||||
#[cfg(feature = "proxy-cidr-monitor")]
|
#[cfg(feature = "proxy-cidr-monitor")]
|
||||||
use crate::gateway::proxy::cidr_monitor::ProxyCidrMonitorRuntime;
|
use crate::gateway::proxy::cidr_monitor::ProxyCidrMonitorRuntime;
|
||||||
#[cfg(feature = "proxy-packet")]
|
#[cfg(feature = "proxy-packet")]
|
||||||
@@ -229,10 +229,16 @@ fn proxy_cidr_snapshot(config: &CoreInstanceRuntimeConfig) -> ProxyCidrSnapshot
|
|||||||
/// lifetime, such as a native packet interface.
|
/// lifetime, such as a native packet interface.
|
||||||
#[async_trait::async_trait]
|
#[async_trait::async_trait]
|
||||||
pub trait InstanceRuntimeHost: std::any::Any + Send + Sync + 'static {
|
pub trait InstanceRuntimeHost: std::any::Any + Send + Sync + 'static {
|
||||||
|
/// Optional Host-owned executor used for construction, lifecycle, and data-plane tasks.
|
||||||
|
/// Hosts without a dedicated executor continue to use the caller's runtime.
|
||||||
|
fn runtime_handle(&self) -> Option<tokio::runtime::Handle> {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
async fn prepare(
|
async fn prepare(
|
||||||
&self,
|
&self,
|
||||||
packet_plane: Arc<CorePacketPlane>,
|
packet_plane: Arc<CorePacketPlane>,
|
||||||
) -> anyhow::Result<Option<Arc<dyn DhcpIpv4Host>>>;
|
) -> anyhow::Result<Option<Arc<dyn DhcpHost>>>;
|
||||||
|
|
||||||
async fn shutdown(&self);
|
async fn shutdown(&self);
|
||||||
|
|
||||||
@@ -270,7 +276,7 @@ impl InstanceRuntimeHost for () {
|
|||||||
async fn prepare(
|
async fn prepare(
|
||||||
&self,
|
&self,
|
||||||
_packet_plane: Arc<CorePacketPlane>,
|
_packet_plane: Arc<CorePacketPlane>,
|
||||||
) -> anyhow::Result<Option<Arc<dyn DhcpIpv4Host>>> {
|
) -> anyhow::Result<Option<Arc<dyn DhcpHost>>> {
|
||||||
Ok(None)
|
Ok(None)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -429,8 +435,8 @@ where
|
|||||||
packet_proxy: Arc<CoreProxyModule<H>>,
|
packet_proxy: Arc<CoreProxyModule<H>>,
|
||||||
#[cfg(feature = "proxy-cidr-monitor")]
|
#[cfg(feature = "proxy-cidr-monitor")]
|
||||||
proxy_cidr_monitor: ProxyCidrMonitorRuntime,
|
proxy_cidr_monitor: ProxyCidrMonitorRuntime,
|
||||||
#[cfg(feature = "dhcp-ipv4")]
|
#[cfg(any(feature = "dhcp-ipv4", feature = "dhcp-ipv6"))]
|
||||||
dhcp_ipv4: DhcpIpv4Runtime,
|
dhcp: DhcpRuntime,
|
||||||
pub(super) packet_egress: Arc<dyn PacketEgressHost>,
|
pub(super) packet_egress: Arc<dyn PacketEgressHost>,
|
||||||
pub(super) packet_receiver: Mutex<Option<HostPacketReceiver>>,
|
pub(super) packet_receiver: Mutex<Option<HostPacketReceiver>>,
|
||||||
pub(super) peer_center: Arc<PeerCenterInstance>,
|
pub(super) peer_center: Arc<PeerCenterInstance>,
|
||||||
@@ -525,6 +531,7 @@ where
|
|||||||
public_ipv6_host,
|
public_ipv6_host,
|
||||||
public_ipv6_events,
|
public_ipv6_events,
|
||||||
);
|
);
|
||||||
|
let running_listeners = Arc::new(RunningListenerRegistry::default());
|
||||||
let stun = (connectivity_mode == CoreConnectivityMode::Full)
|
let stun = (connectivity_mode == CoreConnectivityMode::Full)
|
||||||
.then(|| Self::prepare_stun(&adapters, &config.connectivity));
|
.then(|| Self::prepare_stun(&adapters, &config.connectivity));
|
||||||
let (peer_stun, foreign_rpc_registrar): (
|
let (peer_stun, foreign_rpc_registrar): (
|
||||||
@@ -536,6 +543,7 @@ where
|
|||||||
Arc::new(ForeignDirectConnectorRpcRegistrar::new(
|
Arc::new(ForeignDirectConnectorRpcRegistrar::new(
|
||||||
adapters.host.clone(),
|
adapters.host.clone(),
|
||||||
stun.clone(),
|
stun.clone(),
|
||||||
|
running_listeners.clone(),
|
||||||
)),
|
)),
|
||||||
),
|
),
|
||||||
None => (Arc::new(()), Arc::new(())),
|
None => (Arc::new(()), Arc::new(())),
|
||||||
@@ -627,7 +635,6 @@ where
|
|||||||
accepted_tunnel_handler.clone(),
|
accepted_tunnel_handler.clone(),
|
||||||
)),
|
)),
|
||||||
};
|
};
|
||||||
let running_listeners = Arc::new(RunningListenerRegistry::default());
|
|
||||||
let PreparedListenerPlan {
|
let PreparedListenerPlan {
|
||||||
transports,
|
transports,
|
||||||
external,
|
external,
|
||||||
@@ -783,6 +790,7 @@ where
|
|||||||
host.clone(),
|
host.clone(),
|
||||||
direct_options.tcp_bind.context.clone(),
|
direct_options.tcp_bind.context.clone(),
|
||||||
events.clone(),
|
events.clone(),
|
||||||
|
instance_runtime.runtime_handle(),
|
||||||
);
|
);
|
||||||
#[cfg(feature = "tcp-hole-punch")]
|
#[cfg(feature = "tcp-hole-punch")]
|
||||||
let tcp_hole_punch = stun
|
let tcp_hole_punch = stun
|
||||||
@@ -877,8 +885,8 @@ where
|
|||||||
packet_proxy,
|
packet_proxy,
|
||||||
#[cfg(feature = "proxy-cidr-monitor")]
|
#[cfg(feature = "proxy-cidr-monitor")]
|
||||||
proxy_cidr_monitor,
|
proxy_cidr_monitor,
|
||||||
#[cfg(feature = "dhcp-ipv4")]
|
#[cfg(any(feature = "dhcp-ipv4", feature = "dhcp-ipv6"))]
|
||||||
dhcp_ipv4: DhcpIpv4Runtime::new(),
|
dhcp: DhcpRuntime::new(),
|
||||||
packet_egress,
|
packet_egress,
|
||||||
packet_receiver: Mutex::new(Some(packet_rx)),
|
packet_receiver: Mutex::new(Some(packet_rx)),
|
||||||
peer_center,
|
peer_center,
|
||||||
@@ -929,26 +937,63 @@ where
|
|||||||
anyhow::bail!("runtime config cannot update while instance is stopping or stopped");
|
anyhow::bail!("runtime config cannot update while instance is stopping or stopped");
|
||||||
}
|
}
|
||||||
self.validate_runtime_config_capabilities(&config)?;
|
self.validate_runtime_config_capabilities(&config)?;
|
||||||
|
#[cfg(feature = "proxy-smoltcp-stack")]
|
||||||
|
let prepared = self.prepare_port_forwards(&config).await?;
|
||||||
|
self.commit_runtime_config_under_operation(
|
||||||
|
config,
|
||||||
|
#[cfg(feature = "proxy-smoltcp-stack")]
|
||||||
|
prepared,
|
||||||
|
|| (),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "proxy-smoltcp-stack")]
|
||||||
|
pub(crate) async fn prepare_port_forwards(
|
||||||
|
&self,
|
||||||
|
config: &CoreInstanceRuntimeConfig,
|
||||||
|
) -> anyhow::Result<Option<crate::gateway::PreparedPortForwards<H>>> {
|
||||||
|
self.port_forward_adapter
|
||||||
|
.prepare(&config.services.gateway.port_forwards)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) async fn commit_runtime_config_under_operation(
|
||||||
|
&self,
|
||||||
|
config: CoreInstanceRuntimeConfig,
|
||||||
|
#[cfg(feature = "proxy-smoltcp-stack")] prepared: Option<
|
||||||
|
crate::gateway::PreparedPortForwards<H>,
|
||||||
|
>,
|
||||||
|
commit_config: impl FnOnce(),
|
||||||
|
) -> anyhow::Result<()> {
|
||||||
#[cfg(feature = "test-utils")]
|
#[cfg(feature = "test-utils")]
|
||||||
let reload_acl = self.runtime_config.snapshot().services.acl != config.services.acl;
|
let reload_acl = self.runtime_config.snapshot().services.acl != config.services.acl;
|
||||||
let published = self.peer_manager.update_runtime_config(config).await?;
|
let (published, cleanup) = self
|
||||||
|
.peer_manager
|
||||||
|
.update_runtime_config_with(config, || {
|
||||||
|
#[cfg(feature = "proxy-smoltcp-stack")]
|
||||||
|
let cleanup = prepared.map(|prepared| prepared.commit());
|
||||||
|
#[cfg(not(feature = "proxy-smoltcp-stack"))]
|
||||||
|
let cleanup: Option<tokio::task::JoinHandle<()>> = None;
|
||||||
|
commit_config();
|
||||||
|
cleanup
|
||||||
|
})
|
||||||
|
.await?;
|
||||||
#[cfg(feature = "test-utils")]
|
#[cfg(feature = "test-utils")]
|
||||||
if reload_acl {
|
if reload_acl {
|
||||||
self.acl_reload_count.fetch_add(1, Ordering::Relaxed);
|
self.acl_reload_count.fetch_add(1, Ordering::Relaxed);
|
||||||
}
|
}
|
||||||
self.proxy_cidr_table
|
self.proxy_cidr_table
|
||||||
.update_snapshot(proxy_cidr_snapshot(&published));
|
.update_snapshot(proxy_cidr_snapshot(&published));
|
||||||
#[cfg(feature = "proxy-smoltcp-stack")]
|
if let Some(cleanup) = cleanup {
|
||||||
self.port_forward_adapter
|
let _ = cleanup.await;
|
||||||
.reload(
|
}
|
||||||
&self
|
#[cfg(feature = "proxy-packet")]
|
||||||
.runtime_config
|
if self.state() == CoreInstanceState::Running && self.startup_plan.packet_proxy {
|
||||||
.snapshot()
|
// A running instance may gain its first proxy network here. Start
|
||||||
.services
|
// from the published configuration, just as during initial startup.
|
||||||
.gateway
|
self.start_packet_proxy().await?;
|
||||||
.port_forwards,
|
}
|
||||||
)
|
|
||||||
.await?;
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -81,10 +81,37 @@ where
|
|||||||
.evict_idle_sessions(idle);
|
.evict_idle_sessions(idle);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[doc(hidden)]
|
||||||
|
pub async fn foreign_network_has_gateway_for_test(
|
||||||
|
&self,
|
||||||
|
network_name: &str,
|
||||||
|
peer_id: crate::config::PeerId,
|
||||||
|
) -> Option<bool> {
|
||||||
|
self.peer_manager
|
||||||
|
.foreign_network_has_gateway(network_name, peer_id)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
#[doc(hidden)]
|
#[doc(hidden)]
|
||||||
pub fn evict_unused_peer_sessions_for_test(&self, idle: Duration) {
|
pub fn evict_unused_peer_sessions_for_test(&self, idle: Duration) {
|
||||||
self.peer_manager
|
self.peer_manager
|
||||||
.get_peer_session_store()
|
.get_peer_session_store()
|
||||||
.evict_unused_sessions_idle(idle);
|
.evict_unused_sessions_idle(idle);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[doc(hidden)]
|
||||||
|
pub fn foreign_forward_metered_bytes_for_test(&self, network_name: &str) -> Option<u64> {
|
||||||
|
self.peer_manager
|
||||||
|
.foreign_forward_metered_bytes(network_name)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[doc(hidden)]
|
||||||
|
pub fn network_name_for_test(&self) -> String {
|
||||||
|
self.peer_manager.network_name().to_owned()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[doc(hidden)]
|
||||||
|
pub fn instance_recv_metered_bytes_for_test(&self) -> Option<u64> {
|
||||||
|
self.peer_manager.instance_recv_metered_bytes()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,3 +1,6 @@
|
|||||||
|
#[cfg(all(feature = "system-certificates", target_os = "android"))]
|
||||||
|
compile_error!("the system-certificates feature is not supported on Android");
|
||||||
|
|
||||||
pub mod config;
|
pub mod config;
|
||||||
pub mod connectivity;
|
pub mod connectivity;
|
||||||
pub mod events;
|
pub mod events;
|
||||||
|
|||||||
@@ -145,7 +145,6 @@ impl<Accepted> ListenerFactory<Accepted> {
|
|||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct ListenerManagerOptions {
|
pub struct ListenerManagerOptions {
|
||||||
pub max_listen_retries: usize,
|
|
||||||
pub listen_retry_delay: Duration,
|
pub listen_retry_delay: Duration,
|
||||||
pub accept_retry_delay: Duration,
|
pub accept_retry_delay: Duration,
|
||||||
}
|
}
|
||||||
@@ -153,9 +152,8 @@ pub struct ListenerManagerOptions {
|
|||||||
impl Default for ListenerManagerOptions {
|
impl Default for ListenerManagerOptions {
|
||||||
fn default() -> Self {
|
fn default() -> Self {
|
||||||
Self {
|
Self {
|
||||||
max_listen_retries: 5,
|
listen_retry_delay: Duration::from_millis(100),
|
||||||
listen_retry_delay: Duration::from_secs(1),
|
accept_retry_delay: Duration::from_millis(100),
|
||||||
accept_retry_delay: Duration::from_secs(1),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -342,10 +340,11 @@ where
|
|||||||
Accepted: Send + 'static,
|
Accepted: Send + 'static,
|
||||||
{
|
{
|
||||||
let mut listener = creator();
|
let mut listener = creator();
|
||||||
match listener.listen().await {
|
listener
|
||||||
Ok(()) => Ok(listener),
|
.listen()
|
||||||
Err(error) => Err(error),
|
.await
|
||||||
}
|
.with_context(|| format!("failed to listen on {}", listener.local_url()))?;
|
||||||
|
Ok(listener)
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn run_listener<Accepted, H>(
|
async fn run_listener<Accepted, H>(
|
||||||
@@ -360,7 +359,7 @@ async fn run_listener<Accepted, H>(
|
|||||||
Accepted: Send + 'static,
|
Accepted: Send + 'static,
|
||||||
H: AcceptedSocketHandler<Accepted> + ?Sized + 'static,
|
H: AcceptedSocketHandler<Accepted> + ?Sized + 'static,
|
||||||
{
|
{
|
||||||
let mut listen_error_count = 0;
|
let mut listen_error_count: usize = 0;
|
||||||
loop {
|
loop {
|
||||||
let registered_listener = match initial_listener.take() {
|
let registered_listener = match initial_listener.take() {
|
||||||
Some(listener) => listener,
|
Some(listener) => listener,
|
||||||
@@ -372,18 +371,15 @@ async fn run_listener<Accepted, H>(
|
|||||||
RegisteredListener::new(listener, events.clone(), registry.clone())
|
RegisteredListener::new(listener, events.clone(), registry.clone())
|
||||||
}
|
}
|
||||||
Err(error) => {
|
Err(error) => {
|
||||||
listen_error_count += 1;
|
listen_error_count = listen_error_count.saturating_add(1);
|
||||||
let will_retry = listen_error_count <= options.max_listen_retries;
|
|
||||||
events.emit(CoreEvent::ListenerAddFailed {
|
events.emit(CoreEvent::ListenerAddFailed {
|
||||||
url: listener.local_url(),
|
url: listener.local_url(),
|
||||||
error: format!("{error:?}"),
|
error: format!("{error:?}"),
|
||||||
retry_count: listen_error_count,
|
retry_count: listen_error_count,
|
||||||
will_retry,
|
will_retry: true,
|
||||||
});
|
});
|
||||||
tracing::error!(?error, ?listener, "listener listen error");
|
tracing::error!(?error, ?listener, "listener listen error");
|
||||||
if !will_retry {
|
drop(listener);
|
||||||
return;
|
|
||||||
}
|
|
||||||
crate::foundation::time::sleep(options.listen_retry_delay).await;
|
crate::foundation::time::sleep(options.listen_retry_delay).await;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -391,7 +387,7 @@ async fn run_listener<Accepted, H>(
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
let mut listener = registered_listener.listener;
|
let mut listener = registered_listener.listener;
|
||||||
let _registration = registered_listener.registration;
|
let registration = registered_listener.registration;
|
||||||
|
|
||||||
loop {
|
loop {
|
||||||
let listener_url = listener.local_url();
|
let listener_url = listener.local_url();
|
||||||
@@ -403,7 +399,6 @@ async fn run_listener<Accepted, H>(
|
|||||||
error: format!("{error:?}"),
|
error: format!("{error:?}"),
|
||||||
});
|
});
|
||||||
tracing::error!(?error, ?listener, "listener accept error");
|
tracing::error!(?error, ?listener, "listener accept error");
|
||||||
crate::foundation::time::sleep(options.accept_retry_delay).await;
|
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -422,6 +417,9 @@ async fn run_listener<Accepted, H>(
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
drop(listener);
|
||||||
|
drop(registration);
|
||||||
|
crate::foundation::time::sleep(options.accept_retry_delay).await;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -650,7 +648,6 @@ mod tests {
|
|||||||
ListenerManagerOptions {
|
ListenerManagerOptions {
|
||||||
accept_retry_delay: Duration::from_millis(1),
|
accept_retry_delay: Duration::from_millis(1),
|
||||||
listen_retry_delay: Duration::from_millis(1),
|
listen_retry_delay: Duration::from_millis(1),
|
||||||
max_listen_retries: 0,
|
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -703,7 +700,6 @@ mod tests {
|
|||||||
ListenerManagerOptions {
|
ListenerManagerOptions {
|
||||||
accept_retry_delay: Duration::from_millis(1),
|
accept_retry_delay: Duration::from_millis(1),
|
||||||
listen_retry_delay: Duration::from_millis(1),
|
listen_retry_delay: Duration::from_millis(1),
|
||||||
max_listen_retries: 2,
|
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -750,7 +746,6 @@ mod tests {
|
|||||||
ListenerManagerOptions {
|
ListenerManagerOptions {
|
||||||
accept_retry_delay: Duration::from_millis(1),
|
accept_retry_delay: Duration::from_millis(1),
|
||||||
listen_retry_delay: Duration::from_millis(1),
|
listen_retry_delay: Duration::from_millis(1),
|
||||||
max_listen_retries: 0,
|
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -782,7 +777,8 @@ mod tests {
|
|||||||
true,
|
true,
|
||||||
);
|
);
|
||||||
|
|
||||||
assert!(manager.run().await.is_err());
|
let error = manager.run().await.unwrap_err();
|
||||||
|
assert!(format!("{error:#}").contains("mock://second"));
|
||||||
crate::foundation::time::sleep(Duration::from_millis(20)).await;
|
crate::foundation::time::sleep(Duration::from_millis(20)).await;
|
||||||
assert!(handler.accepted.lock().unwrap().is_empty());
|
assert!(handler.accepted.lock().unwrap().is_empty());
|
||||||
assert!(
|
assert!(
|
||||||
@@ -813,7 +809,6 @@ mod tests {
|
|||||||
ListenerManagerOptions {
|
ListenerManagerOptions {
|
||||||
accept_retry_delay: Duration::from_millis(1),
|
accept_retry_delay: Duration::from_millis(1),
|
||||||
listen_retry_delay: Duration::from_millis(1),
|
listen_retry_delay: Duration::from_millis(1),
|
||||||
max_listen_retries: 0,
|
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -934,7 +929,6 @@ mod tests {
|
|||||||
ListenerManagerOptions {
|
ListenerManagerOptions {
|
||||||
accept_retry_delay: Duration::from_millis(1),
|
accept_retry_delay: Duration::from_millis(1),
|
||||||
listen_retry_delay: Duration::from_millis(1),
|
listen_retry_delay: Duration::from_millis(1),
|
||||||
max_listen_retries: 0,
|
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
let accepts = Arc::new(Mutex::new(VecDeque::from([Ok::<_, anyhow::Error>(
|
let accepts = Arc::new(Mutex::new(VecDeque::from([Ok::<_, anyhow::Error>(
|
||||||
@@ -997,12 +991,13 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn listen_retry_exhaustion_keeps_in_flight_handler_tasks_until_manager_drop() {
|
async fn listen_recovery_keeps_in_flight_handler_tasks_until_manager_drop() {
|
||||||
let (drop_tx, mut drop_rx) = tokio::sync::oneshot::channel();
|
let (drop_tx, mut drop_rx) = tokio::sync::oneshot::channel();
|
||||||
let listen_results = Arc::new(Mutex::new(VecDeque::from([
|
let listen_results = Arc::new(Mutex::new(
|
||||||
Ok(()),
|
std::iter::once(Ok(()))
|
||||||
Err(anyhow::anyhow!("bind failed")),
|
.chain((0..8).map(|_| Err(anyhow::anyhow!("bind failed"))))
|
||||||
])));
|
.collect::<VecDeque<_>>(),
|
||||||
|
));
|
||||||
let accepts = Arc::new(Mutex::new(VecDeque::from([
|
let accepts = Arc::new(Mutex::new(VecDeque::from([
|
||||||
Ok::<_, anyhow::Error>(DropSignal(Some(drop_tx))),
|
Ok::<_, anyhow::Error>(DropSignal(Some(drop_tx))),
|
||||||
Err(anyhow::anyhow!("accept failed")),
|
Err(anyhow::anyhow!("accept failed")),
|
||||||
@@ -1014,7 +1009,6 @@ mod tests {
|
|||||||
ListenerManagerOptions {
|
ListenerManagerOptions {
|
||||||
accept_retry_delay: Duration::from_millis(1),
|
accept_retry_delay: Duration::from_millis(1),
|
||||||
listen_retry_delay: Duration::from_millis(1),
|
listen_retry_delay: Duration::from_millis(1),
|
||||||
max_listen_retries: 0,
|
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
let listener_results = listen_results.clone();
|
let listener_results = listen_results.clone();
|
||||||
@@ -1022,7 +1016,7 @@ mod tests {
|
|||||||
manager.add_listener(
|
manager.add_listener(
|
||||||
move || {
|
move || {
|
||||||
Box::new(DropSignalListener {
|
Box::new(DropSignalListener {
|
||||||
url: "mock://retry-exhausted".parse().unwrap(),
|
url: "mock://retrying".parse().unwrap(),
|
||||||
listen_results: listener_results.clone(),
|
listen_results: listener_results.clone(),
|
||||||
accepts: listener_accepts.clone(),
|
accepts: listener_accepts.clone(),
|
||||||
})
|
})
|
||||||
@@ -1033,15 +1027,22 @@ mod tests {
|
|||||||
manager.run().await.unwrap();
|
manager.run().await.unwrap();
|
||||||
crate::foundation::time::timeout(Duration::from_secs(1), async {
|
crate::foundation::time::timeout(Duration::from_secs(1), async {
|
||||||
loop {
|
loop {
|
||||||
if events.events.lock().unwrap().iter().any(|event| {
|
let recovered = {
|
||||||
matches!(
|
let events = events.events.lock().unwrap();
|
||||||
|
assert!(events.iter().all(|event| !matches!(
|
||||||
event,
|
event,
|
||||||
CoreEvent::ListenerAddFailed {
|
CoreEvent::ListenerAddFailed {
|
||||||
will_retry: false,
|
will_retry: false,
|
||||||
..
|
..
|
||||||
}
|
}
|
||||||
)
|
)));
|
||||||
}) {
|
events
|
||||||
|
.iter()
|
||||||
|
.filter(|event| matches!(event, CoreEvent::ListenerAdded { .. }))
|
||||||
|
.count()
|
||||||
|
== 2
|
||||||
|
};
|
||||||
|
if recovered {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
crate::foundation::time::sleep(Duration::from_millis(1)).await;
|
crate::foundation::time::sleep(Duration::from_millis(1)).await;
|
||||||
|
|||||||
@@ -5,10 +5,11 @@ use easytier_proto::api::config::{
|
|||||||
self, AclPatch, ConfigPatchAction, ExitNodePatch, InstanceConfigPatch, Patchable,
|
self, AclPatch, ConfigPatchAction, ExitNodePatch, InstanceConfigPatch, Patchable,
|
||||||
PortForwardPatch, ProxyNetworkPatch, RoutePatch, UrlPatch, VpnPortalClientPatch,
|
PortForwardPatch, ProxyNetworkPatch, RoutePatch, UrlPatch, VpnPortalClientPatch,
|
||||||
};
|
};
|
||||||
|
use easytier_proto::common::FlagsPatch;
|
||||||
|
use optionize::Optionized as _;
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
config::{
|
config::{
|
||||||
api_input::managed_credential_from_proto,
|
|
||||||
peers::AclRuleConfig,
|
peers::AclRuleConfig,
|
||||||
runtime::CoreInstanceRuntimeConfig,
|
runtime::CoreInstanceRuntimeConfig,
|
||||||
toml::{ConfigLoader as _, TomlConfig},
|
toml::{ConfigLoader as _, TomlConfig},
|
||||||
@@ -50,9 +51,25 @@ where
|
|||||||
// Preserve the existing ordered partial-commit contract: earlier valid
|
// Preserve the existing ordered partial-commit contract: earlier valid
|
||||||
// sub-patches remain applied if a later sub-patch fails.
|
// sub-patches remain applied if a later sub-patch fails.
|
||||||
let patch_result: anyhow::Result<(bool, bool)> = async {
|
let patch_result: anyhow::Result<(bool, bool)> = async {
|
||||||
let result = patch_port_forwards(&candidate, patch.port_forwards);
|
if !patch.port_forwards.is_empty() {
|
||||||
validate_persist_and_commit_candidate(instance, &config, &candidate, persistence).await?;
|
patch_port_forwards(&candidate, patch.port_forwards)?;
|
||||||
result?;
|
// Port forwards have fallible host resources. Reserve them before the
|
||||||
|
// durable write, then commit resources and readback together. A failed
|
||||||
|
// bind or persistence operation must leave the previous rule usable.
|
||||||
|
let normalized = validate_candidate(instance, &candidate)?;
|
||||||
|
let runtime = runtime_config_from_normalized(&normalized);
|
||||||
|
#[cfg(feature = "proxy-smoltcp-stack")]
|
||||||
|
let prepared = instance.prepare_port_forwards(&runtime).await?;
|
||||||
|
persist_candidate_if_changed(instance, &config, &candidate, persistence).await?;
|
||||||
|
instance
|
||||||
|
.commit_runtime_config_under_operation(
|
||||||
|
runtime,
|
||||||
|
#[cfg(feature = "proxy-smoltcp-stack")]
|
||||||
|
prepared,
|
||||||
|
|| config.replace_from_snapshot(&candidate),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
|
|
||||||
let result = patch_acl(&candidate, patch.acl);
|
let result = patch_acl(&candidate, patch.acl);
|
||||||
validate_persist_and_commit_candidate(instance, &config, &candidate, persistence).await?;
|
validate_persist_and_commit_candidate(instance, &config, &candidate, persistence).await?;
|
||||||
@@ -62,7 +79,7 @@ where
|
|||||||
validate_persist_and_commit_candidate(instance, &config, &candidate, persistence).await?;
|
validate_persist_and_commit_candidate(instance, &config, &candidate, persistence).await?;
|
||||||
result?;
|
result?;
|
||||||
|
|
||||||
let result = patch_routes(&candidate, patch.routes);
|
let result = patch_routes(&candidate, patch.routes, patch.enable_manual_routes);
|
||||||
validate_persist_and_commit_candidate(instance, &config, &candidate, persistence).await?;
|
validate_persist_and_commit_candidate(instance, &config, &candidate, persistence).await?;
|
||||||
result?;
|
result?;
|
||||||
|
|
||||||
@@ -90,19 +107,16 @@ where
|
|||||||
{
|
{
|
||||||
candidate.set_ipv4(Some(ipv4.into()));
|
candidate.set_ipv4(Some(ipv4.into()));
|
||||||
}
|
}
|
||||||
if let Some(ipv6) = patch.ipv6 {
|
if let Some(ipv6) = patch.ipv6
|
||||||
|
&& !candidate.get_dhcpv6()
|
||||||
|
{
|
||||||
candidate.set_ipv6(Some(ipv6.into()));
|
candidate.set_ipv6(Some(ipv6.into()));
|
||||||
}
|
}
|
||||||
if let Some(disable_relay_data) = patch.disable_relay_data {
|
candidate.patch_flags(FlagsPatch {
|
||||||
let mut flags = candidate.get_flags();
|
disable_relay_data: patch.disable_relay_data,
|
||||||
flags.disable_relay_data = disable_relay_data;
|
prefer_peer_relay: patch.prefer_peer_relay,
|
||||||
candidate.set_flags(flags);
|
..Default::default()
|
||||||
}
|
});
|
||||||
if let Some(prefer_peer_relay) = patch.prefer_peer_relay {
|
|
||||||
let mut flags = candidate.get_flags();
|
|
||||||
flags.prefer_peer_relay = prefer_peer_relay;
|
|
||||||
candidate.set_flags(flags);
|
|
||||||
}
|
|
||||||
if let Some(enabled) = patch.ipv6_public_addr_provider {
|
if let Some(enabled) = patch.ipv6_public_addr_provider {
|
||||||
candidate.set_ipv6_public_addr_provider(enabled);
|
candidate.set_ipv6_public_addr_provider(enabled);
|
||||||
provider_config_changed = true;
|
provider_config_changed = true;
|
||||||
@@ -188,8 +202,8 @@ where
|
|||||||
let entries = managed
|
let entries = managed
|
||||||
.entries
|
.entries
|
||||||
.iter()
|
.iter()
|
||||||
.map(managed_credential_from_proto)
|
.map(|credential| credential.clone().upgrade())
|
||||||
.collect::<Vec<_>>();
|
.collect::<Result<Vec<_>, _>>()?;
|
||||||
let replacement = credential_manager
|
let replacement = credential_manager
|
||||||
.validate_managed_credentials(&entries)
|
.validate_managed_credentials(&entries)
|
||||||
.map_err(anyhow::Error::msg)?;
|
.map_err(anyhow::Error::msg)?;
|
||||||
@@ -457,15 +471,25 @@ fn patch_proxy_networks(
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn patch_routes(config: &TomlConfig, patches: Vec<RoutePatch>) -> anyhow::Result<()> {
|
fn patch_routes(
|
||||||
if patches.is_empty() {
|
config: &TomlConfig,
|
||||||
|
patches: Vec<RoutePatch>,
|
||||||
|
enable_manual_routes: Option<bool>,
|
||||||
|
) -> anyhow::Result<()> {
|
||||||
|
if patches.is_empty() && enable_manual_routes.is_none() {
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
let mut current = config.get_routes().unwrap_or_default();
|
let mut current = config.get_routes().unwrap_or_default();
|
||||||
let patches = patches.into_iter().map(Into::into).collect::<Vec<_>>();
|
let patches = patches.into_iter().map(Into::into).collect::<Vec<_>>();
|
||||||
trace_patchables(&patches);
|
trace_patchables(&patches);
|
||||||
config::patch_vec(&mut current, patches);
|
config::patch_vec(&mut current, patches);
|
||||||
config.set_routes((!current.is_empty()).then_some(current));
|
// Mode and list form one configuration update. An explicit mode supports
|
||||||
|
// empty manual routes; callers omitting it retain the released semantics.
|
||||||
|
config.set_routes(
|
||||||
|
enable_manual_routes
|
||||||
|
.unwrap_or(!current.is_empty())
|
||||||
|
.then_some(current),
|
||||||
|
);
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -60,6 +60,7 @@ where
|
|||||||
dev_name,
|
dev_name,
|
||||||
my_node_info: Some(MyNodeInfo {
|
my_node_info: Some(MyNodeInfo {
|
||||||
virtual_ipv4: node.ipv4_addr.map(Into::into),
|
virtual_ipv4: node.ipv4_addr.map(Into::into),
|
||||||
|
virtual_ipv6: node.ipv6_addr.map(Into::into),
|
||||||
hostname: node.hostname,
|
hostname: node.hostname,
|
||||||
version: node.version,
|
version: node.version,
|
||||||
ips: Some(node.ip_list),
|
ips: Some(node.ip_list),
|
||||||
|
|||||||
@@ -435,6 +435,7 @@ fn build_heartbeat_request(
|
|||||||
.map(Into::into)
|
.map(Into::into)
|
||||||
.collect(),
|
.collect(),
|
||||||
support_heartbeat_policy: true,
|
support_heartbeat_policy: true,
|
||||||
|
support_manual_routes_patch: true,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -748,6 +749,8 @@ mod tests {
|
|||||||
vec![failed]
|
vec![failed]
|
||||||
);
|
);
|
||||||
assert!(request.support_heartbeat_policy);
|
assert!(request.support_heartbeat_policy);
|
||||||
|
assert!(request.support_manual_routes_patch);
|
||||||
|
assert!(!HeartbeatRequest::default().support_manual_routes_patch);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -0,0 +1,204 @@
|
|||||||
|
//! Local IP flow metadata for unfragmented TCP and UDP packets.
|
||||||
|
|
||||||
|
use std::net::SocketAddr;
|
||||||
|
|
||||||
|
use super::{
|
||||||
|
ZCPacket,
|
||||||
|
ip::{parse_ip_packet, parse_transport_ports},
|
||||||
|
};
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
|
||||||
|
pub struct FlowKey {
|
||||||
|
pub src: SocketAddr,
|
||||||
|
pub dst: SocketAddr,
|
||||||
|
pub protocol: u8,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn parse_flow_key(packet: &ZCPacket) -> Option<FlowKey> {
|
||||||
|
let parsed = parse_ip_packet(packet.payload())?;
|
||||||
|
// Skip every actual fragment, including the first one with ports.
|
||||||
|
if parsed.is_fragment || !parsed.has_valid_length {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let (src_port, dst_port) = parse_transport_ports(parsed.protocol, parsed.transport_payload)?;
|
||||||
|
Some(FlowKey {
|
||||||
|
src: SocketAddr::new(parsed.src_ip, src_port?),
|
||||||
|
dst: SocketAddr::new(parsed.dst_ip, dst_port?),
|
||||||
|
protocol: parsed.protocol,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::packet::{PacketType, ZCPacketType};
|
||||||
|
|
||||||
|
fn ipv4(protocol: u8, flags: u16, id: u16) -> ZCPacket {
|
||||||
|
let len = if protocol == 6 { 40 } else { 28 };
|
||||||
|
let mut bytes = vec![0; len];
|
||||||
|
bytes[0] = 0x45;
|
||||||
|
bytes[2..4].copy_from_slice(&(len as u16).to_be_bytes());
|
||||||
|
bytes[4..6].copy_from_slice(&id.to_be_bytes());
|
||||||
|
bytes[6..8].copy_from_slice(&flags.to_be_bytes());
|
||||||
|
bytes[9] = protocol;
|
||||||
|
bytes[12..16].copy_from_slice(&[10, 0, 0, 1]);
|
||||||
|
bytes[16..20].copy_from_slice(&[10, 0, 0, 2]);
|
||||||
|
bytes[20..24].copy_from_slice(&[0x12, 0x34, 0x56, 0x78]);
|
||||||
|
packet(bytes)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn packet(bytes: Vec<u8>) -> ZCPacket {
|
||||||
|
let mut packet = ZCPacket::new_with_payload(&bytes);
|
||||||
|
packet.fill_peer_manager_hdr(1, 2, PacketType::Data as u8);
|
||||||
|
packet
|
||||||
|
}
|
||||||
|
|
||||||
|
fn ipv6(fragment: Option<u16>) -> ZCPacket {
|
||||||
|
let mut bytes = vec![0; if fragment.is_some() { 64 } else { 56 }];
|
||||||
|
bytes[0] = 0x60;
|
||||||
|
let len = (bytes.len() - 40) as u16;
|
||||||
|
bytes[4..6].copy_from_slice(&len.to_be_bytes());
|
||||||
|
bytes[6] = 0; // Hop-by-hop options before the transport/fragment header.
|
||||||
|
bytes[23] = 1;
|
||||||
|
bytes[39] = 2;
|
||||||
|
bytes[40] = if fragment.is_some() { 44 } else { 17 };
|
||||||
|
let offset = if let Some(flags) = fragment {
|
||||||
|
bytes[48] = 17;
|
||||||
|
bytes[50..52].copy_from_slice(&flags.to_be_bytes());
|
||||||
|
bytes[52..56].copy_from_slice(&99u32.to_be_bytes());
|
||||||
|
56
|
||||||
|
} else {
|
||||||
|
48
|
||||||
|
};
|
||||||
|
bytes[offset..offset + 4].copy_from_slice(&[0x12, 0x34, 0x56, 0x78]);
|
||||||
|
packet(bytes)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn identifies_ipv4_tcp_udp_and_ipv6_extension_headers() {
|
||||||
|
let udp = parse_flow_key(&ipv4(17, 0, 0)).unwrap();
|
||||||
|
assert_eq!(udp.src, "10.0.0.1:4660".parse().unwrap());
|
||||||
|
assert_eq!(udp.dst, "10.0.0.2:22136".parse().unwrap());
|
||||||
|
let tcp = parse_flow_key(&ipv4(6, 0, 0)).unwrap();
|
||||||
|
assert_eq!(tcp.src, udp.src);
|
||||||
|
assert_eq!(tcp.dst, udp.dst);
|
||||||
|
assert_eq!(tcp.protocol, 6);
|
||||||
|
assert_ne!(udp, tcp);
|
||||||
|
assert_eq!(Some(udp), parse_flow_key(&ipv4(17, 0x4000, 0)));
|
||||||
|
let v6 = parse_flow_key(&ipv6(None)).unwrap();
|
||||||
|
assert_eq!(v6.src, "[::1]:4660".parse().unwrap());
|
||||||
|
assert_eq!(Some(v6), parse_flow_key(&ipv6(Some(0))));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn first_middle_and_last_fragments_do_not_supply_a_flow_key() {
|
||||||
|
for protocol in [6, 17, 1, 50] {
|
||||||
|
for flags in [0x2000, 0x2001, 1] {
|
||||||
|
assert!(parse_flow_key(&ipv4(protocol, flags, 99)).is_none());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for protocol in [6, 17, 58, 50] {
|
||||||
|
for flags in [1, 9, 8] {
|
||||||
|
let mut fragment = ipv6(Some(flags));
|
||||||
|
fragment.mut_payload()[48] = protocol;
|
||||||
|
assert!(parse_flow_key(&fragment).is_none());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn flow_key_survives_local_conversions_but_is_not_on_the_wire() {
|
||||||
|
let mut original = ipv4(17, 0, 0);
|
||||||
|
original.flow_key = parse_flow_key(&original);
|
||||||
|
let key = original.flow_key;
|
||||||
|
assert!(key.is_some());
|
||||||
|
let mut converted = original.clone();
|
||||||
|
for kind in [
|
||||||
|
ZCPacketType::TCP,
|
||||||
|
ZCPacketType::UDP,
|
||||||
|
ZCPacketType::DummyTunnel,
|
||||||
|
ZCPacketType::TCP,
|
||||||
|
] {
|
||||||
|
converted = converted.convert_type(kind);
|
||||||
|
assert_eq!(converted.flow_key, key);
|
||||||
|
assert_eq!(converted.payload(), original.payload());
|
||||||
|
}
|
||||||
|
let received = ZCPacket::new_from_buf(converted.inner(), ZCPacketType::TCP);
|
||||||
|
assert!(received.flow_key.is_none());
|
||||||
|
let wrapped = ZCPacket::new_for_foreign_network(&"network".to_owned(), 2, &original);
|
||||||
|
assert_eq!(wrapped.flow_key, key);
|
||||||
|
let unwrapped = wrapped.foreign_network_packet();
|
||||||
|
assert_eq!(unwrapped.flow_key, key);
|
||||||
|
assert_eq!(unwrapped.payload(), original.payload());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn truncated_headers_never_supply_a_flow_key() {
|
||||||
|
for original in [ipv4(17, 0, 0), ipv6(None), ipv6(Some(0))] {
|
||||||
|
for len in 0..original.payload().len() {
|
||||||
|
assert!(parse_flow_key(&packet(original.payload()[..len].to_vec())).is_none());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn invalid_ip_header_lengths_do_not_supply_a_flow_key() {
|
||||||
|
for ihl in [0, 4, 15] {
|
||||||
|
let mut packet = ipv4(17, 0, 0);
|
||||||
|
packet.mut_payload()[0] = 0x40 | ihl;
|
||||||
|
assert!(parse_flow_key(&packet).is_none());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn incomplete_transport_headers_do_not_supply_a_flow_key() {
|
||||||
|
for (protocol, header_len) in [(6, 20), (17, 8)] {
|
||||||
|
for len in 0..header_len {
|
||||||
|
let mut v4 = ipv4(protocol, 0, 0);
|
||||||
|
let offset = v4.payload_offset();
|
||||||
|
v4.mut_inner().truncate(offset + 20 + len);
|
||||||
|
v4.mut_payload()[2..4].copy_from_slice(&((20 + len) as u16).to_be_bytes());
|
||||||
|
assert!(parse_flow_key(&v4).is_none());
|
||||||
|
|
||||||
|
let mut v6 = ipv6(None);
|
||||||
|
let offset = v6.payload_offset();
|
||||||
|
v6.mut_inner().resize(offset + 48 + len, 0);
|
||||||
|
v6.mut_payload()[4..6].copy_from_slice(&((8 + len) as u16).to_be_bytes());
|
||||||
|
v6.mut_payload()[40] = protocol;
|
||||||
|
assert!(parse_flow_key(&v6).is_none());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(all(feature = "zstd", feature = "aes-gcm"))]
|
||||||
|
#[tokio::test]
|
||||||
|
async fn compression_and_encryption_preserve_local_flow_metadata() {
|
||||||
|
use crate::packet::{
|
||||||
|
CompressorAlgo,
|
||||||
|
compressor::{Compressor, DefaultCompressor},
|
||||||
|
};
|
||||||
|
let mut original = ipv4(17, 0, 0);
|
||||||
|
let offset = original.payload_offset();
|
||||||
|
original.mut_inner().resize(offset + 1024, 0);
|
||||||
|
original.mut_payload()[2..4].copy_from_slice(&1024u16.to_be_bytes());
|
||||||
|
original.fill_peer_manager_hdr(1, 2, PacketType::Data as u8);
|
||||||
|
let mut packet = original.clone();
|
||||||
|
packet.flow_key = parse_flow_key(&packet);
|
||||||
|
let key = packet.flow_key;
|
||||||
|
assert!(key.is_some());
|
||||||
|
let compressor = DefaultCompressor::new();
|
||||||
|
compressor
|
||||||
|
.compress(&mut packet, CompressorAlgo::ZstdDefault)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert!(packet.peer_manager_header().unwrap().is_compressed());
|
||||||
|
let encryptor = crate::tunnel::encrypt::create_encryptor("aes-gcm", [1; 16], [2; 32]);
|
||||||
|
encryptor.encrypt(&mut packet).unwrap();
|
||||||
|
assert_eq!(packet.flow_key, key);
|
||||||
|
let mut packet = packet.convert_type(ZCPacketType::UDP);
|
||||||
|
encryptor.decrypt(&mut packet).unwrap();
|
||||||
|
compressor.decompress(&mut packet).await.unwrap();
|
||||||
|
assert_eq!(packet.flow_key, key);
|
||||||
|
assert_eq!(packet.payload(), original.payload());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,194 @@
|
|||||||
|
//! Shared IP and transport-header parsing for packet consumers.
|
||||||
|
|
||||||
|
use std::net::{IpAddr, Ipv4Addr, Ipv6Addr};
|
||||||
|
|
||||||
|
pub(crate) const IP_PROTO_ICMP: u8 = 1;
|
||||||
|
pub(crate) const IP_PROTO_TCP: u8 = 6;
|
||||||
|
pub(crate) const IP_PROTO_UDP: u8 = 17;
|
||||||
|
pub(crate) const IP_PROTO_ICMPV6: u8 = 58;
|
||||||
|
pub(crate) const IP_PROTO_UNSPECIFIED: u8 = u8::MAX;
|
||||||
|
|
||||||
|
pub(crate) const IPV6_HOP_BY_HOP: u8 = 0;
|
||||||
|
pub(crate) const IPV6_ROUTING: u8 = 43;
|
||||||
|
pub(crate) const IPV6_FRAGMENT: u8 = 44;
|
||||||
|
pub(crate) const IPV6_AUTHENTICATION: u8 = 51;
|
||||||
|
pub(crate) const IPV6_DESTINATION_OPTIONS: u8 = 60;
|
||||||
|
|
||||||
|
#[derive(Clone, Copy)]
|
||||||
|
pub(crate) struct ParsedIpPacket<'a> {
|
||||||
|
pub src_ip: IpAddr,
|
||||||
|
pub dst_ip: IpAddr,
|
||||||
|
pub protocol: u8,
|
||||||
|
pub transport_payload: &'a [u8],
|
||||||
|
pub is_non_initial_fragment: bool,
|
||||||
|
pub is_fragment: bool,
|
||||||
|
/// Whether the IP header and declared packet length fit in the input.
|
||||||
|
pub has_valid_length: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn parse_ip_packet(payload: &[u8]) -> Option<ParsedIpPacket<'_>> {
|
||||||
|
let version = payload.first()? >> 4;
|
||||||
|
match version {
|
||||||
|
4 => parse_ipv4_packet(payload),
|
||||||
|
6 => parse_ipv6_packet(payload),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_ipv4_packet(payload: &[u8]) -> Option<ParsedIpPacket<'_>> {
|
||||||
|
if payload.len() < 20 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let header_len = usize::from(payload[0] & 0x0f) * 4;
|
||||||
|
let options_len = header_len.saturating_sub(20);
|
||||||
|
let payload_offset = 20 + options_len;
|
||||||
|
let payload_start = payload_offset.min(payload.len());
|
||||||
|
let total_length = usize::from(u16::from_be_bytes([payload[2], payload[3]]));
|
||||||
|
let payload_len = total_length.saturating_sub(header_len);
|
||||||
|
let payload_end = payload_start.saturating_add(payload_len).min(payload.len());
|
||||||
|
|
||||||
|
Some(ParsedIpPacket {
|
||||||
|
src_ip: IpAddr::V4(Ipv4Addr::new(
|
||||||
|
payload[12],
|
||||||
|
payload[13],
|
||||||
|
payload[14],
|
||||||
|
payload[15],
|
||||||
|
)),
|
||||||
|
dst_ip: IpAddr::V4(Ipv4Addr::new(
|
||||||
|
payload[16],
|
||||||
|
payload[17],
|
||||||
|
payload[18],
|
||||||
|
payload[19],
|
||||||
|
)),
|
||||||
|
protocol: payload[9],
|
||||||
|
transport_payload: payload.get(payload_start..payload_end)?,
|
||||||
|
is_non_initial_fragment: u16::from_be_bytes([payload[6], payload[7]]) & 0x1fff != 0,
|
||||||
|
is_fragment: u16::from_be_bytes([payload[6], payload[7]]) & 0x3fff != 0,
|
||||||
|
has_valid_length: header_len >= 20
|
||||||
|
&& total_length >= header_len
|
||||||
|
&& total_length <= payload.len(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_ipv6_packet(payload: &[u8]) -> Option<ParsedIpPacket<'_>> {
|
||||||
|
if payload.len() < 40 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
let payload_len = usize::from(u16::from_be_bytes([payload[4], payload[5]]));
|
||||||
|
|
||||||
|
// A valid base header still supplies the addresses needed for ACL rules,
|
||||||
|
// even when the upper-layer headers cannot be decoded.
|
||||||
|
let parsed = ParsedIpPacket {
|
||||||
|
src_ip: IpAddr::V6(Ipv6Addr::from(<[u8; 16]>::try_from(&payload[8..24]).ok()?)),
|
||||||
|
dst_ip: IpAddr::V6(Ipv6Addr::from(<[u8; 16]>::try_from(&payload[24..40]).ok()?)),
|
||||||
|
protocol: IP_PROTO_UNSPECIFIED,
|
||||||
|
transport_payload: &[],
|
||||||
|
is_non_initial_fragment: false,
|
||||||
|
is_fragment: false,
|
||||||
|
has_valid_length: 40 + payload_len <= payload.len(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let payload_end = 40usize.saturating_add(payload_len).min(payload.len());
|
||||||
|
let mut protocol = payload[6];
|
||||||
|
let mut payload_offset = 40;
|
||||||
|
let mut is_non_initial_fragment = false;
|
||||||
|
let mut is_fragment = false;
|
||||||
|
let mut saw_fragment_header = false;
|
||||||
|
|
||||||
|
loop {
|
||||||
|
match protocol {
|
||||||
|
IPV6_HOP_BY_HOP | IPV6_ROUTING | IPV6_DESTINATION_OPTIONS => {
|
||||||
|
let header = &payload[payload_offset..payload_end];
|
||||||
|
let Some(&header_len) = header.get(1) else {
|
||||||
|
return Some(parsed);
|
||||||
|
};
|
||||||
|
let extension_len = (usize::from(header_len) + 1) * 8;
|
||||||
|
let next_protocol = header[0];
|
||||||
|
let extension_end = payload_offset + extension_len;
|
||||||
|
if extension_end > payload_end {
|
||||||
|
return Some(parsed);
|
||||||
|
}
|
||||||
|
protocol = next_protocol;
|
||||||
|
payload_offset = extension_end;
|
||||||
|
}
|
||||||
|
IPV6_AUTHENTICATION => {
|
||||||
|
let header = &payload[payload_offset..payload_end];
|
||||||
|
let Some(&header_len) = header.get(1) else {
|
||||||
|
return Some(parsed);
|
||||||
|
};
|
||||||
|
let extension_len = (usize::from(header_len) + 2) * 4;
|
||||||
|
let next_protocol = header[0];
|
||||||
|
let extension_end = payload_offset + extension_len;
|
||||||
|
if extension_end > payload_end {
|
||||||
|
return Some(parsed);
|
||||||
|
}
|
||||||
|
protocol = next_protocol;
|
||||||
|
payload_offset = extension_end;
|
||||||
|
}
|
||||||
|
IPV6_FRAGMENT => {
|
||||||
|
if saw_fragment_header {
|
||||||
|
return Some(parsed);
|
||||||
|
}
|
||||||
|
saw_fragment_header = true;
|
||||||
|
|
||||||
|
let fragment_end = payload_offset + 8;
|
||||||
|
if fragment_end > payload_end {
|
||||||
|
return Some(parsed);
|
||||||
|
}
|
||||||
|
let header = &payload[payload_offset..fragment_end];
|
||||||
|
let fragment_field = u16::from_be_bytes([header[2], header[3]]);
|
||||||
|
is_non_initial_fragment = fragment_field & 0xfff8 != 0;
|
||||||
|
// An atomic fragment has its complete transport header.
|
||||||
|
is_fragment = fragment_field & 0xfff9 != 0;
|
||||||
|
let next_protocol = header[0];
|
||||||
|
payload_offset = fragment_end;
|
||||||
|
|
||||||
|
protocol = next_protocol;
|
||||||
|
if is_non_initial_fragment {
|
||||||
|
// Extension headers after the Fragment Header are only in the first
|
||||||
|
// fragment. Do not interpret tail data as an extension header.
|
||||||
|
// Only ICMPv6 tails can use the response records without a
|
||||||
|
// transport header. Leave other tails to address/group ACLs.
|
||||||
|
if protocol != IP_PROTO_ICMPV6 {
|
||||||
|
protocol = IP_PROTO_UNSPECIFIED;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => break,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let transport_payload = &payload[payload_offset..payload_end];
|
||||||
|
if payload_offset != 40 && parse_transport_ports(protocol, transport_payload).is_none() {
|
||||||
|
return Some(parsed);
|
||||||
|
}
|
||||||
|
|
||||||
|
Some(ParsedIpPacket {
|
||||||
|
protocol,
|
||||||
|
transport_payload,
|
||||||
|
is_non_initial_fragment,
|
||||||
|
is_fragment,
|
||||||
|
..parsed
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn parse_transport_ports(
|
||||||
|
protocol: u8,
|
||||||
|
payload: &[u8],
|
||||||
|
) -> Option<(Option<u16>, Option<u16>)> {
|
||||||
|
let min_len = match protocol {
|
||||||
|
IP_PROTO_TCP => 20,
|
||||||
|
IP_PROTO_UDP => 8,
|
||||||
|
_ => return Some((None, None)),
|
||||||
|
};
|
||||||
|
if payload.len() < min_len {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
Some((
|
||||||
|
Some(u16::from_be_bytes([payload[0], payload[1]])),
|
||||||
|
Some(u16::from_be_bytes([payload[2], payload[3]])),
|
||||||
|
))
|
||||||
|
}
|
||||||
@@ -1,7 +1,11 @@
|
|||||||
pub(crate) mod compressor;
|
pub(crate) mod compressor;
|
||||||
|
pub(crate) mod flow;
|
||||||
mod hole_punch;
|
mod hole_punch;
|
||||||
|
pub(crate) mod ip;
|
||||||
pub mod stun;
|
pub mod stun;
|
||||||
|
|
||||||
|
pub use flow::FlowKey;
|
||||||
|
|
||||||
pub(crate) use hole_punch::{
|
pub(crate) use hole_punch::{
|
||||||
HOLE_PUNCH_PACKET_BODY_LEN, hole_punch_packet_tid, new_hole_punch_packet,
|
HOLE_PUNCH_PACKET_BODY_LEN, hole_punch_packet_tid, new_hole_punch_packet,
|
||||||
};
|
};
|
||||||
@@ -539,6 +543,8 @@ impl ZCPacketType {
|
|||||||
pub struct ZCPacket {
|
pub struct ZCPacket {
|
||||||
inner: BytesMut,
|
inner: BytesMut,
|
||||||
packet_type: ZCPacketType,
|
packet_type: ZCPacketType,
|
||||||
|
/// Local scheduling metadata; never serialized into a tunnel packet.
|
||||||
|
pub(crate) flow_key: Option<FlowKey>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ZCPacket {
|
impl ZCPacket {
|
||||||
@@ -554,6 +560,7 @@ impl ZCPacket {
|
|||||||
Self {
|
Self {
|
||||||
inner: BytesMut::new(),
|
inner: BytesMut::new(),
|
||||||
packet_type: ZCPacketType::NIC,
|
packet_type: ZCPacketType::NIC,
|
||||||
|
flow_key: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -561,6 +568,7 @@ impl ZCPacket {
|
|||||||
Self {
|
Self {
|
||||||
inner: buf,
|
inner: buf,
|
||||||
packet_type,
|
packet_type,
|
||||||
|
flow_key: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -608,6 +616,7 @@ impl ZCPacket {
|
|||||||
.copy_from_slice(foreign_zc_packet.tunnel_payload());
|
.copy_from_slice(foreign_zc_packet.tunnel_payload());
|
||||||
|
|
||||||
ret.fill_peer_manager_hdr(0, 0, PacketType::ForeignNetworkPacket as u8);
|
ret.fill_peer_manager_hdr(0, 0, PacketType::ForeignNetworkPacket as u8);
|
||||||
|
ret.flow_key = foreign_zc_packet.flow_key;
|
||||||
|
|
||||||
ret
|
ret
|
||||||
}
|
}
|
||||||
@@ -783,11 +792,14 @@ impl ZCPacket {
|
|||||||
let mut buf = BytesMut::with_capacity(new_pm_offset + tunnel_payload.len());
|
let mut buf = BytesMut::with_capacity(new_pm_offset + tunnel_payload.len());
|
||||||
unsafe { buf.set_len(new_pm_offset) };
|
unsafe { buf.set_len(new_pm_offset) };
|
||||||
buf.extend_from_slice(tunnel_payload);
|
buf.extend_from_slice(tunnel_payload);
|
||||||
return Self::new_from_buf(buf, target_packet_type);
|
let mut packet = Self::new_from_buf(buf, target_packet_type);
|
||||||
|
packet.flow_key = self.flow_key;
|
||||||
|
return packet;
|
||||||
}
|
}
|
||||||
|
|
||||||
self.inner.advance(new_offset);
|
self.inner.advance(new_offset);
|
||||||
Self::new_from_buf(self.inner, target_packet_type)
|
self.packet_type = target_packet_type;
|
||||||
|
self
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn into_bytes(self) -> Bytes {
|
pub fn into_bytes(self) -> Bytes {
|
||||||
@@ -839,13 +851,15 @@ impl ZCPacket {
|
|||||||
let hdr = self.foreign_network_hdr().unwrap();
|
let hdr = self.foreign_network_hdr().unwrap();
|
||||||
let foreign_hdr_len = hdr.get_header_len();
|
let foreign_hdr_len = hdr.get_header_len();
|
||||||
|
|
||||||
Self::new_from_buf(
|
let mut packet = Self::new_from_buf(
|
||||||
{
|
{
|
||||||
self.inner.advance(foreign_hdr_len + self.payload_offset());
|
self.inner.advance(foreign_hdr_len + self.payload_offset());
|
||||||
self.inner
|
self.inner
|
||||||
},
|
},
|
||||||
ZCPacketType::DummyTunnel,
|
ZCPacketType::DummyTunnel,
|
||||||
)
|
);
|
||||||
|
packet.flow_key = self.flow_key;
|
||||||
|
packet
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn get_src_peer_id(&self) -> Option<u32> {
|
pub fn get_src_peer_id(&self) -> Option<u32> {
|
||||||
|
|||||||
@@ -12,36 +12,21 @@ use quanta::Instant;
|
|||||||
use tokio_util::task::AbortOnDropHandle;
|
use tokio_util::task::AbortOnDropHandle;
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
packet::{PacketType, ZCPacket},
|
packet::{
|
||||||
|
PacketType, ZCPacket,
|
||||||
|
ip::{
|
||||||
|
IP_PROTO_ICMP, IP_PROTO_ICMPV6, IP_PROTO_TCP, IP_PROTO_UDP, IP_PROTO_UNSPECIFIED,
|
||||||
|
ParsedIpPacket, parse_ip_packet, parse_transport_ports,
|
||||||
|
},
|
||||||
|
},
|
||||||
peers::acl::processor::{AclProcessor, AclResult, AclStatKey, AclStatType, PacketInfo},
|
peers::acl::processor::{AclProcessor, AclResult, AclStatKey, AclStatType, PacketInfo},
|
||||||
};
|
};
|
||||||
|
|
||||||
const IP_PROTO_ICMP: u8 = 1;
|
|
||||||
const IP_PROTO_TCP: u8 = 6;
|
|
||||||
const IP_PROTO_UDP: u8 = 17;
|
|
||||||
const IP_PROTO_ICMPV6: u8 = 58;
|
|
||||||
const IP_PROTO_UNSPECIFIED: u8 = u8::MAX;
|
|
||||||
|
|
||||||
const ICMP_ECHO_REPLY: u8 = 0;
|
const ICMP_ECHO_REPLY: u8 = 0;
|
||||||
const ICMP_ECHO_REQUEST: u8 = 8;
|
const ICMP_ECHO_REQUEST: u8 = 8;
|
||||||
const ICMPV6_ECHO_REQUEST: u8 = 128;
|
const ICMPV6_ECHO_REQUEST: u8 = 128;
|
||||||
const ICMPV6_ECHO_REPLY: u8 = 129;
|
const ICMPV6_ECHO_REPLY: u8 = 129;
|
||||||
|
|
||||||
const IPV6_HOP_BY_HOP: u8 = 0;
|
|
||||||
const IPV6_ROUTING: u8 = 43;
|
|
||||||
const IPV6_FRAGMENT: u8 = 44;
|
|
||||||
const IPV6_AUTHENTICATION: u8 = 51;
|
|
||||||
const IPV6_DESTINATION_OPTIONS: u8 = 60;
|
|
||||||
|
|
||||||
#[derive(Clone, Copy)]
|
|
||||||
struct ParsedIpPacket<'a> {
|
|
||||||
src_ip: IpAddr,
|
|
||||||
dst_ip: IpAddr,
|
|
||||||
protocol: u8,
|
|
||||||
transport_payload: &'a [u8],
|
|
||||||
is_non_initial_fragment: bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl ParsedIpPacket<'_> {
|
impl ParsedIpPacket<'_> {
|
||||||
fn can_use_allow_record(&self, is_in: bool) -> bool {
|
fn can_use_allow_record(&self, is_in: bool) -> bool {
|
||||||
let expected_type = match (self.protocol, is_in) {
|
let expected_type = match (self.protocol, is_in) {
|
||||||
@@ -64,159 +49,6 @@ impl ParsedIpPacket<'_> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn parse_ip_packet(payload: &[u8]) -> Option<ParsedIpPacket<'_>> {
|
|
||||||
let version = payload.first()? >> 4;
|
|
||||||
match version {
|
|
||||||
4 => parse_ipv4_packet(payload),
|
|
||||||
6 => parse_ipv6_packet(payload),
|
|
||||||
_ => None,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn parse_ipv4_packet(payload: &[u8]) -> Option<ParsedIpPacket<'_>> {
|
|
||||||
if payload.len() < 20 {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
let header_len = usize::from(payload[0] & 0x0f) * 4;
|
|
||||||
let options_len = header_len.saturating_sub(20);
|
|
||||||
let payload_offset = 20 + options_len;
|
|
||||||
let payload_start = payload_offset.min(payload.len());
|
|
||||||
let total_length = usize::from(u16::from_be_bytes([payload[2], payload[3]]));
|
|
||||||
let payload_len = total_length.saturating_sub(header_len);
|
|
||||||
let payload_end = payload_start.saturating_add(payload_len).min(payload.len());
|
|
||||||
|
|
||||||
Some(ParsedIpPacket {
|
|
||||||
src_ip: IpAddr::V4(Ipv4Addr::new(
|
|
||||||
payload[12],
|
|
||||||
payload[13],
|
|
||||||
payload[14],
|
|
||||||
payload[15],
|
|
||||||
)),
|
|
||||||
dst_ip: IpAddr::V4(Ipv4Addr::new(
|
|
||||||
payload[16],
|
|
||||||
payload[17],
|
|
||||||
payload[18],
|
|
||||||
payload[19],
|
|
||||||
)),
|
|
||||||
protocol: payload[9],
|
|
||||||
transport_payload: payload.get(payload_start..payload_end)?,
|
|
||||||
is_non_initial_fragment: u16::from_be_bytes([payload[6], payload[7]]) & 0x1fff != 0,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
fn parse_ipv6_packet(payload: &[u8]) -> Option<ParsedIpPacket<'_>> {
|
|
||||||
if payload.len() < 40 {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
|
|
||||||
// A valid base header still supplies the addresses needed for ACL rules,
|
|
||||||
// even when the upper-layer headers cannot be decoded.
|
|
||||||
let parsed = ParsedIpPacket {
|
|
||||||
src_ip: IpAddr::V6(Ipv6Addr::from(<[u8; 16]>::try_from(&payload[8..24]).ok()?)),
|
|
||||||
dst_ip: IpAddr::V6(Ipv6Addr::from(<[u8; 16]>::try_from(&payload[24..40]).ok()?)),
|
|
||||||
protocol: IP_PROTO_UNSPECIFIED,
|
|
||||||
transport_payload: &[],
|
|
||||||
is_non_initial_fragment: false,
|
|
||||||
};
|
|
||||||
|
|
||||||
let payload_len = usize::from(u16::from_be_bytes([payload[4], payload[5]]));
|
|
||||||
let payload_end = 40usize.saturating_add(payload_len).min(payload.len());
|
|
||||||
let mut protocol = payload[6];
|
|
||||||
let mut payload_offset = 40;
|
|
||||||
let mut is_non_initial_fragment = false;
|
|
||||||
let mut saw_fragment_header = false;
|
|
||||||
|
|
||||||
loop {
|
|
||||||
match protocol {
|
|
||||||
IPV6_HOP_BY_HOP | IPV6_ROUTING | IPV6_DESTINATION_OPTIONS => {
|
|
||||||
let header = &payload[payload_offset..payload_end];
|
|
||||||
let Some(&header_len) = header.get(1) else {
|
|
||||||
return Some(parsed);
|
|
||||||
};
|
|
||||||
let extension_len = (usize::from(header_len) + 1) * 8;
|
|
||||||
let next_protocol = header[0];
|
|
||||||
let extension_end = payload_offset + extension_len;
|
|
||||||
if extension_end > payload_end {
|
|
||||||
return Some(parsed);
|
|
||||||
}
|
|
||||||
protocol = next_protocol;
|
|
||||||
payload_offset = extension_end;
|
|
||||||
}
|
|
||||||
IPV6_AUTHENTICATION => {
|
|
||||||
let header = &payload[payload_offset..payload_end];
|
|
||||||
let Some(&header_len) = header.get(1) else {
|
|
||||||
return Some(parsed);
|
|
||||||
};
|
|
||||||
let extension_len = (usize::from(header_len) + 2) * 4;
|
|
||||||
let next_protocol = header[0];
|
|
||||||
let extension_end = payload_offset + extension_len;
|
|
||||||
if extension_end > payload_end {
|
|
||||||
return Some(parsed);
|
|
||||||
}
|
|
||||||
protocol = next_protocol;
|
|
||||||
payload_offset = extension_end;
|
|
||||||
}
|
|
||||||
IPV6_FRAGMENT => {
|
|
||||||
if saw_fragment_header {
|
|
||||||
return Some(parsed);
|
|
||||||
}
|
|
||||||
saw_fragment_header = true;
|
|
||||||
|
|
||||||
let fragment_end = payload_offset + 8;
|
|
||||||
if fragment_end > payload_end {
|
|
||||||
return Some(parsed);
|
|
||||||
}
|
|
||||||
let header = &payload[payload_offset..fragment_end];
|
|
||||||
let fragment_field = u16::from_be_bytes([header[2], header[3]]);
|
|
||||||
is_non_initial_fragment = fragment_field & 0xfff8 != 0;
|
|
||||||
let next_protocol = header[0];
|
|
||||||
payload_offset = fragment_end;
|
|
||||||
|
|
||||||
protocol = next_protocol;
|
|
||||||
if is_non_initial_fragment {
|
|
||||||
// Extension headers after the Fragment Header are only in the first
|
|
||||||
// fragment. Do not interpret tail data as an extension header.
|
|
||||||
// Only ICMPv6 tails can use the response records without a
|
|
||||||
// transport header. Leave other tails to address/group ACLs.
|
|
||||||
if protocol != IP_PROTO_ICMPV6 {
|
|
||||||
protocol = IP_PROTO_UNSPECIFIED;
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
_ => break,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let transport_payload = &payload[payload_offset..payload_end];
|
|
||||||
if payload_offset != 40 && parse_transport_ports(protocol, transport_payload).is_none() {
|
|
||||||
return Some(parsed);
|
|
||||||
}
|
|
||||||
|
|
||||||
Some(ParsedIpPacket {
|
|
||||||
protocol,
|
|
||||||
transport_payload,
|
|
||||||
is_non_initial_fragment,
|
|
||||||
..parsed
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
fn parse_transport_ports(protocol: u8, payload: &[u8]) -> Option<(Option<u16>, Option<u16>)> {
|
|
||||||
let min_len = match protocol {
|
|
||||||
IP_PROTO_TCP => 20,
|
|
||||||
IP_PROTO_UDP => 8,
|
|
||||||
_ => return Some((None, None)),
|
|
||||||
};
|
|
||||||
if payload.len() < min_len {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
|
|
||||||
Some((
|
|
||||||
Some(u16::from_be_bytes([payload[0], payload[1]])),
|
|
||||||
Some(u16::from_be_bytes([payload[2], payload[3]])),
|
|
||||||
))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn acl_protocol(protocol: u8) -> Protocol {
|
fn acl_protocol(protocol: u8) -> Protocol {
|
||||||
match protocol {
|
match protocol {
|
||||||
IP_PROTO_TCP => Protocol::Tcp,
|
IP_PROTO_TCP => Protocol::Tcp,
|
||||||
@@ -568,13 +400,18 @@ mod tests {
|
|||||||
|
|
||||||
use easytier_proto::acl::{Acl, AclV1, Action, Chain, ChainType, Protocol, Rule};
|
use easytier_proto::acl::{Acl, AclV1, Action, Chain, ChainType, Protocol, Rule};
|
||||||
|
|
||||||
use crate::peers::acl::processor::PacketInfo;
|
use crate::{
|
||||||
|
packet::ip::{
|
||||||
|
IPV6_AUTHENTICATION, IPV6_DESTINATION_OPTIONS, IPV6_FRAGMENT, IPV6_HOP_BY_HOP,
|
||||||
|
IPV6_ROUTING,
|
||||||
|
},
|
||||||
|
peers::acl::processor::PacketInfo,
|
||||||
|
};
|
||||||
|
|
||||||
use super::{
|
use super::{
|
||||||
AclFilter, ICMP_ECHO_REPLY, ICMP_ECHO_REQUEST, ICMPV6_ECHO_REPLY, ICMPV6_ECHO_REQUEST,
|
AclFilter, ICMP_ECHO_REPLY, ICMP_ECHO_REQUEST, ICMPV6_ECHO_REPLY, ICMPV6_ECHO_REQUEST,
|
||||||
IP_PROTO_ICMP, IP_PROTO_ICMPV6, IP_PROTO_TCP, IP_PROTO_UDP, IP_PROTO_UNSPECIFIED,
|
IP_PROTO_ICMP, IP_PROTO_ICMPV6, IP_PROTO_TCP, IP_PROTO_UDP, IP_PROTO_UNSPECIFIED,
|
||||||
IPV6_AUTHENTICATION, IPV6_DESTINATION_OPTIONS, IPV6_FRAGMENT, IPV6_HOP_BY_HOP,
|
OutboundAllowRecord, PacketType, ZCPacket, acl_protocol, parse_ip_packet,
|
||||||
IPV6_ROUTING, OutboundAllowRecord, PacketType, ZCPacket, acl_protocol, parse_ip_packet,
|
|
||||||
parse_transport_ports,
|
parse_transport_ports,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -199,7 +199,7 @@ impl AttachedPeerRuntime {
|
|||||||
let public_ipv6_runtime =
|
let public_ipv6_runtime =
|
||||||
CorePublicIpv6Runtime::new(runtime_config.clone(), Arc::new(()), Arc::new(()));
|
CorePublicIpv6Runtime::new(runtime_config.clone(), Arc::new(()), Arc::new(()));
|
||||||
let flags = peer_snapshot.flags.clone();
|
let flags = peer_snapshot.flags.clone();
|
||||||
let peer_manager = Arc::new(PeerManagerCore::new(
|
let mut peer_manager = PeerManagerCore::new(
|
||||||
PortablePeerManagerConfig {
|
PortablePeerManagerConfig {
|
||||||
snapshot: peer_snapshot,
|
snapshot: peer_snapshot,
|
||||||
route_algo: RouteAlgoType::Ospf,
|
route_algo: RouteAlgoType::Ospf,
|
||||||
@@ -214,7 +214,9 @@ impl AttachedPeerRuntime {
|
|||||||
Arc::new(()),
|
Arc::new(()),
|
||||||
None,
|
None,
|
||||||
Arc::new(()),
|
Arc::new(()),
|
||||||
)?);
|
)?;
|
||||||
|
peer_manager.inherit_exit_nodes(&network_peer_manager);
|
||||||
|
let peer_manager = Arc::new(peer_manager);
|
||||||
if let Err(error) = peer_manager
|
if let Err(error) = peer_manager
|
||||||
.follow_network_policy(network_runtime_config, config.groups)
|
.follow_network_policy(network_runtime_config, config.groups)
|
||||||
.await
|
.await
|
||||||
@@ -621,6 +623,176 @@ mod tests {
|
|||||||
peer_manager_with_acl(Vec::new())
|
peer_manager_with_acl(Vec::new())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn connect_exit_peer(
|
||||||
|
network: &PeerManagerCore,
|
||||||
|
store: &CoreRuntimeConfigStore,
|
||||||
|
address: Ipv4Addr,
|
||||||
|
) -> (Arc<PeerManagerCore>, HostPacketReceiver) {
|
||||||
|
let mut snapshot = store.snapshot().peer.as_ref().clone();
|
||||||
|
snapshot.runtime.core.routes.ipv4 = Some(IpPrefix {
|
||||||
|
address: address.into(),
|
||||||
|
prefix_len: 24,
|
||||||
|
});
|
||||||
|
if let Some(secure) = snapshot.runtime.secure_mode.as_mut() {
|
||||||
|
let private = StaticSecret::from([address.octets()[3]; 32]);
|
||||||
|
secure.local_private_key = Some(BASE64_STANDARD.encode(private.as_bytes()));
|
||||||
|
secure.local_public_key =
|
||||||
|
Some(BASE64_STANDARD.encode(PublicKey::from(&private).as_bytes()));
|
||||||
|
}
|
||||||
|
let flags = snapshot.flags.clone();
|
||||||
|
let store =
|
||||||
|
CoreRuntimeConfigStore::new(CoreRuntimeConfig::default(), Arc::new(snapshot.clone()));
|
||||||
|
let (sender, receiver) = host_packet_channel();
|
||||||
|
let public_ipv6 = CorePublicIpv6Runtime::new(store.clone(), Arc::new(()), Arc::new(()));
|
||||||
|
let peer = Arc::new(
|
||||||
|
PeerManagerCore::new(
|
||||||
|
PortablePeerManagerConfig {
|
||||||
|
snapshot,
|
||||||
|
route_algo: RouteAlgoType::Ospf,
|
||||||
|
exit_nodes: Vec::new(),
|
||||||
|
foreign_context_default_flags: flags,
|
||||||
|
},
|
||||||
|
Vec::new(),
|
||||||
|
store,
|
||||||
|
Arc::new(()),
|
||||||
|
sender,
|
||||||
|
public_ipv6,
|
||||||
|
Arc::new(()),
|
||||||
|
None,
|
||||||
|
Arc::new(()),
|
||||||
|
)
|
||||||
|
.unwrap(),
|
||||||
|
);
|
||||||
|
peer.run().await.unwrap();
|
||||||
|
let (server, client) = create_ring_tunnel_pair();
|
||||||
|
let (server_result, client_result) = tokio::join!(
|
||||||
|
network.add_tunnel_as_server(server),
|
||||||
|
peer.add_client_tunnel(client),
|
||||||
|
);
|
||||||
|
server_result.unwrap();
|
||||||
|
client_result.unwrap();
|
||||||
|
(peer, receiver)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn check_attached_peer_exit_nodes(secure: bool) {
|
||||||
|
let (network, store) = peer_manager_with_acl_and_secure(Vec::new(), secure);
|
||||||
|
network.run().await.unwrap();
|
||||||
|
let first_ip = Ipv4Addr::new(10, 82, 0, 10);
|
||||||
|
let second_ip = Ipv4Addr::new(10, 82, 0, 11);
|
||||||
|
let (first, mut first_packets) = connect_exit_peer(&network, &store, first_ip).await;
|
||||||
|
let (second, mut second_packets) = connect_exit_peer(&network, &store, second_ip).await;
|
||||||
|
network.update_exit_nodes(vec![first_ip.into()]).await;
|
||||||
|
let client_ip = Ipv4Addr::new(10, 82, 0, 2);
|
||||||
|
let attached = AttachedPeerRuntime::connect(
|
||||||
|
network.clone(),
|
||||||
|
store,
|
||||||
|
AttachedPeerConfig {
|
||||||
|
name: "exit-node-client".to_owned(),
|
||||||
|
virtual_ip: attached_ipv4(client_ip),
|
||||||
|
groups: vec!["ops".to_owned()],
|
||||||
|
identity_private_key: [2; 32],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
for (exit, address) in [(&first, first_ip), (&second, second_ip)] {
|
||||||
|
wait_for_route(&attached.peer_manager, exit.my_peer_id(), address, true).await;
|
||||||
|
wait_for_route(exit, attached.peer_id(), client_ip, true).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
let public_ip = Ipv4Addr::new(8, 8, 8, 8);
|
||||||
|
let mut request = vec![0u8; 28];
|
||||||
|
request[0] = 0x45;
|
||||||
|
request[2..4].copy_from_slice(&28u16.to_be_bytes());
|
||||||
|
request[8] = 64;
|
||||||
|
request[9] = 1;
|
||||||
|
request[12..16].copy_from_slice(&client_ip.octets());
|
||||||
|
request[16..20].copy_from_slice(&public_ip.octets());
|
||||||
|
request[20] = 8;
|
||||||
|
|
||||||
|
for (exit, address, packets) in [
|
||||||
|
(&first, first_ip, &mut first_packets),
|
||||||
|
(&second, second_ip, &mut second_packets),
|
||||||
|
] {
|
||||||
|
if address == second_ip {
|
||||||
|
network.update_exit_nodes(vec![second_ip.into()]).await;
|
||||||
|
}
|
||||||
|
assert_eq!(
|
||||||
|
attached
|
||||||
|
.peer_manager
|
||||||
|
.get_msg_dst_peer(&public_ip.into())
|
||||||
|
.await,
|
||||||
|
(vec![exit.my_peer_id()], true),
|
||||||
|
);
|
||||||
|
attached.send_packet(&request).await.unwrap();
|
||||||
|
let packet = tokio::time::timeout(Duration::from_secs(5), packets.recv())
|
||||||
|
.await
|
||||||
|
.expect("exit node did not receive the client packet")
|
||||||
|
.unwrap()
|
||||||
|
.into_core_packet();
|
||||||
|
assert_eq!(packet.payload(), request.as_slice());
|
||||||
|
let header = packet.peer_manager_header().unwrap();
|
||||||
|
assert!(header.is_exit_node());
|
||||||
|
assert_eq!(header.from_peer_id.get(), attached.peer_id());
|
||||||
|
assert_eq!(header.to_peer_id.get(), exit.my_peer_id());
|
||||||
|
|
||||||
|
let mut reply = request.clone();
|
||||||
|
reply[12..16].copy_from_slice(&public_ip.octets());
|
||||||
|
reply[16..20].copy_from_slice(&client_ip.octets());
|
||||||
|
reply[20] = 0;
|
||||||
|
exit.send_msg_by_ip(ZCPacket::new_with_payload(&reply), client_ip.into(), false)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let received = tokio::time::timeout(Duration::from_secs(5), attached.recv_packet())
|
||||||
|
.await
|
||||||
|
.expect("exit reply did not reach the client")
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(received.payload(), reply.as_slice());
|
||||||
|
}
|
||||||
|
|
||||||
|
network.update_exit_nodes(Vec::new()).await;
|
||||||
|
assert_eq!(
|
||||||
|
attached
|
||||||
|
.peer_manager
|
||||||
|
.get_msg_dst_peer(&public_ip.into())
|
||||||
|
.await,
|
||||||
|
(Vec::new(), false),
|
||||||
|
);
|
||||||
|
// Explicit mesh routes must still win, even with an exit configured.
|
||||||
|
network.update_exit_nodes(vec![second_ip.into()]).await;
|
||||||
|
assert_eq!(
|
||||||
|
attached
|
||||||
|
.peer_manager
|
||||||
|
.get_msg_dst_peer(&first_ip.into())
|
||||||
|
.await,
|
||||||
|
(vec![first.my_peer_id()], false),
|
||||||
|
);
|
||||||
|
request[16..20].copy_from_slice(&first_ip.octets());
|
||||||
|
attached.send_packet(&request).await.unwrap();
|
||||||
|
let packet = tokio::time::timeout(Duration::from_secs(5), first_packets.recv())
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.unwrap()
|
||||||
|
.into_core_packet();
|
||||||
|
assert_eq!(packet.payload(), request.as_slice());
|
||||||
|
assert!(!packet.peer_manager_header().unwrap().is_exit_node());
|
||||||
|
|
||||||
|
attached.close().await;
|
||||||
|
first.clear_resources().await;
|
||||||
|
second.clear_resources().await;
|
||||||
|
network.clear_resources().await;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn attached_peer_inherits_exit_nodes_and_updates() {
|
||||||
|
check_attached_peer_exit_nodes(false).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn secure_attached_peer_inherits_exit_nodes_and_updates() {
|
||||||
|
check_attached_peer_exit_nodes(true).await;
|
||||||
|
}
|
||||||
|
|
||||||
async fn wait_for_route(
|
async fn wait_for_route(
|
||||||
peer: &PeerManagerCore,
|
peer: &PeerManagerCore,
|
||||||
attached_peer_id: PeerId,
|
attached_peer_id: PeerId,
|
||||||
|
|||||||
@@ -5,5 +5,6 @@ pub(crate) mod peer;
|
|||||||
pub(crate) mod peer_conn;
|
pub(crate) mod peer_conn;
|
||||||
pub(crate) mod peer_conn_liveness;
|
pub(crate) mod peer_conn_liveness;
|
||||||
pub(crate) mod peer_conn_ping;
|
pub(crate) mod peer_conn_ping;
|
||||||
|
mod peer_flow;
|
||||||
pub(crate) mod peer_map;
|
pub(crate) mod peer_map;
|
||||||
pub(crate) mod peer_session;
|
pub(crate) mod peer_session;
|
||||||
|
|||||||
@@ -1,7 +1,13 @@
|
|||||||
use std::sync::Arc;
|
use std::{
|
||||||
|
sync::{
|
||||||
|
Arc,
|
||||||
|
atomic::{AtomicBool, Ordering},
|
||||||
|
},
|
||||||
|
time::{Duration, Instant},
|
||||||
|
};
|
||||||
|
|
||||||
use arc_swap::ArcSwapOption;
|
use arc_swap::ArcSwapOption;
|
||||||
use crossbeam::atomic::AtomicCell;
|
use crossbeam_utils::atomic::AtomicCell;
|
||||||
use dashmap::{DashMap, DashSet};
|
use dashmap::{DashMap, DashSet};
|
||||||
use parking_lot::{Mutex, RwLock};
|
use parking_lot::{Mutex, RwLock};
|
||||||
|
|
||||||
@@ -9,7 +15,10 @@ use tokio::{select, sync::mpsc};
|
|||||||
|
|
||||||
use tracing::Instrument;
|
use tracing::Instrument;
|
||||||
|
|
||||||
use super::peer_conn::{PeerConn, PeerConnId};
|
use super::{
|
||||||
|
peer_conn::{PeerConn, PeerConnId},
|
||||||
|
peer_flow::FlowBindings,
|
||||||
|
};
|
||||||
use crate::peers::{
|
use crate::peers::{
|
||||||
PacketRecvChan,
|
PacketRecvChan,
|
||||||
context::{ArcPeerContext, PeerEvent},
|
context::{ArcPeerContext, PeerEvent},
|
||||||
@@ -17,7 +26,8 @@ use crate::peers::{
|
|||||||
};
|
};
|
||||||
use crate::{
|
use crate::{
|
||||||
config::PeerId,
|
config::PeerId,
|
||||||
packet::ZCPacket,
|
foundation::small_map_shard_amount,
|
||||||
|
packet::{FlowKey, ZCPacket},
|
||||||
peers::error::Error,
|
peers::error::Error,
|
||||||
proto::{core_peer::peer::PeerConnInfo, peer_rpc::PeerIdentityType},
|
proto::{core_peer::peer::PeerConnInfo, peer_rpc::PeerIdentityType},
|
||||||
};
|
};
|
||||||
@@ -26,8 +36,19 @@ use tokio_util::task::AbortOnDropHandle;
|
|||||||
type ArcPeerConn = Arc<PeerConn>;
|
type ArcPeerConn = Arc<PeerConn>;
|
||||||
type ConnMap = Arc<DashMap<PeerConnId, ArcPeerConn>>;
|
type ConnMap = Arc<DashMap<PeerConnId, ArcPeerConn>>;
|
||||||
|
|
||||||
fn conn_latency_sort_key(latency_us: u64, is_hole_punched: bool) -> (bool, u64) {
|
fn conn_selection_key(conn: &PeerConn) -> (u8, u64) {
|
||||||
(is_hole_punched && latency_us == 0, latency_us)
|
let latency = conn.get_stats().latency_us;
|
||||||
|
(
|
||||||
|
if latency > 0 {
|
||||||
|
0
|
||||||
|
} else if conn.is_hole_punched() {
|
||||||
|
// Prefer regular connections until hole punching has a measured RTT.
|
||||||
|
2
|
||||||
|
} else {
|
||||||
|
1
|
||||||
|
},
|
||||||
|
latency,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub struct Peer {
|
pub struct Peer {
|
||||||
@@ -45,6 +66,8 @@ pub struct Peer {
|
|||||||
|
|
||||||
default_conn: Arc<ArcSwapOption<PeerConn>>,
|
default_conn: Arc<ArcSwapOption<PeerConn>>,
|
||||||
default_conn_update_lock: Arc<Mutex<()>>,
|
default_conn_update_lock: Arc<Mutex<()>>,
|
||||||
|
flow_bindings: Mutex<FlowBindings>,
|
||||||
|
data_active: Arc<AtomicBool>,
|
||||||
peer_identity_type: Arc<AtomicCell<Option<PeerIdentityType>>>,
|
peer_identity_type: Arc<AtomicCell<Option<PeerIdentityType>>>,
|
||||||
peer_public_key: Arc<RwLock<Option<Vec<u8>>>>,
|
peer_public_key: Arc<RwLock<Option<Vec<u8>>>>,
|
||||||
#[allow(dead_code)]
|
#[allow(dead_code)]
|
||||||
@@ -57,7 +80,7 @@ impl Peer {
|
|||||||
packet_recv_chan: PacketRecvChan,
|
packet_recv_chan: PacketRecvChan,
|
||||||
context: ArcPeerContext,
|
context: ArcPeerContext,
|
||||||
) -> Self {
|
) -> Self {
|
||||||
let conns: ConnMap = Arc::new(DashMap::new());
|
let conns: ConnMap = Arc::new(DashMap::with_shard_amount(small_map_shard_amount()));
|
||||||
let (close_event_sender, mut close_event_receiver) = mpsc::channel(10);
|
let (close_event_sender, mut close_event_receiver) = mpsc::channel(10);
|
||||||
let shutdown_notifier = Arc::new(tokio::sync::Notify::new());
|
let shutdown_notifier = Arc::new(tokio::sync::Notify::new());
|
||||||
let peer_identity_type = Arc::new(AtomicCell::new(None));
|
let peer_identity_type = Arc::new(AtomicCell::new(None));
|
||||||
@@ -66,6 +89,8 @@ impl Peer {
|
|||||||
let peer_public_key_copy = peer_public_key.clone();
|
let peer_public_key_copy = peer_public_key.clone();
|
||||||
let default_conn = Arc::new(ArcSwapOption::empty());
|
let default_conn = Arc::new(ArcSwapOption::empty());
|
||||||
let default_conn_update_lock = Arc::new(Mutex::new(()));
|
let default_conn_update_lock = Arc::new(Mutex::new(()));
|
||||||
|
let data_active = Arc::new(AtomicBool::new(false));
|
||||||
|
let flow_bindings = Mutex::new(FlowBindings::default());
|
||||||
|
|
||||||
let conns_copy = conns.clone();
|
let conns_copy = conns.clone();
|
||||||
let shutdown_notifier_copy = shutdown_notifier.clone();
|
let shutdown_notifier_copy = shutdown_notifier.clone();
|
||||||
@@ -130,12 +155,18 @@ impl Peer {
|
|||||||
|
|
||||||
let conns_copy = conns.clone();
|
let conns_copy = conns.clone();
|
||||||
let default_conn_copy = default_conn.clone();
|
let default_conn_copy = default_conn.clone();
|
||||||
|
let data_active_copy = data_active.clone();
|
||||||
let default_conn_clear_task = AbortOnDropHandle::new(tokio::spawn(async move {
|
let default_conn_clear_task = AbortOnDropHandle::new(tokio::spawn(async move {
|
||||||
loop {
|
loop {
|
||||||
crate::foundation::time::sleep(std::time::Duration::from_secs(5)).await;
|
crate::foundation::time::sleep(Duration::from_secs(5)).await;
|
||||||
if conns_copy.len() > 1 {
|
if conns_copy.len() > 1 {
|
||||||
default_conn_copy.store(None);
|
default_conn_copy.store(None);
|
||||||
}
|
}
|
||||||
|
let active = data_active_copy.swap(false, Ordering::Relaxed);
|
||||||
|
let fast_ping = conns_copy.len() > 1 && active;
|
||||||
|
for conn in conns_copy.iter() {
|
||||||
|
conn.fast_ping.store(fast_ping, Ordering::Relaxed);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@@ -151,6 +182,8 @@ impl Peer {
|
|||||||
shutdown_notifier,
|
shutdown_notifier,
|
||||||
default_conn,
|
default_conn,
|
||||||
default_conn_update_lock,
|
default_conn_update_lock,
|
||||||
|
flow_bindings,
|
||||||
|
data_active,
|
||||||
peer_identity_type,
|
peer_identity_type,
|
||||||
peer_public_key,
|
peer_public_key,
|
||||||
default_conn_clear_task,
|
default_conn_clear_task,
|
||||||
@@ -212,22 +245,19 @@ impl Peer {
|
|||||||
|
|
||||||
fn select_conn(&self) -> Option<ArcPeerConn> {
|
fn select_conn(&self) -> Option<ArcPeerConn> {
|
||||||
let _update_guard = self.default_conn_update_lock.lock();
|
let _update_guard = self.default_conn_update_lock.lock();
|
||||||
if let Some(conn) = self.default_conn.load_full() {
|
if let Some(conn) = self.default_conn.load_full()
|
||||||
|
&& !conn.is_closed()
|
||||||
|
{
|
||||||
return Some(conn);
|
return Some(conn);
|
||||||
}
|
}
|
||||||
|
|
||||||
// A zero latency on a hole-punched connection means the ping loop has not
|
// Unmeasured paths are usable for startup/recovery, but cannot outrank a
|
||||||
// confirmed liveness yet. Prefer any other connection, so a freshly admitted
|
// measured path merely because their RTT is still zero.
|
||||||
// hole-punched path cannot steal traffic before its first successful ping.
|
|
||||||
let selected = self
|
let selected = self
|
||||||
.conns
|
.conns
|
||||||
.iter()
|
.iter()
|
||||||
.min_by_key(|conn| {
|
.filter(|conn| !conn.is_closed())
|
||||||
conn_latency_sort_key(
|
.min_by_key(|conn| conn_selection_key(conn.value()))
|
||||||
conn.value().get_stats().latency_us,
|
|
||||||
conn.value().is_hole_punched(),
|
|
||||||
)
|
|
||||||
})
|
|
||||||
.map(|conn| conn.value().clone());
|
.map(|conn| conn.value().clone());
|
||||||
|
|
||||||
if let Some(conn) = selected.as_ref() {
|
if let Some(conn) = selected.as_ref() {
|
||||||
@@ -236,9 +266,42 @@ impl Peer {
|
|||||||
selected
|
selected
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn select_flow_conn(&self, key: FlowKey) -> Option<ArcPeerConn> {
|
||||||
|
let mut flows = self.flow_bindings.lock();
|
||||||
|
let bucket = flows.bucket(key);
|
||||||
|
if let Some(binding) = flows.buckets.get_mut(&bucket)
|
||||||
|
&& binding.last_active.elapsed() < FlowBindings::IDLE_TIMEOUT
|
||||||
|
{
|
||||||
|
let bound = self
|
||||||
|
.conns
|
||||||
|
.get(&binding.conn_id)
|
||||||
|
.map(|conn| conn.value().clone());
|
||||||
|
if let Some(conn) = bound.filter(|conn| !conn.is_closed()) {
|
||||||
|
binding.last_active = Instant::now();
|
||||||
|
return Some(conn);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let conn = self.select_conn()?;
|
||||||
|
// A collision joins the bucket's active binding rather than evicting an
|
||||||
|
// existing flow. This bounds memory without migrating flows under load.
|
||||||
|
flows.bind(bucket, conn.get_conn_id(), Instant::now());
|
||||||
|
Some(conn)
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn send_msg(&self, msg: ZCPacket) -> Result<(), Error> {
|
pub async fn send_msg(&self, msg: ZCPacket) -> Result<(), Error> {
|
||||||
|
if crate::peers::traffic_metrics::data_packet_payload_len(&msg).is_some() {
|
||||||
|
self.data_active.store(true, Ordering::Relaxed);
|
||||||
|
}
|
||||||
|
if let Some(key) = msg.flow_key {
|
||||||
|
let conn = self
|
||||||
|
.select_flow_conn(key)
|
||||||
|
.ok_or(Error::PeerNoConnectionError(self.peer_node_id))?;
|
||||||
|
return conn.send_msg(msg).await;
|
||||||
|
}
|
||||||
let default_conn = self.default_conn.load();
|
let default_conn = self.default_conn.load();
|
||||||
if let Some(conn) = default_conn.as_ref() {
|
if let Some(conn) = default_conn.as_ref()
|
||||||
|
&& !conn.is_closed()
|
||||||
|
{
|
||||||
conn.send_msg(msg).await?;
|
conn.send_msg(msg).await?;
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
@@ -338,20 +401,269 @@ impl Drop for Peer {
|
|||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::conn_latency_sort_key;
|
use super::*;
|
||||||
|
use crate::{
|
||||||
|
packet::PacketType,
|
||||||
|
peers::{
|
||||||
|
PeerConnectionOrigin, conn::peer_session::PeerSessionStore, create_packet_recv_chan,
|
||||||
|
recv_packet_envelope_from_chan, test_support::NoopPeerContext,
|
||||||
|
},
|
||||||
|
tunnel::ring::create_ring_tunnel_pair,
|
||||||
|
};
|
||||||
|
|
||||||
#[test]
|
fn flow(port: u16) -> FlowKey {
|
||||||
fn measured_relay_precedes_unverified_hole_punch_path() {
|
FlowKey {
|
||||||
assert!(conn_latency_sort_key(20_000, false) < conn_latency_sort_key(0, true));
|
src: ([10, 0, 0, 1], port).into(),
|
||||||
|
dst: ([10, 0, 0, 2], 5000).into(),
|
||||||
|
protocol: 17,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
async fn add_connection(
|
||||||
fn unmeasured_regular_connection_keeps_existing_priority() {
|
peer: &Peer,
|
||||||
assert!(conn_latency_sort_key(0, false) < conn_latency_sort_key(20_000, false));
|
recv: PacketRecvChan,
|
||||||
|
rtt: Option<u32>,
|
||||||
|
) -> (PeerConnId, PeerConn) {
|
||||||
|
let (left, right) = create_ring_tunnel_pair();
|
||||||
|
let sessions = Arc::new(PeerSessionStore::new());
|
||||||
|
let context = Arc::new(NoopPeerContext::default());
|
||||||
|
let mut client = PeerConn::new(1, context.clone(), left, sessions.clone());
|
||||||
|
let mut server = PeerConn::new(2, context, right, sessions);
|
||||||
|
let (left, right) = tokio::join!(
|
||||||
|
client.do_handshake_as_client(),
|
||||||
|
server.do_handshake_as_server_ext(|_, _| Ok(())),
|
||||||
|
);
|
||||||
|
left.unwrap();
|
||||||
|
right.unwrap();
|
||||||
|
server.start_recv_loop(recv).await;
|
||||||
|
if let Some(rtt) = rtt {
|
||||||
|
client.record_latency_for_test(rtt);
|
||||||
|
}
|
||||||
|
let id = client.get_conn_id();
|
||||||
|
peer.add_peer_conn(client).await.unwrap();
|
||||||
|
(id, server)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
fn new_peer() -> Peer {
|
||||||
fn verified_lower_latency_path_can_be_preferred() {
|
let (sender, _receiver) = create_packet_recv_chan();
|
||||||
assert!(conn_latency_sort_key(5_000, true) < conn_latency_sort_key(20_000, false));
|
Peer::new(2, sender, Arc::new(NoopPeerContext::default()))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn send_failure_marks_connection_closed_without_waiting_for_ping() {
|
||||||
|
let (left, right) = create_ring_tunnel_pair();
|
||||||
|
let (stream, _sink) = right.split();
|
||||||
|
drop(stream);
|
||||||
|
let conn = PeerConn::new(
|
||||||
|
1,
|
||||||
|
Arc::new(NoopPeerContext::default()),
|
||||||
|
left,
|
||||||
|
Arc::new(PeerSessionStore::new()),
|
||||||
|
);
|
||||||
|
crate::foundation::time::timeout(Duration::from_secs(1), async {
|
||||||
|
loop {
|
||||||
|
let mut packet = ZCPacket::new_with_payload(b"closed sink");
|
||||||
|
packet.fill_peer_manager_hdr(1, 2, PacketType::Data as u8);
|
||||||
|
if conn.send_msg(packet).await.is_err() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
tokio::task::yield_now().await;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert!(conn.is_closed());
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn packet_skips_failed_connection_before_cache_cleanup(flow_key: Option<FlowKey>) {
|
||||||
|
let peer = new_peer();
|
||||||
|
let (sender, mut receiver) = create_packet_recv_chan();
|
||||||
|
let (old, old_server) = add_connection(&peer, sender.clone(), Some(20_000)).await;
|
||||||
|
let (_, new_server) = add_connection(&peer, sender, Some(1_000)).await;
|
||||||
|
let old_conn = peer.conns.get(&old).unwrap().value().clone();
|
||||||
|
peer.default_conn.store(Some(old_conn.clone()));
|
||||||
|
if let Some(key) = flow_key {
|
||||||
|
assert_eq!(peer.select_flow_conn(key).unwrap().get_conn_id(), old);
|
||||||
|
}
|
||||||
|
// Keep the stale cache entry so the result cannot depend on task ordering.
|
||||||
|
peer.close_event_listener.abort();
|
||||||
|
drop(old_server);
|
||||||
|
crate::foundation::time::timeout(Duration::from_secs(1), async {
|
||||||
|
loop {
|
||||||
|
let mut packet = ZCPacket::new_with_payload(b"closed sink");
|
||||||
|
packet.fill_peer_manager_hdr(1, 2, PacketType::Data as u8);
|
||||||
|
if old_conn.send_msg(packet).await.is_err() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
tokio::task::yield_now().await;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert!(old_conn.is_closed());
|
||||||
|
assert_eq!(peer.get_default_conn_id(), old);
|
||||||
|
|
||||||
|
let mut packet = ZCPacket::new_with_payload(b"packet after failure");
|
||||||
|
packet.fill_peer_manager_hdr(1, 2, PacketType::Data as u8);
|
||||||
|
packet.flow_key = flow_key;
|
||||||
|
peer.send_msg(packet).await.unwrap();
|
||||||
|
let envelope = crate::foundation::time::timeout(
|
||||||
|
Duration::from_secs(1),
|
||||||
|
recv_packet_envelope_from_chan(&mut receiver),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.unwrap();
|
||||||
|
let (packet, ingress) = envelope.into_parts();
|
||||||
|
assert_eq!(packet.payload(), b"packet after failure");
|
||||||
|
assert_eq!(
|
||||||
|
ingress.peer_connection().unwrap().1,
|
||||||
|
new_server.get_conn_id()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn unkeyed_packet_skips_failed_connection_before_cache_cleanup() {
|
||||||
|
packet_skips_failed_connection_before_cache_cleanup(None).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn active_flow_skips_failed_connection_before_cache_cleanup() {
|
||||||
|
packet_skips_failed_connection_before_cache_cleanup(Some(flow(1234))).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn unmeasured_regular_connection_precedes_unverified_hole_punch() {
|
||||||
|
let (tunnel, _remote) = create_ring_tunnel_pair();
|
||||||
|
let regular = PeerConn::new(
|
||||||
|
1,
|
||||||
|
Arc::new(NoopPeerContext::default()),
|
||||||
|
tunnel,
|
||||||
|
Arc::new(PeerSessionStore::new()),
|
||||||
|
);
|
||||||
|
for origin in [
|
||||||
|
PeerConnectionOrigin::TcpHolePunch,
|
||||||
|
PeerConnectionOrigin::UdpHolePunch,
|
||||||
|
] {
|
||||||
|
let (tunnel, _remote) = create_ring_tunnel_pair();
|
||||||
|
let hole_punched = PeerConn::new_with_peer_id_hint_and_origin(
|
||||||
|
1,
|
||||||
|
Arc::new(NoopPeerContext::default()),
|
||||||
|
tunnel,
|
||||||
|
None,
|
||||||
|
Arc::new(PeerSessionStore::new()),
|
||||||
|
origin,
|
||||||
|
);
|
||||||
|
assert!(conn_selection_key(®ular) < conn_selection_key(&hole_punched));
|
||||||
|
hole_punched.record_latency_for_test(1_000);
|
||||||
|
assert!(conn_selection_key(&hole_punched) < conn_selection_key(®ular));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn distinct_flow(peer: &Peer, existing: FlowKey) -> FlowKey {
|
||||||
|
let flows = peer.flow_bindings.lock();
|
||||||
|
(1..=u16::MAX)
|
||||||
|
.map(flow)
|
||||||
|
.find(|key| flows.bucket(*key) != flows.bucket(existing))
|
||||||
|
.unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn active_flow_survives_new_connection_and_periodic_reselection() {
|
||||||
|
let peer = new_peer();
|
||||||
|
let (sender, mut receiver) = create_packet_recv_chan();
|
||||||
|
let (old, old_server) = add_connection(&peer, sender.clone(), Some(20_000)).await;
|
||||||
|
let key = flow(1234);
|
||||||
|
assert_eq!(peer.select_flow_conn(key).unwrap().get_conn_id(), old);
|
||||||
|
let (new, new_server) = add_connection(&peer, sender, Some(1_000)).await;
|
||||||
|
// A slow but open connection must keep its existing flow.
|
||||||
|
for _ in 0..15 {
|
||||||
|
peer.conns
|
||||||
|
.get(&old)
|
||||||
|
.unwrap()
|
||||||
|
.record_latency_for_test(500_000);
|
||||||
|
}
|
||||||
|
peer.default_conn.store(None);
|
||||||
|
assert_eq!(peer.select_flow_conn(key).unwrap().get_conn_id(), old);
|
||||||
|
let new_key = distinct_flow(&peer, key);
|
||||||
|
assert_eq!(peer.select_flow_conn(new_key).unwrap().get_conn_id(), new);
|
||||||
|
|
||||||
|
for (key, expected) in [
|
||||||
|
(key, old_server.get_conn_id()),
|
||||||
|
(new_key, new_server.get_conn_id()),
|
||||||
|
] {
|
||||||
|
let mut packet = ZCPacket::new_with_payload(b"business data");
|
||||||
|
packet.fill_peer_manager_hdr(1, 2, PacketType::Data as u8);
|
||||||
|
packet.flow_key = Some(key);
|
||||||
|
peer.send_msg(packet).await.unwrap();
|
||||||
|
let envelope = crate::foundation::time::timeout(
|
||||||
|
Duration::from_secs(1),
|
||||||
|
recv_packet_envelope_from_chan(&mut receiver),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.unwrap();
|
||||||
|
let (packet, ingress) = envelope.into_parts();
|
||||||
|
assert_eq!(packet.payload(), b"business data");
|
||||||
|
assert_eq!(ingress.peer_connection().unwrap().1, expected);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn idle_flow_can_rebind_and_closed_connection_cannot_trap_active_flow() {
|
||||||
|
let peer = new_peer();
|
||||||
|
let (sender, _receiver) = create_packet_recv_chan();
|
||||||
|
let (old, _old_server) = add_connection(&peer, sender.clone(), Some(20_000)).await;
|
||||||
|
let key = flow(1234);
|
||||||
|
assert_eq!(peer.select_flow_conn(key).unwrap().get_conn_id(), old);
|
||||||
|
let (new, _new_server) = add_connection(&peer, sender, Some(1_000)).await;
|
||||||
|
peer.default_conn.store(None);
|
||||||
|
// Activity renews the idle timeout even across repeated intervals.
|
||||||
|
for _ in 0..2 {
|
||||||
|
{
|
||||||
|
let mut flows = peer.flow_bindings.lock();
|
||||||
|
let bucket = flows.bucket(key);
|
||||||
|
flows.buckets.get_mut(&bucket).unwrap().last_active -=
|
||||||
|
FlowBindings::IDLE_TIMEOUT - Duration::from_secs(1);
|
||||||
|
}
|
||||||
|
assert_eq!(peer.select_flow_conn(key).unwrap().get_conn_id(), old);
|
||||||
|
}
|
||||||
|
{
|
||||||
|
let mut flows = peer.flow_bindings.lock();
|
||||||
|
let bucket = flows.bucket(key);
|
||||||
|
flows.buckets.get_mut(&bucket).unwrap().last_active -= FlowBindings::IDLE_TIMEOUT;
|
||||||
|
}
|
||||||
|
assert_eq!(peer.select_flow_conn(key).unwrap().get_conn_id(), new);
|
||||||
|
peer.close_peer_conn(&new).await.unwrap();
|
||||||
|
crate::foundation::time::timeout(Duration::from_secs(1), async {
|
||||||
|
while peer.conns.contains_key(&new) {
|
||||||
|
tokio::task::yield_now().await;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(peer.select_flow_conn(key).unwrap().get_conn_id(), old);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn bucket_collisions_and_capacity_never_evict_an_active_binding() {
|
||||||
|
let peer = new_peer();
|
||||||
|
let (sender, _receiver) = create_packet_recv_chan();
|
||||||
|
let (old, _old_server) = add_connection(&peer, sender.clone(), Some(20_000)).await;
|
||||||
|
for port in 1..=u16::MAX {
|
||||||
|
assert_eq!(
|
||||||
|
peer.select_flow_conn(flow(port)).unwrap().get_conn_id(),
|
||||||
|
old
|
||||||
|
);
|
||||||
|
}
|
||||||
|
assert!(peer.flow_bindings.lock().buckets.len() <= FlowBindings::BUCKETS);
|
||||||
|
let (_new, _new_server) = add_connection(&peer, sender, Some(1_000)).await;
|
||||||
|
peer.default_conn.store(None);
|
||||||
|
for port in 1..=u16::MAX {
|
||||||
|
assert_eq!(
|
||||||
|
peer.select_flow_conn(flow(port)).unwrap().get_conn_id(),
|
||||||
|
old
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
use arc_swap::ArcSwapOption;
|
use arc_swap::ArcSwapOption;
|
||||||
use crossbeam::atomic::AtomicCell;
|
use crossbeam_utils::atomic::AtomicCell;
|
||||||
use futures::{StreamExt, TryFutureExt};
|
use futures::{StreamExt, TryFutureExt};
|
||||||
use std::{
|
use std::{
|
||||||
any::Any,
|
any::Any,
|
||||||
@@ -7,7 +7,7 @@ use std::{
|
|||||||
pin::Pin,
|
pin::Pin,
|
||||||
sync::{
|
sync::{
|
||||||
Arc,
|
Arc,
|
||||||
atomic::{AtomicU32, Ordering},
|
atomic::{AtomicBool, AtomicU32, Ordering},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -298,6 +298,7 @@ pub struct PeerConn {
|
|||||||
ctrl_resp_sender: broadcast::Sender<ZCPacket>,
|
ctrl_resp_sender: broadcast::Sender<ZCPacket>,
|
||||||
|
|
||||||
latency_stats: Arc<WindowLatency>,
|
latency_stats: Arc<WindowLatency>,
|
||||||
|
pub(super) fast_ping: Arc<AtomicBool>,
|
||||||
throughput: Arc<Throughput>,
|
throughput: Arc<Throughput>,
|
||||||
loss_rate_stats: Arc<AtomicU32>,
|
loss_rate_stats: Arc<AtomicU32>,
|
||||||
liveness: PeerConnLiveness,
|
liveness: PeerConnLiveness,
|
||||||
@@ -398,6 +399,7 @@ impl PeerConn {
|
|||||||
ctrl_resp_sender: ctrl_sender,
|
ctrl_resp_sender: ctrl_sender,
|
||||||
|
|
||||||
latency_stats: Arc::new(WindowLatency::new(15)),
|
latency_stats: Arc::new(WindowLatency::new(15)),
|
||||||
|
fast_ping: Arc::new(AtomicBool::new(false)),
|
||||||
throughput,
|
throughput,
|
||||||
loss_rate_stats: Arc::new(AtomicU32::new(0)),
|
loss_rate_stats: Arc::new(AtomicU32::new(0)),
|
||||||
liveness,
|
liveness,
|
||||||
@@ -1385,22 +1387,31 @@ impl PeerConn {
|
|||||||
self.liveness.clone(),
|
self.liveness.clone(),
|
||||||
self.max_ping_interval(),
|
self.max_ping_interval(),
|
||||||
);
|
);
|
||||||
|
pingpong.fast_ping = self.fast_ping.clone();
|
||||||
|
|
||||||
let close_event_notifier = self.close_event_notifier.clone();
|
let close_event_notifier = self.close_event_notifier.clone();
|
||||||
|
let conn_id = self.conn_id;
|
||||||
|
|
||||||
self.tasks.spawn(async move {
|
self.tasks.spawn(
|
||||||
pingpong.pingpong().await;
|
async move {
|
||||||
|
pingpong.pingpong().await;
|
||||||
|
|
||||||
tracing::warn!(?pingpong, "pingpong task exit");
|
tracing::warn!(?pingpong, "pingpong task exit");
|
||||||
|
|
||||||
close_event_notifier.notify_close();
|
close_event_notifier.notify_close();
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
});
|
}
|
||||||
|
.instrument(tracing::info_span!("peer_conn_pingpong", ?conn_id)),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn send_msg(&self, msg: ZCPacket) -> Result<(), Error> {
|
pub async fn send_msg(&self, msg: ZCPacket) -> Result<(), Error> {
|
||||||
Ok(self.sink.send(msg).await?)
|
if let Err(error) = self.sink.send(msg).await {
|
||||||
|
self.close_event_notifier.notify_close();
|
||||||
|
return Err(error.into());
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn get_peer_id(&self) -> PeerId {
|
pub fn get_peer_id(&self) -> PeerId {
|
||||||
@@ -1459,6 +1470,11 @@ impl PeerConn {
|
|||||||
self.close_event_notifier.clone()
|
self.close_event_notifier.clone()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
pub(super) fn record_latency_for_test(&self, latency_us: u32) {
|
||||||
|
self.latency_stats.record_latency(latency_us);
|
||||||
|
}
|
||||||
|
|
||||||
pub fn get_stats(&self) -> PeerConnStats {
|
pub fn get_stats(&self) -> PeerConnStats {
|
||||||
PeerConnStats {
|
PeerConnStats {
|
||||||
latency_us: self.latency_stats.get_latency_us(),
|
latency_us: self.latency_stats.get_latency_us(),
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
use std::{
|
use std::{
|
||||||
sync::{
|
sync::{
|
||||||
Arc,
|
Arc,
|
||||||
atomic::{AtomicU32, Ordering},
|
atomic::{AtomicBool, AtomicU32, Ordering},
|
||||||
},
|
},
|
||||||
time::{Duration, Instant},
|
time::{Duration, Instant},
|
||||||
};
|
};
|
||||||
@@ -124,6 +124,7 @@ impl PingIntervalController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub struct PeerConnPinger {
|
pub struct PeerConnPinger {
|
||||||
|
pub(super) fast_ping: Arc<AtomicBool>,
|
||||||
my_peer_id: PeerId,
|
my_peer_id: PeerId,
|
||||||
peer_id: PeerId,
|
peer_id: PeerId,
|
||||||
sink: MpscTunnelSender,
|
sink: MpscTunnelSender,
|
||||||
@@ -163,6 +164,7 @@ impl PeerConnPinger {
|
|||||||
) -> Self {
|
) -> Self {
|
||||||
Self {
|
Self {
|
||||||
my_peer_id,
|
my_peer_id,
|
||||||
|
fast_ping: Arc::new(AtomicBool::new(false)),
|
||||||
peer_id,
|
peer_id,
|
||||||
sink,
|
sink,
|
||||||
latency_stats,
|
latency_stats,
|
||||||
@@ -188,13 +190,13 @@ impl PeerConnPinger {
|
|||||||
seq: u32,
|
seq: u32,
|
||||||
liveness_token: Option<u8>,
|
liveness_token: Option<u8>,
|
||||||
) -> Result<PingResponse, Error> {
|
) -> Result<PingResponse, Error> {
|
||||||
|
let now = Instant::now();
|
||||||
// should add seq here. so latency can be calculated more accurately
|
// should add seq here. so latency can be calculated more accurately
|
||||||
let req = Self::new_ping_packet(self.my_peer_id, self.peer_id, seq);
|
let req = Self::new_ping_packet(self.my_peer_id, self.peer_id, seq);
|
||||||
let req_len = req.buf_len() as u64;
|
let req_len = req.buf_len() as u64;
|
||||||
self.sink.send(req).await?;
|
self.sink.send(req).await?;
|
||||||
self.context.record_control_tx(&self.network_name, req_len);
|
self.context.record_control_tx(&self.network_name, req_len);
|
||||||
|
|
||||||
let now = Instant::now();
|
|
||||||
let wait_for_pong = async {
|
let wait_for_pong = async {
|
||||||
loop {
|
loop {
|
||||||
match receiver.recv().await {
|
match receiver.recv().await {
|
||||||
@@ -257,11 +259,17 @@ impl PeerConnPinger {
|
|||||||
let throughput = self.throughput_stats.clone();
|
let throughput = self.throughput_stats.clone();
|
||||||
let controller_loss_counter = loss_counter.clone();
|
let controller_loss_counter = loss_counter.clone();
|
||||||
let max_interval = self.max_interval;
|
let max_interval = self.max_interval;
|
||||||
|
let fast_ping = self.fast_ping.clone();
|
||||||
controller_tasks.spawn(async move {
|
controller_tasks.spawn(async move {
|
||||||
let mut controller =
|
let mut controller =
|
||||||
PingIntervalController::new(throughput, controller_loss_counter, max_interval);
|
PingIntervalController::new(throughput, controller_loss_counter, max_interval);
|
||||||
loop {
|
loop {
|
||||||
controller.tick().await;
|
controller.tick().await;
|
||||||
|
controller.max_interval = if fast_ping.load(Ordering::Relaxed) {
|
||||||
|
Duration::from_secs(1)
|
||||||
|
} else {
|
||||||
|
max_interval
|
||||||
|
};
|
||||||
if !controller.should_send_ping() {
|
if !controller.should_send_ping() {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -290,6 +298,8 @@ impl PeerConnPinger {
|
|||||||
req_seq = req_seq.wrapping_add(1);
|
req_seq = req_seq.wrapping_add(1);
|
||||||
|
|
||||||
if let Ok(response) = &ret {
|
if let Ok(response) = &ret {
|
||||||
|
// A liveness echo proves reachability, not prompt delivery of a
|
||||||
|
// queued packet. Only a matching Pong supplies an RTT sample.
|
||||||
if let PingResponse::Pong(lat) = response {
|
if let PingResponse::Pong(lat) = response {
|
||||||
self.latency_stats.record_latency(*lat as u32);
|
self.latency_stats.record_latency(*lat as u32);
|
||||||
}
|
}
|
||||||
@@ -350,6 +360,70 @@ mod tests {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn pong_latency_includes_waiting_for_send_queue_capacity() {
|
||||||
|
use crate::tunnel::ring::{RingTunnel, create_ring_socket_pair};
|
||||||
|
|
||||||
|
let (left, right) = create_ring_socket_pair(1);
|
||||||
|
let tunnel = MpscTunnel::new(RingTunnel::new(left, None), None);
|
||||||
|
let remote = RingTunnel::new(right, None);
|
||||||
|
let (mut stream, _sink) = remote.split();
|
||||||
|
let sender = tunnel.get_sink();
|
||||||
|
// Fill both the underlying ring and the MPSC queue without draining.
|
||||||
|
assert!(
|
||||||
|
timeout(Duration::from_millis(10), async {
|
||||||
|
loop {
|
||||||
|
sender
|
||||||
|
.send(ZCPacket::new_with_payload(b"queued business"))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.is_err()
|
||||||
|
);
|
||||||
|
|
||||||
|
let (ctrl_sender, mut receiver) = broadcast::channel(16);
|
||||||
|
let pinger = PeerConnPinger::new(
|
||||||
|
1,
|
||||||
|
2,
|
||||||
|
sender,
|
||||||
|
ctrl_sender.clone(),
|
||||||
|
Arc::new(WindowLatency::new(15)),
|
||||||
|
Arc::new(AtomicU32::new(0)),
|
||||||
|
Arc::new(Throughput::new()),
|
||||||
|
Arc::new(NoopPeerContext::default()),
|
||||||
|
"test".to_owned(),
|
||||||
|
PeerConnLiveness::new(),
|
||||||
|
Duration::from_secs(1),
|
||||||
|
);
|
||||||
|
let probe = pinger.do_pingpong_once(&mut receiver, 1, None);
|
||||||
|
tokio::pin!(probe);
|
||||||
|
assert!(
|
||||||
|
timeout(Duration::from_millis(50), &mut probe)
|
||||||
|
.await
|
||||||
|
.is_err()
|
||||||
|
);
|
||||||
|
let reply = tokio::spawn(async move {
|
||||||
|
while let Some(Ok(packet)) = stream.next().await {
|
||||||
|
if packet.peer_manager_header().unwrap().packet_type == PacketType::Ping as u8 {
|
||||||
|
ctrl_sender.send(packet).unwrap();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
panic!("queued Ping was not delivered");
|
||||||
|
});
|
||||||
|
let PingResponse::Pong(rtt) = timeout(Duration::from_secs(1), probe)
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.unwrap()
|
||||||
|
else {
|
||||||
|
panic!("expected a measured Pong");
|
||||||
|
};
|
||||||
|
assert!(rtt >= 50_000, "send queue wait was excluded: {rtt} us");
|
||||||
|
reply.await.unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(not(target_os = "wasi"))]
|
#[cfg(not(target_os = "wasi"))]
|
||||||
#[tokio::test(start_paused = true)]
|
#[tokio::test(start_paused = true)]
|
||||||
async fn one_second_limit_disables_ping_backoff() {
|
async fn one_second_limit_disables_ping_backoff() {
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
//! Bounded flow bindings retained until idle or their connection closes.
|
||||||
|
|
||||||
|
use std::{
|
||||||
|
collections::{HashMap, hash_map::RandomState},
|
||||||
|
hash::BuildHasher,
|
||||||
|
time::{Duration, Instant},
|
||||||
|
};
|
||||||
|
|
||||||
|
use crate::packet::FlowKey;
|
||||||
|
|
||||||
|
use super::peer_conn::PeerConnId;
|
||||||
|
|
||||||
|
pub(super) struct FlowBinding {
|
||||||
|
pub conn_id: PeerConnId,
|
||||||
|
pub last_active: Instant,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Default)]
|
||||||
|
pub(super) struct FlowBindings {
|
||||||
|
hash: RandomState,
|
||||||
|
pub buckets: HashMap<usize, FlowBinding>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FlowBindings {
|
||||||
|
pub const BUCKETS: usize = 4096;
|
||||||
|
pub const IDLE_TIMEOUT: Duration = Duration::from_secs(120);
|
||||||
|
|
||||||
|
pub fn bucket(&self, key: FlowKey) -> usize {
|
||||||
|
self.hash.hash_one(key) as usize % Self::BUCKETS
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn bind(&mut self, bucket: usize, conn_id: PeerConnId, now: Instant) {
|
||||||
|
self.buckets.insert(
|
||||||
|
bucket,
|
||||||
|
FlowBinding {
|
||||||
|
conn_id,
|
||||||
|
last_active: now,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -11,6 +11,7 @@ use tokio::sync::RwLock;
|
|||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
config::PeerId,
|
config::PeerId,
|
||||||
|
foundation::small_map_shard_amount,
|
||||||
packet::ZCPacket,
|
packet::ZCPacket,
|
||||||
peers::{
|
peers::{
|
||||||
context::{ArcPeerContext, NetworkIdentity, PeerEvent},
|
context::{ArcPeerContext, NetworkIdentity, PeerEvent},
|
||||||
@@ -53,7 +54,7 @@ impl PeerMap {
|
|||||||
PeerMap {
|
PeerMap {
|
||||||
context,
|
context,
|
||||||
my_peer_id,
|
my_peer_id,
|
||||||
peer_map: DashMap::new(),
|
peer_map: DashMap::with_shard_amount(small_map_shard_amount()),
|
||||||
packet_send,
|
packet_send,
|
||||||
routes: RwLock::new(Vec::new()),
|
routes: RwLock::new(Vec::new()),
|
||||||
alive_client_urls: Arc::new(Mutex::new(HashMap::new())),
|
alive_client_urls: Arc::new(Mutex::new(HashMap::new())),
|
||||||
@@ -452,12 +453,12 @@ pub(crate) async fn direct_peer_info(peer_maps: &[Arc<PeerMap>]) -> PeerInfoForG
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
let Some(min_lat) = conns
|
let Some(min_lat) = min_measured_latency_us(
|
||||||
.into_iter()
|
conns
|
||||||
.flatten()
|
.into_iter()
|
||||||
.map(|conn| conn.stats.as_ref().unwrap().latency_us)
|
.flatten()
|
||||||
.min()
|
.map(|conn| conn.stats.as_ref().unwrap().latency_us),
|
||||||
else {
|
) else {
|
||||||
continue;
|
continue;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -470,3 +471,37 @@ pub(crate) async fn direct_peer_info(peer_maps: &[Arc<PeerMap>]) -> PeerInfoForG
|
|||||||
}
|
}
|
||||||
ret
|
ret
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Lowest latency among the connections that already have a latency sample.
|
||||||
|
///
|
||||||
|
/// A freshly established connection reports `latency_us == 0` until its first
|
||||||
|
/// ping completes. Treating that as a real value would advertise the peer as
|
||||||
|
/// 1 ms away to the peer center, so latency-first routing could detour through
|
||||||
|
/// a slow or lossy link until the next report. Unmeasured connections are
|
||||||
|
/// skipped instead; a peer with none measured is left out of the report and is
|
||||||
|
/// reported once its first sample arrives, because the peer list changes then.
|
||||||
|
fn min_measured_latency_us(latencies_us: impl IntoIterator<Item = u64>) -> Option<u64> {
|
||||||
|
latencies_us.into_iter().filter(|lat| *lat > 0).min()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::min_measured_latency_us;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn unmeasured_connections_are_not_reported_as_fastest() {
|
||||||
|
assert_eq!(min_measured_latency_us([0, 306_000]), Some(306_000));
|
||||||
|
assert_eq!(min_measured_latency_us([306_000, 0, 93_000]), Some(93_000));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn peer_without_measured_connection_is_skipped() {
|
||||||
|
assert_eq!(min_measured_latency_us([0, 0]), None);
|
||||||
|
assert_eq!(min_measured_latency_us([]), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn sub_millisecond_lan_latency_is_still_reported() {
|
||||||
|
assert_eq!(min_measured_latency_us([400]), Some(400));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ use std::sync::{
|
|||||||
use std::time::{Duration, Instant};
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
use anyhow::anyhow;
|
use anyhow::anyhow;
|
||||||
use crossbeam::atomic::AtomicCell;
|
use crossbeam_utils::atomic::AtomicCell;
|
||||||
use dashmap::DashMap;
|
use dashmap::DashMap;
|
||||||
|
|
||||||
use crate::peers::util::shrink_dashmap;
|
use crate::peers::util::shrink_dashmap;
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user