mirror of
https://github.com/EasyTier/EasyTier.git
synced 2026-10-29 16:05:35 +00:00
Compare commits
72
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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]
|
||||
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:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
sign_windows:
|
||||
description: Submit Windows artifacts for SignPath test signing
|
||||
type: boolean
|
||||
default: true
|
||||
push:
|
||||
branches: [ "develop", "main", "releases/**" ]
|
||||
pull_request:
|
||||
@@ -9,7 +14,8 @@ on:
|
||||
types: [ opened, synchronize, reopened, ready_for_review ]
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
|
||||
# A new PR or branch push must not cancel a full build.
|
||||
group: ${{ github.workflow }}-${{ (github.event_name == 'workflow_dispatch' || startsWith(github.ref_name, 'releases/')) && 'full' || 'check' }}-${{ github.event.pull_request.number || github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
env:
|
||||
@@ -31,7 +37,17 @@ jobs:
|
||||
"easytier-core/**", "easytier-proto/**", "easytier-rpc-build/**",
|
||||
"easytier-web/**", "easytier-contrib/easytier-magisk/**",
|
||||
".github/workflows/core.yml", ".github/actions/**"]
|
||||
FRONTEND_PATHS: >-
|
||||
["easytier-web/frontend/**", "easytier-web/frontend-lib/**",
|
||||
"easytier-web/config-generator/**", "easytier-proto/proto/**",
|
||||
"easytier-core/src/config/**", "easytier-core/src/lib.rs",
|
||||
"pnpm-lock.yaml", "pnpm-workspace.yaml", "package.json",
|
||||
"Cargo.toml", "Cargo.lock", "rust-toolchain.toml", ".cargo/**",
|
||||
"easytier-core/Cargo.toml", "easytier-proto/Cargo.toml",
|
||||
".github/workflows/core.yml", ".github/actions/prepare-pnpm/**"]
|
||||
outputs:
|
||||
full_build: ${{ github.event_name == 'workflow_dispatch' || startsWith(github.ref_name, 'releases/') }}
|
||||
frontend_changed: ${{ steps.path_filter.outputs.frontend }}
|
||||
# Release builds must run even if they only change release metadata.
|
||||
should_skip: >-
|
||||
${{
|
||||
@@ -42,15 +58,16 @@ jobs:
|
||||
steps.path_filter.outputs.relevant != 'true'
|
||||
)
|
||||
) &&
|
||||
github.event_name != 'workflow_dispatch' &&
|
||||
!startsWith(github.ref_name, 'releases/')
|
||||
}}
|
||||
steps:
|
||||
- id: skip_check
|
||||
if: github.event_name != 'workflow_dispatch' && !startsWith(github.ref_name, 'releases/')
|
||||
uses: fkirc/skip-duplicate-actions@v5
|
||||
with:
|
||||
concurrent_skipping: 'same_content_newer'
|
||||
skip_after_successful_duplicate: 'true'
|
||||
cancel_others: 'true'
|
||||
paths: ${{ env.RELEVANT_PATHS }}
|
||||
|
||||
- id: path_filter
|
||||
@@ -61,10 +78,54 @@ jobs:
|
||||
with:
|
||||
filters: |
|
||||
relevant: ${{ env.RELEVANT_PATHS }}
|
||||
frontend: ${{ env.FRONTEND_PATHS }}
|
||||
|
||||
check:
|
||||
name: Check (${{ matrix.TARGET }})
|
||||
needs: pre_job
|
||||
if: needs.pre_job.outputs.should_skip != 'true' && needs.pre_job.outputs.full_build != 'true'
|
||||
strategy:
|
||||
fail-fast: true
|
||||
matrix:
|
||||
include:
|
||||
- TARGET: x86_64-unknown-linux-musl
|
||||
OS: ubuntu-24.04
|
||||
FEATURES: jemalloc
|
||||
- TARGET: aarch64-apple-darwin
|
||||
OS: macos-latest
|
||||
FEATURES: jemalloc
|
||||
- TARGET: x86_64-pc-windows-msvc
|
||||
OS: windows-latest
|
||||
FEATURES: mimalloc
|
||||
runs-on: ${{ matrix.OS }}
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
|
||||
- uses: ./.github/actions/prepare-build
|
||||
with:
|
||||
target: ${{ matrix.TARGET }}
|
||||
gui: false
|
||||
pnpm: false
|
||||
token: ${{ github.token }}
|
||||
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
shared-key: core-check-${{ matrix.TARGET }}
|
||||
save-if: ${{ github.event_name != 'pull_request' }}
|
||||
|
||||
- name: Check Core and Web
|
||||
run: cargo check --locked --all-targets --target ${{ matrix.TARGET }} --features ${{ matrix.FEATURES }}
|
||||
|
||||
build_web:
|
||||
runs-on: ubuntu-latest
|
||||
needs: pre_job
|
||||
if: needs.pre_job.outputs.should_skip != 'true'
|
||||
if: >-
|
||||
needs.pre_job.outputs.should_skip != 'true' &&
|
||||
(
|
||||
needs.pre_job.outputs.full_build == 'true' ||
|
||||
github.event_name != 'pull_request' ||
|
||||
needs.pre_job.outputs.frontend_changed == 'true'
|
||||
)
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
|
||||
@@ -75,6 +136,7 @@ jobs:
|
||||
build-filter: './easytier-web/*'
|
||||
|
||||
- name: Archive artifact
|
||||
if: needs.pre_job.outputs.full_build == 'true'
|
||||
uses: actions/upload-artifact@v5
|
||||
with:
|
||||
name: easytier-web-dashboard
|
||||
@@ -82,7 +144,7 @@ jobs:
|
||||
easytier-web/frontend/dist/*
|
||||
build:
|
||||
strategy:
|
||||
fail-fast: true
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- TARGET: x86_64-unknown-linux-musl
|
||||
@@ -150,7 +212,7 @@ jobs:
|
||||
needs:
|
||||
- pre_job
|
||||
- build_web
|
||||
if: needs.pre_job.outputs.should_skip != 'true'
|
||||
if: needs.pre_job.outputs.should_skip != 'true' && needs.pre_job.outputs.full_build == 'true'
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
|
||||
@@ -168,8 +230,8 @@ jobs:
|
||||
uses: ./.github/actions/prepare-build
|
||||
with:
|
||||
target: ${{ matrix.TARGET }}
|
||||
gui: true
|
||||
pnpm: true
|
||||
gui: false
|
||||
pnpm: false
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
@@ -177,8 +239,8 @@ jobs:
|
||||
# The prefix cache key, this can be changed to start a new cache manually.
|
||||
# default: "v0-rust"
|
||||
prefix-key: ""
|
||||
shared-key: "core-registry"
|
||||
cache-targets: "false"
|
||||
shared-key: core-release-${{ matrix.TARGET }}
|
||||
cache-targets: "true"
|
||||
|
||||
- uses: mlugg/setup-zig@v2
|
||||
if: ${{ contains(matrix.OS, 'ubuntu') }}
|
||||
@@ -286,7 +348,7 @@ jobs:
|
||||
./artifacts/*
|
||||
|
||||
- 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: |
|
||||
mkdir -p ./signpath-artifacts
|
||||
cp \
|
||||
@@ -297,7 +359,7 @@ jobs:
|
||||
./signpath-artifacts/
|
||||
|
||||
- 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
|
||||
uses: actions/upload-artifact@v5
|
||||
with:
|
||||
@@ -306,7 +368,7 @@ jobs:
|
||||
if-no-files-found: error
|
||||
|
||||
- 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
|
||||
with:
|
||||
api-token: ${{ secrets.SIGNPATH_API_TOKEN }}
|
||||
@@ -319,7 +381,7 @@ jobs:
|
||||
output-artifact-directory: ./signpath-signed-artifacts
|
||||
|
||||
- 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
|
||||
with:
|
||||
name: signpath-signed-${{ matrix.ARTIFACT_NAME }}
|
||||
@@ -329,7 +391,7 @@ jobs:
|
||||
build_magisk:
|
||||
runs-on: ubuntu-latest
|
||||
needs: [ pre_job, build_web, build ]
|
||||
if: needs.pre_job.result == 'success' && needs.pre_job.outputs.should_skip != 'true' && !cancelled()
|
||||
if: needs.pre_job.result == 'success' && needs.pre_job.outputs.should_skip != 'true' && needs.pre_job.outputs.full_build == 'true' && !cancelled()
|
||||
steps:
|
||||
- name: Checkout Code
|
||||
uses: actions/checkout@v5 # 必须先检出代码才能获取模块配置
|
||||
@@ -371,9 +433,9 @@ jobs:
|
||||
|
||||
core-result:
|
||||
runs-on: ubuntu-latest
|
||||
needs: [ pre_job, build_web, build, build_magisk ]
|
||||
if: needs.pre_job.result == 'success' && needs.pre_job.outputs.should_skip != 'true' && !cancelled()
|
||||
needs: [ pre_job, check, build_web, build, build_magisk ]
|
||||
if: ${{ always() }}
|
||||
steps:
|
||||
- name: Mark result as failed
|
||||
if: contains(needs.*.result, 'failure')
|
||||
if: contains(needs.*.result, 'failure') || contains(needs.*.result, 'cancelled')
|
||||
run: exit 1
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
name: EasyTier GUI
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
branches: ["develop", "main", "releases/**"]
|
||||
pull_request:
|
||||
@@ -8,7 +9,8 @@ on:
|
||||
types: [opened, synchronize, reopened, ready_for_review]
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
|
||||
# A new PR or branch push must not cancel a full build.
|
||||
group: ${{ github.workflow }}-${{ (github.event_name == 'workflow_dispatch' || startsWith(github.ref_name, 'releases/')) && 'full' || 'check' }}-${{ github.event.pull_request.number || github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
env:
|
||||
@@ -32,6 +34,7 @@ jobs:
|
||||
"easytier-web/frontend-lib/**", ".github/workflows/gui.yml",
|
||||
".github/actions/**"]
|
||||
outputs:
|
||||
full_build: ${{ github.event_name == 'workflow_dispatch' || startsWith(github.ref_name, 'releases/') }}
|
||||
# Release builds must run even if they only change release metadata.
|
||||
should_skip: >-
|
||||
${{
|
||||
@@ -42,15 +45,16 @@ jobs:
|
||||
steps.path_filter.outputs.relevant != 'true'
|
||||
)
|
||||
) &&
|
||||
github.event_name != 'workflow_dispatch' &&
|
||||
!startsWith(github.ref_name, 'releases/')
|
||||
}}
|
||||
steps:
|
||||
- id: skip_check
|
||||
if: github.event_name != 'workflow_dispatch' && !startsWith(github.ref_name, 'releases/')
|
||||
uses: fkirc/skip-duplicate-actions@v5
|
||||
with:
|
||||
concurrent_skipping: 'same_content_newer'
|
||||
skip_after_successful_duplicate: 'true'
|
||||
cancel_others: 'true'
|
||||
paths: ${{ env.RELEVANT_PATHS }}
|
||||
|
||||
- id: path_filter
|
||||
@@ -62,6 +66,29 @@ jobs:
|
||||
filters: |
|
||||
relevant: ${{ env.RELEVANT_PATHS }}
|
||||
|
||||
check:
|
||||
name: Check GUI (Linux)
|
||||
needs: pre_job
|
||||
if: needs.pre_job.outputs.should_skip != 'true' && needs.pre_job.outputs.full_build != 'true'
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
|
||||
- uses: ./.github/actions/prepare-build
|
||||
with:
|
||||
gui: true
|
||||
pnpm: true
|
||||
pnpm-build-filter: easytier-gui...
|
||||
token: ${{ github.token }}
|
||||
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
shared-key: gui-check-x86_64-unknown-linux-gnu
|
||||
save-if: ${{ github.event_name != 'pull_request' }}
|
||||
|
||||
- name: Check GUI
|
||||
run: cargo check --locked --all-targets --package easytier-gui --features custom-protocol
|
||||
|
||||
build-gui:
|
||||
strategy:
|
||||
fail-fast: true
|
||||
@@ -106,7 +133,7 @@ jobs:
|
||||
GUI_TARGET: ${{ matrix.GUI_TARGET }}
|
||||
OSS_BUCKET: ${{ secrets.ALIYUN_OSS_BUCKET }}
|
||||
needs: pre_job
|
||||
if: needs.pre_job.outputs.should_skip != 'true'
|
||||
if: needs.pre_job.outputs.should_skip != 'true' && needs.pre_job.outputs.full_build == 'true'
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
|
||||
@@ -120,7 +147,7 @@ jobs:
|
||||
target: ${{ matrix.TARGET }}
|
||||
gui: true
|
||||
pnpm: true
|
||||
pnpm-build-filter: ''
|
||||
pnpm-build-filter: easytier-gui...
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
@@ -128,8 +155,8 @@ jobs:
|
||||
# The prefix cache key, this can be changed to start a new cache manually.
|
||||
# default: "v0-rust"
|
||||
prefix-key: ""
|
||||
shared-key: "gui-registry"
|
||||
cache-targets: "false"
|
||||
shared-key: gui-release-${{ matrix.GUI_TARGET }}
|
||||
cache-targets: "true"
|
||||
|
||||
- name: copy correct DLLs
|
||||
if: ${{ contains(matrix.GUI_TARGET, 'windows') }}
|
||||
@@ -262,9 +289,9 @@ jobs:
|
||||
|
||||
gui-result:
|
||||
runs-on: ubuntu-latest
|
||||
needs: [ pre_job, build-gui ]
|
||||
if: needs.pre_job.result == 'success' && needs.pre_job.outputs.should_skip != 'true' && !cancelled()
|
||||
needs: [ pre_job, check, build-gui ]
|
||||
if: ${{ always() }}
|
||||
steps:
|
||||
- name: Mark result as failed
|
||||
if: contains(needs.*.result, 'failure')
|
||||
if: contains(needs.*.result, 'failure') || contains(needs.*.result, 'cancelled')
|
||||
run: exit 1
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
name: EasyTier Mobile
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
branches: ["develop", "main", "releases/**"]
|
||||
pull_request:
|
||||
@@ -8,7 +9,8 @@ on:
|
||||
types: [opened, synchronize, reopened, ready_for_review]
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
|
||||
# A new PR or branch push must not cancel a full build.
|
||||
group: ${{ github.workflow }}-${{ (github.event_name == 'workflow_dispatch' || startsWith(github.ref_name, 'releases/')) && 'full' || 'check' }}-${{ github.event.pull_request.number || github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
env:
|
||||
@@ -32,6 +34,7 @@ jobs:
|
||||
"easytier-web/frontend-lib/**", ".github/workflows/mobile.yml",
|
||||
".github/actions/**"]
|
||||
outputs:
|
||||
full_build: ${{ github.event_name == 'workflow_dispatch' || startsWith(github.ref_name, 'releases/') }}
|
||||
# Release builds must run even if they only change release metadata.
|
||||
should_skip: >-
|
||||
${{
|
||||
@@ -42,15 +45,16 @@ jobs:
|
||||
steps.path_filter.outputs.relevant != 'true'
|
||||
)
|
||||
) &&
|
||||
github.event_name != 'workflow_dispatch' &&
|
||||
!startsWith(github.ref_name, 'releases/')
|
||||
}}
|
||||
steps:
|
||||
- id: skip_check
|
||||
if: github.event_name != 'workflow_dispatch' && !startsWith(github.ref_name, 'releases/')
|
||||
uses: fkirc/skip-duplicate-actions@v5
|
||||
with:
|
||||
concurrent_skipping: 'same_content_newer'
|
||||
skip_after_successful_duplicate: 'true'
|
||||
cancel_others: 'true'
|
||||
paths: ${{ env.RELEVANT_PATHS }}
|
||||
|
||||
- id: path_filter
|
||||
@@ -66,19 +70,11 @@ jobs:
|
||||
strategy:
|
||||
fail-fast: true
|
||||
matrix:
|
||||
include:
|
||||
- TARGET: aarch64-linux-android
|
||||
ARCH: aarch64
|
||||
- TARGET: armv7-linux-androideabi
|
||||
ARCH: armv7
|
||||
- TARGET: i686-linux-android
|
||||
ARCH: i686
|
||||
- TARGET: x86_64-linux-android
|
||||
ARCH: x86_64
|
||||
ARCH: ${{ fromJSON(needs.pre_job.outputs.full_build == 'true' && '["aarch64", "armv7", "i686", "x86_64"]' || '["aarch64"]') }}
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
NAME: easytier
|
||||
TARGET: ${{ matrix.TARGET }}
|
||||
TARGET: ${{ matrix.ARCH == 'armv7' && 'armv7-linux-androideabi' || format('{0}-linux-android', matrix.ARCH) }}
|
||||
ARCH: ${{ matrix.ARCH }}
|
||||
OSS_BUCKET: ${{ secrets.ALIYUN_OSS_BUCKET }}
|
||||
needs: pre_job
|
||||
@@ -98,7 +94,7 @@ jobs:
|
||||
- name: Setup Android SDK
|
||||
uses: android-actions/setup-android@v3
|
||||
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 '
|
||||
|
||||
- name: Setup Android Environment
|
||||
@@ -110,10 +106,10 @@ jobs:
|
||||
- name: Prepare build environment
|
||||
uses: ./.github/actions/prepare-build
|
||||
with:
|
||||
target: ${{ matrix.TARGET }}
|
||||
target: ${{ env.TARGET }}
|
||||
gui: false
|
||||
pnpm: true
|
||||
pnpm-build-filter: ''
|
||||
pnpm-build-filter: easytier-gui...
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
@@ -121,15 +117,22 @@ jobs:
|
||||
# The prefix cache key, this can be changed to start a new cache manually.
|
||||
# default: "v0-rust"
|
||||
prefix-key: ""
|
||||
shared-key: "gui-registry"
|
||||
cache-targets: "false"
|
||||
shared-key: mobile-${{ env.TARGET }}-${{ needs.pre_job.outputs.full_build }}
|
||||
cache-targets: "true"
|
||||
save-if: ${{ github.event_name != 'pull_request' }}
|
||||
|
||||
- name: Check Android ARM64
|
||||
if: needs.pre_job.outputs.full_build != 'true'
|
||||
run: cargo check --locked --lib --target "$TARGET" --package easytier-gui --features custom-protocol
|
||||
|
||||
- name: Build
|
||||
if: needs.pre_job.outputs.full_build == 'true'
|
||||
run: |
|
||||
cd easytier-gui
|
||||
pnpm tauri android build --apk --target "$ARCH" --split-per-abi
|
||||
|
||||
- name: Collect artifact
|
||||
if: needs.pre_job.outputs.full_build == 'true'
|
||||
run: |
|
||||
mkdir -p ./artifacts/objects/
|
||||
mv easytier-gui/src-tauri/gen/android/app/build/outputs/apk/*/release/*.apk ./artifacts/objects/
|
||||
@@ -144,6 +147,7 @@ jobs:
|
||||
rm -rf ./artifacts/objects/
|
||||
|
||||
- name: Archive artifact
|
||||
if: needs.pre_job.outputs.full_build == 'true'
|
||||
uses: actions/upload-artifact@v5
|
||||
with:
|
||||
name: easytier-mobile-android-${{ matrix.ARCH }}
|
||||
@@ -153,8 +157,8 @@ jobs:
|
||||
mobile-result:
|
||||
runs-on: ubuntu-latest
|
||||
needs: [ pre_job, build-mobile ]
|
||||
if: needs.pre_job.result == 'success' && needs.pre_job.outputs.should_skip != 'true' && !cancelled()
|
||||
if: ${{ always() }}
|
||||
steps:
|
||||
- name: Mark result as failed
|
||||
if: contains(needs.*.result, 'failure')
|
||||
if: contains(needs.*.result, 'failure') || contains(needs.*.result, 'cancelled')
|
||||
run: exit 1
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
name: EasyTier Nightly Release
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
sha:
|
||||
description: Commit whose full builds the caller has waited for
|
||||
required: true
|
||||
type: string
|
||||
workflow_run:
|
||||
workflows: [EasyTier Core, EasyTier GUI, EasyTier Mobile, EasyTier Test]
|
||||
branches: [main]
|
||||
types: [completed]
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: easytier-nightly-release
|
||||
cancel-in-progress: false
|
||||
|
||||
permissions:
|
||||
actions: read
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
release:
|
||||
if: >-
|
||||
((github.event_name == 'workflow_dispatch' || github.event_name == 'schedule') &&
|
||||
github.ref == 'refs/heads/main') ||
|
||||
(github.event.workflow_run.event == 'workflow_dispatch' &&
|
||||
github.event.workflow_run.conclusion == 'success' &&
|
||||
github.event.workflow_run.head_repository.full_name == github.repository)
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
with:
|
||||
ref: main
|
||||
|
||||
- name: Select complete main builds and prepare release notes
|
||||
id: prepare
|
||||
uses: actions/github-script@v8
|
||||
env:
|
||||
BUILD_SHA: ${{ inputs.sha }}
|
||||
with:
|
||||
script: |
|
||||
const fs = require('node:fs');
|
||||
const { prepare } = require('./.github/scripts/nightly-release.js');
|
||||
const build = await prepare({ github, context, core, sha: process.env.BUILD_SHA });
|
||||
if (!build) return;
|
||||
fs.writeFileSync('nightly-release.json', JSON.stringify(build));
|
||||
for (const workflow of ['core', 'gui', 'mobile']) {
|
||||
core.setOutput(`${workflow}_run_id`, build.runs[workflow]);
|
||||
core.setOutput(`${workflow}_artifact_ids`, build.artifacts[workflow].map(a => a.id).join(','));
|
||||
// download-artifact extracts a single artifact directly into path.
|
||||
const suffix = build.artifacts[workflow].length === 1 ? `/${build.artifacts[workflow][0].name}` : '';
|
||||
core.setOutput(`${workflow}_path`, `nightly-artifacts${suffix}`);
|
||||
}
|
||||
core.setOutput('ready', 'true');
|
||||
|
||||
- name: Download Core artifacts
|
||||
if: steps.prepare.outputs.ready == 'true'
|
||||
uses: actions/download-artifact@v5
|
||||
with:
|
||||
github-token: ${{ github.token }}
|
||||
run-id: ${{ steps.prepare.outputs.core_run_id }}
|
||||
artifact-ids: ${{ steps.prepare.outputs.core_artifact_ids }}
|
||||
path: ${{ steps.prepare.outputs.core_path }}
|
||||
|
||||
- name: Download GUI artifacts
|
||||
if: steps.prepare.outputs.ready == 'true'
|
||||
uses: actions/download-artifact@v5
|
||||
with:
|
||||
github-token: ${{ github.token }}
|
||||
run-id: ${{ steps.prepare.outputs.gui_run_id }}
|
||||
artifact-ids: ${{ steps.prepare.outputs.gui_artifact_ids }}
|
||||
path: ${{ steps.prepare.outputs.gui_path }}
|
||||
|
||||
- name: Download Mobile artifacts
|
||||
if: steps.prepare.outputs.ready == 'true'
|
||||
uses: actions/download-artifact@v5
|
||||
with:
|
||||
github-token: ${{ github.token }}
|
||||
run-id: ${{ steps.prepare.outputs.mobile_run_id }}
|
||||
artifact-ids: ${{ steps.prepare.outputs.mobile_artifact_ids }}
|
||||
path: ${{ steps.prepare.outputs.mobile_path }}
|
||||
|
||||
- name: Package assets like version releases
|
||||
if: steps.prepare.outputs.ready == 'true'
|
||||
uses: actions/github-script@v8
|
||||
with:
|
||||
script: |
|
||||
const fs = require('node:fs');
|
||||
const { packageAssets } = require('./.github/scripts/nightly-release.js');
|
||||
const build = require(`${process.env.GITHUB_WORKSPACE}/nightly-release.json`);
|
||||
await packageAssets({ build, source: 'nightly-artifacts', directory: 'nightly-assets' });
|
||||
fs.writeFileSync('nightly-release.json', JSON.stringify(build));
|
||||
|
||||
- name: Update the fixed nightly prerelease
|
||||
if: steps.prepare.outputs.ready == 'true'
|
||||
uses: actions/github-script@v8
|
||||
with:
|
||||
script: |
|
||||
const { publish } = require('./.github/scripts/nightly-release.js');
|
||||
const build = require(`${process.env.GITHUB_WORKSPACE}/nightly-release.json`);
|
||||
await publish({ github, context, core, build, directory: 'nightly-assets' });
|
||||
@@ -0,0 +1,28 @@
|
||||
name: EasyTier Nightly Retry
|
||||
|
||||
on:
|
||||
workflow_run:
|
||||
workflows: [EasyTier Core, EasyTier GUI, EasyTier Mobile, EasyTier Test]
|
||||
branches: [main]
|
||||
types: [completed]
|
||||
|
||||
permissions:
|
||||
actions: write
|
||||
|
||||
jobs:
|
||||
retry:
|
||||
if: >-
|
||||
github.event.workflow_run.event == 'workflow_dispatch' &&
|
||||
github.event.workflow_run.conclusion == 'failure' &&
|
||||
github.event.workflow_run.run_attempt == 1 &&
|
||||
github.event.workflow_run.head_repository.full_name == github.repository
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Retry failed jobs once
|
||||
uses: actions/github-script@v8
|
||||
with:
|
||||
script: |
|
||||
await github.rest.actions.reRunWorkflowFailedJobs({
|
||||
...context.repo,
|
||||
run_id: context.payload.workflow_run.id,
|
||||
});
|
||||
@@ -0,0 +1,42 @@
|
||||
name: EasyTier Nightly
|
||||
|
||||
on:
|
||||
schedule:
|
||||
# 03:17 Asia/Shanghai; avoid the busy start of the hour.
|
||||
- cron: '17 19 * * *'
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: easytier-nightly
|
||||
cancel-in-progress: false
|
||||
|
||||
permissions:
|
||||
actions: write
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
dispatch:
|
||||
if: github.ref == 'refs/heads/main'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 180
|
||||
outputs:
|
||||
sha: ${{ steps.build.outputs.sha }}
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
|
||||
- name: Build main and wait for successful results
|
||||
id: build
|
||||
uses: actions/github-script@v8
|
||||
with:
|
||||
script: |
|
||||
const dispatch = require('./.github/scripts/nightly-build.js');
|
||||
core.setOutput('sha', await dispatch({ github, context, core }));
|
||||
|
||||
release:
|
||||
needs: dispatch
|
||||
permissions:
|
||||
actions: read
|
||||
contents: write
|
||||
uses: ./.github/workflows/nightly-release.yml
|
||||
with:
|
||||
sha: ${{ needs.dispatch.outputs.sha }}
|
||||
+40
-16
@@ -80,32 +80,66 @@ jobs:
|
||||
uses: ErBWs/setup-ohos@v1
|
||||
|
||||
- name: Install ohrs
|
||||
if: github.event_name != 'pull_request'
|
||||
uses: taiki-e/install-action@v2
|
||||
with:
|
||||
tool: ohrs
|
||||
|
||||
- name: Build HAR
|
||||
id: package
|
||||
env:
|
||||
DEFAULT_BRANCH: ${{ github.event.repository.default_branch }}
|
||||
- name: Prepare OHOS tools
|
||||
run: |
|
||||
set -euo pipefail
|
||||
sudo apt-get install -qqy \
|
||||
pkg-config curl libgl1-mesa-dev expect llvm clang lldb lld
|
||||
rustup component add rustfmt
|
||||
|
||||
sudo mkdir -p "$OHOS_NDK_HOME/native/llvm"
|
||||
sudo tee "$OHOS_NDK_HOME/native/llvm/aarch64-unknown-linux-ohos-clang.sh" >/dev/null <<'EOF'
|
||||
#!/bin/sh
|
||||
exec "$OHOS_NDK_HOME/native/llvm/bin/clang" \
|
||||
-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 \
|
||||
easytier-contrib/easytier-ohrs/Cargo.toml -- --check
|
||||
|
||||
- name: Test OHOS bridge
|
||||
run: |
|
||||
cargo test --locked --manifest-path \
|
||||
easytier-contrib/easytier-ohrs/Cargo.toml \
|
||||
-p easytier-ohos-core -p easytier-ohos-features \
|
||||
--lib -- --test-threads=1
|
||||
|
||||
- name: Check OHOS bindings
|
||||
run: |
|
||||
cargo check --locked --manifest-path \
|
||||
easytier-contrib/easytier-ohrs/Cargo.toml \
|
||||
-p easytier-ohrs --tests
|
||||
|
||||
- name: Check OHOS target
|
||||
if: github.event_name == 'pull_request'
|
||||
working-directory: easytier-contrib/easytier-ohrs
|
||||
run: |
|
||||
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 \
|
||||
--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
|
||||
base_version=$(printf '%s\n' "$cargo_version" "${last_tag#v}" \
|
||||
| sort -V | tail -n 1)
|
||||
@@ -149,17 +183,6 @@ jobs:
|
||||
echo
|
||||
} > "$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
|
||||
source env.sh
|
||||
ohrs build --release --arch aarch
|
||||
@@ -167,6 +190,7 @@ jobs:
|
||||
mv package.har "$package_name.har"
|
||||
|
||||
- name: Upload HAR
|
||||
if: github.event_name != 'pull_request'
|
||||
uses: actions/upload-artifact@v5
|
||||
with:
|
||||
name: ${{ steps.package.outputs.name }}
|
||||
|
||||
@@ -27,6 +27,7 @@ on:
|
||||
required: true
|
||||
|
||||
permissions:
|
||||
actions: read
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
@@ -37,6 +38,17 @@ jobs:
|
||||
name: Checkout
|
||||
uses: actions/checkout@v5
|
||||
|
||||
- name: Validate complete builds
|
||||
uses: actions/github-script@v8
|
||||
env:
|
||||
CORE_RUN_ID: ${{ inputs.core_run_id }}
|
||||
GUI_RUN_ID: ${{ inputs.gui_run_id }}
|
||||
MOBILE_RUN_ID: ${{ inputs.mobile_run_id }}
|
||||
with:
|
||||
script: |
|
||||
const validate = require('./.github/scripts/validate-release-builds.js');
|
||||
await validate({ github, context, core });
|
||||
|
||||
- name: Download Core Artifact
|
||||
uses: dawidd6/action-download-artifact@v11
|
||||
with:
|
||||
|
||||
+47
-28
@@ -1,13 +1,16 @@
|
||||
name: EasyTier Test
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
branches: [ "develop", "main" ]
|
||||
branches: [ "develop", "main", "releases/**" ]
|
||||
pull_request:
|
||||
branches: [ "develop", "main" ]
|
||||
types: [ opened, synchronize, reopened, ready_for_review ]
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
|
||||
# A new PR or branch push must not cancel a full build.
|
||||
group: ${{ github.workflow }}-${{ (github.event_name == 'workflow_dispatch' || startsWith(github.ref_name, 'releases/')) && 'full' || 'check' }}-${{ github.event.pull_request.number || github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
env:
|
||||
@@ -21,27 +24,48 @@ defaults:
|
||||
shell: bash
|
||||
|
||||
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:
|
||||
runs-on: ubuntu-latest
|
||||
if: github.event_name != 'pull_request' || !github.event.pull_request.draft
|
||||
env:
|
||||
RELEVANT_PATHS: >-
|
||||
["Cargo.toml", "Cargo.lock", "rust-toolchain.toml", ".cargo/**",
|
||||
".config/nextest.toml",
|
||||
"pnpm-lock.yaml", "pnpm-workspace.yaml", "package.json", "easytier/**",
|
||||
"easytier-core/**", "easytier-proto/**", "easytier-rpc-build/**",
|
||||
"easytier-web/**", "easytier-gui/src-tauri/**",
|
||||
"tauri-plugin-vpnservice/**", "easytier-contrib/**",
|
||||
".github/workflows/test.yml", ".github/actions/**"]
|
||||
outputs:
|
||||
full_build: ${{ github.event_name == 'workflow_dispatch' || startsWith(github.ref_name, 'releases/') }}
|
||||
should_skip: >-
|
||||
${{
|
||||
steps.skip_check.outputs.should_skip == 'true' ||
|
||||
(
|
||||
github.event_name == 'pull_request' &&
|
||||
steps.path_filter.outputs.relevant != 'true'
|
||||
)
|
||||
steps.skip_check.outputs.should_skip == 'true' ||
|
||||
(
|
||||
github.event_name == 'pull_request' &&
|
||||
steps.path_filter.outputs.relevant != 'true'
|
||||
)
|
||||
) &&
|
||||
github.event_name != 'workflow_dispatch' &&
|
||||
!startsWith(github.ref_name, 'releases/')
|
||||
}}
|
||||
steps:
|
||||
- id: skip_check
|
||||
if: github.event_name != 'workflow_dispatch' && !startsWith(github.ref_name, 'releases/')
|
||||
uses: fkirc/skip-duplicate-actions@v5
|
||||
with:
|
||||
concurrent_skipping: 'never'
|
||||
@@ -69,16 +93,23 @@ jobs:
|
||||
uses: ./.github/actions/prepare-build
|
||||
with:
|
||||
gui: true
|
||||
pnpm: true
|
||||
pnpm: ${{ needs.pre_job.outputs.full_build == 'true' }}
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- uses: actions-rust-lang/setup-rust-toolchain@v1
|
||||
with:
|
||||
components: rustfmt,clippy
|
||||
target: wasm32-wasip1
|
||||
cache: false
|
||||
rustflags: ''
|
||||
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
shared-key: test-check
|
||||
save-if: ${{ github.event_name != 'pull_request' }}
|
||||
|
||||
- uses: taiki-e/install-action@cargo-hack
|
||||
if: needs.pre_job.outputs.full_build == 'true'
|
||||
|
||||
- name: Check formatting
|
||||
if: ${{ !cancelled() }}
|
||||
@@ -89,7 +120,7 @@ jobs:
|
||||
run: cargo clippy --all-targets --features full --all -- -D warnings
|
||||
|
||||
- name: Check features
|
||||
if: ${{ !cancelled() }}
|
||||
if: ${{ !cancelled() && needs.pre_job.outputs.full_build == 'true' }}
|
||||
run: cargo hack check --package easytier --each-feature --exclude-features macos-ne --verbose
|
||||
|
||||
- name: Check WASI
|
||||
@@ -117,8 +148,8 @@ jobs:
|
||||
- name: Prepare build environment
|
||||
uses: ./.github/actions/prepare-build
|
||||
with:
|
||||
gui: true
|
||||
pnpm: true
|
||||
gui: false
|
||||
pnpm: false
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
@@ -136,23 +167,11 @@ jobs:
|
||||
path: tests.tar.zst
|
||||
retention-days: 1
|
||||
|
||||
test_matrix:
|
||||
name: Test (${{ matrix.name }})
|
||||
run-tests:
|
||||
name: Run tests
|
||||
runs-on: ubuntu-latest
|
||||
needs: [ pre_job, pre-test ]
|
||||
if: needs.pre_job.outputs.should_skip != 'true'
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- name: "easytier"
|
||||
opts: "-E 'not test(tests::three_node)' --test-threads 1 --no-fail-fast"
|
||||
|
||||
- name: "three_node"
|
||||
opts: "-E 'test(tests::three_node) and not test(subnet_proxy_three_node_test)' --test-threads 1 --no-fail-fast"
|
||||
|
||||
- name: "three_node::subnet_proxy_three_node_test"
|
||||
opts: "-E 'test(subnet_proxy_three_node_test)' --test-threads 1 --no-fail-fast"
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
|
||||
@@ -187,13 +206,13 @@ jobs:
|
||||
run: |
|
||||
sudo prlimit --pid $$ --nofile=1048576:1048576
|
||||
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:
|
||||
runs-on: ubuntu-latest
|
||||
needs: [ pre_job, check, test_matrix ]
|
||||
if: needs.pre_job.result == 'success' && needs.pre_job.outputs.should_skip != 'true' && !cancelled()
|
||||
needs: [ pre_job, ci-scripts, check, pre-test, run-tests ]
|
||||
if: ${{ always() }}
|
||||
steps:
|
||||
- name: Mark result as failed
|
||||
if: contains(needs.*.result, 'failure')
|
||||
if: contains(needs.*.result, 'failure') || contains(needs.*.result, 'cancelled')
|
||||
run: exit 1
|
||||
|
||||
@@ -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
|
||||
are silent no-ops and must not be advertised as live network capabilities.
|
||||
|
||||
## Embedded logging
|
||||
|
||||
Logging belongs to the WASI runtime, which installs its subscriber at module
|
||||
startup. The independent logger ABI and process-level `LoggerRpc` control
|
||||
the same module-wide level, including before any Instance exists. Instance
|
||||
configuration and lifecycle never initialize or reset logging. The Host owns
|
||||
the output sink through WASI stdout; output errors do not interrupt Core.
|
||||
|
||||
## Web compatibility Host
|
||||
|
||||
The Web compatibility Host runs the portable EasyTier guest in JavaScript
|
||||
|
||||
Generated
+117
-143
@@ -75,25 +75,11 @@ dependencies = [
|
||||
"aead 0.5.2",
|
||||
"aes 0.8.4",
|
||||
"cipher 0.4.4",
|
||||
"ctr 0.9.2",
|
||||
"ghash 0.5.1",
|
||||
"ctr",
|
||||
"ghash",
|
||||
"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]]
|
||||
name = "ahash"
|
||||
version = "0.8.12"
|
||||
@@ -230,7 +216,7 @@ version = "1.1.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc"
|
||||
dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
"windows-sys 0.60.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -241,7 +227,7 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d"
|
||||
dependencies = [
|
||||
"anstyle",
|
||||
"once_cell_polyfill",
|
||||
"windows-sys 0.61.2",
|
||||
"windows-sys 0.60.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1957,15 +1943,6 @@ dependencies = [
|
||||
"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]]
|
||||
name = "ctutils"
|
||||
version = "0.4.2"
|
||||
@@ -2388,7 +2365,7 @@ dependencies = [
|
||||
"libc",
|
||||
"option-ext",
|
||||
"redox_users 0.5.3",
|
||||
"windows-sys 0.61.2",
|
||||
"windows-sys 0.60.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2537,7 +2514,7 @@ dependencies = [
|
||||
"atomic-shim",
|
||||
"atomic-write-file",
|
||||
"atomic_refcell",
|
||||
"base64 0.23.1",
|
||||
"base64 0.22.1",
|
||||
"bon",
|
||||
"boringtun-easytier",
|
||||
"bytecodec",
|
||||
@@ -2570,7 +2547,7 @@ dependencies = [
|
||||
"hickory-proto",
|
||||
"hickory-resolver",
|
||||
"hickory-server",
|
||||
"hkdf 0.13.0",
|
||||
"hkdf",
|
||||
"http",
|
||||
"http-body-util",
|
||||
"humansize",
|
||||
@@ -2595,6 +2572,7 @@ dependencies = [
|
||||
"pin-project-lite",
|
||||
"pnet_datalink",
|
||||
"prost 0.14.4",
|
||||
"prost-types 0.14.4",
|
||||
"quanta",
|
||||
"quinn",
|
||||
"quinn-proto",
|
||||
@@ -2609,7 +2587,7 @@ dependencies = [
|
||||
"serde_json",
|
||||
"serial_test",
|
||||
"service-manager",
|
||||
"sha2 0.11.0",
|
||||
"sha2 0.10.9",
|
||||
"shellexpand",
|
||||
"smoltcp",
|
||||
"socket2",
|
||||
@@ -2663,7 +2641,7 @@ dependencies = [
|
||||
name = "easytier-core"
|
||||
version = "2.7.0"
|
||||
dependencies = [
|
||||
"aes-gcm 0.11.1",
|
||||
"aes-gcm",
|
||||
"anyhow",
|
||||
"arc-swap",
|
||||
"ariadne",
|
||||
@@ -2671,27 +2649,28 @@ dependencies = [
|
||||
"async-trait",
|
||||
"atomic-shim",
|
||||
"auto_impl",
|
||||
"base64 0.23.1",
|
||||
"base64 0.22.1",
|
||||
"bitflags 2.13.2",
|
||||
"bon",
|
||||
"bytecodec",
|
||||
"bytes",
|
||||
"chacha20poly1305 0.11.0",
|
||||
"chacha20poly1305 0.10.1",
|
||||
"chrono",
|
||||
"cidr",
|
||||
"crossbeam",
|
||||
"crossbeam-utils",
|
||||
"dashmap",
|
||||
"easytier-proto",
|
||||
"futures",
|
||||
"getrandom 0.2.17",
|
||||
"getrandom 0.3.4",
|
||||
"guarden",
|
||||
"hmac 0.13.0",
|
||||
"hmac 0.12.1",
|
||||
"http-body-util",
|
||||
"hyper",
|
||||
"hyper-util",
|
||||
"idna",
|
||||
"openssl",
|
||||
"optionize",
|
||||
"ordered_hash_map",
|
||||
"parking_lot",
|
||||
"percent-encoding",
|
||||
@@ -2699,14 +2678,17 @@ dependencies = [
|
||||
"pin-project-lite",
|
||||
"prefix-trie 0.10.1",
|
||||
"prost 0.14.4",
|
||||
"prost-reflect 0.16.5",
|
||||
"prost-types 0.14.4",
|
||||
"quanta",
|
||||
"rand 0.8.8",
|
||||
"ring",
|
||||
"rustls",
|
||||
"rustls-pki-types",
|
||||
"rustls-platform-verifier",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2 0.11.0",
|
||||
"sha2 0.10.9",
|
||||
"smoltcp",
|
||||
"snow",
|
||||
"strum 0.28.0",
|
||||
@@ -2720,7 +2702,7 @@ dependencies = [
|
||||
"url",
|
||||
"uuid",
|
||||
"wasm-bindgen",
|
||||
"webpki-roots 1.0.9",
|
||||
"webpki-roots 1.0.11",
|
||||
"wildmatch",
|
||||
"x25519-dalek 2.0.1",
|
||||
"zerocopy 0.7.35",
|
||||
@@ -2811,12 +2793,12 @@ dependencies = [
|
||||
name = "easytier-ohos-features"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"base64 0.23.1",
|
||||
"base64 0.22.1",
|
||||
"easytier",
|
||||
"flate2",
|
||||
"gethostname",
|
||||
"once_cell",
|
||||
"prost-reflect",
|
||||
"prost-reflect 0.14.7",
|
||||
"rusqlite",
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -2858,24 +2840,27 @@ dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
"auto_impl",
|
||||
"base64 0.23.1",
|
||||
"base64 0.22.1",
|
||||
"bytes",
|
||||
"chrono",
|
||||
"cidr",
|
||||
"hmac 0.13.0",
|
||||
"hmac 0.12.1",
|
||||
"indoc",
|
||||
"optionize",
|
||||
"pbjson",
|
||||
"pbjson-build",
|
||||
"proc-macro2",
|
||||
"prost 0.14.4",
|
||||
"prost-build",
|
||||
"prost-reflect 0.16.5",
|
||||
"prost-types 0.14.4",
|
||||
"prost-wkt-types",
|
||||
"quote",
|
||||
"reqwest 0.13.5",
|
||||
"reqwest",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2 0.11.0",
|
||||
"sha2 0.10.9",
|
||||
"strum 0.28.0",
|
||||
"thiserror 2.0.20",
|
||||
"tokio",
|
||||
"url",
|
||||
@@ -2924,7 +2909,7 @@ dependencies = [
|
||||
"axum-embed",
|
||||
"axum-login",
|
||||
"axum-messages",
|
||||
"base64 0.23.1",
|
||||
"base64 0.22.1",
|
||||
"chrono",
|
||||
"cidr",
|
||||
"clap",
|
||||
@@ -2937,18 +2922,20 @@ dependencies = [
|
||||
"imageproc",
|
||||
"maxminddb",
|
||||
"mimalloc",
|
||||
"oauth2-reqwest",
|
||||
"openidconnect",
|
||||
"optionize",
|
||||
"password-auth",
|
||||
"prost 0.14.4",
|
||||
"rand 0.8.8",
|
||||
"reqwest 0.13.5",
|
||||
"reqwest",
|
||||
"rust-embed",
|
||||
"rust-i18n",
|
||||
"sea-orm",
|
||||
"sea-orm-migration",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2 0.11.0",
|
||||
"sha2 0.10.9",
|
||||
"sqlx",
|
||||
"subtle",
|
||||
"sys-locale",
|
||||
@@ -2956,6 +2943,7 @@ dependencies = [
|
||||
"thunk-rs",
|
||||
"tokio",
|
||||
"tokio-util",
|
||||
"toml 1.1.6+spec-1.1.0",
|
||||
"tower",
|
||||
"tower-http 0.7.1",
|
||||
"tower-sessions",
|
||||
@@ -3025,7 +3013,7 @@ dependencies = [
|
||||
"ff",
|
||||
"generic-array",
|
||||
"group",
|
||||
"hkdf 0.12.4",
|
||||
"hkdf",
|
||||
"pem-rfc7468",
|
||||
"pkcs8",
|
||||
"rand_core 0.6.4",
|
||||
@@ -3232,7 +3220,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"windows-sys 0.61.2",
|
||||
"windows-sys 0.60.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3778,16 +3766,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f0d8a4362ccb29cb0b265253fb0a2728f592895ee6854fd9bc13f2ffda266ff1"
|
||||
dependencies = [
|
||||
"opaque-debug",
|
||||
"polyval 0.6.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ghash"
|
||||
version = "0.6.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2eecf2d5dc9b66b732b97707a0210906b1d30523eb773193ab777c0c84b3e8d5"
|
||||
dependencies = [
|
||||
"polyval 0.7.3",
|
||||
"polyval",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4297,15 +4276,6 @@ dependencies = [
|
||||
"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]]
|
||||
name = "hmac"
|
||||
version = "0.12.1"
|
||||
@@ -5538,7 +5508,7 @@ dependencies = [
|
||||
"png 0.18.1",
|
||||
"serde",
|
||||
"thiserror 2.0.20",
|
||||
"windows-sys 0.61.2",
|
||||
"windows-sys 0.60.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -5940,7 +5910,7 @@ version = "0.50.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5"
|
||||
dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
"windows-sys 0.60.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -6070,7 +6040,6 @@ dependencies = [
|
||||
"getrandom 0.2.17",
|
||||
"http",
|
||||
"rand 0.8.8",
|
||||
"reqwest 0.12.28",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"serde_path_to_error",
|
||||
@@ -6079,6 +6048,16 @@ dependencies = [
|
||||
"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]]
|
||||
name = "objc2"
|
||||
version = "0.6.4"
|
||||
@@ -6414,6 +6393,30 @@ version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
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]]
|
||||
name = "ordered-float"
|
||||
version = "2.10.1"
|
||||
@@ -6474,7 +6477,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"windows-sys 0.61.2",
|
||||
"windows-sys 0.45.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -7001,17 +7004,6 @@ dependencies = [
|
||||
"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]]
|
||||
name = "portable-atomic"
|
||||
version = "1.15.0"
|
||||
@@ -7289,6 +7281,16 @@ dependencies = [
|
||||
"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]]
|
||||
name = "prost-reflect-derive"
|
||||
version = "0.14.0"
|
||||
@@ -7465,7 +7467,7 @@ dependencies = [
|
||||
"once_cell",
|
||||
"socket2",
|
||||
"tracing",
|
||||
"windows-sys 0.61.2",
|
||||
"windows-sys 0.60.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -7743,38 +7745,6 @@ version = "1.9.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
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]]
|
||||
name = "reqwest"
|
||||
version = "0.13.5"
|
||||
@@ -8089,14 +8059,14 @@ dependencies = [
|
||||
"errno",
|
||||
"libc",
|
||||
"linux-raw-sys 0.12.1",
|
||||
"windows-sys 0.61.2",
|
||||
"windows-sys 0.60.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustls"
|
||||
version = "0.23.45"
|
||||
version = "0.23.46"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0d41d731c7d2f962d1ccc364cec258de3c0e93b38c2fb3ba97ac74513048d634"
|
||||
checksum = "48e13bd8c0e9365c43cfa5c9e8f9ad49d3c8444926c9aac819e0e4dc503c8fdf"
|
||||
dependencies = [
|
||||
"aws-lc-rs",
|
||||
"once_cell",
|
||||
@@ -8147,7 +8117,7 @@ dependencies = [
|
||||
"security-framework",
|
||||
"security-framework-sys",
|
||||
"webpki-root-certs",
|
||||
"windows-sys 0.61.2",
|
||||
"windows-sys 0.60.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -8158,9 +8128,9 @@ checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f"
|
||||
|
||||
[[package]]
|
||||
name = "rustls-webpki"
|
||||
version = "0.103.15"
|
||||
version = "0.103.16"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2"
|
||||
checksum = "4443b1b6d39d7c663139942cc4558262562d2c84123bb6282ef177009ca8b281"
|
||||
dependencies = [
|
||||
"aws-lc-rs",
|
||||
"ring",
|
||||
@@ -8788,6 +8758,12 @@ dependencies = [
|
||||
"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]]
|
||||
name = "sha2"
|
||||
version = "0.10.9"
|
||||
@@ -8979,12 +8955,9 @@ version = "0.10.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "599b506ccc4aff8cf7844bc42cf783009a434c1e26c964432560fb6d6ad02d82"
|
||||
dependencies = [
|
||||
"aes-gcm 0.10.3",
|
||||
"blake2 0.10.6",
|
||||
"chacha20poly1305 0.10.1",
|
||||
"curve25519-dalek 4.1.3",
|
||||
"getrandom 0.3.4",
|
||||
"ring",
|
||||
"rustc_version",
|
||||
"sha2 0.10.9",
|
||||
"subtle",
|
||||
@@ -8997,7 +8970,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"windows-sys 0.61.2",
|
||||
"windows-sys 0.60.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -9182,7 +9155,7 @@ dependencies = [
|
||||
"futures-util",
|
||||
"generic-array",
|
||||
"hex",
|
||||
"hkdf 0.12.4",
|
||||
"hkdf",
|
||||
"hmac 0.12.1",
|
||||
"itoa",
|
||||
"log",
|
||||
@@ -9225,7 +9198,7 @@ dependencies = [
|
||||
"futures-core",
|
||||
"futures-util",
|
||||
"hex",
|
||||
"hkdf 0.12.4",
|
||||
"hkdf",
|
||||
"hmac 0.12.1",
|
||||
"home",
|
||||
"itoa",
|
||||
@@ -9633,7 +9606,7 @@ dependencies = [
|
||||
"percent-encoding",
|
||||
"plist",
|
||||
"raw-window-handle",
|
||||
"reqwest 0.13.5",
|
||||
"reqwest",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"serde_repr",
|
||||
@@ -9929,10 +9902,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
|
||||
dependencies = [
|
||||
"fastrand",
|
||||
"getrandom 0.4.3",
|
||||
"getrandom 0.3.4",
|
||||
"once_cell",
|
||||
"rustix 1.1.5",
|
||||
"windows-sys 0.61.2",
|
||||
"windows-sys 0.60.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -9951,7 +9924,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "230a1b821ccbd75b185820a1f1ff7b14d21da1e442e22c0863ea5f08771a8874"
|
||||
dependencies = [
|
||||
"rustix 1.1.5",
|
||||
"windows-sys 0.61.2",
|
||||
"windows-sys 0.60.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -10217,7 +10190,7 @@ dependencies = [
|
||||
"tokio",
|
||||
"tokio-rustls",
|
||||
"tokio-util",
|
||||
"webpki-roots 1.0.9",
|
||||
"webpki-roots 1.0.11",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -10635,7 +10608,7 @@ dependencies = [
|
||||
"png 0.18.1",
|
||||
"serde",
|
||||
"thiserror 2.0.20",
|
||||
"windows-sys 0.61.2",
|
||||
"windows-sys 0.60.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -10675,7 +10648,7 @@ checksum = "d2df906b07856748fa3f6e0ad0cbaa047052d4a7dd609e231c4f72cee8c36f31"
|
||||
[[package]]
|
||||
name = "tun-easytier"
|
||||
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 = [
|
||||
"bytes",
|
||||
"cfg-if",
|
||||
@@ -10742,7 +10715,7 @@ checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e"
|
||||
dependencies = [
|
||||
"memoffset",
|
||||
"tempfile",
|
||||
"windows-sys 0.61.2",
|
||||
"windows-sys 0.60.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -10916,6 +10889,7 @@ dependencies = [
|
||||
"js-sys",
|
||||
"rand 0.10.2",
|
||||
"serde_core",
|
||||
"sha1_smol",
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
@@ -11248,9 +11222,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "webpki-root-certs"
|
||||
version = "1.0.9"
|
||||
version = "1.0.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b96554aa2acc8ccdb7e1c9a58a7a68dd5d13bccc69cd124cb09406db612a1c9b"
|
||||
checksum = "e49d0e65abc00c142e8f9428da991da9f1a250c39be97459901eb12b9b49f62a"
|
||||
dependencies = [
|
||||
"rustls-pki-types",
|
||||
]
|
||||
@@ -11261,14 +11235,14 @@ version = "0.26.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9"
|
||||
dependencies = [
|
||||
"webpki-roots 1.0.9",
|
||||
"webpki-roots 1.0.11",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "webpki-roots"
|
||||
version = "1.0.9"
|
||||
version = "1.0.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7dcd9d09a39985f5344844e66b0c530a33843579125f23e21e9f0f220850f22a"
|
||||
checksum = "f5f280bbb2b287bc8e7de7e3852945a0ff50d1bbcb5ed42b6c7a568c546df171"
|
||||
dependencies = [
|
||||
"rustls-pki-types",
|
||||
]
|
||||
@@ -11387,7 +11361,7 @@ version = "0.1.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
|
||||
dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
"windows-sys 0.48.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -12012,7 +11986,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7d6f32a0ff4a9f6f01231eb2059cc85479330739333e0e58cadf03b6af2cca10"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"windows-sys 0.61.2",
|
||||
"windows-sys 0.60.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
+10
-4
@@ -22,6 +22,7 @@ edition = "2024"
|
||||
rust-version = "1.95"
|
||||
|
||||
[workspace.dependencies]
|
||||
optionize = "0.5"
|
||||
# Shared versions and sources; members select features and optional dependencies.
|
||||
anyhow = "1.0"
|
||||
arc-swap = "1.9"
|
||||
@@ -29,7 +30,8 @@ async-trait = "0.1.92"
|
||||
atomic-shim = "0.2.0"
|
||||
auto_impl = "1.3.0"
|
||||
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"
|
||||
bytecodec = "0.5.0"
|
||||
bytes = "1.12.1"
|
||||
@@ -37,6 +39,7 @@ chrono = "0.4.45"
|
||||
cidr = "0.3.2"
|
||||
clap = "4.6.7"
|
||||
crossbeam = "0.8.5"
|
||||
crossbeam-utils = "0.8.23"
|
||||
dashmap = "6.2.1"
|
||||
easytier = { version = "2.7.0", path = "easytier", default-features = false }
|
||||
easytier-core = { version = "2.7.0", path = "easytier-core", default-features = false }
|
||||
@@ -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-features = { version = "0.1.0", path = "easytier-contrib/easytier-ohrs/crates/easytier-ohos-features", default-features = false }
|
||||
easytier-proto = { version = "2.7.0", path = "easytier-proto", default-features = false }
|
||||
futures = "0.3"
|
||||
futures = { version = "0.3", default-features = false }
|
||||
gethostname = "1.1"
|
||||
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"
|
||||
hyper = { version = "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"
|
||||
serde = "1.0.229"
|
||||
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 }
|
||||
sqlx = "0.8.6"
|
||||
strum = "0.28.0"
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
[](https://github.com/EasyTier/EasyTier/actions/workflows/core.yml)
|
||||
[](https://github.com/EasyTier/EasyTier/actions/workflows/gui.yml)
|
||||
[](https://github.com/EasyTier/EasyTier/actions/workflows/test.yml)
|
||||
[](https://deepwiki.com/EasyTier/EasyTier)
|
||||
[<img src="https://devin.ai/assets/askdeepwiki.png" alt="Ask DeepWiki" height="20"/>](https://deepwiki.com/EasyTier/EasyTier)
|
||||
|
||||
[简体中文](/README_CN.md) | [English](/README.md)
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
<img src="assets/running-page.png" width="300" alt="running page">
|
||||
</p>
|
||||
|
||||
📚 **[Full Documentation](https://easytier.cn/en/)** | 🖥️ **[Web Console](https://easytier.cn/web)** | 📝 **[Download Releases](https://github.com/EasyTier/EasyTier/releases)** | 🧩 **[Third Party Tools](https://easytier.cn/en/guide/installation_gui.html#third-party-graphical-interfaces)** | ❤️ **[Sponsor](#sponsor)**
|
||||
📚 **[Full Documentation](https://easytier.cn/en/)** | 🖥️ **[Web Console](https://easytier.cn/web)** | 📝 **[Download Releases](https://github.com/EasyTier/EasyTier/releases)** | **[Nightly](https://github.com/EasyTier/EasyTier/releases/tag/nightly)** | 🧩 **[Third Party Tools](https://easytier.cn/en/guide/installation_gui.html#third-party-graphical-interfaces)** | ❤️ **[Sponsor](#sponsor)**
|
||||
|
||||
## Features
|
||||
|
||||
@@ -69,8 +69,17 @@ Install via cargo (Latest development version):
|
||||
cargo install --git https://github.com/EasyTier/EasyTier.git easytier
|
||||
```
|
||||
|
||||
Install prebuilt CLI tools with [cargo-binstall](https://github.com/cargo-bins/cargo-binstall) (specify an existing release version):
|
||||
```bash
|
||||
cargo binstall --git https://github.com/EasyTier/EasyTier.git --version 2.6.4 easytier
|
||||
```
|
||||
|
||||
This installs `easytier-core` and `easytier-cli` using the repository's metadata, independently of crates.io publication. On Windows, also copy the DLL and driver files from the matching release ZIP beside the installed binaries; binstall only installs executables.
|
||||
|
||||
[Install pre-built binary](https://github.com/EasyTier/EasyTier/releases) (Recommended, All platforms supported)
|
||||
|
||||
For a smaller executable, try [easytier-mini](https://github.com/EasyTier/easytier-mini), which supports basic TCP/UDP networking. See its documentation for supported features and usage.
|
||||
|
||||
[Install via Docker](https://easytier.cn/en/guide/installation.html#installation-methods)
|
||||
|
||||
[Install OpenWrt ipk package](https://github.com/EasyTier/luci-app-easytier)
|
||||
|
||||
+11
-2
@@ -7,7 +7,7 @@
|
||||
[](https://github.com/EasyTier/EasyTier/actions/workflows/core.yml)
|
||||
[](https://github.com/EasyTier/EasyTier/actions/workflows/gui.yml)
|
||||
[](https://github.com/EasyTier/EasyTier/actions/workflows/test.yml)
|
||||
[](https://deepwiki.com/EasyTier/EasyTier)
|
||||
[<img src="https://devin.ai/assets/askdeepwiki.png" alt="Ask DeepWiki" height="20"/>](https://deepwiki.com/EasyTier/EasyTier)
|
||||
|
||||
[简体中文](/README_CN.md) | [English](/README.md)
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
<img src="assets/running-page.png" width="300" alt="运行页面">
|
||||
</p>
|
||||
|
||||
📚 **[完整文档](https://easytier.cn)** | 🖥️ **[Web 控制台](https://easytier.cn/web)** | 📝 **[下载发布版本](https://github.com/EasyTier/EasyTier/releases)** | 🧩 **[第三方工具](https://easytier.cn/guide/installation_gui.html#%E7%AC%AC%E4%B8%89%E6%96%B9%E5%9B%BE%E5%BD%A2%E7%95%8C%E9%9D%A2)** | ❤️ **[赞助](#赞助)**
|
||||
📚 **[完整文档](https://easytier.cn)** | 🖥️ **[Web 控制台](https://easytier.cn/web)** | 📝 **[下载发布版本](https://github.com/EasyTier/EasyTier/releases)** | **[Nightly](https://github.com/EasyTier/EasyTier/releases/tag/nightly)** | 🧩 **[第三方工具](https://easytier.cn/guide/installation_gui.html#%E7%AC%AC%E4%B8%89%E6%96%B9%E5%9B%BE%E5%BD%A2%E7%95%8C%E9%9D%A2)** | ❤️ **[赞助](#赞助)**
|
||||
|
||||
## 特性
|
||||
|
||||
@@ -69,8 +69,17 @@ irm "https://github.com/EasyTier/EasyTier/blob/main/script/install.ps1?raw=true"
|
||||
cargo install --git https://github.com/EasyTier/EasyTier.git easytier
|
||||
```
|
||||
|
||||
通过 [cargo-binstall](https://github.com/cargo-bins/cargo-binstall) 安装预编译命令行工具(请指定已有 release 的版本):
|
||||
```bash
|
||||
cargo binstall --git https://github.com/EasyTier/EasyTier.git --version 2.6.4 easytier
|
||||
```
|
||||
|
||||
此方式使用仓库中的 metadata 安装 `easytier-core` 和 `easytier-cli`,不依赖 crates.io 发布。在 Windows 上,还需将对应 release ZIP 中的 DLL 和驱动文件复制到安装后的程序旁;binstall 只安装可执行文件。
|
||||
|
||||
[下载预编译文件](https://github.com/EasyTier/EasyTier/releases)(推荐,支持所有平台)
|
||||
|
||||
需要更小的可执行文件,可使用 [easytier-mini](https://github.com/EasyTier/easytier-mini):支持基础 TCP/UDP 组网,功能范围与使用方式见其文档。
|
||||
|
||||
[通过 Docker 安装](https://easytier.cn/guide/installation.html#%E5%AE%89%E8%A3%85%E6%96%B9%E5%BC%8F)
|
||||
|
||||
[安装 OpenWrt ipk 软件包](https://github.com/EasyTier/luci-app-easytier)
|
||||
|
||||
@@ -179,6 +179,21 @@ wire codecs. It does not own socket I/O or connection policy.
|
||||
- UDP session multiplexing, classification, and lifecycle;
|
||||
- in-process Ring sockets.
|
||||
|
||||
UDP sessions expose a scoped hole-punch source observer keyed by transaction
|
||||
ID. Listeners carry a registration handle for their existing receive loop, which
|
||||
reports the first matching source; dropping the observer removes the registration.
|
||||
Cone clients optionally prepare that transaction in the listener-selection RPC;
|
||||
the response echoes its ID only after registration. The registration retains
|
||||
early UDP sources until the cone-sending RPC claims it. Unclaimed registrations
|
||||
expire after 30 seconds through the existing listener cleanup loop, covering
|
||||
local socket and STUN preparation without a separate receive or sending task.
|
||||
Clients omitting the optional transaction retain registration at the sending RPC;
|
||||
older servers omit the echoed ID and do not confirm early registration.
|
||||
Connectivity owns preparation expiry, the active RPC lifetime, candidate selection
|
||||
and bounded sending to the signaled and observed addresses.
|
||||
Cone clients start the existing UDP handshake without requiring a reverse
|
||||
hole-punch packet, and the session layer remains the sole receiver.
|
||||
|
||||
`host/` is the single home of Host capability seams:
|
||||
|
||||
- DNS and DNS record resolution;
|
||||
@@ -349,6 +364,19 @@ A core instance:
|
||||
`CoreProcessRuntime`, plus optional protocol and platform capabilities. The
|
||||
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.
|
||||
`CoreInstance` has one outer cancellation and recovery path for the complete
|
||||
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,
|
||||
configuration, or connectivity state.
|
||||
|
||||
### Native shared TUN ownership
|
||||
|
||||
The native composition owns `SharedVirtualNicRegistry`. Named desktop TUNs
|
||||
are shared within a network namespace; mobile instances attach to one supplied
|
||||
TUN descriptor. Members own address, route, and MTU claims, not the physical
|
||||
device. Bootstrap route cleanup runs only when that device is created. Mobile
|
||||
IPv6 claims are initialized before forwarding and follow public IPv6 updates.
|
||||
Mobile VPN configuration is projected from admitted members' native claims.
|
||||
Admission keeps unchanged attached members ahead of conflicting newcomers or
|
||||
reconfigured members. Rejected members are reported individually, while the
|
||||
remaining members stay attached. Each member's initial claims are applied as
|
||||
one batch before it can open or replace the shared descriptor.
|
||||
Mobile claims update dispatcher tables without configuring the OS interface;
|
||||
the platform VPN owns its addresses, routes, and MTU.
|
||||
If applying native claims fails, the shared NIC is invalidated and its
|
||||
dispatcher is stopped so every member can recreate the device. Restoring only
|
||||
the recorded claims would leave partially applied OS configuration in use.
|
||||
TUN replacement and I/O failures also invalidate the shared NIC. TUN writes
|
||||
have bounded waits so a stalled device cannot indefinitely block claim updates
|
||||
or member cleanup.
|
||||
|
||||
Desktop and mobile shared TUNs use the same lazily initialized native Tokio
|
||||
runtime, independent of every member's executor. It creates the device's async
|
||||
I/O registration, drives dispatchers, and completes member claim cleanup even
|
||||
after the member executor stops or the registration is dropped outside Tokio.
|
||||
The runtime has two workers on desktop to allow independent TUN dispatchers
|
||||
to run concurrently, and retains one worker on mobile. It owns no registry or
|
||||
member claims; those remain with each shared NIC. Cleanup remains asynchronous,
|
||||
and the last member's cleanup stops the dispatcher and releases the desktop
|
||||
device. A process-lifetime runtime does not extend the device's lifetime.
|
||||
Unnamed desktop TUNs and instance-local work retain their existing executors.
|
||||
|
||||
Each member connects through one bounded Ring Tunnel. The shared dispatcher
|
||||
polls member streams in rotation and writes directly to their sinks, with no
|
||||
per-member forwarding tasks or intermediate packet queues. Desktop and mobile
|
||||
use one dispatch loop; mobile descriptor replacement is a control message.
|
||||
The loop consumes Tokio's cooperative budget even when packets are dropped.
|
||||
|
||||
Only the member running the Magic DNS resolver claims its shared fake-IP
|
||||
route. The claim is removed before resolver shutdown and acquired by the next
|
||||
serving member after takeover. Client-only runners do not claim that route.
|
||||
|
||||
The dispatcher preserves desktop kernel-forwarded subnet sources. It corrects
|
||||
wrong-member local sources and, on mobile, synthetic sources. Native local
|
||||
flows and translated flows reserve member-scoped transport tuples so address
|
||||
translation cannot merge sockets using the same port and remote endpoint.
|
||||
Collisions remap the source port or ICMP Echo identifier; replies restore the
|
||||
original tuple. Unrelated route updates and member removals retain mappings.
|
||||
All Echo sequence numbers for one identifier share a reservation; translation
|
||||
preserves the sequence field in each request and reply.
|
||||
Removing an involved address or member releases its mappings. A full member
|
||||
queue drops that packet without blocking other members or control messages.
|
||||
|
||||
With one attached member, packets go directly to that member without destination
|
||||
matching or new source translations. Native flow reservations remain active so
|
||||
later members cannot reuse their tuples. Existing translations continue in both
|
||||
directions after other members leave. Unmodified IPv4 fragments and IPv6
|
||||
extension headers pass through the single-member path unchanged.
|
||||
|
||||
Reservations are bounded at 4096 flows. Activity in either direction refreshes
|
||||
them; table pressure reclaims reservations idle for at least 180 seconds. If
|
||||
all slots remain active, new tracked flows are dropped rather than evicting a
|
||||
live mapping. Desktop external-source forwarding bypasses this table. As with
|
||||
other idle-expiring NAT state, very late replies after tuple reuse cannot be
|
||||
distinguished from replies to the new flow.
|
||||
|
||||
Translation supports unfragmented IPv4 and ordinary IPv6 TCP, UDP, and ICMP
|
||||
Echo traffic. IPv4 fragment and IPv6 extension-header translation remain
|
||||
unsupported. ICMP errors quoting a translated flow are not reverse-translated;
|
||||
in particular, a smaller downstream MTU can prevent PMTU discovery for such
|
||||
flows. This limitation does not affect unmodified flows and is intentionally
|
||||
retained rather than extending the dispatcher into a full ICMP/fragment NAT.
|
||||
|
||||
## Runtime configuration authority
|
||||
|
||||
`TomlConfig` is the authoritative desired configuration used for management
|
||||
@@ -406,6 +507,28 @@ behavior:
|
||||
- gateway and connectivity settings;
|
||||
- runtime configuration patches.
|
||||
|
||||
Manual routing retains three distinct states: absent routes select automatic
|
||||
routing, a present empty list installs no remote subnet routes, and a present
|
||||
nonempty list selects explicit routes. `NetworkConfig.enable_manual_routes`
|
||||
preserves this distinction on readback. `InstanceConfigPatch` applies its
|
||||
optional `enable_manual_routes` together with route edits in one route
|
||||
sub-patch: true retains an empty manual list and false restores automatic
|
||||
routing. Omitting the mode preserves released RoutePatch behavior, including
|
||||
returning to automatic routing when the edited list becomes empty. This does
|
||||
not change the ordered partial-commit contract of a multi-field patch.
|
||||
|
||||
`HeartbeatRequest.support_manual_routes_patch` advertises this complete route
|
||||
contract, including typed empty-manual readback, on the WebClient Session.
|
||||
The Web controller uses the current authenticated heartbeat to choose route
|
||||
patches; observed instance configuration caches contain no capability state.
|
||||
Released clients omit the flag and still receive
|
||||
full configuration replacements for route changes. For their ambiguous empty
|
||||
readback, Web reads the existing `GetConfig.toml_config`, or, when absent, the
|
||||
instance-scoped `ShowNodeInfo` TOML used by 2.6.4. It reads only the instance ID
|
||||
and route field, requires a matching instance, and confirms the actual mode
|
||||
before recording application. Supporting clients use typed readback directly;
|
||||
compact clients with raw GetConfig need no Peer management RPC.
|
||||
|
||||
Host persistence is an effect following a successful core transaction. A Host
|
||||
Adapter must not call back into an instance to obtain a hidden configuration
|
||||
snapshot while core is applying an operation.
|
||||
@@ -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
|
||||
`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
|
||||
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;
|
||||
- a listener owns its wildcard listener 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
|
||||
smoltcp address.
|
||||
|
||||
@@ -305,12 +309,25 @@ flow table using pointer equality.
|
||||
|
||||
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;
|
||||
- only supported EasyTier data packet kinds are consumed;
|
||||
- modified-source packets that are not local loopback traffic pass through;
|
||||
- fragmented UDP response routing remains supported;
|
||||
- a packet is consumed only when the owning flow exists.
|
||||
- local UDP fragments follow the route selected by the first fragment's
|
||||
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
|
||||
pipeline.
|
||||
|
||||
@@ -14,6 +14,7 @@ ffi-dataplane = [
|
||||
"easytier/ffi-dataplane",
|
||||
"easytier-core/proxy-smoltcp-stack",
|
||||
]
|
||||
macos-ne = ["easytier/macos-ne"]
|
||||
|
||||
[dependencies]
|
||||
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) {
|
||||
Ok(Some(instance_id)) => instance_id,
|
||||
Ok(None) => {
|
||||
set_error_msg("instance not found");
|
||||
set_error_msg(&format!("instance not found: {inst_name}"));
|
||||
return -1;
|
||||
}
|
||||
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) {
|
||||
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
|
||||
setting interoperates with the full binary's default configuration.
|
||||
|
||||
Download [pre-built binaries](https://github.com/EasyTier/easytier-mini/releases)
|
||||
from the separate [easytier-mini repository](https://github.com/EasyTier/easytier-mini),
|
||||
which handles builds and releases. The source code is maintained here in the
|
||||
main EasyTier repository.
|
||||
|
||||
Build it with:
|
||||
|
||||
```sh
|
||||
|
||||
@@ -20,7 +20,7 @@ easytier-proto = { workspace = true, features = [
|
||||
"core",
|
||||
"json-rpc",
|
||||
] }
|
||||
futures.workspace = true
|
||||
futures = { workspace = true, features = ["default"] }
|
||||
napi-derive-ohos = "1.1"
|
||||
napi-ohos = { version = "1.1", default-features = false, features = [
|
||||
"serde-json",
|
||||
|
||||
+55
-18
@@ -1,6 +1,8 @@
|
||||
use easytier::proto::ALL_DESCRIPTOR_BYTES;
|
||||
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;
|
||||
|
||||
#[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 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![
|
||||
build_schema_field_node(&key_field),
|
||||
build_schema_field_node(&value_field),
|
||||
build_schema_field_node(&key_field, path),
|
||||
build_schema_field_node(&value_field, path),
|
||||
],
|
||||
Vec::new(),
|
||||
)
|
||||
}
|
||||
|
||||
fn field_children(field: &FieldDescriptor) -> Vec<NetworkConfigSchema> {
|
||||
fn field_children(field: &FieldDescriptor, path: &mut ExpansionPath) -> Vec<NetworkConfigSchema> {
|
||||
if field.is_map()
|
||||
&& 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() {
|
||||
Kind::Message(message_desc) => build_message_children(&message_desc),
|
||||
Kind::Message(message_desc) => build_message_children(&message_desc, path),
|
||||
_ => Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn build_message_children(message_desc: &MessageDescriptor) -> Vec<NetworkConfigSchema> {
|
||||
message_desc
|
||||
.fields()
|
||||
.filter(should_expose_field)
|
||||
.map(|field| build_schema_field_node(&field))
|
||||
.collect()
|
||||
fn build_message_children(
|
||||
message_desc: &MessageDescriptor,
|
||||
path: &mut ExpansionPath,
|
||||
) -> Vec<NetworkConfigSchema> {
|
||||
let full_name = message_desc.full_name().to_string();
|
||||
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(
|
||||
"field",
|
||||
field.name().to_string(),
|
||||
@@ -262,17 +286,26 @@ fn build_schema_field_node(field: &FieldDescriptor) -> NetworkConfigSchema {
|
||||
field_default_value_text(field),
|
||||
enum_options(field.kind()),
|
||||
build_validations(field),
|
||||
field_children(field),
|
||||
field_children(field, path),
|
||||
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> {
|
||||
let mut definitions = Vec::new();
|
||||
|
||||
for message_desc in descriptor_pool().all_messages() {
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -288,12 +321,16 @@ fn collect_definitions() -> Vec<NetworkConfigSchema> {
|
||||
None,
|
||||
Vec::new(),
|
||||
Vec::new(),
|
||||
build_message_children(&message_desc),
|
||||
build_message_children(&message_desc, &mut Vec::new()),
|
||||
Vec::new(),
|
||||
));
|
||||
}
|
||||
|
||||
for enum_desc in descriptor_pool().all_enums() {
|
||||
if is_descriptor_schema(&enum_desc.parent_file()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
definitions.push(build_node(
|
||||
"enum",
|
||||
enum_desc.full_name().to_string(),
|
||||
@@ -329,7 +366,7 @@ fn build_network_config_schema() -> NetworkConfigSchema {
|
||||
None,
|
||||
Vec::new(),
|
||||
Vec::new(),
|
||||
build_message_children(&network_config),
|
||||
build_message_children(&network_config, &mut Vec::new()),
|
||||
collect_definitions(),
|
||||
)
|
||||
}
|
||||
|
||||
+38
-11
@@ -18,6 +18,7 @@ crate-type = ["rlib", "cdylib"]
|
||||
wasm-opt = ["-Oz", "--enable-bulk-memory", "--enable-nontrapping-float-to-int"]
|
||||
|
||||
[dependencies]
|
||||
optionize.workspace = true
|
||||
anyhow.workspace = true
|
||||
ariadne = { version = "0.6", optional = true }
|
||||
arc-swap.workspace = true
|
||||
@@ -30,11 +31,11 @@ bytecodec.workspace = true
|
||||
bytes.workspace = true
|
||||
chrono = { workspace = true, features = ["clock"] }
|
||||
cidr = { workspace = true, features = ["serde"] }
|
||||
crossbeam.workspace = true
|
||||
crossbeam-utils.workspace = true
|
||||
dashmap.workspace = true
|
||||
bon.workspace = true
|
||||
easytier-proto = { workspace = true, features = ["core"] }
|
||||
futures.workspace = true
|
||||
futures = { workspace = true, features = ["std", "async-await"] }
|
||||
guarden.workspace = true
|
||||
hmac.workspace = true
|
||||
http-body-util = { workspace = true, optional = true }
|
||||
@@ -54,10 +55,19 @@ rand.workspace = true
|
||||
quanta.workspace = true
|
||||
ring = { version = "0.17", 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_json.workspace = true
|
||||
sha2.workspace = 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
|
||||
thiserror.workspace = true
|
||||
tracing.workspace = true
|
||||
@@ -74,25 +84,25 @@ tokio-util = { workspace = true, features = ["io", "rt"] }
|
||||
tokio-rustls = { workspace = true, optional = true }
|
||||
url = { workspace = true, features = ["serde"] }
|
||||
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 }
|
||||
x25519-dalek = { workspace = true, features = ["static_secrets"] }
|
||||
zerocopy = { workspace = true, features = ["derive", "simd"] }
|
||||
zstd = { version = "0.14", optional = true }
|
||||
aes-gcm = { version = "0.11.1", optional = true }
|
||||
chacha20poly1305 = { version = "0.11.0", optional = true }
|
||||
# Match snow 0.10's AEAD generation to share aead, cipher and crypto-common.
|
||||
# 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"] }
|
||||
|
||||
[target.'cfg(all(target_arch = "wasm32", target_os = "unknown"))'.dependencies]
|
||||
getrandom-02 = { package = "getrandom", version = "0.2.17", features = ["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"] }
|
||||
wasm-bindgen = "0.2"
|
||||
|
||||
[target.'cfg(not(all(target_arch = "wasm32", target_os = "unknown")))'.dependencies]
|
||||
snow = "0.10.0"
|
||||
|
||||
[features]
|
||||
default = ["aes-gcm", "endpoint-discovery", "extended-services", "management", "tcp-hole-punch"]
|
||||
aes-gcm = ["dep:aes-gcm"]
|
||||
@@ -100,22 +110,31 @@ browser-config = ["config-write", "easytier-proto/api", "easytier-proto/json-rpc
|
||||
chacha20 = ["dep:chacha20poly1305"]
|
||||
openssl-crypto = ["dep:openssl"]
|
||||
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 = []
|
||||
endpoint-discovery = [
|
||||
"dep:http-body-util",
|
||||
"dep:hyper",
|
||||
"dep:hyper-util",
|
||||
"dep:ring",
|
||||
"dep:rustls",
|
||||
"dep:rustls-pki-types",
|
||||
"dep:tokio-rustls",
|
||||
"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-ipv6 = []
|
||||
public-ipv6-provider = []
|
||||
vpn-portal = []
|
||||
wrapped-transport = []
|
||||
extended-services = [
|
||||
"dhcp-ipv4",
|
||||
"dhcp-ipv6",
|
||||
"public-ipv6-provider",
|
||||
"vpn-portal",
|
||||
"wrapped-transport",
|
||||
@@ -127,6 +146,7 @@ management-rpc = ["easytier-proto/api"]
|
||||
proxy-cidr-monitor = []
|
||||
rich-config-errors = ["dep:ariadne"]
|
||||
tcp-hole-punch = []
|
||||
smoltcp = ["proxy-packet"]
|
||||
proxy-packet = [
|
||||
"wrapped-transport",
|
||||
"dep:smoltcp",
|
||||
@@ -152,12 +172,19 @@ test-utils = []
|
||||
tracing-log = ["tracing/log"]
|
||||
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]
|
||||
tokio = { workspace = true, features = ["rt-multi-thread", "test-util"] }
|
||||
|
||||
[package.metadata.cargo-machete]
|
||||
ignored = [
|
||||
# Enable browser entropy backends for transitive rand/snow dependencies.
|
||||
# Enable browser entropy backends for transitive rand/snow/AEAD dependencies.
|
||||
"getrandom-02",
|
||||
"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,
|
||||
};
|
||||
|
||||
use optionize::Optionizable as _;
|
||||
|
||||
use super::{
|
||||
api_input::managed_credential_to_proto,
|
||||
api_input::set_network_flags,
|
||||
toml::{ConfigLoader as _, TomlConfig},
|
||||
};
|
||||
|
||||
@@ -14,6 +16,12 @@ pub fn network_config_from_toml(config: &TomlConfig) -> NetworkConfig {
|
||||
let mut result = NetworkConfig {
|
||||
instance_id: Some(config.get_id().to_string()),
|
||||
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()
|
||||
};
|
||||
|
||||
@@ -99,9 +107,7 @@ pub fn network_config_from_toml(config: &TomlConfig) -> NetworkConfig {
|
||||
});
|
||||
}
|
||||
|
||||
if let Some(routes) = config.get_routes()
|
||||
&& !routes.is_empty()
|
||||
{
|
||||
if let Some(routes) = config.get_routes() {
|
||||
result.enable_manual_routes = Some(true);
|
||||
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
|
||||
.get_managed_credentials()
|
||||
.into_iter()
|
||||
.map(managed_credential_to_proto)
|
||||
.map(|credential| credential.downgrade())
|
||||
.collect();
|
||||
|
||||
let flags = config.get_flags();
|
||||
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);
|
||||
set_network_flags(&mut result, config.get_flags_patch());
|
||||
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
|
||||
}
|
||||
|
||||
|
||||
@@ -3,13 +3,20 @@
|
||||
use std::net::SocketAddr;
|
||||
|
||||
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::{
|
||||
MappedListenerPolicy, normalize_secure_mode_config,
|
||||
toml::{
|
||||
ConfigLoader, ManagedCredentialConfig, NetworkIdentity, PeerConfig, PortForwardConfig,
|
||||
TomlConfigLoader, VpnPortalClientConfig, VpnPortalConfig, gen_default_flags,
|
||||
ConfigLoader, NetworkIdentity, PeerConfig, PortForwardConfig, TomlConfigLoader,
|
||||
VpnPortalClientConfig, VpnPortalConfig,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -51,32 +58,51 @@ pub fn add_proxy_network_to_config(
|
||||
pub type NetworkingMethod = easytier_proto::api::manage::NetworkingMethod;
|
||||
pub type NetworkConfig = easytier_proto::api::manage::NetworkConfig;
|
||||
|
||||
pub(crate) fn managed_credential_from_proto(
|
||||
credential: &manage::ManagedCredentialConfig,
|
||||
) -> ManagedCredentialConfig {
|
||||
ManagedCredentialConfig {
|
||||
credential_id: credential.credential_id.clone(),
|
||||
credential_secret: credential.credential_secret.clone(),
|
||||
groups: credential.groups.clone(),
|
||||
allow_relay: credential.allow_relay,
|
||||
allowed_proxy_cidrs: credential.allowed_proxy_cidrs.clone(),
|
||||
expiry_unix: credential.expiry_unix,
|
||||
reusable: credential.reusable.unwrap_or(true),
|
||||
}
|
||||
}
|
||||
pub(crate) fn set_network_flags(result: &mut NetworkConfig, flags: FlagsPatch) {
|
||||
result.latency_first = flags.latency_first;
|
||||
result.dev_name = flags.dev_name;
|
||||
result.use_smoltcp = flags.use_smoltcp;
|
||||
result.disable_ipv6 = flags.enable_ipv6.map(|enabled| !enabled);
|
||||
result.enable_kcp_proxy = flags.enable_kcp_proxy;
|
||||
result.disable_kcp_input = flags.disable_kcp_input;
|
||||
result.enable_quic_proxy = flags.enable_quic_proxy;
|
||||
result.disable_quic_input = flags.disable_quic_input;
|
||||
result.disable_p2p = flags.disable_p2p;
|
||||
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(
|
||||
credential: ManagedCredentialConfig,
|
||||
) -> manage::ManagedCredentialConfig {
|
||||
manage::ManagedCredentialConfig {
|
||||
credential_id: credential.credential_id,
|
||||
credential_secret: credential.credential_secret,
|
||||
groups: credential.groups,
|
||||
allow_relay: credential.allow_relay,
|
||||
allowed_proxy_cidrs: credential.allowed_proxy_cidrs,
|
||||
expiry_unix: credential.expiry_unix,
|
||||
reusable: Some(credential.reusable),
|
||||
}
|
||||
result.enable_relay_network_whitelist = flags
|
||||
.relay_network_whitelist
|
||||
.as_ref()
|
||||
.map(|list| list != "*");
|
||||
result.relay_network_whitelist = flags
|
||||
.relay_network_whitelist
|
||||
.filter(|list| list != "*")
|
||||
.map(|list| list.split_whitespace().map(ToOwned::to_owned).collect())
|
||||
.unwrap_or_default();
|
||||
}
|
||||
|
||||
pub trait NetworkConfigExt {
|
||||
@@ -96,6 +122,7 @@ const FORM_MANAGED_TOML_FIELDS: &[&str] = &[
|
||||
"ipv6_public_addr_auto",
|
||||
"ipv6_public_addr_prefix",
|
||||
"dhcp",
|
||||
"dhcpv6",
|
||||
"network_identity",
|
||||
"listeners",
|
||||
"mapped_listeners",
|
||||
@@ -112,47 +139,6 @@ const FORM_MANAGED_TOML_FIELDS: &[&str] = &[
|
||||
"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(
|
||||
feature = "browser-config",
|
||||
any(test, all(target_arch = "wasm32", target_os = "unknown"))
|
||||
@@ -184,7 +170,22 @@ pub(crate) fn merge_network_config_toml(
|
||||
.remove("flags")
|
||||
.and_then(|value| value.try_into().ok())
|
||||
.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) {
|
||||
merged_flags.insert((*key).to_owned(), value.clone());
|
||||
} else {
|
||||
@@ -251,7 +252,18 @@ impl NetworkConfigExt for NetworkConfig {
|
||||
.with_context(|| format!("failed to parse instance id: {:?}", self.instance_id))?,
|
||||
);
|
||||
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());
|
||||
|
||||
// The web UI does not expose credential inputs directly, but imported/saved
|
||||
@@ -444,8 +456,8 @@ impl NetworkConfigExt for NetworkConfig {
|
||||
cfg.set_managed_credentials(
|
||||
self.managed_credentials
|
||||
.iter()
|
||||
.map(managed_credential_from_proto)
|
||||
.collect(),
|
||||
.map(|credential| credential.clone().upgrade())
|
||||
.collect::<Result<Vec<_>, _>>()?,
|
||||
);
|
||||
|
||||
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 {
|
||||
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()
|
||||
&& !acl.is_empty()
|
||||
{
|
||||
cfg.set_acl(Some(acl.clone()));
|
||||
}
|
||||
|
||||
if let Some(data_compress_algo) = self.data_compress_algo {
|
||||
if data_compress_algo < 1 {
|
||||
flags.data_compress_algo = 1;
|
||||
} else {
|
||||
flags.data_compress_algo = data_compress_algo
|
||||
}
|
||||
}
|
||||
|
||||
cfg.set_flags(flags);
|
||||
cfg.patch_flags(FlagsPatch {
|
||||
dev_name: self.dev_name.clone(),
|
||||
enable_ipv6: self.disable_ipv6.map(|disabled| !disabled),
|
||||
socket_mark: self.socket_mark,
|
||||
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(" ")
|
||||
} 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)
|
||||
}
|
||||
|
||||
@@ -651,6 +565,12 @@ impl NetworkConfigExt for NetworkConfig {
|
||||
}
|
||||
|
||||
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();
|
||||
result.network_name = Some(network_identity.network_name.clone());
|
||||
@@ -735,9 +655,7 @@ impl NetworkConfigExt for NetworkConfig {
|
||||
});
|
||||
}
|
||||
|
||||
if let Some(routes) = config.get_routes()
|
||||
&& !routes.is_empty()
|
||||
{
|
||||
if let Some(routes) = config.get_routes() {
|
||||
result.enable_manual_routes = Some(true);
|
||||
result.routes = routes.iter().map(|r| r.to_string()).collect();
|
||||
}
|
||||
@@ -764,65 +682,11 @@ impl NetworkConfigExt for NetworkConfig {
|
||||
result.managed_credentials = config
|
||||
.get_managed_credentials()
|
||||
.into_iter()
|
||||
.map(managed_credential_to_proto)
|
||||
.map(|credential| credential.downgrade())
|
||||
.collect();
|
||||
let flags = config.get_flags();
|
||||
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);
|
||||
|
||||
set_network_flags(&mut result, config.get_flags_patch());
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -832,6 +696,7 @@ mod tests {
|
||||
#![allow(deprecated)]
|
||||
|
||||
use super::*;
|
||||
use easytier_proto::common::Flags;
|
||||
|
||||
fn api_portal_config() -> 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]
|
||||
fn vpn_portal_api_config_round_trips_through_toml_model() {
|
||||
let input = NetworkConfig {
|
||||
@@ -969,6 +1062,62 @@ mod tests {
|
||||
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")]
|
||||
#[test]
|
||||
fn browser_merge_preserves_fields_outside_the_shared_form() {
|
||||
@@ -1006,11 +1155,74 @@ disable_p2p = true
|
||||
Some("edited-network")
|
||||
);
|
||||
assert_eq!(merged["flags"]["default_protocol"].as_str(), Some("udp"));
|
||||
assert!(
|
||||
!merged["flags"]
|
||||
.as_table()
|
||||
.unwrap()
|
||||
.contains_key("disable_p2p")
|
||||
assert_eq!(merged["flags"]["disable_p2p"].as_bool(), Some(false));
|
||||
}
|
||||
|
||||
#[cfg(feature = "browser-config")]
|
||||
#[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;
|
||||
|
||||
/// 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 {
|
||||
#[strum(to_string = "xor", serialize = "xor")]
|
||||
#[serde(rename = "xor")]
|
||||
Xor,
|
||||
|
||||
#[default]
|
||||
#[strum(
|
||||
to_string = "aes-gcm",
|
||||
serialize = "aes-gcm",
|
||||
serialize = "openssl-aes-gcm"
|
||||
)]
|
||||
#[serde(rename = "aes-gcm", alias = "openssl-aes-gcm")]
|
||||
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,
|
||||
|
||||
#[strum(
|
||||
to_string = "chacha20",
|
||||
serialize = "chacha20",
|
||||
serialize = "chacha20-poly1305",
|
||||
serialize = "openssl-chacha20"
|
||||
)]
|
||||
#[serde(
|
||||
rename = "chacha20",
|
||||
alias = "chacha20-poly1305",
|
||||
alias = "openssl-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)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
@@ -10,12 +10,15 @@ pub mod api_input;
|
||||
target_os = "unknown"
|
||||
))]
|
||||
mod browser;
|
||||
mod dhcp;
|
||||
mod encryption;
|
||||
pub mod gateway;
|
||||
pub mod peers;
|
||||
pub mod runtime;
|
||||
pub mod toml;
|
||||
|
||||
pub use dhcp::DhcpConfig;
|
||||
pub(crate) use dhcp::valid_ipv6_subnet;
|
||||
pub use encryption::EncryptionAlgorithm;
|
||||
|
||||
pub(crate) const DEFAULT_UDP_STUN_SERVERS: &[&str] = &[
|
||||
@@ -48,6 +51,7 @@ use std::{
|
||||
use anyhow::Context as _;
|
||||
use base64::{Engine as _, prelude::BASE64_STANDARD};
|
||||
use easytier_proto::{common as common_pb, core_config as pb};
|
||||
use optionize::Optionizable;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use url::Url;
|
||||
|
||||
@@ -280,6 +284,8 @@ pub struct ForeignNetworkConfig {
|
||||
pub cidrs: Vec<IpPrefix>,
|
||||
}
|
||||
|
||||
#[optionize::optionized]
|
||||
#[optionize(object = pb::PeerPolicyConfig)]
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct PeerPolicyConfig {
|
||||
pub p2p_enabled: bool,
|
||||
@@ -522,28 +528,15 @@ impl From<ForeignNetworkConfig> for pb::ForeignNetworkConfig {
|
||||
|
||||
impl From<pb::PeerPolicyConfig> for PeerPolicyConfig {
|
||||
fn from(value: pb::PeerPolicyConfig) -> Self {
|
||||
let default = Self::default();
|
||||
Self {
|
||||
p2p_enabled: value.p2p_enabled.unwrap_or(default.p2p_enabled),
|
||||
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),
|
||||
}
|
||||
let mut config = Self::default();
|
||||
config.load(value);
|
||||
config
|
||||
}
|
||||
}
|
||||
|
||||
impl From<PeerPolicyConfig> for pb::PeerPolicyConfig {
|
||||
fn from(value: PeerPolicyConfig) -> Self {
|
||||
Self {
|
||||
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),
|
||||
}
|
||||
value.downgrade()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
|
||||
use anyhow::Context as _;
|
||||
use cidr::Ipv6Cidr;
|
||||
use easytier_proto::common::{FlagsInConfig, PeerFeatureFlag, SecureModeConfig, StunInfo};
|
||||
use easytier_proto::common::{Flags, PeerFeatureFlag, SecureModeConfig, StunInfo};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::proto::acl::{Acl, AclV1, Action, Chain, ChainType, GroupInfo, Protocol, Rule};
|
||||
@@ -236,7 +236,7 @@ pub struct PeerRuntimeSnapshot {
|
||||
pub runtime: PeerRuntimeConfig,
|
||||
pub easytier_version: String,
|
||||
pub avoid_relay_data_preference: bool,
|
||||
pub flags: FlagsInConfig,
|
||||
pub flags: Flags,
|
||||
pub pinned_peers: Vec<(url::Url, Option<String>)>,
|
||||
pub peer_group_memberships: Vec<PeerGroupIdentity>,
|
||||
pub acl_group_declarations: Vec<PeerGroupIdentity>,
|
||||
@@ -246,7 +246,7 @@ pub struct 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;
|
||||
Self {
|
||||
runtime,
|
||||
@@ -274,7 +274,7 @@ impl Default for PeerRuntimeSnapshot {
|
||||
secure_mode: None,
|
||||
host_routing: HostRoutingPolicy::default(),
|
||||
},
|
||||
FlagsInConfig::default(),
|
||||
Flags::default(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
use std::{collections::BTreeSet, sync::Arc};
|
||||
|
||||
use arc_swap::ArcSwap;
|
||||
use cidr::Ipv4Cidr;
|
||||
use cidr::{Ipv4Cidr, Ipv6Cidr};
|
||||
use parking_lot::Mutex;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
@@ -16,6 +16,12 @@ use super::{
|
||||
pub struct CoreRuntimeConfig {
|
||||
pub acl: AclRuleConfig,
|
||||
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,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub manual_routes: Option<BTreeSet<Ipv4Cidr>>,
|
||||
@@ -30,6 +36,9 @@ impl Default for CoreRuntimeConfig {
|
||||
Self {
|
||||
acl: AclRuleConfig::default(),
|
||||
dhcp_ipv4: false,
|
||||
dhcp_ipv4_cidr: None,
|
||||
dhcp_ipv6: false,
|
||||
dhcp_ipv6_cidr: None,
|
||||
gateway: GatewayRuntimeConfig::default(),
|
||||
manual_routes: None,
|
||||
proxy: ProxyRuntimeConfig::default(),
|
||||
|
||||
+273
-236
@@ -6,172 +6,24 @@ use std::{
|
||||
sync::{Arc, Mutex},
|
||||
};
|
||||
|
||||
use super::normalize_secure_mode_config;
|
||||
use super::{DhcpConfig, normalize_secure_mode_config};
|
||||
pub use super::{EncryptionAlgorithm, gateway::PortForwardConfig};
|
||||
use anyhow::Context;
|
||||
#[cfg(feature = "rich-config-errors")]
|
||||
use ariadne::{CharSet, Config as AriadneConfig, IndexType, Label, Report, ReportKind, Source};
|
||||
use optionize::Optionizable;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::proto::{
|
||||
acl::Acl,
|
||||
common::{CompressionAlgoPb, SecureModeConfig},
|
||||
};
|
||||
use crate::proto::{acl::Acl, common::SecureModeConfig};
|
||||
|
||||
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 {
|
||||
"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, &)]
|
||||
pub trait ConfigLoader: Send + Sync {
|
||||
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 set_ipv6_public_addr_prefix(&self, prefix: Option<cidr::Ipv6Cidr>);
|
||||
|
||||
fn get_dhcp(&self) -> bool;
|
||||
fn set_dhcp(&self, dhcp: bool);
|
||||
fn get_dhcp_config(&self) -> DhcpConfig;
|
||||
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(
|
||||
&self,
|
||||
@@ -231,7 +102,12 @@ pub trait ConfigLoader: Send + Sync {
|
||||
fn set_vpn_portal_config(&self, config: VpnPortalConfig);
|
||||
|
||||
fn get_flags(&self) -> Flags;
|
||||
/// Replace all flags with explicitly supplied values.
|
||||
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 set_exit_nodes(&self, nodes: Vec<IpAddr>);
|
||||
@@ -486,19 +362,30 @@ fn default_true() -> bool {
|
||||
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)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct ManagedCredentialConfig {
|
||||
#[optionize(flatten)]
|
||||
pub credential_id: String,
|
||||
#[optionize(flatten)]
|
||||
pub credential_secret: String,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
#[optionize(flatten)]
|
||||
pub groups: Vec<String>,
|
||||
#[serde(default)]
|
||||
#[optionize(flatten)]
|
||||
pub allow_relay: bool,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
#[optionize(flatten)]
|
||||
pub allowed_proxy_cidrs: Vec<String>,
|
||||
#[optionize(flatten)]
|
||||
pub expiry_unix: i64,
|
||||
#[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,
|
||||
}
|
||||
|
||||
@@ -529,7 +416,8 @@ struct Config {
|
||||
ipv6_public_addr_provider: Option<bool>,
|
||||
ipv6_public_addr_auto: Option<bool>,
|
||||
ipv6_public_addr_prefix: Option<String>,
|
||||
dhcp: Option<bool>,
|
||||
dhcp: Option<DhcpConfig>,
|
||||
dhcpv6: Option<DhcpConfig<cidr::Ipv6Cidr>>,
|
||||
network_identity: Option<NetworkIdentity>,
|
||||
listeners: Option<Vec<url::Url>>,
|
||||
mapped_listeners: Option<Vec<url::Url>>,
|
||||
@@ -548,10 +436,8 @@ struct Config {
|
||||
|
||||
secure_mode: Option<SecureModeConfig>,
|
||||
|
||||
flags: Option<serde_json::Map<String, serde_json::Value>>,
|
||||
|
||||
#[serde(skip)]
|
||||
flags_struct: Option<Flags>,
|
||||
#[serde(default)]
|
||||
flags: FlagsPatch,
|
||||
|
||||
acl: Option<Acl>,
|
||||
|
||||
@@ -631,7 +517,6 @@ impl TomlConfig {
|
||||
fn config_for_dump(&self) -> Config {
|
||||
let mut config = self.config.lock().unwrap().clone();
|
||||
Self::normalize_config_source(&mut config);
|
||||
config.flags = Some(flags_diff_from_default(&self.get_flags()));
|
||||
config
|
||||
}
|
||||
|
||||
@@ -705,10 +590,6 @@ impl TomlConfig {
|
||||
}
|
||||
|
||||
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
|
||||
.secure_mode
|
||||
.take()
|
||||
@@ -745,17 +626,6 @@ impl TomlConfig {
|
||||
|
||||
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")]
|
||||
@@ -875,14 +745,22 @@ impl ConfigLoader for TomlConfig {
|
||||
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()
|
||||
}
|
||||
|
||||
fn set_dhcp(&self, dhcp: bool) {
|
||||
fn set_dhcp_config(&self, dhcp: DhcpConfig) {
|
||||
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(
|
||||
&self,
|
||||
cidr: cidr::Ipv4Cidr,
|
||||
@@ -1019,16 +897,27 @@ impl ConfigLoader for TomlConfig {
|
||||
}
|
||||
|
||||
fn get_flags(&self) -> Flags {
|
||||
self.config
|
||||
.lock()
|
||||
.unwrap()
|
||||
.flags_struct
|
||||
.clone()
|
||||
.unwrap_or_default()
|
||||
Flags::resolve(self.get_flags_patch())
|
||||
}
|
||||
|
||||
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> {
|
||||
@@ -1205,19 +1094,113 @@ mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn parse_error_preserves_source_and_location() {
|
||||
let error =
|
||||
TomlConfig::new_from_str_with_source("fixture.toml", "dhcp = \"yes\"").unwrap_err();
|
||||
let display = error.to_string();
|
||||
fn dhcp_accepts_booleans_and_ipv4_subnets() {
|
||||
assert!(!TomlConfig::default().get_dhcp());
|
||||
for (value, expected) in [
|
||||
("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"));
|
||||
assert!(display.contains("dhcp = \"yes\""));
|
||||
assert!(display.contains("invalid type: string"));
|
||||
assert!(
|
||||
error
|
||||
.chain()
|
||||
.any(|cause| cause.downcast_ref::<toml::de::Error>().is_some())
|
||||
);
|
||||
#[test]
|
||||
fn dhcpv6_config_round_trip_and_validation() {
|
||||
assert!(!TomlConfig::default().get_dhcpv6());
|
||||
for value in [
|
||||
"true",
|
||||
"false",
|
||||
"'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")]
|
||||
@@ -1406,37 +1389,9 @@ source = "web"
|
||||
#[cfg(test)]
|
||||
mod compatibility_tests {
|
||||
use super::*;
|
||||
use crate::proto::common::CompressionAlgoPb;
|
||||
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]
|
||||
fn socket_mark_config_file_roundtrip_none_some_and_zero() {
|
||||
// Omitting the flag leaves socket_mark unset (None) -> SO_MARK untouched.
|
||||
@@ -1486,13 +1441,91 @@ socket_mark = 66
|
||||
..cfg.get_flags()
|
||||
});
|
||||
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")]
|
||||
#[test]
|
||||
fn dump_preserves_flags_that_differ_from_easytier_defaults() {
|
||||
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.enable_quic_proxy = true;
|
||||
flags.disable_tcp_hole_punching = true;
|
||||
@@ -1973,13 +2006,16 @@ mod diagnostic_compatibility_tests {
|
||||
|
||||
#[test]
|
||||
fn non_ascii_before_typed_error_keeps_byte_location() {
|
||||
let error = TomlConfig::new_from_str("hostname = \"节点\"\ndhcp = \"yes\"")
|
||||
.unwrap_err()
|
||||
.to_string();
|
||||
for field in ["dhcp", "dhcpv6"] {
|
||||
let invalid_field = format!("{field} = \"yes\"");
|
||||
let error = TomlConfig::new_from_str(&format!("hostname = \"节点\"\n{invalid_field}"))
|
||||
.unwrap_err()
|
||||
.to_string();
|
||||
|
||||
assert!(error.contains("dhcp = \"yes\""));
|
||||
assert!(error.contains('^'));
|
||||
assert!(error.contains("invalid type: string"));
|
||||
assert!(error.contains(&invalid_field));
|
||||
assert!(error.contains('^'));
|
||||
assert!(error.contains("invalid DHCP CIDR"));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "rich-config-errors")]
|
||||
@@ -1996,7 +2032,7 @@ mod diagnostic_compatibility_tests {
|
||||
}
|
||||
|
||||
#[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(
|
||||
"flags-fixture.toml",
|
||||
"[flags]\nsocket_mark = \"bad\"",
|
||||
@@ -2005,12 +2041,13 @@ mod diagnostic_compatibility_tests {
|
||||
let display = error.to_string();
|
||||
|
||||
assert!(display.contains("flags-fixture.toml"));
|
||||
assert!(display.contains("failed to load config"));
|
||||
assert!(display.contains("failed to parse flags"));
|
||||
assert!(display.contains("failed to parse config TOML"));
|
||||
assert!(display.contains("socket_mark = \"bad\""));
|
||||
assert!(display.contains('^'));
|
||||
assert!(
|
||||
error
|
||||
.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(
|
||||
host,
|
||||
Arc::new(crate::connectivity::NoLocalListeners),
|
||||
SocketContext::default().with_socket_mark(Some(7)),
|
||||
Some(Arc::new(FixedStunProvider)),
|
||||
);
|
||||
@@ -744,6 +745,14 @@ mod tests {
|
||||
response.public_ipv4,
|
||||
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 {
|
||||
|
||||
@@ -18,7 +18,7 @@ use crate::{
|
||||
connectivity::hole_punch::policy::{should_background_p2p_with_peer, should_try_p2p_with_peer},
|
||||
connectivity::stun::{StunInfoProvider, StunSocketMapper},
|
||||
connectivity::{
|
||||
LocalListenerUrls, NoLocalListeners,
|
||||
LocalListenerUrls,
|
||||
protocol::{
|
||||
ClientProtocolUpgrader, ProtocolTransport, protocol_transport, protocol_uses_udp,
|
||||
},
|
||||
@@ -915,10 +915,10 @@ where
|
||||
{
|
||||
pub fn new_for_foreign_network_with_stun(
|
||||
host: Arc<H>,
|
||||
running_listeners: Arc<dyn LocalListenerUrls>,
|
||||
socket_context: SocketContext,
|
||||
stun: Option<Arc<dyn StunInfoProvider>>,
|
||||
) -> Self {
|
||||
let running_listeners = Arc::new(NoLocalListeners);
|
||||
Self {
|
||||
host,
|
||||
peer_manager: None,
|
||||
@@ -997,14 +997,23 @@ where
|
||||
{
|
||||
host: Arc<H>,
|
||||
stun: Arc<dyn StunInfoProvider>,
|
||||
running_listeners: Arc<dyn LocalListenerUrls>,
|
||||
}
|
||||
|
||||
impl<H> ForeignDirectConnectorRpcRegistrar<H>
|
||||
where
|
||||
H: DirectConnectorHost,
|
||||
{
|
||||
pub fn new(host: Arc<H>, stun: Arc<dyn StunInfoProvider>) -> Self {
|
||||
Self { host, stun }
|
||||
pub fn new(
|
||||
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(
|
||||
DirectConnectorRpcHandler::new_for_foreign_network_with_stun(
|
||||
self.host.clone(),
|
||||
self.running_listeners.clone(),
|
||||
socket_context,
|
||||
Some(self.stun.clone()),
|
||||
),
|
||||
@@ -1201,6 +1211,7 @@ fn is_public_ipv4(ip: Ipv4Addr) -> bool {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::connectivity::NoLocalListeners;
|
||||
|
||||
impl<H> DirectConnectorRpcHandler<H>
|
||||
where
|
||||
|
||||
@@ -331,6 +331,7 @@ where
|
||||
};
|
||||
|
||||
let layer = self.session_layer(socket.clone());
|
||||
let hole_punch_observers = layer.hole_punch_observers();
|
||||
let conn_counter = Arc::new(CoreUdpPunchConnCounter {
|
||||
layer: Arc::downgrade(&layer),
|
||||
});
|
||||
@@ -338,6 +339,7 @@ where
|
||||
|
||||
Ok(UdpPunchListener {
|
||||
socket,
|
||||
hole_punch_observers,
|
||||
mapped_addr: resolved.mapped_addr,
|
||||
conn_counter,
|
||||
acceptor,
|
||||
|
||||
@@ -16,7 +16,7 @@ use crate::{
|
||||
config::PeerId,
|
||||
connectivity::stun::StunInfoProvider,
|
||||
packet::{HOLE_PUNCH_PACKET_BODY_LEN, new_hole_punch_packet},
|
||||
socket::udp::{UdpBindOptions, VirtualUdpSocketFactory},
|
||||
socket::udp::{UdpBindOptions, VirtualUdpSocket},
|
||||
};
|
||||
|
||||
use super::{
|
||||
@@ -62,17 +62,29 @@ where
|
||||
tracing::info!(?dst_peer_id, "start hole punching");
|
||||
let tid = rand::random();
|
||||
|
||||
let udp_array = UdpSocketArray::new_with_context(1, runtime.clone(), runtime.socket_context());
|
||||
|
||||
let resp = signaling
|
||||
.select_punch_listener(
|
||||
dst_peer_id,
|
||||
SelectPunchListener {
|
||||
force_new: false,
|
||||
prefer_port_mapping: true,
|
||||
transaction_id: Some(tid),
|
||||
},
|
||||
)
|
||||
.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 local_socket = UdpHolePunchRuntime::bind_udp(
|
||||
@@ -96,14 +108,12 @@ where
|
||||
"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();
|
||||
|
||||
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 punch_task = AbortOnDropHandle::new(tokio::spawn(async move {
|
||||
let _punch_task = AbortOnDropHandle::new(tokio::spawn(async move {
|
||||
if let Err(e) = signaling_for_task
|
||||
.send_punch_packet_cone(
|
||||
dst_peer_id,
|
||||
@@ -122,33 +132,27 @@ where
|
||||
}
|
||||
}));
|
||||
|
||||
let mut finish_time: Option<Instant> = None;
|
||||
while finish_time.is_none() || finish_time.as_ref().unwrap().elapsed().as_millis() < 1000 {
|
||||
crate::foundation::time::sleep(std::time::Duration::from_millis(200)).await;
|
||||
|
||||
if finish_time.is_none() && punch_task.is_finished() {
|
||||
finish_time = Some(Instant::now());
|
||||
}
|
||||
|
||||
let Some(socket) = udp_array.try_fetch_punched_socket(tid) else {
|
||||
tracing::debug!("no punched socket found, send some more hole punch packets");
|
||||
send_from_local(&udp_array, &punch_packet, remote_mapped_addr).await?;
|
||||
continue;
|
||||
};
|
||||
|
||||
tracing::debug!(?socket, ?tid, "punched socket found, try connect with it");
|
||||
|
||||
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));
|
||||
// The session layer owns receive while connecting. A reachable listener can
|
||||
// reply to SYN's actual source even when the STUN candidate differs.
|
||||
for _ in 0..2 {
|
||||
let connect = runtime.connect_with_socket(local_socket.clone(), remote_mapped_addr);
|
||||
tokio::pin!(connect);
|
||||
loop {
|
||||
tokio::select! {
|
||||
result = &mut connect => {
|
||||
match result {
|
||||
Ok(socket) => {
|
||||
tracing::info!(?socket, "hole punched");
|
||||
return Ok(Some(socket));
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!(?e, "failed to connect with socket");
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!(?e, "failed to connect with socket");
|
||||
_ = crate::foundation::time::sleep(std::time::Duration::from_millis(200)) => {
|
||||
send_from_local(local_socket.as_ref(), &punch_packet, remote_mapped_addr).await?;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -157,17 +161,20 @@ where
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
async fn send_from_local<R>(
|
||||
udp_array: &UdpSocketArray<R>,
|
||||
async fn send_from_local<S>(
|
||||
socket: &S,
|
||||
punch_packet: &[u8],
|
||||
remote_mapped_addr: SocketAddr,
|
||||
) -> UdpHolePunchClientResult<()>
|
||||
where
|
||||
R: VirtualUdpSocketFactory,
|
||||
S: VirtualUdpSocket,
|
||||
{
|
||||
udp_array
|
||||
.send_with_all(punch_packet, remote_mapped_addr)
|
||||
.await?;
|
||||
for _ in 0..3 {
|
||||
socket
|
||||
.send_to(punch_packet, remote_mapped_addr)
|
||||
.await
|
||||
.map_err(anyhow::Error::from)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -353,6 +360,7 @@ where
|
||||
SelectPunchListener {
|
||||
force_new: false,
|
||||
prefer_port_mapping: true,
|
||||
transaction_id: None,
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
@@ -614,6 +622,10 @@ mod tests {
|
||||
|
||||
struct MockSocket {
|
||||
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,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
@@ -622,12 +634,35 @@ mod tests {
|
||||
Ok(self.local_addr)
|
||||
}
|
||||
|
||||
async fn send_to(&self, _data: &[u8], _addr: SocketAddr) -> io::Result<usize> {
|
||||
Ok(0)
|
||||
async fn send_to(&self, data: &[u8], addr: SocketAddr) -> io::Result<usize> {
|
||||
self.sent.lock().unwrap().push((data.to_vec(), addr));
|
||||
if data.len() == 16 && data[4] == crate::packet::UdpPacketType::Syn as u8 {
|
||||
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)> {
|
||||
std::future::pending().await
|
||||
async fn recv_from(&self, buf: &mut [u8]) -> io::Result<(usize, SocketAddr)> {
|
||||
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 +670,8 @@ mod tests {
|
||||
bind_count: AtomicUsize,
|
||||
resolve_count: AtomicUsize,
|
||||
bind_options: tokio::sync::Mutex<Vec<UdpBindOptions>>,
|
||||
sockets: std::sync::Mutex<Vec<std::sync::Weak<MockSocket>>>,
|
||||
drop_syn_packets: usize,
|
||||
}
|
||||
|
||||
impl MockRuntime {
|
||||
@@ -643,6 +680,8 @@ mod tests {
|
||||
bind_count: AtomicUsize::new(0),
|
||||
resolve_count: AtomicUsize::new(0),
|
||||
bind_options: tokio::sync::Mutex::new(Vec::new()),
|
||||
sockets: std::sync::Mutex::new(Vec::new()),
|
||||
drop_syn_packets: 0,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -654,18 +693,25 @@ mod tests {
|
||||
async fn bind_udp(&self, options: UdpBindOptions) -> anyhow::Result<Arc<Self::Socket>> {
|
||||
self.bind_options.lock().await.push(options);
|
||||
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)),
|
||||
}))
|
||||
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),
|
||||
});
|
||||
self.sockets.lock().unwrap().push(Arc::downgrade(&socket));
|
||||
Ok(socket)
|
||||
}
|
||||
|
||||
async fn resolve_udp_public_addr(
|
||||
&self,
|
||||
_socket: Arc<Self::Socket>,
|
||||
socket: Arc<Self::Socket>,
|
||||
) -> anyhow::Result<super::super::UdpResolvedPublicAddr> {
|
||||
assert_eq!(socket.local_addr()?.port(), 10000);
|
||||
self.resolve_count.fetch_add(1, Ordering::Relaxed);
|
||||
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,
|
||||
})
|
||||
}
|
||||
@@ -686,10 +732,12 @@ mod tests {
|
||||
|
||||
async fn connect_with_socket(
|
||||
&self,
|
||||
_socket: Arc<Self::Socket>,
|
||||
_remote: SocketAddr,
|
||||
socket: Arc<Self::Socket>,
|
||||
remote: SocketAddr,
|
||||
) -> 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 +845,8 @@ mod tests {
|
||||
}
|
||||
|
||||
struct RecordingSignaling {
|
||||
listener_requests: tokio::sync::Mutex<Vec<SelectPunchListener>>,
|
||||
confirm_transaction_id: bool,
|
||||
easy_requests: tokio::sync::Mutex<Vec<SendPunchPacketEasySym>>,
|
||||
hard_requests: tokio::sync::Mutex<Vec<SendPunchPacketHardSym>>,
|
||||
both_requests: tokio::sync::Mutex<Vec<SendPunchPacketBothEasySym>>,
|
||||
@@ -804,9 +854,100 @@ mod tests {
|
||||
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 {
|
||||
fn new(next_port_index: u32) -> Self {
|
||||
Self {
|
||||
listener_requests: tokio::sync::Mutex::new(Vec::new()),
|
||||
confirm_transaction_id: true,
|
||||
easy_requests: tokio::sync::Mutex::new(Vec::new()),
|
||||
hard_requests: tokio::sync::Mutex::new(Vec::new()),
|
||||
both_requests: tokio::sync::Mutex::new(Vec::new()),
|
||||
@@ -832,10 +973,16 @@ mod tests {
|
||||
async fn select_punch_listener(
|
||||
&self,
|
||||
_dst_peer_id: PeerId,
|
||||
_request: SelectPunchListener,
|
||||
request: SelectPunchListener,
|
||||
) -> 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 {
|
||||
listener_mapped_addr: SocketAddr::from(([127, 0, 0, 1], 30000)),
|
||||
transaction_id,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -56,6 +56,7 @@ fn select_listener_request_to_rpc(request: SelectPunchListener) -> SelectPunchLi
|
||||
SelectPunchListenerRequest {
|
||||
force_new: request.force_new,
|
||||
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 {
|
||||
force_new: input.force_new,
|
||||
prefer_port_mapping: input.prefer_port_mapping,
|
||||
transaction_id: input.transaction_id,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -70,6 +72,7 @@ fn select_listener_response_from_rpc(
|
||||
response: SelectPunchListenerResponse,
|
||||
) -> Result<CoreSelectPunchListenerResponse, UdpHolePunchSignalError> {
|
||||
Ok(CoreSelectPunchListenerResponse {
|
||||
transaction_id: response.transaction_id,
|
||||
listener_mapped_addr: SocketAddr::from(
|
||||
response
|
||||
.listener_mapped_addr
|
||||
@@ -83,6 +86,7 @@ fn select_listener_response_to_rpc(
|
||||
) -> SelectPunchListenerResponse {
|
||||
SelectPunchListenerResponse {
|
||||
listener_mapped_addr: Some(response.listener_mapped_addr.into()),
|
||||
transaction_id: response.transaction_id,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -553,8 +557,62 @@ where
|
||||
mod tests {
|
||||
use std::{future, net::Ipv4Addr, time::Duration};
|
||||
|
||||
use prost::Message;
|
||||
|
||||
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]
|
||||
fn outbound_rpc_controllers_preserve_timeouts() {
|
||||
assert_eq!(cone_controller().timeout_ms, 4000);
|
||||
@@ -568,14 +626,17 @@ mod tests {
|
||||
let domain_request = SelectPunchListener {
|
||||
force_new: true,
|
||||
prefer_port_mapping: false,
|
||||
transaction_id: Some(0),
|
||||
};
|
||||
let rpc_request = select_listener_request_to_rpc(domain_request.clone());
|
||||
assert!(rpc_request.force_new);
|
||||
assert!(!rpc_request.prefer_port_mapping);
|
||||
assert_eq!(rpc_request.transaction_id, Some(0));
|
||||
assert_eq!(
|
||||
select_listener_request_from_rpc(SelectPunchListenerRequest {
|
||||
force_new: true,
|
||||
prefer_port_mapping: false,
|
||||
transaction_id: Some(0),
|
||||
}),
|
||||
domain_request
|
||||
);
|
||||
@@ -583,6 +644,7 @@ mod tests {
|
||||
let mapped_addr: SocketAddr = "198.51.100.1:31001".parse().unwrap();
|
||||
let core_response = CoreSelectPunchListenerResponse {
|
||||
listener_mapped_addr: mapped_addr,
|
||||
transaction_id: Some(0),
|
||||
};
|
||||
let rpc_response = select_listener_response_to_rpc(core_response.clone());
|
||||
assert_eq!(
|
||||
|
||||
@@ -17,7 +17,10 @@ use crate::{
|
||||
peers::PeerConnectionOrigin,
|
||||
socket::{
|
||||
ListenerConnectionCounter, SocketContext,
|
||||
udp::{UdpBindOptions, UdpSession, VirtualUdpSocket, VirtualUdpSocketFactory},
|
||||
udp::{
|
||||
UdpBindOptions, UdpHolePunchObserverRegistry, UdpSession, VirtualUdpSocket,
|
||||
VirtualUdpSocketFactory,
|
||||
},
|
||||
},
|
||||
tunnel::Tunnel,
|
||||
};
|
||||
@@ -73,6 +76,7 @@ fn udp_url(addr: SocketAddr) -> url::Url {
|
||||
|
||||
pub struct UdpPunchListener<S> {
|
||||
pub socket: Arc<S>,
|
||||
pub hole_punch_observers: UdpHolePunchObserverRegistry,
|
||||
pub mapped_addr: SocketAddr,
|
||||
pub conn_counter: Arc<dyn ListenerConnectionCounter>,
|
||||
pub acceptor: Box<dyn UdpPunchAcceptor>,
|
||||
@@ -88,11 +92,13 @@ pub struct UdpResolvedPublicAddr {
|
||||
pub struct SelectPunchListener {
|
||||
pub force_new: bool,
|
||||
pub prefer_port_mapping: bool,
|
||||
pub transaction_id: Option<u32>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct SelectPunchListenerResponse {
|
||||
pub listener_mapped_addr: SocketAddr,
|
||||
pub transaction_id: Option<u32>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
|
||||
@@ -8,7 +8,7 @@ use std::{
|
||||
};
|
||||
|
||||
use anyhow::Context;
|
||||
use crossbeam::atomic::AtomicCell;
|
||||
use crossbeam_utils::atomic::AtomicCell;
|
||||
use quanta::Instant;
|
||||
use rand::{Rng, seq::SliceRandom as _};
|
||||
use tokio::{
|
||||
@@ -35,6 +35,9 @@ use super::{
|
||||
};
|
||||
|
||||
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> {
|
||||
/// Kept for listener-selection side effects and tests; production callers
|
||||
@@ -266,8 +269,16 @@ where
|
||||
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 {
|
||||
listener_mapped_addr: selected.mapped_addr,
|
||||
transaction_id: request.transaction_id,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -276,17 +287,12 @@ where
|
||||
request: SendPunchPacketCone,
|
||||
) -> Result<(), UdpHolePunchSignalError> {
|
||||
let _admission = self.admit().await?;
|
||||
let listener = self
|
||||
let (listener, observer) = self
|
||||
.common
|
||||
.find_listener(&request.listener_mapped_addr)
|
||||
.observe_hole_punch(&request.listener_mapped_addr, request.transaction_id)
|
||||
.await
|
||||
.ok_or_else(|| {
|
||||
UdpHolePunchSignalError::RemoteRejected(
|
||||
"send_punch_packet_for_cone failed to find listener".into(),
|
||||
)
|
||||
})?;
|
||||
|
||||
send_cone_hole_punch_packets(listener, &request)
|
||||
.map_err(Self::anyhow_to_signal_error)?;
|
||||
send_cone_hole_punch_packets(listener, &request, observer)
|
||||
.await
|
||||
.map_err(Self::anyhow_to_signal_error)
|
||||
}
|
||||
@@ -386,6 +392,7 @@ where
|
||||
let mut index = 0;
|
||||
while index < listeners.len() {
|
||||
let listener = &listeners[index];
|
||||
listener.hole_punch_observers.expire_prepared();
|
||||
let active = listener.last_active_time.load().elapsed().as_secs() < 40
|
||||
|| listener.last_select_time.load().elapsed().as_secs() < 30;
|
||||
if active {
|
||||
@@ -473,6 +480,36 @@ where
|
||||
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(
|
||||
&self,
|
||||
force_new_listener: bool,
|
||||
@@ -572,6 +609,7 @@ where
|
||||
|
||||
struct UdpPunchListenerRecord<S> {
|
||||
socket: Arc<S>,
|
||||
hole_punch_observers: crate::socket::udp::UdpHolePunchObserverRegistry,
|
||||
tasks: Mutex<JoinSet<()>>,
|
||||
connection_tasks: Arc<Mutex<JoinSet<()>>>,
|
||||
running: Arc<AtomicCell<bool>>,
|
||||
@@ -595,6 +633,7 @@ where
|
||||
{
|
||||
let UdpPunchListener {
|
||||
socket,
|
||||
hole_punch_observers,
|
||||
mapped_addr,
|
||||
conn_counter,
|
||||
mut acceptor,
|
||||
@@ -646,6 +685,7 @@ where
|
||||
|
||||
Self {
|
||||
socket,
|
||||
hole_punch_observers,
|
||||
tasks: Mutex::new(tasks),
|
||||
connection_tasks,
|
||||
running,
|
||||
@@ -726,6 +766,7 @@ where
|
||||
pub async fn send_cone_hole_punch_packets<S>(
|
||||
udp: Arc<S>,
|
||||
request: &SendPunchPacketCone,
|
||||
mut observer: crate::socket::udp::UdpHolePunchObserver,
|
||||
) -> anyhow::Result<()>
|
||||
where
|
||||
S: VirtualUdpSocket + 'static,
|
||||
@@ -738,21 +779,47 @@ where
|
||||
);
|
||||
}
|
||||
|
||||
for _ in 0..request.packet_batch_count {
|
||||
tracing::info!(?request, "sending hole punching packet");
|
||||
|
||||
let udp = udp.as_ref();
|
||||
let send_batch = |dest_addr| async move {
|
||||
for _ in 0..request.packet_count_per_batch {
|
||||
let udp_packet =
|
||||
new_hole_punch_packet(request.transaction_id, HOLE_PUNCH_PACKET_BODY_LEN);
|
||||
if let Err(err) = udp
|
||||
.send_to(&udp_packet.into_bytes(), request.dest_addr)
|
||||
.await
|
||||
{
|
||||
tracing::error!(?err, "failed to send hole punch packet to dest addr");
|
||||
if let Err(err) = udp.send_to(&udp_packet.into_bytes(), dest_addr).await {
|
||||
tracing::error!(
|
||||
?err,
|
||||
?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(())
|
||||
@@ -971,6 +1038,8 @@ mod tests {
|
||||
local_addr: SocketAddr,
|
||||
sent: tokio::sync::Mutex<Vec<(Vec<u8>, SocketAddr)>>,
|
||||
fail_next_send: AtomicUsize,
|
||||
incoming: StdMutex<VecDeque<(Vec<u8>, SocketAddr)>>,
|
||||
incoming_notify: tokio::sync::Notify,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
@@ -988,8 +1057,15 @@ mod tests {
|
||||
Ok(data.len())
|
||||
}
|
||||
|
||||
async fn recv_from(&self, _buf: &mut [u8]) -> io::Result<(usize, SocketAddr)> {
|
||||
std::future::pending().await
|
||||
async fn recv_from(&self, buf: &mut [u8]) -> io::Result<(usize, SocketAddr)> {
|
||||
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 +1082,7 @@ mod tests {
|
||||
|
||||
struct MockAcceptor {
|
||||
sockets: VecDeque<UdpPunchSocket>,
|
||||
_layer: Arc<crate::socket::udp::UdpSessionLayer<MockSocket>>,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
@@ -1039,6 +1116,8 @@ mod tests {
|
||||
local_addr: SocketAddr::from(([127, 0, 0, 1], 0)),
|
||||
sent: tokio::sync::Mutex::new(Vec::new()),
|
||||
fail_next_send: AtomicUsize::new(0),
|
||||
incoming: StdMutex::new(VecDeque::new()),
|
||||
incoming_notify: tokio::sync::Notify::new(),
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -1130,16 +1209,22 @@ mod tests {
|
||||
}
|
||||
|
||||
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 {
|
||||
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),
|
||||
}),
|
||||
socket,
|
||||
hole_punch_observers: layer.hole_punch_observers(),
|
||||
mapped_addr: SocketAddr::from(([203, 0, 113, 1], port)),
|
||||
conn_counter: Arc::new(MockCounter::default()),
|
||||
acceptor: Box::new(MockAcceptor {
|
||||
sockets: sockets.into(),
|
||||
_layer: layer,
|
||||
}),
|
||||
port_mapping_lease: None,
|
||||
}
|
||||
@@ -1206,6 +1291,8 @@ mod tests {
|
||||
local_addr: SocketAddr::from(([127, 0, 0, 1], 10001)),
|
||||
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 remote_addr = SocketAddr::from(([127, 0, 0, 1], 20001));
|
||||
let session =
|
||||
@@ -1231,12 +1318,248 @@ mod tests {
|
||||
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]
|
||||
async fn cone_packet_sender_keeps_old_batch_shape() {
|
||||
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 request = SendPunchPacketCone {
|
||||
listener_mapped_addr: SocketAddr::from(([203, 0, 113, 1], 10002)),
|
||||
@@ -1247,7 +1570,12 @@ mod tests {
|
||||
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
|
||||
.unwrap();
|
||||
|
||||
@@ -1262,6 +1590,8 @@ mod tests {
|
||||
local_addr: SocketAddr::from(([127, 0, 0, 1], 10004)),
|
||||
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 request = SendPunchPacketCone {
|
||||
listener_mapped_addr: SocketAddr::from(([203, 0, 113, 1], 10004)),
|
||||
@@ -1272,7 +1602,12 @@ mod tests {
|
||||
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
|
||||
.unwrap_err();
|
||||
|
||||
@@ -1286,6 +1621,8 @@ mod tests {
|
||||
local_addr: SocketAddr::from(([127, 0, 0, 1], 10005)),
|
||||
sent: tokio::sync::Mutex::new(Vec::new()),
|
||||
fail_next_send: AtomicUsize::new(1),
|
||||
incoming: StdMutex::new(VecDeque::new()),
|
||||
incoming_notify: tokio::sync::Notify::new(),
|
||||
});
|
||||
let request = SendPunchPacketCone {
|
||||
listener_mapped_addr: SocketAddr::from(([203, 0, 113, 1], 10005)),
|
||||
@@ -1296,7 +1633,12 @@ mod tests {
|
||||
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
|
||||
.unwrap();
|
||||
|
||||
@@ -1310,6 +1652,8 @@ mod tests {
|
||||
local_addr: SocketAddr::from(([127, 0, 0, 1], 10003)),
|
||||
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 next_idx = send_symmetric_hole_punch_packet(
|
||||
&[10, 11, 12],
|
||||
@@ -1335,6 +1679,8 @@ mod tests {
|
||||
local_addr: SocketAddr::from(([127, 0, 0, 1], 10006)),
|
||||
sent: tokio::sync::Mutex::new(Vec::new()),
|
||||
fail_next_send: AtomicUsize::new(1),
|
||||
incoming: StdMutex::new(VecDeque::new()),
|
||||
incoming_notify: tokio::sync::Notify::new(),
|
||||
});
|
||||
|
||||
let err = send_symmetric_hole_punch_packet(
|
||||
|
||||
@@ -7,6 +7,8 @@ use hyper::{Request, header};
|
||||
use hyper_util::rt::TokioIo;
|
||||
use rand::{Rng as _, seq::SliceRandom};
|
||||
use rustls::pki_types::ServerName;
|
||||
#[cfg(feature = "system-certificates")]
|
||||
use rustls_platform_verifier::BuilderVerifierExt as _;
|
||||
use tokio::io::{AsyncRead, AsyncWrite};
|
||||
use tokio_rustls::TlsConnector;
|
||||
use tokio_util::task::AbortOnDropHandle;
|
||||
@@ -184,12 +186,23 @@ where
|
||||
|
||||
if request.url.scheme() == "https" {
|
||||
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(),
|
||||
};
|
||||
let tls_config = rustls::ClientConfig::builder()
|
||||
.with_root_certificates(root_store)
|
||||
.with_no_client_auth();
|
||||
});
|
||||
let tls_config = tls_config.with_no_client_auth();
|
||||
let stream = TlsConnector::from(Arc::new(tls_config))
|
||||
.connect(server_name, socket)
|
||||
.await
|
||||
@@ -631,6 +644,44 @@ mod tests {
|
||||
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]
|
||||
fn http_discovery_interprets_redirect_and_body_forms() {
|
||||
let query = resolve_http_endpoint(HttpDiscoveryResponse {
|
||||
|
||||
@@ -1381,13 +1381,14 @@ mod tests {
|
||||
|
||||
#[tokio::test]
|
||||
async fn timeout_budget_reports_the_active_stage() {
|
||||
let error =
|
||||
with_timeout_budget("connect", Instant::now(), Duration::from_millis(1), async {
|
||||
crate::foundation::time::sleep(Duration::from_millis(20)).await;
|
||||
Ok::<(), anyhow::Error>(())
|
||||
})
|
||||
.await
|
||||
.unwrap_err();
|
||||
let error = with_timeout_budget(
|
||||
"connect",
|
||||
Instant::now(),
|
||||
Duration::from_millis(1),
|
||||
std::future::pending::<anyhow::Result<()>>(),
|
||||
)
|
||||
.await
|
||||
.unwrap_err();
|
||||
|
||||
assert!(error.to_string().contains("connect timeout after"));
|
||||
}
|
||||
|
||||
@@ -14,3 +14,15 @@ pub mod stats;
|
||||
pub(crate) mod task;
|
||||
pub(crate) mod time;
|
||||
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 {
|
||||
fn try_consume(&self, bytes: u64) -> bool;
|
||||
|
||||
/// Bytes charged by this limiter so far.
|
||||
fn metered_bytes(&self) -> u64 {
|
||||
0
|
||||
}
|
||||
}
|
||||
|
||||
impl ByteLimiter for () {
|
||||
@@ -37,6 +42,7 @@ struct BucketState {
|
||||
available_tokens: u64,
|
||||
last_refill: Instant,
|
||||
refill_remainder: u64,
|
||||
metered_bytes: u64,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
@@ -78,6 +84,7 @@ impl TokenBucket {
|
||||
available_tokens: config.capacity,
|
||||
last_refill: Instant::now(),
|
||||
refill_remainder: 0,
|
||||
metered_bytes: 0,
|
||||
}),
|
||||
config,
|
||||
stop_notifier: Notify::new(),
|
||||
@@ -132,15 +139,30 @@ impl TokenBucket {
|
||||
let tokens = tokens.min(self.config.capacity);
|
||||
|
||||
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 {
|
||||
return false;
|
||||
}
|
||||
|
||||
state.available_tokens -= tokens;
|
||||
state.metered_bytes = state.metered_bytes.saturating_add(tokens);
|
||||
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.
|
||||
pub async fn consume(&self, tokens: u64) {
|
||||
let mut remaining = tokens;
|
||||
@@ -166,6 +188,7 @@ impl TokenBucket {
|
||||
self.refill(&mut state, Instant::now());
|
||||
if state.available_tokens >= tokens {
|
||||
state.available_tokens -= tokens;
|
||||
state.metered_bytes = state.metered_bytes.saturating_add(tokens);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -195,6 +218,10 @@ impl ByteLimiter for TokenBucket {
|
||||
fn try_consume(&self, bytes: u64) -> bool {
|
||||
TokenBucket::try_consume(self, bytes)
|
||||
}
|
||||
|
||||
fn metered_bytes(&self) -> u64 {
|
||||
TokenBucket::metered_bytes(self)
|
||||
}
|
||||
}
|
||||
|
||||
pub struct TokenBucketManager {
|
||||
@@ -295,10 +322,14 @@ mod tests {
|
||||
#[tokio::test]
|
||||
async fn test_initial_state() {
|
||||
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
|
||||
assert!(bucket.try_consume(1000));
|
||||
assert!(!bucket.try_consume(1)); // Should be empty now
|
||||
assert!(bucket.try_consume_locked(&mut state, 1000, now));
|
||||
assert!(!bucket.try_consume_locked(&mut state, 1, now)); // Should be empty now
|
||||
}
|
||||
|
||||
/// Test token consumption behavior
|
||||
@@ -306,14 +337,18 @@ mod tests {
|
||||
async fn test_consumption() {
|
||||
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
|
||||
assert!(bucket.try_consume(1000));
|
||||
assert!(bucket.try_consume_locked(&mut state, 1000, now));
|
||||
|
||||
// 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
|
||||
assert!(bucket.try_consume(500));
|
||||
assert!(bucket.try_consume_locked(&mut state, 500, now));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
//! Shared data-plane flow registration and ownership.
|
||||
|
||||
use std::{
|
||||
net::{IpAddr, SocketAddr},
|
||||
net::SocketAddr,
|
||||
sync::{
|
||||
Arc,
|
||||
atomic::{AtomicUsize, Ordering},
|
||||
@@ -26,6 +26,16 @@ pub(crate) struct FlowKey {
|
||||
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)]
|
||||
pub(crate) struct FlowCountChange {
|
||||
pub previous: usize,
|
||||
@@ -136,14 +146,12 @@ impl<V> FlowTable<V> {
|
||||
self.entries.is_empty()
|
||||
}
|
||||
|
||||
pub fn contains_key(&self, entry: &FlowKey) -> bool {
|
||||
self.entries.contains_key(entry)
|
||||
pub(super) fn registration(&self, entry: &FlowKey) -> Option<u64> {
|
||||
self.entries.get(entry).map(|flow| flow.registration)
|
||||
}
|
||||
|
||||
pub fn contains_destination_ip(&self, destination: IpAddr) -> bool {
|
||||
self.entries
|
||||
.iter()
|
||||
.any(|entry| entry.key().dst.ip() == destination)
|
||||
pub fn contains_key(&self, entry: &FlowKey) -> bool {
|
||||
self.entries.contains_key(entry)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -252,7 +260,7 @@ impl<V> FlowTable<V> {
|
||||
fn increment_count(&self) -> FlowCountChange {
|
||||
let previous = self
|
||||
.count
|
||||
.fetch_update(Ordering::AcqRel, Ordering::Acquire, |count| {
|
||||
.try_update(Ordering::AcqRel, Ordering::Acquire, |count| {
|
||||
count.checked_add(1)
|
||||
})
|
||||
.expect("flow count overflow");
|
||||
@@ -294,7 +302,7 @@ impl<V> FlowTable<V> {
|
||||
|
||||
let previous = self
|
||||
.count
|
||||
.fetch_update(Ordering::AcqRel, Ordering::Acquire, |count| {
|
||||
.try_update(Ordering::AcqRel, Ordering::Acquire, |count| {
|
||||
count.checked_sub(delta)
|
||||
})
|
||||
.expect("flow count underflow");
|
||||
@@ -376,7 +384,6 @@ mod tests {
|
||||
assert!(!table.try_insert(first.clone(), 2));
|
||||
assert!(table.try_insert(second.clone(), 3));
|
||||
assert_eq!(table.count(), 2);
|
||||
assert!(table.contains_destination_ip(first.dst.ip()));
|
||||
|
||||
let retained = table.retain(|entry, _| entry == &second);
|
||||
assert_eq!(retained.removed, 1);
|
||||
|
||||
@@ -42,7 +42,7 @@ use crate::{
|
||||
},
|
||||
packet::{PacketType, ZCPacket},
|
||||
peers::{
|
||||
PeerPacketFilter,
|
||||
PeerPacketFilter, PeerPacketFilterResult,
|
||||
peer_manager::{PeerManagerCore, PipelineRegistrationGuard},
|
||||
},
|
||||
socket::{
|
||||
@@ -52,7 +52,7 @@ use crate::{
|
||||
VirtualTcpListener, VirtualTcpListenerFactory, VirtualTcpSocket,
|
||||
VirtualTcpSocketFactory,
|
||||
},
|
||||
udp::{UdpBindOptions, VirtualUdpSocket, VirtualUdpSocketFactory},
|
||||
udp::VirtualUdpSocketFactory,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -69,6 +69,9 @@ mod tcp;
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
mod udp;
|
||||
mod udp_fragments;
|
||||
|
||||
use udp_fragments::UdpFragments;
|
||||
|
||||
use self::{
|
||||
deadline::{DataPlaneDeadline, DataPlaneIoDeadline},
|
||||
@@ -169,12 +172,23 @@ pub(super) enum FlowData {
|
||||
Udp,
|
||||
}
|
||||
|
||||
const UDP_ENTRY: FlowKind = FlowKind::Udp;
|
||||
const TCP_ENTRY: FlowKind = FlowKind::Tcp;
|
||||
const TCP_LISTEN_ENTRY: FlowKind = FlowKind::TcpListen;
|
||||
|
||||
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>
|
||||
where
|
||||
@@ -195,6 +209,7 @@ where
|
||||
|
||||
net: Arc<Mutex<Option<SmoltcpPlane>>>,
|
||||
pub(super) entries: FlowSet,
|
||||
udp_fragments: Arc<std::sync::Mutex<UdpFragments>>,
|
||||
|
||||
data_plane_consumers: Arc<DataPlaneConsumers>,
|
||||
// Tracks whether the smoltcp `net` is ready for data-plane callers.
|
||||
@@ -207,8 +222,8 @@ impl<H> PeerPacketFilter for DataPlaneRuntime<H>
|
||||
where
|
||||
H: VirtualTcpSocketFactory + VirtualTcpListenerFactory + VirtualUdpSocketFactory,
|
||||
{
|
||||
async fn try_process_packet_from_peer(&self, packet: ZCPacket) -> Option<ZCPacket> {
|
||||
if self.entries.is_idle() {
|
||||
async fn try_process_packet_from_peer(&self, packet: ZCPacket) -> PeerPacketFilterResult {
|
||||
if self.entries.is_idle() && !packet::is_udp_fragment(&packet) {
|
||||
if tracing::enabled!(tracing::Level::TRACE)
|
||||
&& let Some(hdr) = packet.peer_manager_header()
|
||||
&& matches!(
|
||||
@@ -257,11 +272,11 @@ where
|
||||
);
|
||||
}
|
||||
}
|
||||
return Some(packet);
|
||||
return PeerPacketFilterResult::Pass(packet);
|
||||
}
|
||||
let route = self.entries.route_peer_packet(&packet, true);
|
||||
let (entry_key, tcp_flags) = match route {
|
||||
PeerPacketRoute::Pass => return Some(packet),
|
||||
PeerPacketRoute::Pass => return PeerPacketFilterResult::Pass(packet),
|
||||
PeerPacketRoute::Unmatched { entry, tcp_flags } => {
|
||||
tracing::trace!(
|
||||
entry_key = ?entry,
|
||||
@@ -271,34 +286,20 @@ where
|
||||
entry_count = self.entries.count(),
|
||||
"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::FragmentedUdp { source, mirror } => {
|
||||
let source: IpAddr = source.into();
|
||||
tracing::trace!(
|
||||
is_in_entries = mirror,
|
||||
"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"
|
||||
),
|
||||
}
|
||||
PeerPacketRoute::FragmentedUdp { destination } => {
|
||||
let local_ip = Self::runtime_ipv4(&self.runtime_config).map(|ip| ip.address());
|
||||
if Some(destination) != local_ip {
|
||||
return PeerPacketFilterResult::Pass(packet);
|
||||
}
|
||||
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)),
|
||||
entries: Arc::new(FlowTable::default()),
|
||||
udp_fragments: Arc::new(std::sync::Mutex::new(UdpFragments::default())),
|
||||
|
||||
data_plane_consumers: Arc::new(DataPlaneConsumers::new()),
|
||||
data_plane_net_ready: tokio::sync::watch::channel(false).0,
|
||||
@@ -392,12 +394,21 @@ where
|
||||
let peer_manager = self.peer_manager.clone();
|
||||
let packet_recv = self.packet_recv.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_net_ready = self.data_plane_net_ready.clone();
|
||||
self.runtime_tasks.lock().unwrap().spawn(async move {
|
||||
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();
|
||||
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();
|
||||
|
||||
if !data_plane_active {
|
||||
@@ -429,12 +440,12 @@ where
|
||||
let _ = data_plane_net_ready.send_replace(false);
|
||||
select! {
|
||||
_ = peer_changes.changed() => {}
|
||||
_ = fragment_gc.tick() => {}
|
||||
_ = data_plane_consumers.changed() => {}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
let cur_ipv4 = Self::runtime_ipv4(&runtime_config);
|
||||
if prev_ipv4 != cur_ipv4 {
|
||||
let old_ipv4 = prev_ipv4;
|
||||
prev_ipv4 = cur_ipv4;
|
||||
@@ -487,6 +498,7 @@ where
|
||||
|
||||
select! {
|
||||
_ = peer_changes.changed() => {}
|
||||
_ = fragment_gc.tick() => {}
|
||||
_ = data_plane_consumers.changed() => {}
|
||||
}
|
||||
}
|
||||
@@ -549,6 +561,7 @@ where
|
||||
let _ = self.data_plane_net_ready.send_replace(false);
|
||||
self.entries.clear();
|
||||
Self::shutdown_tasks(&self.runtime_tasks).await;
|
||||
*self.udp_fragments.lock().unwrap() = UdpFragments::default();
|
||||
}
|
||||
|
||||
pub(crate) async fn stop_runtime(&self) {
|
||||
@@ -687,7 +700,7 @@ where
|
||||
generation.ensure_open()?;
|
||||
|
||||
Ok(DataPlaneTcpStream::new(
|
||||
Box::new(stream),
|
||||
DataPlaneTcpIo::Smoltcp(stream),
|
||||
local_addr,
|
||||
Some(data_plane_ref),
|
||||
DataPlaneTcpStreamRoute::Outbound { _flow: flow },
|
||||
@@ -709,7 +722,7 @@ where
|
||||
.await?;
|
||||
let local_addr = socket.local_addr().map_err(DataPlaneError::from)?;
|
||||
Ok(DataPlaneTcpStream::new(
|
||||
Box::new(socket),
|
||||
DataPlaneTcpIo::External(Box::new(socket)),
|
||||
local_addr,
|
||||
None,
|
||||
DataPlaneTcpStreamRoute::External,
|
||||
@@ -747,7 +760,7 @@ where
|
||||
)))
|
||||
.await?;
|
||||
Ok(DataPlaneTcpStream::new(
|
||||
stream,
|
||||
DataPlaneTcpIo::External(stream),
|
||||
source_addr,
|
||||
None,
|
||||
DataPlaneTcpStreamRoute::External,
|
||||
@@ -943,19 +956,44 @@ where
|
||||
) -> DataPlaneResult<DataPlaneUdpSocket> {
|
||||
let data_plane_ref = self.acquire_data_plane_ref()?;
|
||||
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),
|
||||
local_port,
|
||||
))
|
||||
.with_context(self.socket_context.clone());
|
||||
let reservation = generation
|
||||
.while_open(deadline.run(self.host.bind_udp(reservation_options)))
|
||||
.await?;
|
||||
let reserved_port = reservation
|
||||
.local_addr()
|
||||
.map_err(DataPlaneError::from)?
|
||||
.port();
|
||||
let bind_addr = SocketAddr::new(IpAddr::V4(ipv4_addr.address()), reserved_port);
|
||||
|
||||
// The bound endpoint is also the port lease. Reserve it before creating
|
||||
// the smoltcp socket, which does not check other sockets for duplicates.
|
||||
let try_register = |port| {
|
||||
let local_addr = SocketAddr::new(ipv4_addr.address().into(), port);
|
||||
FlowLease::try_register(
|
||||
self.entries.clone(),
|
||||
FlowKey::udp_bind(local_addr),
|
||||
FlowData::Udp,
|
||||
)
|
||||
.map(|flow| (local_addr, flow))
|
||||
};
|
||||
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
|
||||
.while_open(deadline.run(smoltcp_net.udp_bind(bind_addr)))
|
||||
.await?;
|
||||
@@ -965,10 +1003,8 @@ where
|
||||
|
||||
Ok(DataPlaneUdpSocket {
|
||||
socket,
|
||||
flows: self.entries.clone(),
|
||||
routes: std::sync::Mutex::new(std::collections::HashMap::new()),
|
||||
_bind_flow: bind_flow,
|
||||
local_addr,
|
||||
_reservation: reservation,
|
||||
_data_plane_lease: data_plane_ref,
|
||||
generation,
|
||||
})
|
||||
|
||||
@@ -4,10 +4,7 @@ use std::net::{IpAddr, Ipv4Addr, SocketAddr};
|
||||
|
||||
use smoltcp::wire::{IPV4_HEADER_LEN, IpProtocol, Ipv4Packet, TcpPacket, UdpPacket};
|
||||
|
||||
use crate::{
|
||||
gateway::proxy::ip_reassembler::IpReassembler,
|
||||
packet::{PacketType, ZCPacket},
|
||||
};
|
||||
use crate::packet::{PacketType, ZCPacket};
|
||||
|
||||
use super::flow::{FlowKey, FlowKind, FlowTable};
|
||||
|
||||
@@ -22,7 +19,7 @@ enum ClassifiedPeerPacket {
|
||||
entry: FlowKey,
|
||||
},
|
||||
FragmentedUdp {
|
||||
source: Ipv4Addr,
|
||||
destination: Ipv4Addr,
|
||||
},
|
||||
Unsupported,
|
||||
}
|
||||
@@ -38,8 +35,7 @@ pub(crate) enum PeerPacketRoute {
|
||||
tcp_flags: Option<u8>,
|
||||
},
|
||||
FragmentedUdp {
|
||||
source: Ipv4Addr,
|
||||
mirror: bool,
|
||||
destination: Ipv4Addr,
|
||||
},
|
||||
}
|
||||
fn classify_peer_ipv4_payload(payload: &[u8]) -> ClassifiedPeerPacket {
|
||||
@@ -72,9 +68,9 @@ fn classify_peer_ipv4_payload(payload: &[u8]) -> ClassifiedPeerPacket {
|
||||
}
|
||||
}
|
||||
IpProtocol::Udp => {
|
||||
if IpReassembler::is_packet_fragmented(&ipv4) {
|
||||
if ipv4.frag_offset() != 0 || ipv4.more_frags() {
|
||||
return ClassifiedPeerPacket::FragmentedUdp {
|
||||
source: ipv4.src_addr(),
|
||||
destination: ipv4.dst_addr(),
|
||||
};
|
||||
}
|
||||
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 {
|
||||
u8::from(tcp.fin())
|
||||
| (u8::from(tcp.syn()) << 1)
|
||||
@@ -108,18 +132,7 @@ impl<V> FlowTable<V> {
|
||||
packet: &ZCPacket,
|
||||
allow_tcp_listen_fallback: bool,
|
||||
) -> PeerPacketRoute {
|
||||
let Some(header) = packet.peer_manager_header() else {
|
||||
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 {
|
||||
if !is_data_plane_packet(packet) {
|
||||
return PeerPacketRoute::Pass;
|
||||
}
|
||||
|
||||
@@ -144,12 +157,9 @@ impl<V> FlowTable<V> {
|
||||
};
|
||||
(entry, Some(flags))
|
||||
}
|
||||
ClassifiedPeerPacket::Udp { entry } => (entry, None),
|
||||
ClassifiedPeerPacket::FragmentedUdp { source } => {
|
||||
return PeerPacketRoute::FragmentedUdp {
|
||||
source,
|
||||
mirror: self.contains_destination_ip(source.into()),
|
||||
};
|
||||
ClassifiedPeerPacket::Udp { entry } => (FlowKey::udp_bind(entry.src), None),
|
||||
ClassifiedPeerPacket::FragmentedUdp { destination } => {
|
||||
return PeerPacketRoute::FragmentedUdp { destination };
|
||||
}
|
||||
ClassifiedPeerPacket::Unsupported => return PeerPacketRoute::Pass,
|
||||
};
|
||||
@@ -234,7 +244,7 @@ mod tests {
|
||||
assert_eq!(
|
||||
classify_peer_ipv4_payload(&fragmented),
|
||||
ClassifiedPeerPacket::FragmentedUdp {
|
||||
source: Ipv4Addr::new(10, 1, 1, 2),
|
||||
destination: Ipv4Addr::new(10, 2, 2, 3),
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -314,34 +324,51 @@ mod tests {
|
||||
);
|
||||
}
|
||||
#[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);
|
||||
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 entry = FlowKey::udp_bind("10.2.2.3:4321".parse().unwrap());
|
||||
assert_eq!(
|
||||
table.route_peer_ipv4_payload(&packet, false),
|
||||
PeerPacketRoute::FragmentedUdp {
|
||||
source: Ipv4Addr::new(10, 1, 1, 2),
|
||||
mirror: false,
|
||||
PeerPacketRoute::Unmatched {
|
||||
entry: entry.clone(),
|
||||
tcp_flags: None
|
||||
}
|
||||
);
|
||||
|
||||
table.insert(
|
||||
FlowKey {
|
||||
src: "10.2.2.3:4321".parse().unwrap(),
|
||||
dst: "10.1.1.2:1234".parse().unwrap(),
|
||||
kind: FlowKind::Udp,
|
||||
},
|
||||
(),
|
||||
);
|
||||
table.insert(entry.clone(), ());
|
||||
assert_eq!(
|
||||
table.route_peer_ipv4_payload(&packet, false),
|
||||
PeerPacketRoute::FragmentedUdp {
|
||||
source: Ipv4Addr::new(10, 1, 1, 2),
|
||||
mirror: true,
|
||||
PeerPacketRoute::Deliver {
|
||||
entry,
|
||||
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]
|
||||
fn flow_table_routes_loopback_modified_source_packets() {
|
||||
|
||||
@@ -6,7 +6,7 @@ use std::{
|
||||
sync::{Arc, Mutex},
|
||||
};
|
||||
|
||||
use crossbeam::atomic::AtomicCell;
|
||||
use crossbeam_utils::atomic::AtomicCell;
|
||||
use tokio::sync::Notify;
|
||||
use tokio_util::sync::CancellationToken;
|
||||
|
||||
|
||||
@@ -77,7 +77,9 @@ impl SmoltcpPlane {
|
||||
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(
|
||||
device,
|
||||
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 {
|
||||
self.generation.clone()
|
||||
}
|
||||
|
||||
@@ -25,7 +25,7 @@ pub(super) enum DataPlaneTcpStreamRoute {
|
||||
|
||||
/// A TCP stream created by the data-plane API.
|
||||
pub struct DataPlaneTcpStream {
|
||||
pub(super) stream: DataPlaneTcpIo,
|
||||
stream: Option<DataPlaneTcpIo>,
|
||||
pub(super) local_addr: SocketAddr,
|
||||
pub(super) _route: DataPlaneTcpStreamRoute,
|
||||
pub(super) _data_plane_lease: Option<DataPlaneLease>,
|
||||
@@ -53,7 +53,7 @@ impl DataPlaneTcpStream {
|
||||
generation: DataPlaneIoGuard,
|
||||
) -> Self {
|
||||
Self {
|
||||
stream,
|
||||
stream: Some(stream),
|
||||
local_addr,
|
||||
_data_plane_lease: data_plane_lease,
|
||||
_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 {
|
||||
pub fn local_addr(&self) -> SocketAddr {
|
||||
self.local_addr
|
||||
@@ -99,7 +114,7 @@ impl DataPlaneTcpListener {
|
||||
FlowData::DataPlaneRoute,
|
||||
);
|
||||
let accepted = DataPlaneTcpStream::new(
|
||||
Box::new(stream),
|
||||
DataPlaneTcpIo::Smoltcp(stream),
|
||||
local_addr,
|
||||
Some(self.data_plane_lease.clone()),
|
||||
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() {
|
||||
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()));
|
||||
}
|
||||
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>> {
|
||||
@@ -143,7 +158,7 @@ impl AsyncWrite for DataPlaneTcpStream {
|
||||
{
|
||||
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(
|
||||
@@ -155,6 +170,6 @@ impl AsyncWrite for DataPlaneTcpStream {
|
||||
{
|
||||
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 smoltcp::wire::{IpAddress, IpProtocol, Ipv4Packet, TcpPacket};
|
||||
use smoltcp::wire::{IpAddress, IpProtocol, Ipv4Packet, TcpPacket, UdpPacket};
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
|
||||
use super::*;
|
||||
@@ -36,6 +36,7 @@ fn test_gateway() -> Arc<DataPlaneRuntime<TestHost>> {
|
||||
packet_recv: Arc::new(Mutex::new(packet_recv)),
|
||||
net: Arc::new(Mutex::new(None)),
|
||||
entries: Arc::new(FlowTable::default()),
|
||||
udp_fragments: Arc::new(std::sync::Mutex::new(UdpFragments::default())),
|
||||
data_plane_consumers: Arc::new(DataPlaneConsumers::new()),
|
||||
data_plane_net_ready: tokio::sync::watch::channel(false).0,
|
||||
pipeline_guard: Mutex::new(None),
|
||||
@@ -196,26 +197,45 @@ fn build_tcp_packet(src: SocketAddr, dst: SocketAddr) -> Vec<u8> {
|
||||
buf
|
||||
}
|
||||
|
||||
fn build_udp_followup_fragment(src: Ipv4Addr, dst: Ipv4Addr) -> Vec<u8> {
|
||||
let mut buf = vec![0u8; 28];
|
||||
{
|
||||
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]);
|
||||
pub(super) const UDP_LOCAL_IP: Ipv4Addr = Ipv4Addr::new(10, 144, 144, 1);
|
||||
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);
|
||||
|
||||
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]
|
||||
@@ -440,18 +460,6 @@ async fn data_plane_sessions_report_udp_truncation() {
|
||||
.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 send = session_a
|
||||
.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");
|
||||
|
||||
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);
|
||||
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;
|
||||
}
|
||||
|
||||
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]
|
||||
async fn data_plane_udp_pingpong() {
|
||||
let (a, b) = setup_data_plane_pair().await;
|
||||
let timeout = Duration::from_secs(10);
|
||||
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 addr_a = SocketAddr::new(a.ip.address().into(), socket_a.local_addr().port());
|
||||
let addr_b = SocketAddr::new(b.ip.address().into(), socket_b.local_addr().port());
|
||||
|
||||
socket_b.send_to(b"warmup", addr_a).await.unwrap();
|
||||
socket_a.send_to(b"ping", addr_b).await.unwrap();
|
||||
let mut buf = [0u8; 16];
|
||||
let (len, from) = tokio::time::timeout(timeout, socket_b.recv_from(&mut buf))
|
||||
.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;
|
||||
}
|
||||
let first = a.gateway.data_plane_udp_bind(0, timeout).await.unwrap();
|
||||
let second = a.gateway.data_plane_udp_bind(0, timeout).await.unwrap();
|
||||
let receiver = b.gateway.data_plane_udp_bind(0, timeout).await.unwrap();
|
||||
assert_ne!(first.local_addr(), second.local_addr());
|
||||
for sender in [&first, &second] {
|
||||
assert_ne!(sender.local_addr().port(), 0);
|
||||
assert_eq!(sender.local_addr().ip(), IpAddr::V4(a.ip.address()));
|
||||
send_udp(sender, &receiver, b"ping").await;
|
||||
send_udp(&receiver, sender, b"pong").await;
|
||||
}
|
||||
|
||||
// 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;
|
||||
}
|
||||
|
||||
@@ -581,60 +611,125 @@ async fn data_plane_udp_carries_maximum_ipv4_payload() {
|
||||
let timeout = Duration::from_secs(10);
|
||||
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 addr_a = SocketAddr::new(a.ip.address().into(), socket_a.local_addr().port());
|
||||
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);
|
||||
|
||||
send_udp(&socket_a, &socket_b, &vec![0x5a; 65_507]).await;
|
||||
stop_data_plane_pair(&a, &b).await;
|
||||
}
|
||||
|
||||
#[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 timeout = Duration::from_secs(10);
|
||||
let socket = a.gateway.data_plane_udp_bind(0, timeout).await.unwrap();
|
||||
let first = SocketAddr::new(b.ip.address().into(), 31001);
|
||||
let second = SocketAddr::new(b.ip.address().into(), 31002);
|
||||
|
||||
socket.send_to(b"one", first).await.unwrap();
|
||||
socket.send_to(b"two", second).await.unwrap();
|
||||
assert_eq!(a.gateway.entries.count(), 2);
|
||||
|
||||
drop(socket);
|
||||
let first = a.gateway.data_plane_udp_bind(48000, timeout).await.unwrap();
|
||||
let second = b.gateway.data_plane_udp_bind(48000, timeout).await.unwrap();
|
||||
let error = a
|
||||
.gateway
|
||||
.data_plane_udp_bind(48000, timeout)
|
||||
.await
|
||||
.err()
|
||||
.unwrap();
|
||||
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);
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
#[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]
|
||||
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 endpoint = data_plane_endpoint(host.clone(), "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(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
|
||||
.gateway
|
||||
.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;
|
||||
drop((fixed, socket));
|
||||
gateway.stop_runtime().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);
|
||||
|
||||
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 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));
|
||||
unmatched_packet.fill_peer_manager_hdr(1, 2, PacketType::DataWithKcpSrcModified as u8);
|
||||
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]);
|
||||
malformed_packet.fill_peer_manager_hdr(1, 2, PacketType::DataWithQuicSrcModified as u8);
|
||||
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;
|
||||
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);
|
||||
|
||||
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;
|
||||
assert!(receiver.try_recv().is_err());
|
||||
}
|
||||
|
||||
#[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 local = SocketAddr::new(IpAddr::V4(Ipv4Addr::new(10, 144, 144, 1)), 40000);
|
||||
let remote = SocketAddr::new(IpAddr::V4(Ipv4Addr::new(10, 144, 144, 3)), 53);
|
||||
gateway.entries.insert(
|
||||
FlowKey {
|
||||
src: local,
|
||||
dst: remote,
|
||||
kind: UDP_ENTRY,
|
||||
},
|
||||
FlowData::Udp,
|
||||
);
|
||||
assert_eq!(gateway.entries.count(), 1);
|
||||
|
||||
let mut packet = ZCPacket::new_with_payload(&build_udp_followup_fragment(
|
||||
match remote.ip() {
|
||||
IpAddr::V4(ip) => ip,
|
||||
IpAddr::V6(_) => unreachable!(),
|
||||
},
|
||||
match local.ip() {
|
||||
IpAddr::V4(ip) => ip,
|
||||
IpAddr::V6(_) => unreachable!(),
|
||||
},
|
||||
));
|
||||
packet.fill_peer_manager_hdr(1, 2, PacketType::Data as u8);
|
||||
|
||||
let result = gateway.try_process_packet_from_peer(packet).await;
|
||||
assert!(result.is_some());
|
||||
gateway.runtime_config.update_peer_with(|peer| {
|
||||
peer.runtime.core.routes.ipv4 = Some(IpPrefix::new(UDP_LOCAL_IP.into(), 24).unwrap());
|
||||
});
|
||||
gateway
|
||||
.entries
|
||||
.insert(FlowKey::udp_bind(UDP_LOCAL), FlowData::Udp);
|
||||
for (destination, packet_type, to_peer, buffered) in [
|
||||
(UDP_LOCAL_IP, PacketType::Data, 2, true),
|
||||
(Ipv4Addr::new(10, 144, 144, 9), PacketType::Data, 2, false),
|
||||
(UDP_LOCAL_IP, PacketType::DataWithKcpSrcModified, 1, true),
|
||||
(UDP_LOCAL_IP, PacketType::DataWithKcpSrcModified, 2, false),
|
||||
(UDP_LOCAL_IP, PacketType::DataWithQuicSrcModified, 1, true),
|
||||
(UDP_LOCAL_IP, PacketType::DataWithQuicSrcModified, 2, false),
|
||||
] {
|
||||
let [_, mut tail] = build_udp_fragments();
|
||||
let mut ip = Ipv4Packet::new_unchecked(tail.mut_payload());
|
||||
ip.set_dst_addr(destination);
|
||||
ip.fill_checksum();
|
||||
tail.fill_peer_manager_hdr(1, to_peer, packet_type as u8);
|
||||
let expected = tail.clone().into_bytes();
|
||||
let result = gateway.try_process_packet_from_peer(tail).await;
|
||||
if buffered {
|
||||
assert!(matches!(result, PeerPacketFilterResult::Consumed));
|
||||
} else {
|
||||
let PeerPacketFilterResult::Pass(packet) = result else {
|
||||
panic!("unrelated fragment buffered")
|
||||
};
|
||||
assert_eq!(packet.into_bytes(), expected);
|
||||
}
|
||||
assert!(gateway.packet_recv.lock().await.try_recv().is_err());
|
||||
}
|
||||
}
|
||||
|
||||
let mut receiver = gateway.packet_recv.lock().await;
|
||||
let received = receiver.try_recv().unwrap();
|
||||
assert_eq!(
|
||||
received.peer_manager_header().unwrap().packet_type,
|
||||
PacketType::Data as u8
|
||||
#[tokio::test]
|
||||
async fn data_plane_udp_receives_fragments_after_unbound_datagrams() {
|
||||
let endpoint = data_plane_endpoint(
|
||||
Arc::new(TestHost::default()),
|
||||
"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.
|
||||
|
||||
use std::{
|
||||
any::Any,
|
||||
collections::{HashMap, hash_map::Entry},
|
||||
net::SocketAddr,
|
||||
sync::{Arc, Mutex},
|
||||
};
|
||||
use std::{net::SocketAddr, sync::Arc};
|
||||
|
||||
use super::{
|
||||
DataPlaneIoGuard, DataPlaneLease, DataPlaneUdpIo, FlowData, FlowKey, FlowLease, FlowSet,
|
||||
UDP_ENTRY,
|
||||
};
|
||||
use super::{DataPlaneIoGuard, DataPlaneLease, DataPlaneUdpIo, FlowData, FlowLease};
|
||||
|
||||
pub struct DataPlaneUdpSocket {
|
||||
pub(super) socket: Arc<DataPlaneUdpIo>,
|
||||
pub(super) flows: FlowSet,
|
||||
pub(super) routes: Mutex<HashMap<SocketAddr, FlowLease<FlowData>>>,
|
||||
pub(super) _bind_flow: FlowLease<FlowData>,
|
||||
pub(super) local_addr: SocketAddr,
|
||||
pub(super) _reservation: Arc<dyn Any + Send + Sync>,
|
||||
pub(super) _data_plane_lease: DataPlaneLease,
|
||||
pub(super) generation: DataPlaneIoGuard,
|
||||
}
|
||||
@@ -31,21 +21,6 @@ impl DataPlaneUdpSocket {
|
||||
self.generation
|
||||
.ensure_open()
|
||||
.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! {
|
||||
biased;
|
||||
_ = 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,
|
||||
};
|
||||
#[cfg(feature = "proxy-smoltcp-stack")]
|
||||
pub(crate) use port_forward::PortForwardAdapter;
|
||||
pub(crate) use port_forward::{PortForwardAdapter, PreparedPortForwards};
|
||||
#[cfg(feature = "proxy-smoltcp-stack")]
|
||||
pub(crate) use socks5::Socks5GatewayAdapter;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
//! Host port-forward adapter backed by the data-plane runtime.
|
||||
|
||||
use std::{
|
||||
collections::HashMap,
|
||||
net::SocketAddr,
|
||||
sync::{
|
||||
Arc,
|
||||
@@ -9,17 +10,17 @@ use std::{
|
||||
time::Duration,
|
||||
};
|
||||
|
||||
use crossbeam::atomic::AtomicCell;
|
||||
use crossbeam_utils::atomic::AtomicCell;
|
||||
use dashmap::DashMap;
|
||||
use quanta::Instant;
|
||||
use tokio::{
|
||||
select,
|
||||
sync::{Mutex, OwnedMutexGuard, OwnedSemaphorePermit, Semaphore},
|
||||
task::JoinSet,
|
||||
task::{JoinHandle, JoinSet},
|
||||
};
|
||||
use tokio_util::{
|
||||
sync::{CancellationToken, DropGuard},
|
||||
task::AbortOnDropHandle,
|
||||
sync::CancellationToken,
|
||||
task::{AbortOnDropHandle, TaskTracker},
|
||||
};
|
||||
|
||||
use crate::{
|
||||
@@ -94,24 +95,133 @@ struct UdpClientReservation {
|
||||
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>
|
||||
where
|
||||
H: VirtualTcpSocketFactory + VirtualTcpListenerFactory + VirtualUdpSocketFactory,
|
||||
{
|
||||
operation: Mutex<()>,
|
||||
operation: Arc<Mutex<()>>,
|
||||
started: AtomicBool,
|
||||
runtime_handle: Option<tokio::runtime::Handle>,
|
||||
runtime_config: CoreRuntimeConfigStore,
|
||||
data_plane: Arc<DataPlaneRuntime<H>>,
|
||||
host: Arc<H>,
|
||||
socket_context: SocketContext,
|
||||
events: Arc<dyn CoreEventSink>,
|
||||
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_response_tasks: Arc<DashMap<UdpClientKey, AbortOnDropHandle<()>>>,
|
||||
udp_client_admission: Arc<Mutex<()>>,
|
||||
udp_client_slots: Arc<Semaphore>,
|
||||
consumer_lease: Mutex<Option<DataPlaneConsumerLease>>,
|
||||
consumer_lease: std::sync::Mutex<Option<DataPlaneConsumerLease>>,
|
||||
}
|
||||
|
||||
impl<H> PortForwardAdapter<H>
|
||||
@@ -124,27 +234,29 @@ where
|
||||
host: Arc<H>,
|
||||
socket_context: SocketContext,
|
||||
events: Arc<dyn CoreEventSink>,
|
||||
runtime_handle: Option<tokio::runtime::Handle>,
|
||||
) -> Arc<Self> {
|
||||
Arc::new(Self {
|
||||
operation: Mutex::new(()),
|
||||
operation: Arc::new(Mutex::new(())),
|
||||
started: AtomicBool::new(false),
|
||||
runtime_handle,
|
||||
runtime_config,
|
||||
data_plane,
|
||||
host,
|
||||
socket_context,
|
||||
events,
|
||||
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_response_tasks: Arc::new(DashMap::new()),
|
||||
udp_client_admission: Arc::new(Mutex::new(())),
|
||||
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<()> {
|
||||
let _operation = self.operation.lock().await;
|
||||
pub(crate) async fn start(self: &Arc<Self>) -> anyhow::Result<()> {
|
||||
let operation = self.operation.clone().lock_owned().await;
|
||||
if self.started.load(Ordering::Acquire) {
|
||||
return Ok(());
|
||||
}
|
||||
@@ -160,95 +272,157 @@ where
|
||||
.gateway
|
||||
.port_forwards
|
||||
.clone();
|
||||
if let Err(error) = self.apply_port_forwards(&cfgs).await {
|
||||
self.stop_inner().await;
|
||||
return Err(error);
|
||||
}
|
||||
let prepared = match self.prepare_inner(&cfgs, operation).await {
|
||||
Ok(prepared) => prepared,
|
||||
Err(error) => {
|
||||
self.stop_inner().await;
|
||||
return Err(error);
|
||||
}
|
||||
};
|
||||
let cleanup = prepared.commit();
|
||||
self.started.store(true, Ordering::Release);
|
||||
let _ = cleanup.await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) async fn reload(&self, cfgs: &[PortForwardConfig]) -> anyhow::Result<()> {
|
||||
let _operation = self.operation.lock().await;
|
||||
if !self.started.load(Ordering::Acquire) {
|
||||
return Ok(());
|
||||
pub(crate) async fn prepare(
|
||||
self: &Arc<Self>,
|
||||
cfgs: &[PortForwardConfig],
|
||||
) -> 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 {
|
||||
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!(
|
||||
"unsupported protocol: {}, only support udp / tcp",
|
||||
cfg.proto
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if !cfgs.is_empty() {
|
||||
let mut consumer_lease = self.consumer_lease.lock().await;
|
||||
if consumer_lease.is_none() {
|
||||
consumer_lease.replace(self.data_plane.acquire_consumer_lease()?);
|
||||
let key = (cfg.proto.clone(), cfg.bind_addr);
|
||||
if let Some(previous) = configs.insert(key, cfg.clone()) {
|
||||
anyhow::ensure!(
|
||||
previous == cfg,
|
||||
"conflicting port-forward targets for {} {}",
|
||||
cfg.proto,
|
||||
cfg.bind_addr
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
self.cancel_tokens.retain(|current, _| {
|
||||
let keep = cfgs.iter().any(|next| {
|
||||
if next.dst_addr.ip().is_unspecified() {
|
||||
current.bind_addr == next.bind_addr && current.proto == next.proto
|
||||
} else {
|
||||
current == next
|
||||
}
|
||||
});
|
||||
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));
|
||||
let consumer_lease = if !configs.is_empty() && self.consumer_lease.lock().unwrap().is_none()
|
||||
{
|
||||
Some(self.data_plane.acquire_consumer_lease()?)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let mut sockets = Vec::new();
|
||||
for cfg in cfgs {
|
||||
if !self.cancel_tokens.contains_key(cfg) {
|
||||
self.add_port_forward(cfg.clone()).await?;
|
||||
let mut cfg = cfg.clone();
|
||||
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;
|
||||
}
|
||||
}
|
||||
if cfgs.is_empty() {
|
||||
self.consumer_lease.lock().await.take();
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
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
|
||||
let socket = if cfg.proto == "tcp" {
|
||||
PortForwardSocket::Tcp(
|
||||
self.host
|
||||
.bind_tcp(self.tcp_listen_options(cfg.bind_addr))
|
||||
.await?,
|
||||
)
|
||||
}
|
||||
} 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(())
|
||||
Ok(PreparedPortForwards {
|
||||
adapter: self.clone(),
|
||||
operation,
|
||||
configs,
|
||||
sockets,
|
||||
consumer_lease,
|
||||
})
|
||||
}
|
||||
|
||||
async fn add_tcp_port_forward(&self, cfg: &PortForwardConfig) -> anyhow::Result<()> {
|
||||
let (bind_addr, dst_addr) = (cfg.bind_addr, cfg.dst_addr);
|
||||
fn tcp_listen_options(&self, bind_addr: SocketAddr) -> TcpListenOptions {
|
||||
let options = TcpListenOptions::port_forward(bind_addr);
|
||||
let bind = options
|
||||
.bind
|
||||
.clone()
|
||||
.with_context(self.socket_context.clone());
|
||||
let options = options.with_bind(bind);
|
||||
let listener = self.host.bind_tcp(options.clone()).await?;
|
||||
options.with_bind(bind)
|
||||
}
|
||||
|
||||
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");
|
||||
let cancel = CancellationToken::new();
|
||||
self.cancel_tokens
|
||||
.insert(cfg.clone(), cancel.clone().drop_guard());
|
||||
let config = Arc::new(std::sync::Mutex::new(cfg.clone()));
|
||||
let task_cancel = cancel.clone();
|
||||
let task_config = config.clone();
|
||||
|
||||
let data_plane = self.data_plane.clone();
|
||||
let connections = Arc::new(std::sync::Mutex::new(JoinSet::new()));
|
||||
@@ -257,7 +431,8 @@ where
|
||||
"TCP port-forward connections",
|
||||
));
|
||||
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);
|
||||
loop {
|
||||
let Some((incoming, source_addr)) =
|
||||
@@ -265,6 +440,7 @@ where
|
||||
else {
|
||||
break;
|
||||
};
|
||||
let dst_addr = task_config.lock().unwrap().dst_addr;
|
||||
tracing::info!(
|
||||
?bind_addr,
|
||||
?source_addr,
|
||||
@@ -299,22 +475,27 @@ where
|
||||
?dst_addr,
|
||||
"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<()> {
|
||||
let (bind_addr, dst_addr) = (cfg.bind_addr, cfg.dst_addr);
|
||||
let forward = cfg.clone();
|
||||
let socket = self
|
||||
.host
|
||||
.bind_udp(
|
||||
UdpBindOptions::port_forward(bind_addr).with_context(self.socket_context.clone()),
|
||||
)
|
||||
.await?;
|
||||
fn add_udp_port_forward(
|
||||
&self,
|
||||
cfg: &PortForwardConfig,
|
||||
socket: Arc<<H as VirtualUdpSocketFactory>::Socket>,
|
||||
) -> PortForwardListener {
|
||||
let bind_addr = cfg.bind_addr;
|
||||
let cancel = CancellationToken::new();
|
||||
self.cancel_tokens
|
||||
.insert(cfg.clone(), cancel.clone().drop_guard());
|
||||
let config = Arc::new(std::sync::Mutex::new(cfg.clone()));
|
||||
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 host = self.host.clone();
|
||||
@@ -323,7 +504,8 @@ where
|
||||
let response_tasks = self.udp_response_tasks.clone();
|
||||
let client_admission = self.udp_client_admission.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 {
|
||||
data_plane,
|
||||
host,
|
||||
@@ -342,6 +524,8 @@ where
|
||||
}
|
||||
},
|
||||
};
|
||||
let forward = task_config.lock().unwrap().clone();
|
||||
let dst_addr = forward.dst_addr;
|
||||
let key = UdpClientKey {
|
||||
client_addr,
|
||||
forward: forward.clone(),
|
||||
@@ -376,6 +560,13 @@ where
|
||||
continue;
|
||||
};
|
||||
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 client = Arc::new(UdpClientInfo {
|
||||
flow: flow.clone(),
|
||||
@@ -390,7 +581,7 @@ where
|
||||
let response_slot = slot;
|
||||
response_tasks.insert(
|
||||
key.clone(),
|
||||
AbortOnDropHandle::new(tokio::spawn(async move {
|
||||
AbortOnDropHandle::new(response_tracker.spawn(async move {
|
||||
let _slot = response_slot;
|
||||
let mut buf = vec![0u8; MAX_UDP_PAYLOAD_SIZE];
|
||||
loop {
|
||||
@@ -417,6 +608,7 @@ where
|
||||
}
|
||||
})),
|
||||
);
|
||||
drop(current);
|
||||
drop(admission);
|
||||
client
|
||||
}
|
||||
@@ -426,9 +618,14 @@ where
|
||||
tracing::error!(?error, ?dst_addr, "send UDP data-plane packet failed");
|
||||
}
|
||||
}
|
||||
});
|
||||
}));
|
||||
|
||||
Ok(())
|
||||
PortForwardListener {
|
||||
config,
|
||||
cancel,
|
||||
task,
|
||||
udp_tasks,
|
||||
}
|
||||
}
|
||||
|
||||
fn start_udp_reaper(&self) {
|
||||
@@ -450,14 +647,29 @@ where
|
||||
|
||||
async fn stop_inner(&self) {
|
||||
self.started.store(false, Ordering::Release);
|
||||
let listeners = std::mem::take(&mut *self.listeners.lock().unwrap());
|
||||
tracing::info!(
|
||||
forward_count = self.cancel_tokens.len(),
|
||||
forward_count = listeners.len(),
|
||||
"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_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 = self.tasks.lock().unwrap();
|
||||
std::mem::replace(&mut *tasks, JoinSet::new())
|
||||
@@ -621,6 +833,9 @@ where
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod reload_tests;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::{io, sync::atomic::AtomicUsize};
|
||||
@@ -892,15 +1107,8 @@ mod tests {
|
||||
response_tasks.insert(replacement.clone(), pending_response_task(replacement_slot));
|
||||
drop(admission_guard);
|
||||
|
||||
tokio::time::timeout(Duration::from_secs(1), async {
|
||||
while clients.contains_key(&replacement) {
|
||||
tokio::task::yield_now().await;
|
||||
}
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(!response_tasks.contains_key(&replacement));
|
||||
|
||||
// Release the test's permit reference and wait for the full eviction;
|
||||
// the client and response-task entries are removed separately.
|
||||
drop(replacement_client);
|
||||
let second_slot = tokio::time::timeout(Duration::from_secs(1), second)
|
||||
.await
|
||||
@@ -908,6 +1116,9 @@ mod tests {
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
|
||||
assert!(!clients.contains_key(&replacement));
|
||||
assert!(!response_tasks.contains_key(&replacement));
|
||||
|
||||
drop(second_slot);
|
||||
tokio::task::yield_now().await;
|
||||
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},
|
||||
task::JoinSet,
|
||||
};
|
||||
use tokio_util::sync::CancellationToken;
|
||||
|
||||
use crate::{
|
||||
packet::ZCPacket,
|
||||
peers::{
|
||||
PeerPacketFilter,
|
||||
PeerPacketFilter, PeerPacketFilterResult,
|
||||
peer_manager::{PeerManagerCore, PipelineRegistrationGuard},
|
||||
},
|
||||
};
|
||||
@@ -26,23 +27,6 @@ use super::{
|
||||
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> {
|
||||
peer_manager: Arc<PeerManagerCore>,
|
||||
runtime: Arc<R>,
|
||||
@@ -52,6 +36,8 @@ pub struct IcmpProxyService<R: IcmpProxyRuntime + 'static> {
|
||||
pipeline_guard: std::sync::Mutex<Option<PipelineRegistrationGuard>>,
|
||||
tasks: std::sync::Mutex<JoinSet<()>>,
|
||||
socket: std::sync::Mutex<Option<Arc<R::Socket>>>,
|
||||
socket_init: tokio::sync::Mutex<()>,
|
||||
stopped: CancellationToken,
|
||||
runtime_started: AtomicBool,
|
||||
started: AtomicBool,
|
||||
}
|
||||
@@ -73,6 +59,8 @@ impl<R: IcmpProxyRuntime + 'static> IcmpProxyService<R> {
|
||||
pipeline_guard: std::sync::Mutex::new(None),
|
||||
tasks: std::sync::Mutex::new(JoinSet::new()),
|
||||
socket: std::sync::Mutex::new(None),
|
||||
socket_init: tokio::sync::Mutex::new(()),
|
||||
stopped: CancellationToken::new(),
|
||||
runtime_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 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() {
|
||||
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);
|
||||
self.tasks.lock().unwrap().spawn(async move {
|
||||
loop {
|
||||
@@ -176,9 +126,13 @@ impl<R: IcmpProxyRuntime + 'static> IcmpProxyService<R> {
|
||||
}
|
||||
|
||||
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) {
|
||||
return;
|
||||
}
|
||||
self.stopped.cancel();
|
||||
if let Some(guard) = self.pipeline_guard.lock().unwrap().take() {
|
||||
guard.close();
|
||||
}
|
||||
@@ -186,7 +140,61 @@ impl<R: IcmpProxyRuntime + 'static> IcmpProxyService<R> {
|
||||
if self.runtime_started.swap(false, Ordering::AcqRel) {
|
||||
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> {
|
||||
@@ -204,14 +212,13 @@ impl<R: IcmpProxyRuntime + 'static> IcmpProxyService<R> {
|
||||
destination,
|
||||
packet: request,
|
||||
} => {
|
||||
let socket = self.socket.lock().unwrap().clone();
|
||||
match socket {
|
||||
Some(socket) => {
|
||||
match self.socket().await {
|
||||
Ok(socket) => {
|
||||
if let Err(err) = socket.send(destination, &request).await {
|
||||
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
|
||||
}
|
||||
@@ -249,35 +256,69 @@ struct IcmpProxyServiceFilter<R: IcmpProxyRuntime + 'static> {
|
||||
|
||||
#[async_trait::async_trait]
|
||||
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 {
|
||||
return Some(packet);
|
||||
return PeerPacketFilterResult::Pass(packet);
|
||||
};
|
||||
service.handle_peer_packet(packet).await
|
||||
service.handle_peer_packet(packet).await.into()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::{
|
||||
net::{IpAddr, Ipv4Addr},
|
||||
sync::{
|
||||
Arc,
|
||||
atomic::{AtomicUsize, Ordering},
|
||||
},
|
||||
};
|
||||
use std::sync::atomic::AtomicUsize;
|
||||
|
||||
use smoltcp::wire::{Icmpv4Message, Icmpv4Packet, IpProtocol, Ipv4Packet};
|
||||
use tokio::sync::Semaphore;
|
||||
|
||||
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)]
|
||||
struct PartialStartRuntime {
|
||||
stop_count: AtomicUsize,
|
||||
struct TestSocket {
|
||||
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 {
|
||||
ProxyRuntimeSnapshot::default()
|
||||
self.snapshot
|
||||
}
|
||||
|
||||
fn is_ip_local_virtual_ip(&self, _ip: &IpAddr) -> bool {
|
||||
@@ -285,53 +326,252 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
struct NoopIcmpSocket;
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl IcmpProxySocket for NoopIcmpSocket {
|
||||
async fn send(
|
||||
&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;
|
||||
impl IcmpProxyRuntime for TestRuntime {
|
||||
type Socket = TestSocket;
|
||||
|
||||
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) {
|
||||
self.stop_count.fetch_add(1, Ordering::AcqRel);
|
||||
self.stops.fetch_add(1, Ordering::AcqRel);
|
||||
self.active.store(false, Ordering::Release);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn failed_runtime_start_rolls_back_partial_resources() {
|
||||
let runtime = PartialStartRuntime::default();
|
||||
fn service(
|
||||
runtime: Arc<TestRuntime>,
|
||||
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());
|
||||
assert_eq!(runtime.stop_count.load(Ordering::Acquire), 1);
|
||||
fn runtime(no_tun: bool, enable_exit_node: bool) -> Arc<TestRuntime> {
|
||||
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]
|
||||
async fn no_tun_suppresses_start_error_after_rollback() {
|
||||
let runtime = PartialStartRuntime::default();
|
||||
async fn no_tun_local_echo_does_not_open_socket() {
|
||||
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();
|
||||
assert!(socket.is_none());
|
||||
if socket.is_some() {
|
||||
runtime.stop_icmp();
|
||||
}
|
||||
assert_eq!(runtime.stop_count.load(Ordering::Acquire), 1);
|
||||
#[tokio::test]
|
||||
async fn dynamic_proxy_networks_open_once_and_follow_updated_rules() {
|
||||
let runtime = runtime(false, false);
|
||||
let table = Arc::new(ProxyCidrTable::new());
|
||||
let service = service(runtime.clone(), table.clone());
|
||||
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(());
|
||||
}
|
||||
|
||||
// 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.tcp_started.store(true, Ordering::Release);
|
||||
if let Err(error) = self.tcp.start(true).await {
|
||||
self.stop_started();
|
||||
return Err(error);
|
||||
}
|
||||
self.tcp.start(true).await?;
|
||||
|
||||
if let Some(icmp) = &self.icmp {
|
||||
self.icmp_started.store(true, Ordering::Release);
|
||||
@@ -398,7 +398,6 @@ where
|
||||
.proxy
|
||||
.icmp_failure_is_fatal
|
||||
{
|
||||
self.stop_started();
|
||||
return Err(error);
|
||||
}
|
||||
tracing::warn!(?error, "optional ICMP proxy runtime failed to start");
|
||||
@@ -407,6 +406,7 @@ where
|
||||
|
||||
self.udp_started.store(true, Ordering::Release);
|
||||
self.udp.start().await;
|
||||
rollback.defuse();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ use std::{
|
||||
};
|
||||
|
||||
use cidr::Ipv4Inet;
|
||||
use crossbeam::atomic::AtomicCell;
|
||||
use crossbeam_utils::atomic::AtomicCell;
|
||||
use dashmap::{DashMap, mapref::entry::Entry};
|
||||
use smoltcp::wire::{IpAddress, IpProtocol, Ipv4Packet, TcpPacket};
|
||||
|
||||
@@ -186,7 +186,7 @@ impl TcpProxyEngine {
|
||||
for _ in 0..u16::MAX {
|
||||
let translated_port = self
|
||||
.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 })
|
||||
})
|
||||
.expect("translated port counter update cannot fail");
|
||||
|
||||
@@ -11,7 +11,7 @@ use crate::{
|
||||
foundation::time::timeout,
|
||||
packet::ZCPacket,
|
||||
peers::{
|
||||
NicPacketFilter, PeerPacketFilter,
|
||||
NicPacketFilter, PeerPacketFilter, PeerPacketFilterResult,
|
||||
peer_manager::{PeerManagerCore, PipelineRegistrationGuard},
|
||||
},
|
||||
socket::{
|
||||
@@ -544,11 +544,11 @@ struct TcpProxyServiceFilter<
|
||||
impl<R: TcpProxyRuntime + 'static, F: VirtualTcpListenerFactory, C: TcpProxyDestinationConnector>
|
||||
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 {
|
||||
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 crate::packet::ZCPacket;
|
||||
use crate::peers::PeerPacketFilter;
|
||||
use crate::peers::peer_manager::{PeerManagerCore, PipelineRegistrationGuard};
|
||||
use crate::peers::{PeerPacketFilter, PeerPacketFilterResult};
|
||||
|
||||
use super::cidr_table::ProxyCidrTable;
|
||||
use super::traits::{UdpProxyResponseSink, UdpProxyRuntime};
|
||||
@@ -208,10 +208,10 @@ struct UdpProxyServiceFilter<R: UdpProxyRuntime + 'static> {
|
||||
|
||||
#[async_trait::async_trait]
|
||||
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 {
|
||||
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,
|
||||
packet::{PacketType, ZCPacket, ZCPacketType},
|
||||
peers::{
|
||||
PeerPacketFilter,
|
||||
PeerPacketFilter, PeerPacketFilterResult,
|
||||
peer_manager::{PeerManagerCore, PipelineRegistrationGuard},
|
||||
},
|
||||
process_runtime::ProtectedTcpPortRegistry,
|
||||
@@ -147,15 +147,15 @@ struct WrappedTransportPeerFilter {
|
||||
|
||||
#[async_trait]
|
||||
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 {
|
||||
return Some(packet);
|
||||
return PeerPacketFilterResult::Pass(packet);
|
||||
};
|
||||
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 {
|
||||
return Some(packet);
|
||||
return PeerPacketFilterResult::Pass(packet);
|
||||
};
|
||||
let from_peer_id = header.from_peer_id.get();
|
||||
if let Err(error) = engine
|
||||
@@ -169,7 +169,7 @@ impl PeerPacketFilter for WrappedTransportPeerFilter {
|
||||
"failed to inject wrapped transport packet"
|
||||
);
|
||||
}
|
||||
None
|
||||
PeerPacketFilterResult::Consumed
|
||||
}
|
||||
}
|
||||
|
||||
@@ -797,7 +797,10 @@ mod tests {
|
||||
let mut packet = ZCPacket::new_with_payload(b"payload");
|
||||
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!(
|
||||
*engine.injections.lock().unwrap(),
|
||||
[(
|
||||
@@ -811,7 +814,10 @@ mod tests {
|
||||
drop(engine);
|
||||
let mut packet = ZCPacket::new_with_payload(b"stale");
|
||||
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]
|
||||
|
||||
@@ -3,5 +3,5 @@ mod tokio_smoltcp;
|
||||
|
||||
pub(super) use stack::{SmolTcpStack, output_dst_ip};
|
||||
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 {
|
||||
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.
|
||||
pub fn new<D: device::AsyncDevice + 'static>(device: D, config: NetConfig) -> Net {
|
||||
Self::new2(device, config)
|
||||
@@ -119,18 +122,44 @@ impl Net {
|
||||
Net {
|
||||
reactor: Arc::new(reactor),
|
||||
ip_addr: config.ip_addr,
|
||||
from_port: AtomicU16::new(10001),
|
||||
from_port: AtomicU16::new(Self::EPHEMERAL_PORT_START + 1),
|
||||
stopper,
|
||||
_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 {
|
||||
self.from_port
|
||||
.fetch_update(Ordering::SeqCst, Ordering::SeqCst, |x| {
|
||||
Some(if x > 60000 { 10000 } else { x + 1 })
|
||||
.try_update(Ordering::SeqCst, Ordering::SeqCst, |port| {
|
||||
Some(Self::next_port(port))
|
||||
})
|
||||
.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.
|
||||
pub async fn tcp_bind(&self, addr: SocketAddr) -> io::Result<TcpListener> {
|
||||
let addr = self.set_address(addr);
|
||||
@@ -188,3 +217,36 @@ impl Drop for Net {
|
||||
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,
|
||||
task::{Context as TaskContext, Poll, Waker},
|
||||
};
|
||||
use tokio::{pin, select, sync::Notify};
|
||||
use tokio::{
|
||||
pin, select,
|
||||
sync::{Notify, mpsc},
|
||||
};
|
||||
|
||||
use crate::foundation::time::sleep;
|
||||
|
||||
@@ -50,11 +53,18 @@ struct TcpListenerRegistry {
|
||||
|
||||
type SharedTcpListenerRegistry = Arc<Mutex<TcpListenerRegistry>>;
|
||||
|
||||
struct ClosingTcpSocket {
|
||||
handle: OwnedSocketHandle,
|
||||
deadline: Instant,
|
||||
_keep_alive: Box<dyn Send>,
|
||||
}
|
||||
|
||||
pub(crate) struct Reactor {
|
||||
notify: Arc<Notify>,
|
||||
iface: BufferInterface,
|
||||
socket_allocator: SocketAlloctor,
|
||||
tcp_listeners: SharedTcpListenerRegistry,
|
||||
closing_tcp: mpsc::UnboundedSender<ClosingTcpSocket>,
|
||||
}
|
||||
|
||||
async fn receive(
|
||||
@@ -203,12 +213,14 @@ fn update_tcp_listeners(
|
||||
wakers
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
async fn run(
|
||||
mut async_iface: impl super::device::AsyncDevice,
|
||||
iface: BufferInterface,
|
||||
mut device: BufferDevice,
|
||||
socket_allocator: SocketAlloctor,
|
||||
tcp_listeners: SharedTcpListenerRegistry,
|
||||
mut closing_tcp: mpsc::UnboundedReceiver<ClosingTcpSocket>,
|
||||
notify: Arc<Notify>,
|
||||
stopper: Arc<Notify>,
|
||||
) -> io::Result<()> {
|
||||
@@ -219,21 +231,36 @@ async fn run(
|
||||
.max_burst_size
|
||||
.unwrap_or(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);
|
||||
|
||||
loop {
|
||||
let packets = device.take_send_queue();
|
||||
|
||||
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() {
|
||||
let start = Instant::now();
|
||||
let deadline = {
|
||||
let mut deadline = {
|
||||
iface
|
||||
.lock()
|
||||
.poll_delay(start, &socket_allocator.sockets().lock())
|
||||
.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 {
|
||||
timer
|
||||
@@ -243,6 +270,7 @@ async fn run(
|
||||
_ = &mut timer => {},
|
||||
_ = receive(&mut async_iface,&mut recv_buf) => {}
|
||||
_ = notify.notified() => {}
|
||||
Some(socket) = closing_tcp.recv() => closing.push(socket),
|
||||
_ = stopper.notified() => break,
|
||||
};
|
||||
}
|
||||
@@ -290,6 +318,21 @@ async fn run(
|
||||
iface.poll(now, &mut device, &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)
|
||||
for (_, socket) in sockets.iter_mut() {
|
||||
if let Socket::Tcp(tcp) = socket
|
||||
@@ -309,6 +352,30 @@ async fn run(
|
||||
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 {
|
||||
pub fn new(
|
||||
async_device: impl super::device::AsyncDevice,
|
||||
@@ -321,12 +388,14 @@ impl Reactor {
|
||||
let notify = Arc::new(Notify::new());
|
||||
let socket_allocator = SocketAlloctor::new(buffer_size);
|
||||
let tcp_listeners = Arc::new(Mutex::new(TcpListenerRegistry::default()));
|
||||
let (closing_tx, closing_rx) = mpsc::unbounded_channel();
|
||||
let fut = run(
|
||||
async_device,
|
||||
iface.clone(),
|
||||
device,
|
||||
socket_allocator.clone(),
|
||||
tcp_listeners.clone(),
|
||||
closing_rx,
|
||||
notify.clone(),
|
||||
stopper,
|
||||
);
|
||||
@@ -337,6 +406,7 @@ impl Reactor {
|
||||
iface,
|
||||
socket_allocator,
|
||||
tcp_listeners,
|
||||
closing_tcp: closing_tx,
|
||||
},
|
||||
fut,
|
||||
)
|
||||
@@ -350,6 +420,21 @@ impl Reactor {
|
||||
|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> {
|
||||
MutexGuard::map(self.iface.lock(), |iface| iface.context())
|
||||
}
|
||||
|
||||
@@ -97,6 +97,15 @@ pub struct 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(
|
||||
reactor: Arc<Reactor>,
|
||||
local_endpoint: IpEndpoint,
|
||||
@@ -331,21 +340,21 @@ impl UdpSocket {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::time::Duration as StdDuration;
|
||||
use std::{sync::Arc, time::Duration as StdDuration};
|
||||
|
||||
use smoltcp::{
|
||||
iface::Config,
|
||||
phy::{DeviceCapabilities, Medium},
|
||||
socket::tcp,
|
||||
time::Duration,
|
||||
wire::{HardwareAddress, Ipv4Address, TcpControl, TcpSeqNumber},
|
||||
wire::{HardwareAddress, Ipv4Address, Ipv4Packet, TcpControl, TcpPacket, TcpSeqNumber},
|
||||
};
|
||||
|
||||
use super::super::{
|
||||
Net, NetConfig, channel_device,
|
||||
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_PORT: u16 = 34569;
|
||||
@@ -417,6 +426,136 @@ mod tests {
|
||||
.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]
|
||||
async fn duplicate_syn_does_not_consume_another_backlog_slot() {
|
||||
let (net, ingress, mut egress) = test_net();
|
||||
|
||||
@@ -114,6 +114,7 @@ pub(crate) struct TcpPacketSummary {
|
||||
pub(crate) control: TcpControl,
|
||||
pub(crate) sequence: TcpSeqNumber,
|
||||
pub(crate) dst_port: u16,
|
||||
pub(crate) ack_number: Option<TcpSeqNumber>,
|
||||
}
|
||||
|
||||
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,
|
||||
sequence: repr.seq_number,
|
||||
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.
|
||||
|
||||
use std::{
|
||||
collections::HashMap,
|
||||
io,
|
||||
net::{IpAddr, SocketAddr},
|
||||
pin::Pin,
|
||||
sync::{
|
||||
Arc,
|
||||
Arc, Mutex,
|
||||
atomic::{AtomicUsize, Ordering},
|
||||
},
|
||||
task::{Context, Poll},
|
||||
@@ -70,11 +71,16 @@ impl VirtualTcpSocket for TestTcpSocket {
|
||||
pub struct TestTcpListener {
|
||||
address: SocketAddr,
|
||||
active_listeners: Arc<AtomicUsize>,
|
||||
listeners: Option<Arc<Mutex<TestTcpListeners>>>,
|
||||
incoming: Option<tokio::sync::Mutex<tokio::sync::mpsc::UnboundedReceiver<TestTcpSocket>>>,
|
||||
}
|
||||
|
||||
impl Drop for TestTcpListener {
|
||||
fn drop(&mut self) {
|
||||
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)> {
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -108,6 +120,8 @@ impl VirtualUdpSocket for TestUdpSocket {
|
||||
}
|
||||
}
|
||||
|
||||
pub type TestTcpListeners = HashMap<SocketAddr, tokio::sync::mpsc::UnboundedSender<TestTcpSocket>>;
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct TestHost {
|
||||
pub tcp_binds: AtomicUsize,
|
||||
@@ -115,21 +129,44 @@ pub struct TestHost {
|
||||
pub udp_binds: AtomicUsize,
|
||||
pub proxy_nat_connections:
|
||||
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,
|
||||
}
|
||||
|
||||
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]
|
||||
impl VirtualTcpSocketFactory for TestHost {
|
||||
type Socket = TestTcpSocket;
|
||||
|
||||
async fn connect_tcp(&self, options: TcpConnectOptions) -> anyhow::Result<Self::Socket> {
|
||||
if options.purpose != TcpSocketPurpose::ProxyNat {
|
||||
anyhow::bail!("test host does not connect non-proxy TCP sockets");
|
||||
let connections = match options.purpose {
|
||||
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
|
||||
.proxy_nat_connections
|
||||
.as_ref()
|
||||
.ok_or_else(|| anyhow::anyhow!("test host proxy NAT is disabled"))?;
|
||||
.as_ref()
|
||||
.ok_or_else(|| anyhow::anyhow!("test host TCP connections are disabled"))?;
|
||||
let (socket, peer) = tokio::io::duplex(1024);
|
||||
connections
|
||||
.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>> {
|
||||
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 {
|
||||
anyhow::bail!("test host rejected SOCKS5 listener");
|
||||
}
|
||||
@@ -159,10 +200,26 @@ impl VirtualTcpListenerFactory for TestHost {
|
||||
} else {
|
||||
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);
|
||||
Ok(Arc::new(TestTcpListener {
|
||||
address,
|
||||
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;
|
||||
|
||||
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 PACKET_PROXY_AVAILABLE: bool = cfg!(feature = "proxy-packet");
|
||||
const PROXY_CIDR_MONITOR_AVAILABLE: bool = cfg!(feature = "proxy-cidr-monitor");
|
||||
@@ -28,6 +29,7 @@ fn validate_snapshot(
|
||||
) -> anyhow::Result<()> {
|
||||
let core = &peer.runtime.core;
|
||||
require(DHCP_IPV4_AVAILABLE, runtime.dhcp_ipv4, "DHCP IPv4")?;
|
||||
require(DHCP_IPV6_AVAILABLE, runtime.dhcp_ipv6, "DHCP IPv6")?;
|
||||
require(
|
||||
SMOLTCP_GATEWAY_AVAILABLE,
|
||||
runtime.gateway.socks5_bind.is_some() || !runtime.gateway.port_forwards.is_empty(),
|
||||
|
||||
@@ -92,6 +92,16 @@ impl Default for 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 {
|
||||
!self.ignore_unsupported_config
|
||||
|| self
|
||||
@@ -210,14 +220,20 @@ impl CoreInstanceConfig {
|
||||
network_name: network_name.clone(),
|
||||
},
|
||||
routes: RouteConfig {
|
||||
ipv4: config.get_ipv4().map(|value| IpPrefix {
|
||||
address: value.address().into(),
|
||||
prefix_len: value.network_length(),
|
||||
}),
|
||||
ipv6: config.get_ipv6().map(|value| IpPrefix {
|
||||
address: value.address().into(),
|
||||
prefix_len: value.network_length(),
|
||||
}),
|
||||
ipv4: config
|
||||
.get_ipv4()
|
||||
.filter(|_| !config.get_dhcp())
|
||||
.map(|value| IpPrefix {
|
||||
address: value.address().into(),
|
||||
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
|
||||
.into_iter()
|
||||
.map(|proxy| ProxyNetworkConfig {
|
||||
@@ -296,6 +312,9 @@ impl CoreInstanceConfig {
|
||||
whitelist_priority: None,
|
||||
},
|
||||
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 {
|
||||
socks5_bind,
|
||||
port_forwards: if host.ignore_unsupported_config && !host.gateway_enabled {
|
||||
@@ -407,6 +426,24 @@ mod tests {
|
||||
|
||||
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]
|
||||
fn tcp_stun_servers_follow_toml_override_rules() {
|
||||
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};
|
||||
|
||||
#[cfg(feature = "dhcp-ipv4")]
|
||||
use crate::gateway::dhcp::{DhcpIpv4Host, DhcpIpv4RouteSource};
|
||||
#[cfg(any(feature = "dhcp-ipv4", feature = "dhcp-ipv6"))]
|
||||
use crate::gateway::dhcp::{DhcpHost, DhcpRouteSource};
|
||||
|
||||
use super::{CoreInstance, CoreInstanceHost, CoreInstanceState};
|
||||
|
||||
@@ -66,30 +66,39 @@ where
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "dhcp-ipv4")]
|
||||
async fn start_dhcp_ipv4(&self, host: Option<Arc<dyn DhcpIpv4Host>>) -> anyhow::Result<()> {
|
||||
if !self.runtime_config.snapshot().services.dhcp_ipv4 {
|
||||
#[cfg(any(feature = "dhcp-ipv4", feature = "dhcp-ipv6"))]
|
||||
async fn start_dhcp(&self, host: Option<Arc<dyn DhcpHost>>) -> anyhow::Result<()> {
|
||||
let config = self.runtime_config.snapshot();
|
||||
if !config.services.dhcp_ipv4 && !config.services.dhcp_ipv6 {
|
||||
return Ok(());
|
||||
}
|
||||
let host = host.ok_or_else(|| {
|
||||
anyhow::anyhow!("DHCP IPv4 is enabled but no host adapter was provided")
|
||||
})?;
|
||||
let route_source: Arc<dyn DhcpIpv4RouteSource> = self.peer_manager.clone();
|
||||
self.dhcp_ipv4
|
||||
let host = host
|
||||
.ok_or_else(|| anyhow::anyhow!("DHCP is enabled but no host adapter was provided"))?;
|
||||
let route_source: Arc<dyn DhcpRouteSource> = self.peer_manager.clone();
|
||||
self.dhcp
|
||||
.start(route_source, self.runtime_config.clone(), host)
|
||||
.await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[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 has_proxy_networks = !config.peer.runtime.core.routes.proxy_networks.is_empty();
|
||||
if config.services.proxy.should_start(has_proxy_networks) {
|
||||
self.packet_proxy
|
||||
.start()
|
||||
if let Some(runtime) = self.runtime_handle() {
|
||||
let proxy = self.packet_proxy.clone();
|
||||
tokio_util::task::AbortOnDropHandle::new(
|
||||
runtime.spawn(async move { proxy.start().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(())
|
||||
}
|
||||
@@ -125,9 +134,9 @@ where
|
||||
self.public_ipv6_provider.start().await;
|
||||
|
||||
let dhcp_host = self.instance_runtime.prepare(self.packet_plane()).await?;
|
||||
#[cfg(feature = "dhcp-ipv4")]
|
||||
self.start_dhcp_ipv4(dhcp_host).await?;
|
||||
#[cfg(not(feature = "dhcp-ipv4"))]
|
||||
#[cfg(any(feature = "dhcp-ipv4", feature = "dhcp-ipv6"))]
|
||||
self.start_dhcp(dhcp_host).await?;
|
||||
#[cfg(not(any(feature = "dhcp-ipv4", feature = "dhcp-ipv6")))]
|
||||
let _ = dhcp_host;
|
||||
#[cfg(feature = "wrapped-transport")]
|
||||
if let Some(wrapped_transport) = &self.wrapped_transport {
|
||||
@@ -173,8 +182,8 @@ where
|
||||
self.vpn_portal.stop().await;
|
||||
#[cfg(feature = "public-ipv6-provider")]
|
||||
self.public_ipv6_provider.stop().await;
|
||||
#[cfg(feature = "dhcp-ipv4")]
|
||||
self.dhcp_ipv4.stop().await;
|
||||
#[cfg(any(feature = "dhcp-ipv4", feature = "dhcp-ipv6"))]
|
||||
self.dhcp.stop().await;
|
||||
#[cfg(feature = "proxy-cidr-monitor")]
|
||||
self.proxy_cidr_monitor.stop().await;
|
||||
if let Some(listener) = &self.listener {
|
||||
@@ -216,8 +225,25 @@ where
|
||||
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.
|
||||
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 state = self.state();
|
||||
if state != CoreInstanceState::Created {
|
||||
@@ -260,6 +286,21 @@ where
|
||||
}
|
||||
|
||||
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();
|
||||
let mut recovery = self.recovery_guard();
|
||||
let _operation = self.operation.lock().await;
|
||||
|
||||
@@ -387,7 +387,11 @@ where
|
||||
let instance_state_changes = self.instance_state_changes.clone();
|
||||
runtime.spawn(async move {
|
||||
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();
|
||||
notifier.notify_one();
|
||||
@@ -479,9 +483,9 @@ where
|
||||
}
|
||||
|
||||
pub fn data_plane_runtime_handle(&self, instance_id: &Uuid) -> Option<tokio::runtime::Handle> {
|
||||
self.instance(*instance_id)?;
|
||||
self.runtime_handle
|
||||
.clone()
|
||||
self.instance(*instance_id)?
|
||||
.runtime_handle()
|
||||
.or_else(|| self.runtime_handle.clone())
|
||||
.or_else(|| tokio::runtime::Handle::try_current().ok())
|
||||
}
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ mod management;
|
||||
mod management_extension;
|
||||
mod management_state;
|
||||
pub mod manager;
|
||||
mod packet_io;
|
||||
pub(crate) mod packet_io;
|
||||
mod packet_plane;
|
||||
#[cfg(feature = "proxy-packet")]
|
||||
mod packet_proxy_extension;
|
||||
@@ -58,7 +58,7 @@ use crate::{
|
||||
},
|
||||
},
|
||||
events::CoreEventSink,
|
||||
gateway::dhcp::DhcpIpv4Host,
|
||||
gateway::dhcp::DhcpHost,
|
||||
host::{
|
||||
dns::{DnsRecordResolver, DnsResolver},
|
||||
packet::{HostPacketReceiver, PacketSink, host_packet_channel},
|
||||
@@ -96,8 +96,8 @@ use crate::gateway::vpn_portal::PortalRuntimeConfig;
|
||||
#[cfg(feature = "public-ipv6-provider")]
|
||||
use crate::peers::public_ipv6::provider::PublicIpv6ProviderPlatform;
|
||||
|
||||
#[cfg(feature = "dhcp-ipv4")]
|
||||
use crate::gateway::dhcp::DhcpIpv4Runtime;
|
||||
#[cfg(any(feature = "dhcp-ipv4", feature = "dhcp-ipv6"))]
|
||||
use crate::gateway::dhcp::DhcpRuntime;
|
||||
#[cfg(feature = "proxy-cidr-monitor")]
|
||||
use crate::gateway::proxy::cidr_monitor::ProxyCidrMonitorRuntime;
|
||||
#[cfg(feature = "proxy-packet")]
|
||||
@@ -229,10 +229,16 @@ fn proxy_cidr_snapshot(config: &CoreInstanceRuntimeConfig) -> ProxyCidrSnapshot
|
||||
/// lifetime, such as a native packet interface.
|
||||
#[async_trait::async_trait]
|
||||
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(
|
||||
&self,
|
||||
packet_plane: Arc<CorePacketPlane>,
|
||||
) -> anyhow::Result<Option<Arc<dyn DhcpIpv4Host>>>;
|
||||
) -> anyhow::Result<Option<Arc<dyn DhcpHost>>>;
|
||||
|
||||
async fn shutdown(&self);
|
||||
|
||||
@@ -270,7 +276,7 @@ impl InstanceRuntimeHost for () {
|
||||
async fn prepare(
|
||||
&self,
|
||||
_packet_plane: Arc<CorePacketPlane>,
|
||||
) -> anyhow::Result<Option<Arc<dyn DhcpIpv4Host>>> {
|
||||
) -> anyhow::Result<Option<Arc<dyn DhcpHost>>> {
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
@@ -429,8 +435,8 @@ where
|
||||
packet_proxy: Arc<CoreProxyModule<H>>,
|
||||
#[cfg(feature = "proxy-cidr-monitor")]
|
||||
proxy_cidr_monitor: ProxyCidrMonitorRuntime,
|
||||
#[cfg(feature = "dhcp-ipv4")]
|
||||
dhcp_ipv4: DhcpIpv4Runtime,
|
||||
#[cfg(any(feature = "dhcp-ipv4", feature = "dhcp-ipv6"))]
|
||||
dhcp: DhcpRuntime,
|
||||
pub(super) packet_egress: Arc<dyn PacketEgressHost>,
|
||||
pub(super) packet_receiver: Mutex<Option<HostPacketReceiver>>,
|
||||
pub(super) peer_center: Arc<PeerCenterInstance>,
|
||||
@@ -525,6 +531,7 @@ where
|
||||
public_ipv6_host,
|
||||
public_ipv6_events,
|
||||
);
|
||||
let running_listeners = Arc::new(RunningListenerRegistry::default());
|
||||
let stun = (connectivity_mode == CoreConnectivityMode::Full)
|
||||
.then(|| Self::prepare_stun(&adapters, &config.connectivity));
|
||||
let (peer_stun, foreign_rpc_registrar): (
|
||||
@@ -536,6 +543,7 @@ where
|
||||
Arc::new(ForeignDirectConnectorRpcRegistrar::new(
|
||||
adapters.host.clone(),
|
||||
stun.clone(),
|
||||
running_listeners.clone(),
|
||||
)),
|
||||
),
|
||||
None => (Arc::new(()), Arc::new(())),
|
||||
@@ -627,7 +635,6 @@ where
|
||||
accepted_tunnel_handler.clone(),
|
||||
)),
|
||||
};
|
||||
let running_listeners = Arc::new(RunningListenerRegistry::default());
|
||||
let PreparedListenerPlan {
|
||||
transports,
|
||||
external,
|
||||
@@ -783,6 +790,7 @@ where
|
||||
host.clone(),
|
||||
direct_options.tcp_bind.context.clone(),
|
||||
events.clone(),
|
||||
instance_runtime.runtime_handle(),
|
||||
);
|
||||
#[cfg(feature = "tcp-hole-punch")]
|
||||
let tcp_hole_punch = stun
|
||||
@@ -877,8 +885,8 @@ where
|
||||
packet_proxy,
|
||||
#[cfg(feature = "proxy-cidr-monitor")]
|
||||
proxy_cidr_monitor,
|
||||
#[cfg(feature = "dhcp-ipv4")]
|
||||
dhcp_ipv4: DhcpIpv4Runtime::new(),
|
||||
#[cfg(any(feature = "dhcp-ipv4", feature = "dhcp-ipv6"))]
|
||||
dhcp: DhcpRuntime::new(),
|
||||
packet_egress,
|
||||
packet_receiver: Mutex::new(Some(packet_rx)),
|
||||
peer_center,
|
||||
@@ -929,26 +937,63 @@ where
|
||||
anyhow::bail!("runtime config cannot update while instance is stopping or stopped");
|
||||
}
|
||||
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")]
|
||||
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")]
|
||||
if reload_acl {
|
||||
self.acl_reload_count.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
self.proxy_cidr_table
|
||||
.update_snapshot(proxy_cidr_snapshot(&published));
|
||||
#[cfg(feature = "proxy-smoltcp-stack")]
|
||||
self.port_forward_adapter
|
||||
.reload(
|
||||
&self
|
||||
.runtime_config
|
||||
.snapshot()
|
||||
.services
|
||||
.gateway
|
||||
.port_forwards,
|
||||
)
|
||||
.await?;
|
||||
if let Some(cleanup) = cleanup {
|
||||
let _ = cleanup.await;
|
||||
}
|
||||
#[cfg(feature = "proxy-packet")]
|
||||
if self.state() == CoreInstanceState::Running && self.startup_plan.packet_proxy {
|
||||
// A running instance may gain its first proxy network here. Start
|
||||
// from the published configuration, just as during initial startup.
|
||||
self.start_packet_proxy().await?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
||||
@@ -81,10 +81,37 @@ where
|
||||
.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)]
|
||||
pub fn evict_unused_peer_sessions_for_test(&self, idle: Duration) {
|
||||
self.peer_manager
|
||||
.get_peer_session_store()
|
||||
.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 connectivity;
|
||||
pub mod events;
|
||||
|
||||
@@ -145,7 +145,6 @@ impl<Accepted> ListenerFactory<Accepted> {
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ListenerManagerOptions {
|
||||
pub max_listen_retries: usize,
|
||||
pub listen_retry_delay: Duration,
|
||||
pub accept_retry_delay: Duration,
|
||||
}
|
||||
@@ -153,9 +152,8 @@ pub struct ListenerManagerOptions {
|
||||
impl Default for ListenerManagerOptions {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
max_listen_retries: 5,
|
||||
listen_retry_delay: Duration::from_secs(1),
|
||||
accept_retry_delay: Duration::from_secs(1),
|
||||
listen_retry_delay: Duration::from_millis(100),
|
||||
accept_retry_delay: Duration::from_millis(100),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -342,10 +340,11 @@ where
|
||||
Accepted: Send + 'static,
|
||||
{
|
||||
let mut listener = creator();
|
||||
match listener.listen().await {
|
||||
Ok(()) => Ok(listener),
|
||||
Err(error) => Err(error),
|
||||
}
|
||||
listener
|
||||
.listen()
|
||||
.await
|
||||
.with_context(|| format!("failed to listen on {}", listener.local_url()))?;
|
||||
Ok(listener)
|
||||
}
|
||||
|
||||
async fn run_listener<Accepted, H>(
|
||||
@@ -360,7 +359,7 @@ async fn run_listener<Accepted, H>(
|
||||
Accepted: Send + 'static,
|
||||
H: AcceptedSocketHandler<Accepted> + ?Sized + 'static,
|
||||
{
|
||||
let mut listen_error_count = 0;
|
||||
let mut listen_error_count: usize = 0;
|
||||
loop {
|
||||
let registered_listener = match initial_listener.take() {
|
||||
Some(listener) => listener,
|
||||
@@ -372,18 +371,15 @@ async fn run_listener<Accepted, H>(
|
||||
RegisteredListener::new(listener, events.clone(), registry.clone())
|
||||
}
|
||||
Err(error) => {
|
||||
listen_error_count += 1;
|
||||
let will_retry = listen_error_count <= options.max_listen_retries;
|
||||
listen_error_count = listen_error_count.saturating_add(1);
|
||||
events.emit(CoreEvent::ListenerAddFailed {
|
||||
url: listener.local_url(),
|
||||
error: format!("{error:?}"),
|
||||
retry_count: listen_error_count,
|
||||
will_retry,
|
||||
will_retry: true,
|
||||
});
|
||||
tracing::error!(?error, ?listener, "listener listen error");
|
||||
if !will_retry {
|
||||
return;
|
||||
}
|
||||
drop(listener);
|
||||
crate::foundation::time::sleep(options.listen_retry_delay).await;
|
||||
continue;
|
||||
}
|
||||
@@ -391,7 +387,7 @@ async fn run_listener<Accepted, H>(
|
||||
}
|
||||
};
|
||||
let mut listener = registered_listener.listener;
|
||||
let _registration = registered_listener.registration;
|
||||
let registration = registered_listener.registration;
|
||||
|
||||
loop {
|
||||
let listener_url = listener.local_url();
|
||||
@@ -403,7 +399,6 @@ async fn run_listener<Accepted, H>(
|
||||
error: format!("{error:?}"),
|
||||
});
|
||||
tracing::error!(?error, ?listener, "listener accept error");
|
||||
crate::foundation::time::sleep(options.accept_retry_delay).await;
|
||||
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 {
|
||||
accept_retry_delay: Duration::from_millis(1),
|
||||
listen_retry_delay: Duration::from_millis(1),
|
||||
max_listen_retries: 0,
|
||||
},
|
||||
);
|
||||
|
||||
@@ -703,7 +700,6 @@ mod tests {
|
||||
ListenerManagerOptions {
|
||||
accept_retry_delay: Duration::from_millis(1),
|
||||
listen_retry_delay: Duration::from_millis(1),
|
||||
max_listen_retries: 2,
|
||||
},
|
||||
);
|
||||
|
||||
@@ -750,7 +746,6 @@ mod tests {
|
||||
ListenerManagerOptions {
|
||||
accept_retry_delay: Duration::from_millis(1),
|
||||
listen_retry_delay: Duration::from_millis(1),
|
||||
max_listen_retries: 0,
|
||||
},
|
||||
);
|
||||
|
||||
@@ -782,7 +777,8 @@ mod tests {
|
||||
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;
|
||||
assert!(handler.accepted.lock().unwrap().is_empty());
|
||||
assert!(
|
||||
@@ -813,7 +809,6 @@ mod tests {
|
||||
ListenerManagerOptions {
|
||||
accept_retry_delay: Duration::from_millis(1),
|
||||
listen_retry_delay: Duration::from_millis(1),
|
||||
max_listen_retries: 0,
|
||||
},
|
||||
);
|
||||
|
||||
@@ -934,7 +929,6 @@ mod tests {
|
||||
ListenerManagerOptions {
|
||||
accept_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>(
|
||||
@@ -997,12 +991,13 @@ mod tests {
|
||||
}
|
||||
|
||||
#[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 listen_results = Arc::new(Mutex::new(VecDeque::from([
|
||||
Ok(()),
|
||||
Err(anyhow::anyhow!("bind failed")),
|
||||
])));
|
||||
let listen_results = Arc::new(Mutex::new(
|
||||
std::iter::once(Ok(()))
|
||||
.chain((0..8).map(|_| Err(anyhow::anyhow!("bind failed"))))
|
||||
.collect::<VecDeque<_>>(),
|
||||
));
|
||||
let accepts = Arc::new(Mutex::new(VecDeque::from([
|
||||
Ok::<_, anyhow::Error>(DropSignal(Some(drop_tx))),
|
||||
Err(anyhow::anyhow!("accept failed")),
|
||||
@@ -1014,7 +1009,6 @@ mod tests {
|
||||
ListenerManagerOptions {
|
||||
accept_retry_delay: Duration::from_millis(1),
|
||||
listen_retry_delay: Duration::from_millis(1),
|
||||
max_listen_retries: 0,
|
||||
},
|
||||
);
|
||||
let listener_results = listen_results.clone();
|
||||
@@ -1022,7 +1016,7 @@ mod tests {
|
||||
manager.add_listener(
|
||||
move || {
|
||||
Box::new(DropSignalListener {
|
||||
url: "mock://retry-exhausted".parse().unwrap(),
|
||||
url: "mock://retrying".parse().unwrap(),
|
||||
listen_results: listener_results.clone(),
|
||||
accepts: listener_accepts.clone(),
|
||||
})
|
||||
@@ -1033,15 +1027,22 @@ mod tests {
|
||||
manager.run().await.unwrap();
|
||||
crate::foundation::time::timeout(Duration::from_secs(1), async {
|
||||
loop {
|
||||
if events.events.lock().unwrap().iter().any(|event| {
|
||||
matches!(
|
||||
let recovered = {
|
||||
let events = events.events.lock().unwrap();
|
||||
assert!(events.iter().all(|event| !matches!(
|
||||
event,
|
||||
CoreEvent::ListenerAddFailed {
|
||||
will_retry: false,
|
||||
..
|
||||
}
|
||||
)
|
||||
}) {
|
||||
)));
|
||||
events
|
||||
.iter()
|
||||
.filter(|event| matches!(event, CoreEvent::ListenerAdded { .. }))
|
||||
.count()
|
||||
== 2
|
||||
};
|
||||
if recovered {
|
||||
break;
|
||||
}
|
||||
crate::foundation::time::sleep(Duration::from_millis(1)).await;
|
||||
|
||||
@@ -5,10 +5,11 @@ use easytier_proto::api::config::{
|
||||
self, AclPatch, ConfigPatchAction, ExitNodePatch, InstanceConfigPatch, Patchable,
|
||||
PortForwardPatch, ProxyNetworkPatch, RoutePatch, UrlPatch, VpnPortalClientPatch,
|
||||
};
|
||||
use easytier_proto::common::FlagsPatch;
|
||||
use optionize::Optionized as _;
|
||||
|
||||
use crate::{
|
||||
config::{
|
||||
api_input::managed_credential_from_proto,
|
||||
peers::AclRuleConfig,
|
||||
runtime::CoreInstanceRuntimeConfig,
|
||||
toml::{ConfigLoader as _, TomlConfig},
|
||||
@@ -50,9 +51,25 @@ where
|
||||
// Preserve the existing ordered partial-commit contract: earlier valid
|
||||
// sub-patches remain applied if a later sub-patch fails.
|
||||
let patch_result: anyhow::Result<(bool, bool)> = async {
|
||||
let result = patch_port_forwards(&candidate, patch.port_forwards);
|
||||
validate_persist_and_commit_candidate(instance, &config, &candidate, persistence).await?;
|
||||
result?;
|
||||
if !patch.port_forwards.is_empty() {
|
||||
patch_port_forwards(&candidate, patch.port_forwards)?;
|
||||
// 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);
|
||||
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?;
|
||||
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?;
|
||||
result?;
|
||||
|
||||
@@ -90,19 +107,16 @@ where
|
||||
{
|
||||
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()));
|
||||
}
|
||||
if let Some(disable_relay_data) = patch.disable_relay_data {
|
||||
let mut flags = candidate.get_flags();
|
||||
flags.disable_relay_data = disable_relay_data;
|
||||
candidate.set_flags(flags);
|
||||
}
|
||||
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);
|
||||
}
|
||||
candidate.patch_flags(FlagsPatch {
|
||||
disable_relay_data: patch.disable_relay_data,
|
||||
prefer_peer_relay: patch.prefer_peer_relay,
|
||||
..Default::default()
|
||||
});
|
||||
if let Some(enabled) = patch.ipv6_public_addr_provider {
|
||||
candidate.set_ipv6_public_addr_provider(enabled);
|
||||
provider_config_changed = true;
|
||||
@@ -188,8 +202,8 @@ where
|
||||
let entries = managed
|
||||
.entries
|
||||
.iter()
|
||||
.map(managed_credential_from_proto)
|
||||
.collect::<Vec<_>>();
|
||||
.map(|credential| credential.clone().upgrade())
|
||||
.collect::<Result<Vec<_>, _>>()?;
|
||||
let replacement = credential_manager
|
||||
.validate_managed_credentials(&entries)
|
||||
.map_err(anyhow::Error::msg)?;
|
||||
@@ -457,15 +471,25 @@ fn patch_proxy_networks(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn patch_routes(config: &TomlConfig, patches: Vec<RoutePatch>) -> anyhow::Result<()> {
|
||||
if patches.is_empty() {
|
||||
fn patch_routes(
|
||||
config: &TomlConfig,
|
||||
patches: Vec<RoutePatch>,
|
||||
enable_manual_routes: Option<bool>,
|
||||
) -> anyhow::Result<()> {
|
||||
if patches.is_empty() && enable_manual_routes.is_none() {
|
||||
return Ok(());
|
||||
}
|
||||
let mut current = config.get_routes().unwrap_or_default();
|
||||
let patches = patches.into_iter().map(Into::into).collect::<Vec<_>>();
|
||||
trace_patchables(&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(())
|
||||
}
|
||||
|
||||
|
||||
@@ -60,6 +60,7 @@ where
|
||||
dev_name,
|
||||
my_node_info: Some(MyNodeInfo {
|
||||
virtual_ipv4: node.ipv4_addr.map(Into::into),
|
||||
virtual_ipv6: node.ipv6_addr.map(Into::into),
|
||||
hostname: node.hostname,
|
||||
version: node.version,
|
||||
ips: Some(node.ip_list),
|
||||
|
||||
@@ -435,6 +435,7 @@ fn build_heartbeat_request(
|
||||
.map(Into::into)
|
||||
.collect(),
|
||||
support_heartbeat_policy: true,
|
||||
support_manual_routes_patch: true,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -748,6 +749,8 @@ mod tests {
|
||||
vec![failed]
|
||||
);
|
||||
assert!(request.support_heartbeat_policy);
|
||||
assert!(request.support_manual_routes_patch);
|
||||
assert!(!HeartbeatRequest::default().support_manual_routes_patch);
|
||||
}
|
||||
|
||||
#[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 flow;
|
||||
mod hole_punch;
|
||||
pub(crate) mod ip;
|
||||
pub mod stun;
|
||||
|
||||
pub use flow::FlowKey;
|
||||
|
||||
pub(crate) use hole_punch::{
|
||||
HOLE_PUNCH_PACKET_BODY_LEN, hole_punch_packet_tid, new_hole_punch_packet,
|
||||
};
|
||||
@@ -539,6 +543,8 @@ impl ZCPacketType {
|
||||
pub struct ZCPacket {
|
||||
inner: BytesMut,
|
||||
packet_type: ZCPacketType,
|
||||
/// Local scheduling metadata; never serialized into a tunnel packet.
|
||||
pub(crate) flow_key: Option<FlowKey>,
|
||||
}
|
||||
|
||||
impl ZCPacket {
|
||||
@@ -554,6 +560,7 @@ impl ZCPacket {
|
||||
Self {
|
||||
inner: BytesMut::new(),
|
||||
packet_type: ZCPacketType::NIC,
|
||||
flow_key: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -561,6 +568,7 @@ impl ZCPacket {
|
||||
Self {
|
||||
inner: buf,
|
||||
packet_type,
|
||||
flow_key: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -608,6 +616,7 @@ impl ZCPacket {
|
||||
.copy_from_slice(foreign_zc_packet.tunnel_payload());
|
||||
|
||||
ret.fill_peer_manager_hdr(0, 0, PacketType::ForeignNetworkPacket as u8);
|
||||
ret.flow_key = foreign_zc_packet.flow_key;
|
||||
|
||||
ret
|
||||
}
|
||||
@@ -783,11 +792,14 @@ impl ZCPacket {
|
||||
let mut buf = BytesMut::with_capacity(new_pm_offset + tunnel_payload.len());
|
||||
unsafe { buf.set_len(new_pm_offset) };
|
||||
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::new_from_buf(self.inner, target_packet_type)
|
||||
self.packet_type = target_packet_type;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn into_bytes(self) -> Bytes {
|
||||
@@ -839,13 +851,15 @@ impl ZCPacket {
|
||||
let hdr = self.foreign_network_hdr().unwrap();
|
||||
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
|
||||
},
|
||||
ZCPacketType::DummyTunnel,
|
||||
)
|
||||
);
|
||||
packet.flow_key = self.flow_key;
|
||||
packet
|
||||
}
|
||||
|
||||
pub fn get_src_peer_id(&self) -> Option<u32> {
|
||||
|
||||
@@ -12,36 +12,21 @@ use quanta::Instant;
|
||||
use tokio_util::task::AbortOnDropHandle;
|
||||
|
||||
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},
|
||||
};
|
||||
|
||||
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_REQUEST: u8 = 8;
|
||||
const ICMPV6_ECHO_REQUEST: u8 = 128;
|
||||
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<'_> {
|
||||
fn can_use_allow_record(&self, is_in: bool) -> bool {
|
||||
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 {
|
||||
match protocol {
|
||||
IP_PROTO_TCP => Protocol::Tcp,
|
||||
@@ -568,13 +400,18 @@ mod tests {
|
||||
|
||||
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::{
|
||||
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,
|
||||
IPV6_AUTHENTICATION, IPV6_DESTINATION_OPTIONS, IPV6_FRAGMENT, IPV6_HOP_BY_HOP,
|
||||
IPV6_ROUTING, OutboundAllowRecord, PacketType, ZCPacket, acl_protocol, parse_ip_packet,
|
||||
OutboundAllowRecord, PacketType, ZCPacket, acl_protocol, parse_ip_packet,
|
||||
parse_transport_ports,
|
||||
};
|
||||
|
||||
|
||||
@@ -199,7 +199,7 @@ impl AttachedPeerRuntime {
|
||||
let public_ipv6_runtime =
|
||||
CorePublicIpv6Runtime::new(runtime_config.clone(), Arc::new(()), Arc::new(()));
|
||||
let flags = peer_snapshot.flags.clone();
|
||||
let peer_manager = Arc::new(PeerManagerCore::new(
|
||||
let mut peer_manager = PeerManagerCore::new(
|
||||
PortablePeerManagerConfig {
|
||||
snapshot: peer_snapshot,
|
||||
route_algo: RouteAlgoType::Ospf,
|
||||
@@ -214,7 +214,9 @@ impl AttachedPeerRuntime {
|
||||
Arc::new(()),
|
||||
None,
|
||||
Arc::new(()),
|
||||
)?);
|
||||
)?;
|
||||
peer_manager.inherit_exit_nodes(&network_peer_manager);
|
||||
let peer_manager = Arc::new(peer_manager);
|
||||
if let Err(error) = peer_manager
|
||||
.follow_network_policy(network_runtime_config, config.groups)
|
||||
.await
|
||||
@@ -621,6 +623,176 @@ mod tests {
|
||||
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(
|
||||
peer: &PeerManagerCore,
|
||||
attached_peer_id: PeerId,
|
||||
|
||||
@@ -5,5 +5,6 @@ pub(crate) mod peer;
|
||||
pub(crate) mod peer_conn;
|
||||
pub(crate) mod peer_conn_liveness;
|
||||
pub(crate) mod peer_conn_ping;
|
||||
mod peer_flow;
|
||||
pub(crate) mod peer_map;
|
||||
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 crossbeam::atomic::AtomicCell;
|
||||
use crossbeam_utils::atomic::AtomicCell;
|
||||
use dashmap::{DashMap, DashSet};
|
||||
use parking_lot::{Mutex, RwLock};
|
||||
|
||||
@@ -9,7 +15,10 @@ use tokio::{select, sync::mpsc};
|
||||
|
||||
use tracing::Instrument;
|
||||
|
||||
use super::peer_conn::{PeerConn, PeerConnId};
|
||||
use super::{
|
||||
peer_conn::{PeerConn, PeerConnId},
|
||||
peer_flow::FlowBindings,
|
||||
};
|
||||
use crate::peers::{
|
||||
PacketRecvChan,
|
||||
context::{ArcPeerContext, PeerEvent},
|
||||
@@ -17,7 +26,8 @@ use crate::peers::{
|
||||
};
|
||||
use crate::{
|
||||
config::PeerId,
|
||||
packet::ZCPacket,
|
||||
foundation::small_map_shard_amount,
|
||||
packet::{FlowKey, ZCPacket},
|
||||
peers::error::Error,
|
||||
proto::{core_peer::peer::PeerConnInfo, peer_rpc::PeerIdentityType},
|
||||
};
|
||||
@@ -26,8 +36,19 @@ use tokio_util::task::AbortOnDropHandle;
|
||||
type ArcPeerConn = Arc<PeerConn>;
|
||||
type ConnMap = Arc<DashMap<PeerConnId, ArcPeerConn>>;
|
||||
|
||||
fn conn_latency_sort_key(latency_us: u64, is_hole_punched: bool) -> (bool, u64) {
|
||||
(is_hole_punched && latency_us == 0, latency_us)
|
||||
fn conn_selection_key(conn: &PeerConn) -> (u8, u64) {
|
||||
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 {
|
||||
@@ -45,6 +66,8 @@ pub struct Peer {
|
||||
|
||||
default_conn: Arc<ArcSwapOption<PeerConn>>,
|
||||
default_conn_update_lock: Arc<Mutex<()>>,
|
||||
flow_bindings: Mutex<FlowBindings>,
|
||||
data_active: Arc<AtomicBool>,
|
||||
peer_identity_type: Arc<AtomicCell<Option<PeerIdentityType>>>,
|
||||
peer_public_key: Arc<RwLock<Option<Vec<u8>>>>,
|
||||
#[allow(dead_code)]
|
||||
@@ -57,7 +80,7 @@ impl Peer {
|
||||
packet_recv_chan: PacketRecvChan,
|
||||
context: ArcPeerContext,
|
||||
) -> 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 shutdown_notifier = Arc::new(tokio::sync::Notify::new());
|
||||
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 default_conn = Arc::new(ArcSwapOption::empty());
|
||||
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 shutdown_notifier_copy = shutdown_notifier.clone();
|
||||
@@ -130,12 +155,18 @@ impl Peer {
|
||||
|
||||
let conns_copy = conns.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 {
|
||||
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 {
|
||||
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,
|
||||
default_conn,
|
||||
default_conn_update_lock,
|
||||
flow_bindings,
|
||||
data_active,
|
||||
peer_identity_type,
|
||||
peer_public_key,
|
||||
default_conn_clear_task,
|
||||
@@ -212,22 +245,19 @@ impl Peer {
|
||||
|
||||
fn select_conn(&self) -> Option<ArcPeerConn> {
|
||||
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);
|
||||
}
|
||||
|
||||
// A zero latency on a hole-punched connection means the ping loop has not
|
||||
// confirmed liveness yet. Prefer any other connection, so a freshly admitted
|
||||
// hole-punched path cannot steal traffic before its first successful ping.
|
||||
// Unmeasured paths are usable for startup/recovery, but cannot outrank a
|
||||
// measured path merely because their RTT is still zero.
|
||||
let selected = self
|
||||
.conns
|
||||
.iter()
|
||||
.min_by_key(|conn| {
|
||||
conn_latency_sort_key(
|
||||
conn.value().get_stats().latency_us,
|
||||
conn.value().is_hole_punched(),
|
||||
)
|
||||
})
|
||||
.filter(|conn| !conn.is_closed())
|
||||
.min_by_key(|conn| conn_selection_key(conn.value()))
|
||||
.map(|conn| conn.value().clone());
|
||||
|
||||
if let Some(conn) = selected.as_ref() {
|
||||
@@ -236,9 +266,42 @@ impl Peer {
|
||||
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> {
|
||||
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();
|
||||
if let Some(conn) = default_conn.as_ref() {
|
||||
if let Some(conn) = default_conn.as_ref()
|
||||
&& !conn.is_closed()
|
||||
{
|
||||
conn.send_msg(msg).await?;
|
||||
return Ok(());
|
||||
}
|
||||
@@ -338,20 +401,269 @@ impl Drop for Peer {
|
||||
|
||||
#[cfg(test)]
|
||||
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 measured_relay_precedes_unverified_hole_punch_path() {
|
||||
assert!(conn_latency_sort_key(20_000, false) < conn_latency_sort_key(0, true));
|
||||
fn flow(port: u16) -> FlowKey {
|
||||
FlowKey {
|
||||
src: ([10, 0, 0, 1], port).into(),
|
||||
dst: ([10, 0, 0, 2], 5000).into(),
|
||||
protocol: 17,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unmeasured_regular_connection_keeps_existing_priority() {
|
||||
assert!(conn_latency_sort_key(0, false) < conn_latency_sort_key(20_000, false));
|
||||
async fn add_connection(
|
||||
peer: &Peer,
|
||||
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 verified_lower_latency_path_can_be_preferred() {
|
||||
assert!(conn_latency_sort_key(5_000, true) < conn_latency_sort_key(20_000, false));
|
||||
fn new_peer() -> Peer {
|
||||
let (sender, _receiver) = create_packet_recv_chan();
|
||||
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 crossbeam::atomic::AtomicCell;
|
||||
use crossbeam_utils::atomic::AtomicCell;
|
||||
use futures::{StreamExt, TryFutureExt};
|
||||
use std::{
|
||||
any::Any,
|
||||
@@ -7,7 +7,7 @@ use std::{
|
||||
pin::Pin,
|
||||
sync::{
|
||||
Arc,
|
||||
atomic::{AtomicU32, Ordering},
|
||||
atomic::{AtomicBool, AtomicU32, Ordering},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -298,6 +298,7 @@ pub struct PeerConn {
|
||||
ctrl_resp_sender: broadcast::Sender<ZCPacket>,
|
||||
|
||||
latency_stats: Arc<WindowLatency>,
|
||||
pub(super) fast_ping: Arc<AtomicBool>,
|
||||
throughput: Arc<Throughput>,
|
||||
loss_rate_stats: Arc<AtomicU32>,
|
||||
liveness: PeerConnLiveness,
|
||||
@@ -398,6 +399,7 @@ impl PeerConn {
|
||||
ctrl_resp_sender: ctrl_sender,
|
||||
|
||||
latency_stats: Arc::new(WindowLatency::new(15)),
|
||||
fast_ping: Arc::new(AtomicBool::new(false)),
|
||||
throughput,
|
||||
loss_rate_stats: Arc::new(AtomicU32::new(0)),
|
||||
liveness,
|
||||
@@ -1385,22 +1387,31 @@ impl PeerConn {
|
||||
self.liveness.clone(),
|
||||
self.max_ping_interval(),
|
||||
);
|
||||
pingpong.fast_ping = self.fast_ping.clone();
|
||||
|
||||
let close_event_notifier = self.close_event_notifier.clone();
|
||||
let conn_id = self.conn_id;
|
||||
|
||||
self.tasks.spawn(async move {
|
||||
pingpong.pingpong().await;
|
||||
self.tasks.spawn(
|
||||
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> {
|
||||
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 {
|
||||
@@ -1459,6 +1470,11 @@ impl PeerConn {
|
||||
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 {
|
||||
PeerConnStats {
|
||||
latency_us: self.latency_stats.get_latency_us(),
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
use std::{
|
||||
sync::{
|
||||
Arc,
|
||||
atomic::{AtomicU32, Ordering},
|
||||
atomic::{AtomicBool, AtomicU32, Ordering},
|
||||
},
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
@@ -124,6 +124,7 @@ impl PingIntervalController {
|
||||
}
|
||||
|
||||
pub struct PeerConnPinger {
|
||||
pub(super) fast_ping: Arc<AtomicBool>,
|
||||
my_peer_id: PeerId,
|
||||
peer_id: PeerId,
|
||||
sink: MpscTunnelSender,
|
||||
@@ -163,6 +164,7 @@ impl PeerConnPinger {
|
||||
) -> Self {
|
||||
Self {
|
||||
my_peer_id,
|
||||
fast_ping: Arc::new(AtomicBool::new(false)),
|
||||
peer_id,
|
||||
sink,
|
||||
latency_stats,
|
||||
@@ -188,13 +190,13 @@ impl PeerConnPinger {
|
||||
seq: u32,
|
||||
liveness_token: Option<u8>,
|
||||
) -> Result<PingResponse, Error> {
|
||||
let now = Instant::now();
|
||||
// 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_len = req.buf_len() as u64;
|
||||
self.sink.send(req).await?;
|
||||
self.context.record_control_tx(&self.network_name, req_len);
|
||||
|
||||
let now = Instant::now();
|
||||
let wait_for_pong = async {
|
||||
loop {
|
||||
match receiver.recv().await {
|
||||
@@ -257,11 +259,17 @@ impl PeerConnPinger {
|
||||
let throughput = self.throughput_stats.clone();
|
||||
let controller_loss_counter = loss_counter.clone();
|
||||
let max_interval = self.max_interval;
|
||||
let fast_ping = self.fast_ping.clone();
|
||||
controller_tasks.spawn(async move {
|
||||
let mut controller =
|
||||
PingIntervalController::new(throughput, controller_loss_counter, max_interval);
|
||||
loop {
|
||||
controller.tick().await;
|
||||
controller.max_interval = if fast_ping.load(Ordering::Relaxed) {
|
||||
Duration::from_secs(1)
|
||||
} else {
|
||||
max_interval
|
||||
};
|
||||
if !controller.should_send_ping() {
|
||||
continue;
|
||||
}
|
||||
@@ -290,6 +298,8 @@ impl PeerConnPinger {
|
||||
req_seq = req_seq.wrapping_add(1);
|
||||
|
||||
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 {
|
||||
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"))]
|
||||
#[tokio::test(start_paused = true)]
|
||||
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::{
|
||||
config::PeerId,
|
||||
foundation::small_map_shard_amount,
|
||||
packet::ZCPacket,
|
||||
peers::{
|
||||
context::{ArcPeerContext, NetworkIdentity, PeerEvent},
|
||||
@@ -53,7 +54,7 @@ impl PeerMap {
|
||||
PeerMap {
|
||||
context,
|
||||
my_peer_id,
|
||||
peer_map: DashMap::new(),
|
||||
peer_map: DashMap::with_shard_amount(small_map_shard_amount()),
|
||||
packet_send,
|
||||
routes: RwLock::new(Vec::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;
|
||||
}
|
||||
}
|
||||
let Some(min_lat) = conns
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.map(|conn| conn.stats.as_ref().unwrap().latency_us)
|
||||
.min()
|
||||
else {
|
||||
let Some(min_lat) = min_measured_latency_us(
|
||||
conns
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.map(|conn| conn.stats.as_ref().unwrap().latency_us),
|
||||
) else {
|
||||
continue;
|
||||
};
|
||||
|
||||
@@ -470,3 +471,37 @@ pub(crate) async fn direct_peer_info(peer_maps: &[Arc<PeerMap>]) -> PeerInfoForG
|
||||
}
|
||||
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 anyhow::anyhow;
|
||||
use crossbeam::atomic::AtomicCell;
|
||||
use crossbeam_utils::atomic::AtomicCell;
|
||||
use dashmap::DashMap;
|
||||
|
||||
use crate::peers::util::shrink_dashmap;
|
||||
|
||||
@@ -13,10 +13,10 @@ use cidr::{Ipv4Cidr, Ipv4Inet, Ipv6Cidr, Ipv6Inet};
|
||||
use dashmap::DashMap;
|
||||
use easytier_proto::{
|
||||
acl::Acl,
|
||||
common::{FlagsInConfig, PeerFeatureFlag, SecureModeConfig, StunInfo, TunnelInfo},
|
||||
common::{Flags, PeerFeatureFlag, SecureModeConfig, StunInfo, TunnelInfo},
|
||||
peer_rpc::{PeerGroupInfo, TrustedCredentialPubkeyProof},
|
||||
};
|
||||
use hmac::{Hmac, KeyInit, Mac};
|
||||
use hmac::{Hmac, Mac};
|
||||
use sha2::Sha256;
|
||||
|
||||
pub use crate::config::{NetworkIdentity, NetworkSecretDigest};
|
||||
@@ -28,6 +28,7 @@ use crate::{
|
||||
RouteConfig, TrafficConfig,
|
||||
},
|
||||
events::{CoreEvent, CoreEventSink},
|
||||
foundation::small_map_shard_amount,
|
||||
foundation::stats::{LabelSet, LabelType, MetricName, StatsManager},
|
||||
foundation::token_bucket::{ArcByteLimiter, BucketConfig, TokenBucketManager},
|
||||
peers::{
|
||||
@@ -71,7 +72,7 @@ pub struct PeerRuntimeSnapshotInput {
|
||||
pub routes: RouteConfig,
|
||||
pub network_identity: NetworkIdentity,
|
||||
pub stun_info: StunInfo,
|
||||
pub flags: FlagsInConfig,
|
||||
pub flags: Flags,
|
||||
pub secure_mode: Option<SecureModeConfig>,
|
||||
pub host_routing: HostRoutingPolicy,
|
||||
pub acl: Option<Acl>,
|
||||
@@ -89,7 +90,7 @@ struct PeerTrafficLimits {
|
||||
}
|
||||
|
||||
impl PeerTrafficLimits {
|
||||
fn from_portable(runtime: &PeerRuntimeConfig, flags: &FlagsInConfig) -> Self {
|
||||
fn from_portable(runtime: &PeerRuntimeConfig, flags: &Flags) -> Self {
|
||||
let traffic = &runtime.core.traffic;
|
||||
Self {
|
||||
instance_recv_bps: Self::normalize(
|
||||
@@ -438,7 +439,7 @@ pub(crate) struct TrustedKeyMapManager {
|
||||
impl TrustedKeyMapManager {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
network_trusted_keys: DashMap::new(),
|
||||
network_trusted_keys: DashMap::with_shard_amount(small_map_shard_amount()),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -527,7 +528,7 @@ pub(crate) struct PeerPacketPolicy {
|
||||
}
|
||||
|
||||
impl PeerPacketPolicy {
|
||||
fn from_flags(flags: &FlagsInConfig) -> Self {
|
||||
fn from_flags(flags: &Flags) -> Self {
|
||||
Self {
|
||||
disable_relay_data: flags.disable_relay_data,
|
||||
p2p_only: flags.p2p_only,
|
||||
@@ -549,8 +550,8 @@ pub(crate) trait PeerContext: Send + Sync {
|
||||
self.network_identity().network_name
|
||||
}
|
||||
|
||||
fn flags(&self) -> FlagsInConfig {
|
||||
FlagsInConfig::default()
|
||||
fn flags(&self) -> Flags {
|
||||
Flags::default()
|
||||
}
|
||||
|
||||
fn packet_policy(&self) -> PeerPacketPolicy {
|
||||
@@ -743,7 +744,7 @@ impl PeerContext for CorePeerContext {
|
||||
self.snapshot().runtime.network_identity.clone()
|
||||
}
|
||||
|
||||
fn flags(&self) -> FlagsInConfig {
|
||||
fn flags(&self) -> Flags {
|
||||
self.snapshot().flags.clone()
|
||||
}
|
||||
|
||||
@@ -1082,7 +1083,7 @@ pub(crate) mod tests {
|
||||
}
|
||||
|
||||
fn submitted_snapshot(hostname: &str, disable_relay_data: bool) -> PeerRuntimeSnapshot {
|
||||
let mut flags = FlagsInConfig::default();
|
||||
let mut flags = Flags::default();
|
||||
flags.disable_relay_data = disable_relay_data;
|
||||
PeerRuntimeSnapshot::new(
|
||||
PeerRuntimeConfig {
|
||||
@@ -1103,7 +1104,7 @@ pub(crate) mod tests {
|
||||
)
|
||||
}
|
||||
|
||||
fn host_snapshot_input(flags: FlagsInConfig, acl: Option<Acl>) -> PeerRuntimeSnapshotInput {
|
||||
fn host_snapshot_input(flags: Flags, acl: Option<Acl>) -> PeerRuntimeSnapshotInput {
|
||||
PeerRuntimeSnapshotInput {
|
||||
node: NodeConfig {
|
||||
peer_id: None,
|
||||
@@ -1136,7 +1137,7 @@ pub(crate) mod tests {
|
||||
|
||||
#[test]
|
||||
fn host_input_derives_peer_policy_features_and_traffic() {
|
||||
let mut flags = FlagsInConfig::default();
|
||||
let mut flags = Flags::default();
|
||||
flags.disable_p2p = true;
|
||||
flags.need_p2p = true;
|
||||
flags.relay_all_peer_rpc = true;
|
||||
@@ -1196,7 +1197,7 @@ pub(crate) mod tests {
|
||||
}),
|
||||
}),
|
||||
};
|
||||
let mut flags = FlagsInConfig::default();
|
||||
let mut flags = Flags::default();
|
||||
flags.relay_network_whitelist = "other-network".to_owned();
|
||||
|
||||
let snapshot = PeerRuntimeSnapshot::from_host_input(host_snapshot_input(flags, Some(acl)));
|
||||
@@ -1274,7 +1275,7 @@ pub(crate) mod tests {
|
||||
let mut runtime = PeerRuntimeSnapshot::default().runtime;
|
||||
runtime.core.traffic.instance_recv_bps_limit = Some(0);
|
||||
runtime.core.traffic.foreign_relay_bps_limit = Some(2048);
|
||||
let mut flags = FlagsInConfig::default();
|
||||
let mut flags = Flags::default();
|
||||
flags.instance_recv_bps_limit = 1024;
|
||||
flags.foreign_relay_bps_limit = 4096;
|
||||
|
||||
@@ -1287,7 +1288,7 @@ pub(crate) mod tests {
|
||||
#[test]
|
||||
fn legacy_traffic_limits_ignore_unlimited_sentinels() {
|
||||
let runtime = PeerRuntimeSnapshot::default().runtime;
|
||||
let mut flags = FlagsInConfig::default();
|
||||
let mut flags = Flags::default();
|
||||
flags.instance_recv_bps_limit = 1024;
|
||||
flags.foreign_relay_bps_limit = u64::MAX;
|
||||
|
||||
@@ -1302,7 +1303,7 @@ pub(crate) mod tests {
|
||||
let mut runtime = PeerRuntimeSnapshot::default().runtime;
|
||||
runtime.core.traffic.instance_recv_bps_limit = Some(u64::MAX);
|
||||
runtime.core.traffic.foreign_relay_bps_limit = Some(u64::MAX);
|
||||
let mut flags = FlagsInConfig::default();
|
||||
let mut flags = Flags::default();
|
||||
flags.instance_recv_bps_limit = 1024;
|
||||
flags.foreign_relay_bps_limit = 2048;
|
||||
|
||||
@@ -1315,7 +1316,7 @@ pub(crate) mod tests {
|
||||
fn portable_traffic_limits_default_to_unlimited() {
|
||||
let runtime = PeerRuntimeSnapshot::default().runtime;
|
||||
|
||||
let snapshot = PeerRuntimeSnapshot::new(runtime, FlagsInConfig::default());
|
||||
let snapshot = PeerRuntimeSnapshot::new(runtime, Flags::default());
|
||||
|
||||
assert_eq!(snapshot.traffic_limits(), PeerTrafficLimits::default());
|
||||
}
|
||||
@@ -1550,7 +1551,7 @@ pub(crate) mod tests {
|
||||
local_exit_node_fallback: true,
|
||||
},
|
||||
};
|
||||
let mut flags = FlagsInConfig::default();
|
||||
let mut flags = Flags::default();
|
||||
flags.p2p_only = true;
|
||||
let acl = Acl {
|
||||
acl_v1: Some(easytier_proto::acl::AclV1 {
|
||||
@@ -1626,7 +1627,7 @@ pub(crate) mod tests {
|
||||
live.tcp_nat_type = 4;
|
||||
let context = core_owned_context(
|
||||
runtime,
|
||||
FlagsInConfig::default(),
|
||||
Flags::default(),
|
||||
Some(Arc::new(TestStunInfoSource(live.clone()))),
|
||||
);
|
||||
|
||||
@@ -1636,7 +1637,7 @@ pub(crate) mod tests {
|
||||
|
||||
fn core_owned_context(
|
||||
runtime: PeerRuntimeConfig,
|
||||
flags: FlagsInConfig,
|
||||
flags: Flags,
|
||||
stun_info_source: Option<Arc<dyn PeerStunInfoSource>>,
|
||||
) -> CorePeerContext {
|
||||
core_owned_context_with_acl(runtime, flags, stun_info_source, None)
|
||||
@@ -1644,7 +1645,7 @@ pub(crate) mod tests {
|
||||
|
||||
fn core_owned_context_with_acl(
|
||||
runtime: PeerRuntimeConfig,
|
||||
flags: FlagsInConfig,
|
||||
flags: Flags,
|
||||
stun_info_source: Option<Arc<dyn PeerStunInfoSource>>,
|
||||
acl: Option<&Acl>,
|
||||
) -> CorePeerContext {
|
||||
@@ -1679,7 +1680,7 @@ pub(crate) mod tests {
|
||||
secure_mode: None,
|
||||
host_routing: HostRoutingPolicy::default(),
|
||||
},
|
||||
FlagsInConfig::default(),
|
||||
Flags::default(),
|
||||
None,
|
||||
)
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user