mirror of
https://github.com/EasyTier/EasyTier.git
synced 2026-10-29 08:03:54 +00:00
Compare commits
45
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
62803a9509 | ||
|
|
10cc636d7f | ||
|
|
9136f08a9c | ||
|
|
eb59c0f51c | ||
|
|
dcb40b46d9 | ||
|
|
179187fcb5 | ||
|
|
374455cbc6 | ||
|
|
99db92a3ae | ||
|
|
7588062f7e | ||
|
|
da6d721b97 | ||
|
|
ec694f7cb8 | ||
|
|
8839b4b2f1 | ||
|
|
0ca2fde0fb | ||
|
|
d25ccd4c17 | ||
|
|
15a3d43039 | ||
|
|
76439ce562 | ||
|
|
36b4ffcc74 | ||
|
|
03078b4791 | ||
|
|
7c8ca07812 | ||
|
|
4d4b5ad8dc | ||
|
|
0da58e68df | ||
|
|
32dc7f1f40 | ||
|
|
99fce327d5 | ||
|
|
1c6992d6cb | ||
|
|
9bc1a0941f | ||
|
|
c01e2baafa | ||
|
|
d31396f04c | ||
|
|
4b4f0c3cb3 | ||
|
|
1649ec0013 | ||
|
|
f8a45ccc20 | ||
|
|
7592951b80 | ||
|
|
83baead059 | ||
|
|
250c2ad8d6 | ||
|
|
018750a785 | ||
|
|
24afb4ecb4 | ||
|
|
d96eaa668b | ||
|
|
bd06395573 | ||
|
|
db289e87bb | ||
|
|
2583d867c3 | ||
|
|
7fdfe90291 | ||
|
|
7af7bdc16a | ||
|
|
983afda177 | ||
|
|
4837468d43 | ||
|
|
eb32d2a930 | ||
|
|
728ba94b50 |
@@ -3,3 +3,28 @@ slow-timeout = { period = "60s", terminate-after = 1 }
|
||||
|
||||
[profile.default.junit]
|
||||
path = "junit.xml"
|
||||
|
||||
[profile.ci]
|
||||
test-threads = 32
|
||||
|
||||
[test-groups.subnet-proxy]
|
||||
max-threads = 8
|
||||
|
||||
[[profile.default.overrides]]
|
||||
filter = 'test(tests::three_node::subnet_proxy_three_node_test)'
|
||||
test-group = 'subnet-proxy'
|
||||
|
||||
# These tests modify or inspect host-wide interfaces, routes, or BPF state.
|
||||
# They must exclude all other tests, including the isolated network fixtures.
|
||||
# Run exclusive tests first so the parallel pool does not repeatedly drain.
|
||||
[[profile.default.overrides]]
|
||||
filter = 'test(tests::upnp_test) | test(common::ifcfg::netlink::tests) | test(instance::public_ipv6_provider::platform::tests) | test(instance::dns_server::tests) | test(socket::fake_tcp::netfilter::linux_bpf::tests)'
|
||||
threads-required = 'num-test-threads'
|
||||
priority = 100
|
||||
|
||||
# These assertions require a reply within 100 ms, on the first ping, or a
|
||||
# minimum measured throughput. Competing tests can delay packet processing.
|
||||
[[profile.default.overrides]]
|
||||
filter = 'test(tests::three_node::p2p_only_test) | test(tests::three_node::relay_peer_e2e_encryption) | test(tests::three_node::whitelist_test) | test(relay_bps_limit_test) | test(instance_recv_bps_limit_test)'
|
||||
threads-required = 'num-test-threads'
|
||||
priority = 100
|
||||
|
||||
@@ -0,0 +1,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}.`);
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,216 @@
|
||||
name: Validate BPF veth initialization
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: ["diagnose/bpf-2710"]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
actions: read
|
||||
|
||||
jobs:
|
||||
diagnose:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- name: Wait for the fixed test artifact
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
for attempt in $(seq 1 180); do
|
||||
if gh api repos/EasyTier/EasyTier/actions/runs/38869099758/artifacts --jq '.artifacts[].name' | grep -qx tests; then
|
||||
exit 0
|
||||
fi
|
||||
sleep 5
|
||||
done
|
||||
exit 1
|
||||
- uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: tests
|
||||
run-id: 38869099758
|
||||
github-token: ${{ github.token }}
|
||||
- name: Prepare fixed CI binary
|
||||
run: |
|
||||
mkdir -p diagnostics
|
||||
tar --zstd -xf tests.tar.zst
|
||||
python3 - <<'PY'
|
||||
import pathlib, re
|
||||
binaries = [p for p in pathlib.Path('target/debug/deps').iterdir() if re.fullmatch(r'easytier-[0-9a-f]+', p.name)]
|
||||
assert len(binaries) == 1, binaries
|
||||
pathlib.Path('diagnostics/binary-path').write_text(str(binaries[0].resolve()))
|
||||
PY
|
||||
sudo apt-get update -qq
|
||||
sudo apt-get install -y -qq strace tcpdump
|
||||
uname -a > diagnostics/environment.txt
|
||||
systemctl is-active systemd-udevd systemd-networkd NetworkManager >> diagnostics/environment.txt || true
|
||||
sudo ip -ts -s -d monitor link > diagnostics/links.log &
|
||||
sudo tcpdump -i any -U -w diagnostics/frames.pcap 'tcp port 23456' > diagnostics/tcpdump.log 2>&1 &
|
||||
sudo bash -c 'echo "p:bpf_noop noop_enqueue" >> /sys/kernel/tracing/kprobe_events; echo 1 > /sys/kernel/tracing/events/kprobes/bpf_noop/enable' || true
|
||||
sudo cat /sys/kernel/tracing/trace_pipe > diagnostics/kernel-trace.log &
|
||||
- name: Build send observation hook
|
||||
run: |
|
||||
cat > diagnostics/send-observer.c <<'EOF'
|
||||
#define _GNU_SOURCE
|
||||
#include <dlfcn.h>
|
||||
#include <stdio.h>
|
||||
#include <sys/socket.h>
|
||||
#include <netpacket/packet.h>
|
||||
#include <net/if.h>
|
||||
#include <errno.h>
|
||||
#include <time.h>
|
||||
static ssize_t (*real_sendto)(int,const void*,size_t,int,const struct sockaddr*,socklen_t);
|
||||
__attribute__((constructor)) static void initialize(void) {
|
||||
real_sendto = dlsym(RTLD_NEXT, "sendto");
|
||||
}
|
||||
static void read_value(const char *name, const char *key, char *out) {
|
||||
char path[256];
|
||||
snprintf(path, sizeof(path), "/sys/class/net/%s/%s", name, key);
|
||||
FILE *f = fopen(path, "r");
|
||||
if (f) { if (!fgets(out, 64, f)) out[0] = 0; fclose(f); }
|
||||
}
|
||||
ssize_t sendto(int fd, const void *buf, size_t len, int flags,
|
||||
const struct sockaddr *addr, socklen_t addrlen) {
|
||||
ssize_t ret = real_sendto(fd, buf, len, flags, addr, addrlen);
|
||||
int saved_errno = errno;
|
||||
if (addr && addr->sa_family == AF_PACKET) {
|
||||
const struct sockaddr_ll *ll = (const struct sockaddr_ll *)addr;
|
||||
char name[IF_NAMESIZE] = {0}, drops[64] = {0}, state[64] = {0}, carrier[64] = {0};
|
||||
char packets[64] = {0};
|
||||
struct timespec timestamp;
|
||||
clock_gettime(CLOCK_REALTIME, ×tamp);
|
||||
if_indextoname(ll->sll_ifindex, name);
|
||||
read_value(name, "statistics/tx_dropped", drops);
|
||||
read_value(name, "operstate", state);
|
||||
read_value(name, "carrier", carrier);
|
||||
read_value(name, "statistics/tx_packets", packets);
|
||||
fprintf(stderr, "SEND_OBSERVE time=%ld.%09ld dev=%s len=%zu ret=%zd errno=%d tx_dropped=%s operstate=%s carrier=%s tx_packets=%s\n", timestamp.tv_sec, timestamp.tv_nsec, name, len, ret, saved_errno, drops, state, carrier, packets);
|
||||
}
|
||||
errno = saved_errno;
|
||||
return ret;
|
||||
}
|
||||
EOF
|
||||
gcc -shared -fPIC -O2 diagnostics/send-observer.c -ldl -o diagnostics/send-observer.so
|
||||
- name: Repeat fixed test with post-send observation
|
||||
run: |
|
||||
sudo -E python3 -u - <<'PY'
|
||||
import os, pathlib, subprocess, time
|
||||
root = pathlib.Path.cwd()
|
||||
env = os.environ.copy()
|
||||
env['EASYTIER_LINUX_BPF_INTEGRATION'] = 'required'
|
||||
env['LD_PRELOAD'] = str(root / 'diagnostics/send-observer.so')
|
||||
binary = (root / 'diagnostics/binary-path').read_text()
|
||||
case = 'socket::fake_tcp::netfilter::linux_bpf::tests::linux_bpf_tun_sends_ipv4_frame_to_ethernet_interface'
|
||||
start = time.monotonic()
|
||||
failures = 0
|
||||
timeouts = 0
|
||||
for i in range(1, 21):
|
||||
result = subprocess.run([binary, case, '--exact', '--nocapture'], env=env, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True)
|
||||
if i <= 3 or result.returncode:
|
||||
(root / f'diagnostics/attempt-{i}.log').write_text(result.stdout)
|
||||
if result.returncode:
|
||||
failures += 1
|
||||
print('REPRODUCED', i, 'elapsed', time.monotonic() - start, flush=True)
|
||||
print(result.stdout, flush=True)
|
||||
if 'timed out waiting for Ethernet frame' in result.stdout:
|
||||
timeouts += 1
|
||||
elif 'assertion `left == right` failed' not in result.stdout:
|
||||
break
|
||||
if timeouts == 3:
|
||||
break
|
||||
if i % 100 == 0:
|
||||
print('PASS', i, 'elapsed', time.monotonic() - start, flush=True)
|
||||
else:
|
||||
print('COMPLETED 20 ATTEMPTS', flush=True)
|
||||
print('TOTAL FAILURES', failures, 'TIMEOUTS', timeouts, flush=True)
|
||||
assert failures == 0
|
||||
PY
|
||||
- name: Repeat fixed binary without observation hooks
|
||||
run: |
|
||||
sudo -E python3 -u - <<'PY'
|
||||
import os, pathlib, subprocess, time
|
||||
root = pathlib.Path.cwd()
|
||||
env = os.environ.copy()
|
||||
env['EASYTIER_LINUX_BPF_INTEGRATION'] = 'required'
|
||||
binary = (root / 'diagnostics/binary-path').read_text()
|
||||
case = 'socket::fake_tcp::netfilter::linux_bpf::tests::linux_bpf_tun_sends_ipv4_frame_to_ethernet_interface'
|
||||
start = time.monotonic()
|
||||
failures = 0
|
||||
for i in range(1, 3001):
|
||||
result = subprocess.run([binary, case, '--exact', '--nocapture'], env=env, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True)
|
||||
if result.returncode:
|
||||
failures += 1
|
||||
(root / f'diagnostics/baseline-{i}.log').write_text(result.stdout)
|
||||
if 'timed out waiting for Ethernet frame' in result.stdout:
|
||||
print('ORIGINAL TIMEOUT', i, 'elapsed', time.monotonic() - start, flush=True)
|
||||
print(result.stdout, flush=True)
|
||||
break
|
||||
if 'assertion `left == right` failed' not in result.stdout:
|
||||
print('UNEXPECTED', i, result.stdout, flush=True)
|
||||
break
|
||||
if i % 500 == 0:
|
||||
print('BASELINE', i, 'failures', failures, 'elapsed', time.monotonic() - start, flush=True)
|
||||
else:
|
||||
print('COMPLETED 3000 FIXED ATTEMPTS', flush=True)
|
||||
assert failures == 0
|
||||
PY
|
||||
- name: Validate fixed fixture under worker scheduling pressure
|
||||
run: |
|
||||
cat > diagnostics/cpu-pressure.c <<'EOF'
|
||||
#define _GNU_SOURCE
|
||||
#include <sched.h>
|
||||
#include <stdlib.h>
|
||||
#include <time.h>
|
||||
#include <stdio.h>
|
||||
int main(int argc, char **argv) {
|
||||
cpu_set_t mask;
|
||||
CPU_ZERO(&mask);
|
||||
CPU_SET(atoi(argv[1]), &mask);
|
||||
if (sched_setaffinity(0, sizeof(mask), &mask)) { perror("affinity"); return 1; }
|
||||
struct sched_param param = {.sched_priority = 10};
|
||||
if (sched_setscheduler(0, SCHED_FIFO, ¶m)) { perror("scheduler"); return 1; }
|
||||
struct timespec start, now;
|
||||
clock_gettime(CLOCK_MONOTONIC, &start);
|
||||
do { clock_gettime(CLOCK_MONOTONIC, &now); } while (now.tv_sec - start.tv_sec < 300);
|
||||
return 0;
|
||||
}
|
||||
EOF
|
||||
gcc -O2 diagnostics/cpu-pressure.c -o diagnostics/cpu-pressure
|
||||
sudo -E python3 -u - <<'PY'
|
||||
import os, pathlib, subprocess, time
|
||||
root = pathlib.Path.cwd()
|
||||
env = os.environ.copy()
|
||||
env['EASYTIER_LINUX_BPF_INTEGRATION'] = 'required'
|
||||
env['LD_PRELOAD'] = str(root / 'diagnostics/send-observer.so')
|
||||
binary = (root / 'diagnostics/binary-path').read_text()
|
||||
case = 'socket::fake_tcp::netfilter::linux_bpf::tests::linux_bpf_tun_sends_ipv4_frame_to_ethernet_interface'
|
||||
hogs = []
|
||||
try:
|
||||
os.sched_setscheduler(0, os.SCHED_FIFO, os.sched_param(50))
|
||||
for cpu in sorted(os.sched_getaffinity(0)):
|
||||
hogs.append(subprocess.Popen([str(root / 'diagnostics/cpu-pressure'), str(cpu)]))
|
||||
failures = 0
|
||||
timeouts = 0
|
||||
for i in range(1, 51):
|
||||
assert all(proc.poll() is None for proc in hogs), 'CPU load ended early'
|
||||
result = subprocess.run(['chrt', '-f', '20', binary, case, '--exact', '--nocapture'], env=env, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, timeout=10)
|
||||
(root / f'diagnostics/pressure-fixed-{i}.log').write_text(result.stdout)
|
||||
failures += bool(result.returncode)
|
||||
timeouts += 'timed out waiting for Ethernet frame' in result.stdout
|
||||
print('PRESSURE fixed', i, result.returncode, result.stdout, flush=True)
|
||||
assert 'tx_dropped=0\n' in result.stdout
|
||||
assert all(proc.poll() is None for proc in hogs), 'CPU load ended early'
|
||||
print('PRESSURE TOTAL fixed', 'failures', failures, 'timeouts', timeouts, flush=True)
|
||||
assert failures == 0
|
||||
finally:
|
||||
for proc in hogs:
|
||||
proc.kill()
|
||||
for proc in hogs:
|
||||
proc.wait()
|
||||
os.sched_setscheduler(0, os.SCHED_OTHER, os.sched_param(0))
|
||||
PY
|
||||
- uses: actions/upload-artifact@v4
|
||||
if: always()
|
||||
with:
|
||||
name: bpf-diagnostics
|
||||
path: diagnostics
|
||||
+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
|
||||
@@ -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 }}
|
||||
+38
-15
@@ -80,29 +80,62 @@ 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)
|
||||
@@ -149,17 +182,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 +189,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
+87
-114
@@ -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"
|
||||
@@ -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"
|
||||
@@ -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,16 @@ 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",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2 0.11.0",
|
||||
"sha2 0.10.9",
|
||||
"smoltcp",
|
||||
"snow",
|
||||
"strum 0.28.0",
|
||||
@@ -2811,12 +2792,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 +2839,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 +2908,7 @@ dependencies = [
|
||||
"axum-embed",
|
||||
"axum-login",
|
||||
"axum-messages",
|
||||
"base64 0.23.1",
|
||||
"base64 0.22.1",
|
||||
"chrono",
|
||||
"cidr",
|
||||
"clap",
|
||||
@@ -2937,18 +2921,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 +2942,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 +3012,7 @@ dependencies = [
|
||||
"ff",
|
||||
"generic-array",
|
||||
"group",
|
||||
"hkdf 0.12.4",
|
||||
"hkdf",
|
||||
"pem-rfc7468",
|
||||
"pkcs8",
|
||||
"rand_core 0.6.4",
|
||||
@@ -3778,16 +3765,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 +4275,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"
|
||||
@@ -6070,7 +6039,6 @@ dependencies = [
|
||||
"getrandom 0.2.17",
|
||||
"http",
|
||||
"rand 0.8.8",
|
||||
"reqwest 0.12.28",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"serde_path_to_error",
|
||||
@@ -6079,6 +6047,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 +6392,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"
|
||||
@@ -7001,17 +7003,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 +7280,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"
|
||||
@@ -7743,38 +7744,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"
|
||||
@@ -8788,6 +8757,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 +8954,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",
|
||||
@@ -9182,7 +9154,7 @@ dependencies = [
|
||||
"futures-util",
|
||||
"generic-array",
|
||||
"hex",
|
||||
"hkdf 0.12.4",
|
||||
"hkdf",
|
||||
"hmac 0.12.1",
|
||||
"itoa",
|
||||
"log",
|
||||
@@ -9225,7 +9197,7 @@ dependencies = [
|
||||
"futures-core",
|
||||
"futures-util",
|
||||
"hex",
|
||||
"hkdf 0.12.4",
|
||||
"hkdf",
|
||||
"hmac 0.12.1",
|
||||
"home",
|
||||
"itoa",
|
||||
@@ -9633,7 +9605,7 @@ dependencies = [
|
||||
"percent-encoding",
|
||||
"plist",
|
||||
"raw-window-handle",
|
||||
"reqwest 0.13.5",
|
||||
"reqwest",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"serde_repr",
|
||||
@@ -10675,7 +10647,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=5ba6be073e5b486b68c12f80fac5cfb50601f024#5ba6be073e5b486b68c12f80fac5cfb50601f024"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"cfg-if",
|
||||
@@ -10916,6 +10888,7 @@ dependencies = [
|
||||
"js-sys",
|
||||
"rand 0.10.2",
|
||||
"serde_core",
|
||||
"sha1_smol",
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
|
||||
+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,14 @@ A core instance:
|
||||
`CoreProcessRuntime`, plus optional protocol and platform capabilities. The
|
||||
bundle carries capabilities, not preconstructed portable managers.
|
||||
|
||||
The standard native factory owns one continuously driven Tokio executor per
|
||||
instance. `multi_thread` selects a multi-thread or current-thread runtime;
|
||||
`multi_thread_count` sets the worker count with the historical minimum of two.
|
||||
Construction, start/stop, and FFI data-plane tasks use that executor, independent
|
||||
of the process management runtime. Dropping its Host owner shuts down the
|
||||
executor without blocking a worker on its own termination. Compact and WASI
|
||||
Hosts retain their externally supplied execution model.
|
||||
|
||||
Each Module owns partial-start cleanup for its internal resources.
|
||||
`CoreInstance` has one outer cancellation and recovery path for the complete
|
||||
serial startup. `Running` therefore means the Host runtime and every enabled
|
||||
@@ -390,6 +413,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 +502,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.
|
||||
|
||||
@@ -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(),
|
||||
)
|
||||
}
|
||||
|
||||
+30
-10
@@ -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 }
|
||||
@@ -58,6 +59,14 @@ 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 +83,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"]
|
||||
@@ -106,16 +115,20 @@ 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",
|
||||
]
|
||||
dhcp-ipv4 = []
|
||||
dhcp-ipv6 = []
|
||||
public-ipv6-provider = []
|
||||
vpn-portal = []
|
||||
wrapped-transport = []
|
||||
extended-services = [
|
||||
"dhcp-ipv4",
|
||||
"dhcp-ipv6",
|
||||
"public-ipv6-provider",
|
||||
"vpn-portal",
|
||||
"wrapped-transport",
|
||||
@@ -152,12 +165,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,50 @@ 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.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 +121,7 @@ const FORM_MANAGED_TOML_FIELDS: &[&str] = &[
|
||||
"ipv6_public_addr_auto",
|
||||
"ipv6_public_addr_prefix",
|
||||
"dhcp",
|
||||
"dhcpv6",
|
||||
"network_identity",
|
||||
"listeners",
|
||||
"mapped_listeners",
|
||||
@@ -112,47 +138,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 +169,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 +251,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 +455,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 +476,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 +494,59 @@ 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,
|
||||
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 +563,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 +653,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 +680,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 +694,7 @@ mod tests {
|
||||
#![allow(deprecated)]
|
||||
|
||||
use super::*;
|
||||
use easytier_proto::common::Flags;
|
||||
|
||||
fn api_portal_config() -> manage::VpnPortalConfig {
|
||||
manage::VpnPortalConfig {
|
||||
@@ -853,6 +716,219 @@ 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 vpn_portal_api_config_round_trips_through_toml_model() {
|
||||
let input = NetworkConfig {
|
||||
@@ -969,6 +1045,46 @@ mod tests {
|
||||
assert!(config.get_vpn_portal_config().is_none());
|
||||
}
|
||||
|
||||
#[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 +1122,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())
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -187,9 +187,16 @@ where
|
||||
let root_store = 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();
|
||||
// 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")?
|
||||
.with_root_certificates(root_store)
|
||||
.with_no_client_auth();
|
||||
let stream = TlsConnector::from(Arc::new(tls_config))
|
||||
.connect(server_name, socket)
|
||||
.await
|
||||
@@ -631,6 +638,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
|
||||
}
|
||||
|
||||
@@ -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,7 +172,6 @@ pub(super) enum FlowData {
|
||||
Udp,
|
||||
}
|
||||
|
||||
const UDP_ENTRY: FlowKind = FlowKind::Udp;
|
||||
const TCP_ENTRY: FlowKind = FlowKind::Tcp;
|
||||
const TCP_LISTEN_ENTRY: FlowKind = FlowKind::TcpListen;
|
||||
|
||||
@@ -195,6 +197,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 +210,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 +260,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 +274,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 +316,7 @@ where
|
||||
),
|
||||
}
|
||||
|
||||
None
|
||||
PeerPacketFilterResult::Consumed
|
||||
}
|
||||
}
|
||||
|
||||
@@ -359,6 +348,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 +382,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 +428,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 +486,7 @@ where
|
||||
|
||||
select! {
|
||||
_ = peer_changes.changed() => {}
|
||||
_ = fragment_gc.tick() => {}
|
||||
_ = data_plane_consumers.changed() => {}
|
||||
}
|
||||
}
|
||||
@@ -549,6 +549,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) {
|
||||
@@ -943,19 +944,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 +991,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()
|
||||
}
|
||||
|
||||
@@ -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())
|
||||
@@ -541,37 +549,39 @@ async fn listener_and_accepted_stream_own_independent_flow_lifetimes() {
|
||||
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 +591,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 +908,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 +939,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 +972,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;
|
||||
}
|
||||
@@ -15,7 +15,7 @@ use tokio::{
|
||||
use crate::{
|
||||
packet::ZCPacket,
|
||||
peers::{
|
||||
PeerPacketFilter,
|
||||
PeerPacketFilter, PeerPacketFilterResult,
|
||||
peer_manager::{PeerManagerCore, PipelineRegistrationGuard},
|
||||
},
|
||||
};
|
||||
@@ -249,11 +249,11 @@ 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()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,42 @@ 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 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))
|
||||
@@ -159,10 +194,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(),
|
||||
|
||||
@@ -210,14 +210,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 +302,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 +416,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(
|
||||
|
||||
@@ -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,16 +66,16 @@ 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(())
|
||||
@@ -125,9 +125,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 +173,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 +216,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 +277,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;
|
||||
|
||||
@@ -479,9 +479,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())
|
||||
}
|
||||
|
||||
|
||||
@@ -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>,
|
||||
@@ -783,6 +789,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 +884,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 +936,57 @@ 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;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
||||
@@ -628,6 +628,57 @@ mod portable_runtime {
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(feature = "dhcp-ipv6")]
|
||||
#[tokio::test]
|
||||
async fn runtime_update_preserves_dhcp_owned_ipv6() {
|
||||
let mut initial = test_config("dhcp-runtime-update");
|
||||
initial.connectivity.runtime.dhcp_ipv6 = true;
|
||||
let instance = build_instance(initial).unwrap();
|
||||
let lease = IpPrefix {
|
||||
address: "fd12::7".parse().unwrap(),
|
||||
prefix_len: 64,
|
||||
};
|
||||
instance.runtime_config.update_peer_with(|peer| {
|
||||
peer.runtime.core.routes.ipv6 = Some(lease.clone());
|
||||
});
|
||||
|
||||
let mut replacement = test_config("dhcp-runtime-update");
|
||||
replacement.connectivity.runtime.dhcp_ipv6 = true;
|
||||
instance
|
||||
.update_runtime_config(runtime_snapshot(&replacement))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
instance
|
||||
.runtime_config
|
||||
.snapshot()
|
||||
.peer
|
||||
.runtime
|
||||
.core
|
||||
.routes
|
||||
.ipv6,
|
||||
Some(lease)
|
||||
);
|
||||
|
||||
let static_replacement = test_config("dhcp-runtime-update");
|
||||
instance
|
||||
.update_runtime_config(runtime_snapshot(&static_replacement))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
instance
|
||||
.runtime_config
|
||||
.snapshot()
|
||||
.peer
|
||||
.runtime
|
||||
.core
|
||||
.routes
|
||||
.ipv6,
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn core_instance_is_a_direct_managed_record() {
|
||||
let instance = build_instance(test_config("managed-directly")).unwrap();
|
||||
@@ -833,6 +884,88 @@ network_secret = "network-secret"
|
||||
assert!(error.to_string().contains("instance is not ready"));
|
||||
}
|
||||
|
||||
#[cfg(feature = "management")]
|
||||
#[tokio::test]
|
||||
async fn manual_route_patch_preserves_empty_mode_without_restart() {
|
||||
use easytier_proto::api::config::{ConfigPatchAction, InstanceConfigPatch, RoutePatch};
|
||||
|
||||
let (packet_sink, _packet_receiver) = tokio::sync::mpsc::channel(16);
|
||||
let config = TomlConfig::new_from_str(
|
||||
"[network_identity]\nnetwork_name = 'manual-routes'\nnetwork_secret = 'secret'",
|
||||
)
|
||||
.unwrap();
|
||||
let instance =
|
||||
CoreInstance::from_toml(config, adapters(None, Arc::new(packet_sink))).unwrap();
|
||||
instance.start().await.unwrap();
|
||||
let peer_id = instance.peer_id();
|
||||
let persistence = RecordingConfigPatchPersistence {
|
||||
writes: std::sync::Mutex::new(Vec::new()),
|
||||
fail: AtomicBool::new(true),
|
||||
};
|
||||
let route: cidr::Ipv4Cidr = "10.20.0.0/24".parse().unwrap();
|
||||
let add = RoutePatch {
|
||||
action: ConfigPatchAction::Add as i32,
|
||||
cidr: Some("10.20.0.0/24".parse::<cidr::Ipv4Inet>().unwrap().into()),
|
||||
};
|
||||
let clear = RoutePatch {
|
||||
action: ConfigPatchAction::Clear as i32,
|
||||
..Default::default()
|
||||
};
|
||||
let failed = crate::management::apply_config_patch(
|
||||
&instance,
|
||||
InstanceConfigPatch {
|
||||
enable_manual_routes: Some(true),
|
||||
routes: vec![add],
|
||||
..Default::default()
|
||||
},
|
||||
Some(&persistence),
|
||||
)
|
||||
.await;
|
||||
assert!(failed.is_err());
|
||||
assert_eq!(instance.toml_config().unwrap().get_routes(), None);
|
||||
assert_eq!(
|
||||
instance.runtime_config.snapshot().services.manual_routes,
|
||||
None
|
||||
);
|
||||
assert!(persistence.writes.lock().unwrap().is_empty());
|
||||
persistence.fail.store(false, Ordering::Relaxed);
|
||||
|
||||
for (mode, patches, expected) in [
|
||||
(Some(true), Vec::new(), Some(Vec::new())),
|
||||
(Some(true), vec![add], Some(vec![route])),
|
||||
(Some(true), vec![clear], Some(Vec::new())),
|
||||
(Some(false), Vec::new(), None),
|
||||
// Released callers that omit the new mode keep their old behavior.
|
||||
(None, vec![add], Some(vec![route])),
|
||||
(None, vec![clear], None),
|
||||
] {
|
||||
crate::management::apply_config_patch(
|
||||
&instance,
|
||||
InstanceConfigPatch {
|
||||
enable_manual_routes: mode,
|
||||
routes: patches,
|
||||
..Default::default()
|
||||
},
|
||||
Some(&persistence),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(instance.toml_config().unwrap().get_routes(), expected);
|
||||
assert_eq!(
|
||||
instance.runtime_config.snapshot().services.manual_routes,
|
||||
expected
|
||||
.as_ref()
|
||||
.map(|routes| routes.iter().copied().collect())
|
||||
);
|
||||
let writes = persistence.writes.lock().unwrap();
|
||||
let persisted = TomlConfig::new_from_str(writes.last().unwrap()).unwrap();
|
||||
assert_eq!(persisted.get_routes(), expected);
|
||||
assert_eq!(instance.peer_id(), peer_id);
|
||||
assert_eq!(instance.state(), CoreInstanceState::Running);
|
||||
}
|
||||
instance.stop().await;
|
||||
}
|
||||
|
||||
#[cfg(feature = "management")]
|
||||
#[tokio::test]
|
||||
async fn managed_credential_patch_is_durable_atomic_and_does_not_restart() {
|
||||
@@ -1332,7 +1465,7 @@ virtual_ip = "10.82.0.2/24"
|
||||
async fn prepare(
|
||||
&self,
|
||||
_packet_plane: Arc<CorePacketPlane>,
|
||||
) -> anyhow::Result<Option<Arc<dyn DhcpIpv4Host>>> {
|
||||
) -> anyhow::Result<Option<Arc<dyn DhcpHost>>> {
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
@@ -1366,7 +1499,7 @@ virtual_ip = "10.82.0.2/24"
|
||||
async fn prepare(
|
||||
&self,
|
||||
_packet_plane: Arc<CorePacketPlane>,
|
||||
) -> anyhow::Result<Option<Arc<dyn DhcpIpv4Host>>> {
|
||||
) -> anyhow::Result<Option<Arc<dyn DhcpHost>>> {
|
||||
anyhow::bail!("host prepare failed")
|
||||
}
|
||||
|
||||
@@ -1477,7 +1610,7 @@ virtual_ip = "10.82.0.2/24"
|
||||
async fn prepare(
|
||||
&self,
|
||||
_packet_plane: Arc<CorePacketPlane>,
|
||||
) -> anyhow::Result<Option<Arc<dyn DhcpIpv4Host>>> {
|
||||
) -> anyhow::Result<Option<Arc<dyn DhcpHost>>> {
|
||||
self.prepare_started.notify_one();
|
||||
self.prepare_release.notified().await;
|
||||
Ok(None)
|
||||
@@ -1513,6 +1646,162 @@ virtual_ip = "10.82.0.2/24"
|
||||
assert_eq!(runtime_host.shutdown_calls.load(Ordering::Relaxed), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn host_executor_routes_lifecycle_and_cancelled_start() {
|
||||
struct ExecutorRuntimeHost {
|
||||
handle: tokio::runtime::Handle,
|
||||
block_prepare: bool,
|
||||
prepare_started: Notify,
|
||||
shutdown_calls: AtomicUsize,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl InstanceRuntimeHost for ExecutorRuntimeHost {
|
||||
fn runtime_handle(&self) -> Option<tokio::runtime::Handle> {
|
||||
Some(self.handle.clone())
|
||||
}
|
||||
|
||||
async fn prepare(
|
||||
&self,
|
||||
_packet_plane: Arc<CorePacketPlane>,
|
||||
) -> anyhow::Result<Option<Arc<dyn DhcpHost>>> {
|
||||
assert_eq!(tokio::runtime::Handle::current().id(), self.handle.id());
|
||||
self.prepare_started.notify_one();
|
||||
if self.block_prepare {
|
||||
std::future::pending::<()>().await;
|
||||
}
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
async fn shutdown(&self) {
|
||||
assert_eq!(tokio::runtime::Handle::current().id(), self.handle.id());
|
||||
self.shutdown_calls.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
|
||||
for block_prepare in [false, true] {
|
||||
let executor = tokio::runtime::Builder::new_multi_thread()
|
||||
.worker_threads(2)
|
||||
.enable_all()
|
||||
.build()
|
||||
.unwrap();
|
||||
let caller = tokio::runtime::Builder::new_current_thread()
|
||||
.enable_all()
|
||||
.build()
|
||||
.unwrap();
|
||||
let runtime_host = Arc::new(ExecutorRuntimeHost {
|
||||
handle: executor.handle().clone(),
|
||||
block_prepare,
|
||||
prepare_started: Notify::new(),
|
||||
shutdown_calls: AtomicUsize::new(0),
|
||||
});
|
||||
let instance = {
|
||||
let _runtime = executor.enter();
|
||||
let (packet_sink, _packet_receiver) = tokio::sync::mpsc::channel(16);
|
||||
let mut adapters = adapters(None, Arc::new(packet_sink));
|
||||
adapters.instance_runtime = runtime_host.clone();
|
||||
CoreInstance::new(test_config("host-executor"), adapters).unwrap()
|
||||
};
|
||||
caller.block_on(async {
|
||||
if block_prepare {
|
||||
let start = tokio::spawn({
|
||||
let instance = instance.clone();
|
||||
async move { instance.start().await }
|
||||
});
|
||||
tokio::time::timeout(
|
||||
Duration::from_secs(2),
|
||||
runtime_host.prepare_started.notified(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
start.abort();
|
||||
assert!(start.await.unwrap_err().is_cancelled());
|
||||
} else {
|
||||
instance.start().await.unwrap();
|
||||
assert_eq!(instance.state(), CoreInstanceState::Running);
|
||||
instance.stop().await;
|
||||
}
|
||||
tokio::time::timeout(Duration::from_secs(2), async {
|
||||
while instance.state() != CoreInstanceState::Stopped {
|
||||
tokio::time::sleep(Duration::from_millis(1)).await;
|
||||
}
|
||||
})
|
||||
.await
|
||||
.expect("lifecycle cleanup must complete on the Host executor");
|
||||
assert_eq!(runtime_host.shutdown_calls.load(Ordering::Relaxed), 1);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cancelled_stop_before_executor_poll_still_finishes_cleanup() {
|
||||
struct ExecutorRuntimeHost {
|
||||
handle: tokio::runtime::Handle,
|
||||
shutdown_calls: AtomicUsize,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl InstanceRuntimeHost for ExecutorRuntimeHost {
|
||||
fn runtime_handle(&self) -> Option<tokio::runtime::Handle> {
|
||||
Some(self.handle.clone())
|
||||
}
|
||||
|
||||
async fn prepare(
|
||||
&self,
|
||||
_packet_plane: Arc<CorePacketPlane>,
|
||||
) -> anyhow::Result<Option<Arc<dyn DhcpHost>>> {
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
async fn shutdown(&self) {
|
||||
assert_eq!(tokio::runtime::Handle::current().id(), self.handle.id());
|
||||
self.shutdown_calls.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
|
||||
let executor = tokio::runtime::Builder::new_current_thread()
|
||||
.enable_all()
|
||||
.build()
|
||||
.unwrap();
|
||||
let runtime_host = Arc::new(ExecutorRuntimeHost {
|
||||
handle: executor.handle().clone(),
|
||||
shutdown_calls: AtomicUsize::new(0),
|
||||
});
|
||||
let instance = {
|
||||
let _runtime = executor.enter();
|
||||
let (packet_sink, _packet_receiver) = tokio::sync::mpsc::channel(16);
|
||||
let mut adapters = adapters(None, Arc::new(packet_sink));
|
||||
adapters.instance_runtime = runtime_host.clone();
|
||||
CoreInstance::new(test_config("cancel-stop-before-poll"), adapters).unwrap()
|
||||
};
|
||||
executor.block_on(instance.start()).unwrap();
|
||||
assert_eq!(instance.state(), CoreInstanceState::Running);
|
||||
|
||||
let caller = tokio::runtime::Builder::new_current_thread()
|
||||
.enable_all()
|
||||
.build()
|
||||
.unwrap();
|
||||
caller.block_on(async {
|
||||
// The instance executor is not driven here, so the dispatched stop
|
||||
// cannot be polled before its caller is cancelled.
|
||||
let mut stop = Box::pin(instance.stop());
|
||||
assert!(futures::poll!(&mut stop).is_pending());
|
||||
drop(stop);
|
||||
});
|
||||
drop(caller);
|
||||
|
||||
executor.block_on(async {
|
||||
tokio::time::timeout(Duration::from_secs(2), async {
|
||||
while instance.state() != CoreInstanceState::Stopped {
|
||||
tokio::time::sleep(Duration::from_millis(1)).await;
|
||||
}
|
||||
})
|
||||
.await
|
||||
.expect("cancelled stop must still finish on the instance executor");
|
||||
});
|
||||
assert_eq!(runtime_host.shutdown_calls.load(Ordering::Relaxed), 1);
|
||||
}
|
||||
|
||||
#[cfg(feature = "management")]
|
||||
#[tokio::test]
|
||||
async fn cancelled_delete_finishes_stop_and_keeps_wait_blocked() {
|
||||
@@ -1542,7 +1831,7 @@ virtual_ip = "10.82.0.2/24"
|
||||
async fn prepare(
|
||||
&self,
|
||||
_packet_plane: Arc<CorePacketPlane>,
|
||||
) -> anyhow::Result<Option<Arc<dyn DhcpIpv4Host>>> {
|
||||
) -> anyhow::Result<Option<Arc<dyn DhcpHost>>> {
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
@@ -1832,7 +2121,7 @@ virtual_ip = "10.82.0.2/24"
|
||||
async fn prepare(
|
||||
&self,
|
||||
_packet_plane: Arc<CorePacketPlane>,
|
||||
) -> anyhow::Result<Option<Arc<dyn DhcpIpv4Host>>> {
|
||||
) -> anyhow::Result<Option<Arc<dyn DhcpHost>>> {
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
@@ -2692,6 +2981,268 @@ virtual_ip = "10.82.0.2/24"
|
||||
assert_eq!(host.active_tcp_listeners.load(Ordering::Relaxed), 0);
|
||||
}
|
||||
|
||||
#[cfg(all(feature = "management", feature = "proxy-smoltcp-stack"))]
|
||||
mod port_forward_reload {
|
||||
use super::*;
|
||||
use easytier_proto::api::config::{
|
||||
ConfigPatchAction, InstanceConfigPatch, PortForwardPatch,
|
||||
};
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
use tokio::sync::mpsc::UnboundedReceiver;
|
||||
|
||||
fn forward(bind_port: u16, destination_port: u16) -> PortForwardConfig {
|
||||
PortForwardConfig {
|
||||
bind_addr: SocketAddr::from(([127, 0, 0, 1], bind_port)),
|
||||
dst_addr: SocketAddr::from(([10, 144, 0, 2], destination_port)),
|
||||
proto: "tcp".to_owned(),
|
||||
}
|
||||
}
|
||||
|
||||
async fn start() -> (
|
||||
Arc<CoreInstance<TestHost>>,
|
||||
Arc<TestHost>,
|
||||
UnboundedReceiver<(SocketAddr, tokio::io::DuplexStream)>,
|
||||
) {
|
||||
let (connections, receiver) = tokio::sync::mpsc::unbounded_channel();
|
||||
let host = Arc::new(TestHost {
|
||||
port_forward_connections: Some(connections),
|
||||
tcp_listeners: Some(Arc::new(std::sync::Mutex::new(Default::default()))),
|
||||
..Default::default()
|
||||
});
|
||||
let config =
|
||||
TomlConfig::new_from_str("instance_name = \"port-forward-reload\"").unwrap();
|
||||
config.set_port_forwards(vec![forward(19491, 19381)]);
|
||||
let (packet_sink, _packet_receiver) = tokio::sync::mpsc::channel(16);
|
||||
let instance = CoreInstance::from_toml(
|
||||
config,
|
||||
adapters_with_host(host.clone(), None, Arc::new(packet_sink)),
|
||||
)
|
||||
.unwrap();
|
||||
instance.start().await.unwrap();
|
||||
(instance, host, receiver)
|
||||
}
|
||||
|
||||
async fn assert_destination(
|
||||
host: &TestHost,
|
||||
receiver: &mut UnboundedReceiver<(SocketAddr, tokio::io::DuplexStream)>,
|
||||
config: &PortForwardConfig,
|
||||
) -> (tokio::io::DuplexStream, tokio::io::DuplexStream) {
|
||||
let mut incoming = host.connect_to_listener(config.bind_addr);
|
||||
let outgoing = tokio::time::timeout(Duration::from_secs(2), async {
|
||||
let (destination, mut outgoing) = receiver.recv().await.unwrap();
|
||||
assert_eq!(destination, config.dst_addr);
|
||||
incoming.write_all(b"request").await.unwrap();
|
||||
let mut data = [0; 7];
|
||||
outgoing.read_exact(&mut data).await.unwrap();
|
||||
assert_eq!(&data, b"request");
|
||||
outgoing
|
||||
})
|
||||
.await
|
||||
.expect("port forward must accept and forward to its committed destination");
|
||||
(incoming, outgoing)
|
||||
}
|
||||
|
||||
fn patch(
|
||||
removed: &[PortForwardConfig],
|
||||
added: &[PortForwardConfig],
|
||||
) -> InstanceConfigPatch {
|
||||
InstanceConfigPatch {
|
||||
port_forwards: removed
|
||||
.iter()
|
||||
.map(|config| (ConfigPatchAction::Remove, config))
|
||||
.chain(added.iter().map(|config| (ConfigPatchAction::Add, config)))
|
||||
.map(|(action, config)| PortForwardPatch {
|
||||
action: action as i32,
|
||||
cfg: Some(config.clone().into()),
|
||||
})
|
||||
.collect(),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
fn persistence(fail: bool) -> RecordingConfigPatchPersistence {
|
||||
RecordingConfigPatchPersistence {
|
||||
writes: std::sync::Mutex::new(Vec::new()),
|
||||
fail: AtomicBool::new(fail),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn destination_changes_reuse_listener() {
|
||||
let (instance, host, mut connections) = start().await;
|
||||
let (mut established_client, mut established_destination) =
|
||||
assert_destination(&host, &mut connections, &forward(19491, 19381)).await;
|
||||
let initial_binds = host.tcp_binds.load(Ordering::Relaxed);
|
||||
|
||||
for port in [19382, 19383, 19381] {
|
||||
let config = forward(19491, port);
|
||||
let mut runtime = (*instance.runtime_config.snapshot()).clone();
|
||||
runtime.services.gateway.port_forwards = vec![config.clone()];
|
||||
instance.update_runtime_config(runtime).await.unwrap();
|
||||
|
||||
assert_eq!(host.tcp_binds.load(Ordering::Relaxed), initial_binds);
|
||||
assert_eq!(host.active_tcp_listeners.load(Ordering::Relaxed), 1);
|
||||
assert_destination(&host, &mut connections, &config).await;
|
||||
tokio::time::timeout(Duration::from_secs(2), async {
|
||||
let payload = port.to_be_bytes();
|
||||
established_client.write_all(&payload).await.unwrap();
|
||||
let mut received = [0; 2];
|
||||
established_destination
|
||||
.read_exact(&mut received)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(received, payload);
|
||||
established_destination.write_all(&payload).await.unwrap();
|
||||
established_client.read_exact(&mut received).await.unwrap();
|
||||
assert_eq!(received, payload);
|
||||
})
|
||||
.await
|
||||
.expect("changing the destination must preserve established TCP connections");
|
||||
}
|
||||
instance.stop().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn failed_runtime_bind_keeps_committed_snapshot() {
|
||||
let (instance, host, mut connections) = start().await;
|
||||
let original = forward(19491, 19381);
|
||||
let rejected = forward(19492, 19382);
|
||||
*host.reject_tcp_bind.lock().unwrap() = Some(rejected.bind_addr);
|
||||
let mut runtime = (*instance.runtime_config.snapshot()).clone();
|
||||
runtime.services.gateway.port_forwards = vec![original.clone(), rejected];
|
||||
|
||||
assert!(
|
||||
instance
|
||||
.update_runtime_config(runtime.clone())
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
assert_eq!(
|
||||
instance
|
||||
.runtime_config
|
||||
.snapshot()
|
||||
.services
|
||||
.gateway
|
||||
.port_forwards,
|
||||
std::slice::from_ref(&original)
|
||||
);
|
||||
assert_eq!(host.active_tcp_listeners.load(Ordering::Relaxed), 1);
|
||||
assert_destination(&host, &mut connections, &original).await;
|
||||
|
||||
*host.reject_tcp_bind.lock().unwrap() = None;
|
||||
instance.update_runtime_config(runtime).await.unwrap();
|
||||
assert_eq!(host.active_tcp_listeners.load(Ordering::Relaxed), 2);
|
||||
instance.stop().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn failed_patch_bind_releases_prepared_listener_and_retries() {
|
||||
let (instance, host, mut connections) = start().await;
|
||||
let original = forward(19491, 19381);
|
||||
let added = [forward(19492, 19382), forward(19493, 19383)];
|
||||
let before = instance.toml_config().unwrap().dump();
|
||||
let persistence = persistence(false);
|
||||
let patch = patch(&[], &added);
|
||||
*host.reject_tcp_bind.lock().unwrap() = Some(added[1].bind_addr);
|
||||
let initial_binds = host.tcp_binds.load(Ordering::Relaxed);
|
||||
|
||||
let error =
|
||||
crate::management::apply_config_patch(&instance, patch.clone(), Some(&persistence))
|
||||
.await
|
||||
.unwrap_err();
|
||||
|
||||
assert!(error.to_string().contains("test host rejected TCP bind"));
|
||||
assert_eq!(host.tcp_binds.load(Ordering::Relaxed), initial_binds + 2);
|
||||
assert_eq!(host.active_tcp_listeners.load(Ordering::Relaxed), 1);
|
||||
assert_eq!(instance.toml_config().unwrap().dump(), before);
|
||||
assert_eq!(
|
||||
instance
|
||||
.runtime_config
|
||||
.snapshot()
|
||||
.services
|
||||
.gateway
|
||||
.port_forwards,
|
||||
std::slice::from_ref(&original)
|
||||
);
|
||||
assert!(persistence.writes.lock().unwrap().is_empty());
|
||||
assert_destination(&host, &mut connections, &original).await;
|
||||
|
||||
*host.reject_tcp_bind.lock().unwrap() = None;
|
||||
crate::management::apply_config_patch(&instance, patch, Some(&persistence))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(host.active_tcp_listeners.load(Ordering::Relaxed), 3);
|
||||
assert_eq!(persistence.writes.lock().unwrap().len(), 1);
|
||||
for config in &added {
|
||||
assert_destination(&host, &mut connections, config).await;
|
||||
}
|
||||
instance.stop().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn failed_patch_persistence_keeps_destination_and_releases_prepared_listener() {
|
||||
let (instance, host, mut connections) = start().await;
|
||||
let original = forward(19491, 19381);
|
||||
let replacement = forward(19491, 19382);
|
||||
let added = forward(19492, 19383);
|
||||
let before = instance.toml_config().unwrap().dump();
|
||||
let persistence = persistence(true);
|
||||
let patch = patch(
|
||||
std::slice::from_ref(&original),
|
||||
&[replacement.clone(), added.clone()],
|
||||
);
|
||||
|
||||
let error =
|
||||
crate::management::apply_config_patch(&instance, patch.clone(), Some(&persistence))
|
||||
.await
|
||||
.unwrap_err();
|
||||
|
||||
assert!(
|
||||
error
|
||||
.to_string()
|
||||
.contains("injected config persistence failure")
|
||||
);
|
||||
assert_eq!(host.active_tcp_listeners.load(Ordering::Relaxed), 1);
|
||||
assert_eq!(instance.toml_config().unwrap().dump(), before);
|
||||
assert_eq!(
|
||||
instance
|
||||
.runtime_config
|
||||
.snapshot()
|
||||
.services
|
||||
.gateway
|
||||
.port_forwards,
|
||||
std::slice::from_ref(&original)
|
||||
);
|
||||
assert!(persistence.writes.lock().unwrap().is_empty());
|
||||
assert_destination(&host, &mut connections, &original).await;
|
||||
|
||||
persistence.fail.store(false, Ordering::Relaxed);
|
||||
crate::management::apply_config_patch(&instance, patch, Some(&persistence))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(host.active_tcp_listeners.load(Ordering::Relaxed), 2);
|
||||
assert_eq!(persistence.writes.lock().unwrap().len(), 1);
|
||||
assert_destination(&host, &mut connections, &replacement).await;
|
||||
assert_destination(&host, &mut connections, &added).await;
|
||||
instance.stop().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn removed_listener_can_be_rebound_immediately() {
|
||||
let (instance, host, mut connections) = start().await;
|
||||
let original = (*instance.runtime_config.snapshot()).clone();
|
||||
let mut empty = original.clone();
|
||||
empty.services.gateway.port_forwards.clear();
|
||||
|
||||
instance.update_runtime_config(empty).await.unwrap();
|
||||
assert_eq!(host.active_tcp_listeners.load(Ordering::Relaxed), 0);
|
||||
instance.update_runtime_config(original).await.unwrap();
|
||||
assert_eq!(host.active_tcp_listeners.load(Ordering::Relaxed), 1);
|
||||
assert_destination(&host, &mut connections, &forward(19491, 19381)).await;
|
||||
instance.stop().await;
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "proxy-packet")]
|
||||
#[tokio::test]
|
||||
async fn runtime_core_instance_owns_wrapped_transport_source_nat() {
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use arc_swap::ArcSwapOption;
|
||||
use crossbeam::atomic::AtomicCell;
|
||||
use crossbeam_utils::atomic::AtomicCell;
|
||||
use dashmap::{DashMap, DashSet};
|
||||
use parking_lot::{Mutex, RwLock};
|
||||
|
||||
@@ -17,6 +17,7 @@ use crate::peers::{
|
||||
};
|
||||
use crate::{
|
||||
config::PeerId,
|
||||
foundation::small_map_shard_amount,
|
||||
packet::ZCPacket,
|
||||
peers::error::Error,
|
||||
proto::{core_peer::peer::PeerConnInfo, peer_rpc::PeerIdentityType},
|
||||
@@ -57,7 +58,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));
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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())),
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -5,13 +5,14 @@ pub(crate) mod client;
|
||||
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::{
|
||||
collections::HashSet,
|
||||
future,
|
||||
sync::{Arc, Weak},
|
||||
time::SystemTime,
|
||||
};
|
||||
|
||||
use dashmap::{DashMap, DashSet};
|
||||
use easytier_proto::common::FlagsInConfig;
|
||||
use dashmap::DashMap;
|
||||
use easytier_proto::common::Flags;
|
||||
use guarden::defer;
|
||||
use tokio::sync::{
|
||||
Mutex, RwLock, RwLockReadGuard,
|
||||
@@ -84,7 +85,7 @@ fn build_foreign_peer_context(
|
||||
network: &NetworkIdentity,
|
||||
parent_context: &Arc<CorePeerContext>,
|
||||
relay_data: bool,
|
||||
mut flags: FlagsInConfig,
|
||||
mut flags: Flags,
|
||||
) -> Arc<CorePeerContext> {
|
||||
let parent_context_dyn: ArcPeerContext = parent_context.clone();
|
||||
let parent_flags = parent_context_dyn.flags();
|
||||
@@ -358,7 +359,7 @@ struct ForeignNetworkEntry {
|
||||
|
||||
traffic_metrics: Arc<TrafficMetricRecorder>,
|
||||
event_handler_started: AtomicBool,
|
||||
indexed_peers: DashSet<PeerId>,
|
||||
indexed_peers: parking_lot::Mutex<HashSet<PeerId>>,
|
||||
|
||||
tasks: Mutex<JoinSet<()>>,
|
||||
|
||||
@@ -372,7 +373,7 @@ impl ForeignNetworkEntry {
|
||||
my_peer_id: PeerId,
|
||||
rpc_registrar: Arc<dyn ForeignNetworkRpcRegistrar>,
|
||||
parent_context: Arc<CorePeerContext>,
|
||||
foreign_context_default_flags: FlagsInConfig,
|
||||
foreign_context_default_flags: Flags,
|
||||
relay_data: bool,
|
||||
peer_session_store: Arc<PeerSessionStore>,
|
||||
pm_packet_sender: PacketRecvChan,
|
||||
@@ -499,7 +500,7 @@ impl ForeignNetworkEntry {
|
||||
|
||||
traffic_metrics,
|
||||
event_handler_started: AtomicBool::new(false),
|
||||
indexed_peers: DashSet::new(),
|
||||
indexed_peers: parking_lot::Mutex::new(HashSet::new()),
|
||||
|
||||
tasks: Mutex::new(JoinSet::new()),
|
||||
|
||||
@@ -624,7 +625,7 @@ impl Drop for ForeignNetworkEntry {
|
||||
|
||||
struct ForeignNetworkManagerData {
|
||||
network_peer_maps: DashMap<String, Arc<ForeignNetworkEntry>>,
|
||||
peer_network_map: DashMap<PeerId, DashSet<String>>,
|
||||
peer_network_map: DashMap<PeerId, HashSet<String>>,
|
||||
network_peer_last_update: DashMap<String, SystemTime>,
|
||||
global_peer_map: Weak<PeerMap>,
|
||||
lock: std::sync::Mutex<()>,
|
||||
@@ -638,19 +639,21 @@ impl ForeignNetworkManagerData {
|
||||
) {
|
||||
let indexed_peers = entry
|
||||
.indexed_peers
|
||||
.lock()
|
||||
.iter()
|
||||
.map(|peer_id| *peer_id)
|
||||
.copied()
|
||||
.collect::<Vec<_>>();
|
||||
for peer_id in indexed_peers {
|
||||
self.peer_network_map.remove_if(&peer_id, |_, networks| {
|
||||
networks.remove(network_name);
|
||||
networks.is_empty()
|
||||
});
|
||||
self.peer_network_map
|
||||
.remove_if_mut(&peer_id, |_, networks| {
|
||||
networks.remove(network_name);
|
||||
networks.is_empty()
|
||||
});
|
||||
}
|
||||
entry.indexed_peers.clear();
|
||||
entry.indexed_peers.lock().clear();
|
||||
}
|
||||
|
||||
fn get_peer_network(&self, peer_id: PeerId) -> Option<DashSet<String>> {
|
||||
fn get_peer_network(&self, peer_id: PeerId) -> Option<HashSet<String>> {
|
||||
self.peer_network_map.get(&peer_id).map(|v| v.clone())
|
||||
}
|
||||
|
||||
@@ -697,11 +700,12 @@ impl ForeignNetworkManagerData {
|
||||
self.remove_entry_from_peer_index_locked(network_name, &expected_entry);
|
||||
self.network_peer_last_update.remove(network_name);
|
||||
} else {
|
||||
self.peer_network_map.remove_if(&peer_id, |_, networks| {
|
||||
networks.remove(network_name);
|
||||
networks.is_empty()
|
||||
});
|
||||
expected_entry.indexed_peers.remove(&peer_id);
|
||||
self.peer_network_map
|
||||
.remove_if_mut(&peer_id, |_, networks| {
|
||||
networks.remove(network_name);
|
||||
networks.is_empty()
|
||||
});
|
||||
expected_entry.indexed_peers.lock().remove(&peer_id);
|
||||
}
|
||||
shrink_dashmap(&self.peer_network_map, None);
|
||||
shrink_dashmap(&self.network_peer_maps, None);
|
||||
@@ -738,7 +742,7 @@ impl ForeignNetworkManagerData {
|
||||
.entry(peer_id)
|
||||
.or_default()
|
||||
.insert(network_name.clone());
|
||||
expected_entry.indexed_peers.insert(peer_id);
|
||||
expected_entry.indexed_peers.lock().insert(peer_id);
|
||||
self.network_peer_last_update
|
||||
.insert(network_name.clone(), SystemTime::now());
|
||||
true
|
||||
@@ -803,7 +807,7 @@ impl ForeignNetworkManagerData {
|
||||
.remove_if(network_name, |_, entry| {
|
||||
Arc::ptr_eq(entry, &expected_entry)
|
||||
&& entry.peer_map.is_empty()
|
||||
&& entry.indexed_peers.is_empty()
|
||||
&& entry.indexed_peers.lock().is_empty()
|
||||
})
|
||||
.is_some()
|
||||
{
|
||||
@@ -822,7 +826,7 @@ impl ForeignNetworkManagerData {
|
||||
relay_data: bool,
|
||||
rpc_registrar: Arc<dyn ForeignNetworkRpcRegistrar>,
|
||||
parent_context: Arc<CorePeerContext>,
|
||||
foreign_context_default_flags: FlagsInConfig,
|
||||
foreign_context_default_flags: Flags,
|
||||
peer_session_store: Arc<PeerSessionStore>,
|
||||
pm_packet_sender: &PacketRecvChan,
|
||||
) -> Option<(Arc<ForeignNetworkEntry>, bool)> {
|
||||
@@ -874,7 +878,7 @@ enum ForeignNetworkManagerState {
|
||||
pub(crate) struct ForeignNetworkManager {
|
||||
rpc_registrar: Arc<dyn ForeignNetworkRpcRegistrar>,
|
||||
parent_context: Arc<CorePeerContext>,
|
||||
foreign_context_default_flags: FlagsInConfig,
|
||||
foreign_context_default_flags: Flags,
|
||||
peer_session_store: Arc<PeerSessionStore>,
|
||||
packet_sender_to_mgr: PacketRecvChan,
|
||||
|
||||
@@ -912,7 +916,7 @@ impl ForeignNetworkManager {
|
||||
pub fn new(
|
||||
rpc_registrar: Arc<dyn ForeignNetworkRpcRegistrar>,
|
||||
parent_context: Arc<CorePeerContext>,
|
||||
foreign_context_default_flags: FlagsInConfig,
|
||||
foreign_context_default_flags: Flags,
|
||||
peer_session_store: Arc<PeerSessionStore>,
|
||||
packet_sender_to_mgr: PacketRecvChan,
|
||||
global_peer_map: Weak<PeerMap>,
|
||||
@@ -1593,7 +1597,7 @@ mod tests {
|
||||
};
|
||||
|
||||
use dashmap::DashMap;
|
||||
use easytier_proto::common::{FlagsInConfig, PeerFeatureFlag};
|
||||
use easytier_proto::common::{Flags, PeerFeatureFlag};
|
||||
|
||||
use super::{
|
||||
ForeignNetworkEntry, ForeignNetworkManager, ForeignNetworkManagerData,
|
||||
@@ -1638,7 +1642,7 @@ mod tests {
|
||||
1,
|
||||
Arc::new(()),
|
||||
parent,
|
||||
FlagsInConfig::default(),
|
||||
Flags::default(),
|
||||
true,
|
||||
Arc::new(PeerSessionStore::new()),
|
||||
packet_sender,
|
||||
@@ -1669,10 +1673,10 @@ mod tests {
|
||||
.entry(peer_id)
|
||||
.or_default()
|
||||
.insert(network_name.clone());
|
||||
entry.indexed_peers.insert(peer_id);
|
||||
entry.indexed_peers.lock().insert(peer_id);
|
||||
}
|
||||
data.peer_network_map
|
||||
.get(&20)
|
||||
.get_mut(&20)
|
||||
.unwrap()
|
||||
.insert(other_network.clone());
|
||||
|
||||
@@ -1697,11 +1701,11 @@ mod tests {
|
||||
.get(&20)
|
||||
.unwrap()
|
||||
.iter()
|
||||
.map(|network| network.clone())
|
||||
.cloned()
|
||||
.collect::<Vec<_>>(),
|
||||
[other_network]
|
||||
);
|
||||
assert!(entry.indexed_peers.is_empty());
|
||||
assert!(entry.indexed_peers.lock().is_empty());
|
||||
assert!(!data.network_peer_last_update.contains_key(&network_name));
|
||||
}
|
||||
|
||||
@@ -1730,7 +1734,7 @@ mod tests {
|
||||
.entry(peer_id)
|
||||
.or_default()
|
||||
.insert(network_name.clone());
|
||||
current_entry.indexed_peers.insert(peer_id);
|
||||
current_entry.indexed_peers.lock().insert(peer_id);
|
||||
}
|
||||
assert!(
|
||||
data.peer_network_map
|
||||
@@ -1738,7 +1742,7 @@ mod tests {
|
||||
.unwrap()
|
||||
.contains(&network_name)
|
||||
);
|
||||
assert!(current_entry.indexed_peers.contains(&10));
|
||||
assert!(current_entry.indexed_peers.lock().contains(&10));
|
||||
|
||||
data.remove_peer(10, &network_name, &Arc::downgrade(&old_entry));
|
||||
assert!(
|
||||
@@ -1759,7 +1763,7 @@ mod tests {
|
||||
assert!(!data.network_peer_maps.contains_key(&network_name));
|
||||
assert!(!data.peer_network_map.contains_key(&10));
|
||||
assert!(!data.peer_network_map.contains_key(&11));
|
||||
assert!(current_entry.indexed_peers.is_empty());
|
||||
assert!(current_entry.indexed_peers.lock().is_empty());
|
||||
}
|
||||
|
||||
impl ForeignNetworkManager {
|
||||
@@ -1825,7 +1829,7 @@ mod tests {
|
||||
|
||||
struct FeatureContext {
|
||||
avoid_relay_data: AtomicBool,
|
||||
flags: FlagsInConfig,
|
||||
flags: Flags,
|
||||
hostname: String,
|
||||
}
|
||||
|
||||
@@ -1833,7 +1837,7 @@ mod tests {
|
||||
fn new(avoid_relay_data: bool) -> Self {
|
||||
Self {
|
||||
avoid_relay_data: AtomicBool::new(avoid_relay_data),
|
||||
flags: FlagsInConfig::default(),
|
||||
flags: Flags::default(),
|
||||
hostname: String::new(),
|
||||
}
|
||||
}
|
||||
@@ -1851,7 +1855,7 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
fn flags(&self) -> FlagsInConfig {
|
||||
fn flags(&self) -> Flags {
|
||||
self.flags.clone()
|
||||
}
|
||||
|
||||
@@ -1873,6 +1877,8 @@ mod tests {
|
||||
assert!(check_network_in_relay_whitelist("*", "any-network").is_ok());
|
||||
assert!(check_network_in_relay_whitelist("", "net1").is_err());
|
||||
assert!(check_network_in_relay_whitelist("net1 net2*", "net3").is_err());
|
||||
assert!(check_network_in_relay_whitelist("net1,net2*", "net1").is_ok());
|
||||
assert!(check_network_in_relay_whitelist("net1, net2*", "net2-west").is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1943,7 +1949,7 @@ mod tests {
|
||||
network_secret: Some("secret".to_owned()),
|
||||
network_secret_digest: None,
|
||||
};
|
||||
let mut defaults = FlagsInConfig::default();
|
||||
let mut defaults = Flags::default();
|
||||
defaults.mtu = 1400;
|
||||
defaults.relay_network_whitelist = "baseline".to_owned();
|
||||
let foreign = build_foreign_peer_context(&network, &parent, false, defaults);
|
||||
|
||||
@@ -159,11 +159,28 @@ pub async fn recv_packet_from_chan(
|
||||
.map(|envelope| envelope.packet)
|
||||
}
|
||||
|
||||
/// Returned packets continue at the next peer-packet filter.
|
||||
#[derive(Debug)]
|
||||
pub enum PeerPacketFilterResult {
|
||||
Consumed,
|
||||
Pass(ZCPacket),
|
||||
PassBatch(Vec<ZCPacket>),
|
||||
}
|
||||
|
||||
impl From<Option<ZCPacket>> for PeerPacketFilterResult {
|
||||
fn from(packet: Option<ZCPacket>) -> Self {
|
||||
match packet {
|
||||
Some(packet) => Self::Pass(packet),
|
||||
None => Self::Consumed,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
#[auto_impl::auto_impl(Arc)]
|
||||
pub trait PeerPacketFilter {
|
||||
async fn try_process_packet_from_peer(&self, zc_packet: ZCPacket) -> Option<ZCPacket> {
|
||||
Some(zc_packet)
|
||||
async fn try_process_packet_from_peer(&self, zc_packet: ZCPacket) -> PeerPacketFilterResult {
|
||||
PeerPacketFilterResult::Pass(zc_packet)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ use std::{
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
|
||||
use crossbeam::atomic::AtomicCell;
|
||||
use crossbeam_utils::atomic::AtomicCell;
|
||||
use futures::Future;
|
||||
use std::sync::RwLock;
|
||||
use tokio::sync::Mutex;
|
||||
|
||||
@@ -37,7 +37,7 @@ use crate::{
|
||||
CompressorAlgo, PacketType, ZCPacket,
|
||||
compressor::{Compressor as _, DefaultCompressor},
|
||||
},
|
||||
proto::common::{FlagsInConfig, PeerFeatureFlag, StunInfo, Url as ProtoUrl},
|
||||
proto::common::{Flags, PeerFeatureFlag, StunInfo, Url as ProtoUrl},
|
||||
proto::core_peer::peer::{ListPublicIpv6InfoResponse, PeerConnInfo, Route as CoreRoute},
|
||||
tunnel::{
|
||||
Tunnel,
|
||||
@@ -50,7 +50,7 @@ use crate::{
|
||||
|
||||
use super::{
|
||||
BoxNicPacketFilter, BoxPeerPacketFilter, PacketRecvChanReceiver, PeerConnectionOrigin,
|
||||
PeerPacketFilter, PeerPacketIngress,
|
||||
PeerPacketFilter, PeerPacketFilterResult, PeerPacketIngress,
|
||||
acl::AclFilter,
|
||||
conn::{
|
||||
peer_conn::{PeerConn, PeerConnId},
|
||||
@@ -98,6 +98,7 @@ pub struct PeerSnapshot {
|
||||
pub struct NodeSnapshot {
|
||||
pub peer_id: PeerId,
|
||||
pub ipv4_addr: Option<cidr::Ipv4Inet>,
|
||||
pub ipv6_addr: Option<cidr::Ipv6Inet>,
|
||||
pub proxy_networks: Vec<ProxyNetworkConfig>,
|
||||
pub hostname: String,
|
||||
pub stun_info: StunInfo,
|
||||
@@ -213,14 +214,14 @@ pub struct PortablePeerManagerConfig {
|
||||
///
|
||||
/// This is explicit because those contexts participate in the same
|
||||
/// handshake as the parent but do not inherit all parent policy flags.
|
||||
pub foreign_context_default_flags: FlagsInConfig,
|
||||
pub foreign_context_default_flags: Flags,
|
||||
}
|
||||
|
||||
impl PortablePeerManagerConfig {
|
||||
pub fn new(mut runtime: PeerRuntimeConfig) -> Self {
|
||||
let policy = &runtime.core.peer_policy;
|
||||
let traffic = &runtime.core.traffic;
|
||||
let flags = FlagsInConfig {
|
||||
let flags = Flags {
|
||||
enable_encryption: policy.encryption_required,
|
||||
encryption_algorithm: crate::config::EncryptionAlgorithm::default().to_string(),
|
||||
disable_p2p: !policy.p2p_enabled,
|
||||
@@ -283,6 +284,9 @@ fn retain_runtime_owned_peer_state(
|
||||
if current.services.dhcp_ipv4 && next.services.dhcp_ipv4 {
|
||||
next_peer.runtime.core.routes.ipv4 = current.peer.runtime.core.routes.ipv4.clone();
|
||||
}
|
||||
if current.services.dhcp_ipv6 && next.services.dhcp_ipv6 {
|
||||
next_peer.runtime.core.routes.ipv6 = current.peer.runtime.core.routes.ipv6.clone();
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_portable_routes(routes: &crate::config::RouteConfig) -> anyhow::Result<()> {
|
||||
@@ -453,7 +457,7 @@ struct NicPacketProcessor {
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl PeerPacketFilter for NicPacketProcessor {
|
||||
async fn try_process_packet_from_peer(&self, packet: ZCPacket) -> Option<ZCPacket> {
|
||||
async fn try_process_packet_from_peer(&self, packet: ZCPacket) -> PeerPacketFilterResult {
|
||||
let hdr = packet.peer_manager_header().unwrap();
|
||||
if hdr.packet_type == PacketType::Data as u8 && !hdr.is_not_send_to_tun() {
|
||||
if hdr.is_encrypted() || hdr.is_compressed() {
|
||||
@@ -464,7 +468,7 @@ impl PeerPacketFilter for NicPacketProcessor {
|
||||
compressed = hdr.is_compressed(),
|
||||
"dropping packet before nic because it is not fully decoded"
|
||||
);
|
||||
return None;
|
||||
return PeerPacketFilterResult::Consumed;
|
||||
}
|
||||
tracing::trace!(?packet, "send packet to nic channel");
|
||||
if let Err(error) = self
|
||||
@@ -476,9 +480,9 @@ impl PeerPacketFilter for NicPacketProcessor {
|
||||
"dropping packet because nic channel cannot accept it"
|
||||
);
|
||||
}
|
||||
None
|
||||
PeerPacketFilterResult::Consumed
|
||||
} else {
|
||||
Some(packet)
|
||||
PeerPacketFilterResult::Pass(packet)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -489,16 +493,16 @@ struct PeerRpcPacketProcessor {
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl PeerPacketFilter for PeerRpcPacketProcessor {
|
||||
async fn try_process_packet_from_peer(&self, packet: ZCPacket) -> Option<ZCPacket> {
|
||||
async fn try_process_packet_from_peer(&self, packet: ZCPacket) -> PeerPacketFilterResult {
|
||||
let hdr = packet.peer_manager_header().unwrap();
|
||||
if hdr.packet_type == PacketType::TaRpc as u8
|
||||
|| hdr.packet_type == PacketType::RpcReq as u8
|
||||
|| hdr.packet_type == PacketType::RpcResp as u8
|
||||
{
|
||||
self.peer_rpc_tspt_sender.send(packet).unwrap();
|
||||
None
|
||||
PeerPacketFilterResult::Consumed
|
||||
} else {
|
||||
Some(packet)
|
||||
PeerPacketFilterResult::Pass(packet)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -956,7 +960,7 @@ impl PeerManagerCore {
|
||||
is_secure_mode_enabled: bool,
|
||||
data_compress_algo: CompressorAlgo,
|
||||
exit_nodes: Vec<IpAddr>,
|
||||
foreign_context_default_flags: FlagsInConfig,
|
||||
foreign_context_default_flags: Flags,
|
||||
foreign_rpc_registrar: Arc<dyn ForeignNetworkRpcRegistrar>,
|
||||
) -> Self {
|
||||
let stats_manager = core_context.stats_manager();
|
||||
@@ -1265,6 +1269,7 @@ impl PeerManagerCore {
|
||||
NodeSnapshot {
|
||||
peer_id: self.my_peer_id,
|
||||
ipv4_addr: self.context.ipv4(),
|
||||
ipv6_addr: self.context.ipv6(),
|
||||
proxy_networks: self.context.proxy_networks(),
|
||||
hostname: self.context.hostname(),
|
||||
stun_info: self.context.stun_info(),
|
||||
@@ -1331,6 +1336,16 @@ impl PeerManagerCore {
|
||||
&self,
|
||||
config: CoreInstanceRuntimeConfig,
|
||||
) -> anyhow::Result<Arc<CoreInstanceRuntimeConfig>> {
|
||||
self.update_runtime_config_with(config, || ())
|
||||
.await
|
||||
.map(|(published, ())| published)
|
||||
}
|
||||
|
||||
pub(crate) async fn update_runtime_config_with<T>(
|
||||
&self,
|
||||
config: CoreInstanceRuntimeConfig,
|
||||
before_publish: impl FnOnce() -> T,
|
||||
) -> anyhow::Result<(Arc<CoreInstanceRuntimeConfig>, T)> {
|
||||
let _update = self.runtime_config_update.lock().await;
|
||||
let current = self.runtime_config.snapshot();
|
||||
let refresh_acl_groups = current.peer.peer_group_memberships
|
||||
@@ -1340,6 +1355,9 @@ impl PeerManagerCore {
|
||||
let next_acl = reload_acl.then(|| config.services.acl.clone());
|
||||
let next_built_acl = next_acl.as_ref().map(AclRuleConfig::build).transpose()?;
|
||||
|
||||
// Commit prepared host resources only after all fallible validation,
|
||||
// with no cancellation point before publishing their configuration.
|
||||
let committed = before_publish();
|
||||
self.set_avoid_relay_data_preference(config.peer.avoid_relay_data_preference);
|
||||
let published = self
|
||||
.runtime_config
|
||||
@@ -1352,7 +1370,7 @@ impl PeerManagerCore {
|
||||
if refresh_acl_groups {
|
||||
self.route.refresh_acl_groups().await;
|
||||
}
|
||||
Ok(published)
|
||||
Ok((published, committed))
|
||||
}
|
||||
|
||||
/// Keeps this manager's ACL rules and group assignments aligned with a
|
||||
@@ -3175,25 +3193,33 @@ impl PeerPacketRouter {
|
||||
return;
|
||||
}
|
||||
|
||||
let mut processed = false;
|
||||
let mut zc_packet = Some(ret);
|
||||
tracing::trace!(?zc_packet, "try_process_packet_from_peer");
|
||||
tracing::trace!(?ret, "try_process_packet_from_peer");
|
||||
let pipelines = self.peer_packet_process_pipeline.load_full();
|
||||
for pipeline in pipelines.iter().rev() {
|
||||
if let Some(filter) = pipeline.filter_if_active() {
|
||||
zc_packet = filter
|
||||
.try_process_packet_from_peer(zc_packet.unwrap())
|
||||
.await;
|
||||
process_peer_packet(&pipelines, ret).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn process_peer_packet(pipelines: &[PeerPipelineEntry], packet: ZCPacket) {
|
||||
let mut first = Some((packet, pipelines.len()));
|
||||
// The ordinary single-packet path does not allocate this queue.
|
||||
let mut pending = Vec::new();
|
||||
'packets: while let Some((mut packet, next)) = first.take().or_else(|| pending.pop()) {
|
||||
for (index, pipeline) in pipelines[..next].iter().enumerate().rev() {
|
||||
let Some(filter) = pipeline.filter_if_active() else {
|
||||
continue;
|
||||
};
|
||||
match filter.try_process_packet_from_peer(packet).await {
|
||||
PeerPacketFilterResult::Consumed => continue 'packets,
|
||||
PeerPacketFilterResult::Pass(next) => packet = next,
|
||||
PeerPacketFilterResult::PassBatch(packets) => {
|
||||
// Resume each packet after the filter that produced it.
|
||||
pending.extend(packets.into_iter().rev().map(|packet| (packet, index)));
|
||||
continue 'packets;
|
||||
}
|
||||
if zc_packet.is_none() {
|
||||
processed = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if !processed {
|
||||
tracing::error!(?zc_packet, "unhandled packet");
|
||||
}
|
||||
}
|
||||
tracing::error!(?packet, "unhandled packet");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3538,6 +3564,58 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn packet_batches_resume_at_next_filter_in_order() {
|
||||
struct Filter {
|
||||
stage: u8,
|
||||
seen: Arc<std::sync::Mutex<Vec<(u8, u8)>>>,
|
||||
}
|
||||
#[async_trait::async_trait]
|
||||
impl PeerPacketFilter for Filter {
|
||||
async fn try_process_packet_from_peer(
|
||||
&self,
|
||||
packet: ZCPacket,
|
||||
) -> PeerPacketFilterResult {
|
||||
self.seen
|
||||
.lock()
|
||||
.unwrap()
|
||||
.push((self.stage, packet.payload()[0]));
|
||||
match self.stage {
|
||||
0 => PeerPacketFilterResult::Consumed,
|
||||
1 | 2 => {
|
||||
let mut extra = packet.clone();
|
||||
extra.mut_payload()[0] += if self.stage == 1 { 10 } else { 1 };
|
||||
PeerPacketFilterResult::PassBatch(vec![packet, extra])
|
||||
}
|
||||
_ => PeerPacketFilterResult::Pass(packet),
|
||||
}
|
||||
}
|
||||
}
|
||||
let seen = Arc::new(std::sync::Mutex::new(Vec::new()));
|
||||
let pipelines = (0..4)
|
||||
.map(|stage| {
|
||||
permanent_peer_pipeline_entry(Box::new(Filter {
|
||||
stage,
|
||||
seen: seen.clone(),
|
||||
}))
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
process_peer_packet(&pipelines, ZCPacket::new_with_payload(&[0])).await;
|
||||
assert_eq!(
|
||||
*seen.lock().unwrap(),
|
||||
vec![
|
||||
(3, 0),
|
||||
(2, 0),
|
||||
(1, 0),
|
||||
(0, 0),
|
||||
(0, 10),
|
||||
(1, 1),
|
||||
(0, 1),
|
||||
(0, 11),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
struct RuntimeConfigStunInfoSource(CoreRuntimeConfigStore);
|
||||
|
||||
impl PeerStunInfoSource for RuntimeConfigStunInfoSource {
|
||||
@@ -3607,7 +3685,7 @@ mod tests {
|
||||
.await;
|
||||
|
||||
assert!(result.is_ok(), "a full host queue blocked the peer router");
|
||||
assert!(result.unwrap().is_none());
|
||||
assert!(matches!(result.unwrap(), PeerPacketFilterResult::Consumed));
|
||||
assert_eq!(nic_receiver.try_recv().unwrap().payload(), b"queued");
|
||||
}
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ use tokio::sync::{Mutex, OwnedMutexGuard, oneshot};
|
||||
|
||||
use crate::{
|
||||
config::PeerId,
|
||||
foundation::small_map_shard_amount,
|
||||
foundation::time::{Duration, timeout},
|
||||
packet::{PacketType, ZCPacket},
|
||||
peers::{
|
||||
@@ -146,10 +147,10 @@ impl RelayPeerMap {
|
||||
metric_network_name,
|
||||
my_peer_id,
|
||||
peer_session_store,
|
||||
states: DashMap::new(),
|
||||
pending_handshakes: DashMap::new(),
|
||||
handshake_locks: DashMap::new(),
|
||||
pending_packets: DashMap::new(),
|
||||
states: DashMap::with_shard_amount(small_map_shard_amount()),
|
||||
pending_handshakes: DashMap::with_shard_amount(small_map_shard_amount()),
|
||||
handshake_locks: DashMap::with_shard_amount(small_map_shard_amount()),
|
||||
pending_packets: DashMap::with_shard_amount(small_map_shard_amount()),
|
||||
is_secure_mode_enabled,
|
||||
})
|
||||
}
|
||||
@@ -774,13 +775,13 @@ mod tests {
|
||||
use super::*;
|
||||
use crate::{
|
||||
peers::context::{ArcPeerContext, NetworkIdentity, PeerContext},
|
||||
proto::common::{FlagsInConfig, SecureModeConfig},
|
||||
proto::common::{Flags, SecureModeConfig},
|
||||
};
|
||||
|
||||
struct RelayTestContext {
|
||||
network_identity: NetworkIdentity,
|
||||
secure_mode: SecureModeConfig,
|
||||
flags: FlagsInConfig,
|
||||
flags: Flags,
|
||||
}
|
||||
|
||||
impl PeerContext for RelayTestContext {
|
||||
@@ -792,7 +793,7 @@ mod tests {
|
||||
Some(self.secure_mode.clone())
|
||||
}
|
||||
|
||||
fn flags(&self) -> FlagsInConfig {
|
||||
fn flags(&self) -> Flags {
|
||||
self.flags.clone()
|
||||
}
|
||||
}
|
||||
@@ -866,7 +867,7 @@ mod tests {
|
||||
local_private_key: Some(BASE64_STANDARD.encode(private.as_bytes())),
|
||||
local_public_key: Some(BASE64_STANDARD.encode(public.as_bytes())),
|
||||
},
|
||||
flags: FlagsInConfig {
|
||||
flags: Flags {
|
||||
encryption_algorithm: "aes-gcm".to_owned(),
|
||||
..Default::default()
|
||||
},
|
||||
|
||||
@@ -12,7 +12,7 @@ use std::{
|
||||
use arc_swap::ArcSwap;
|
||||
use atomic_shim::AtomicU64;
|
||||
use cidr::{IpCidr, Ipv4Cidr, Ipv6Cidr, Ipv6Inet};
|
||||
use crossbeam::atomic::AtomicCell;
|
||||
use crossbeam_utils::atomic::AtomicCell;
|
||||
use dashmap::DashMap;
|
||||
use ordered_hash_map::OrderedHashMap;
|
||||
use parking_lot::{RwLock, lock_api::RwLockUpgradableReadGuard};
|
||||
@@ -34,6 +34,7 @@ use tokio::{
|
||||
use crate::{
|
||||
config::PeerId,
|
||||
config::peers::PeerGroupIdentity,
|
||||
foundation::small_map_shard_amount,
|
||||
peers::{
|
||||
PeerPacketFilter,
|
||||
context::{
|
||||
@@ -200,7 +201,7 @@ impl OspfRouteSnapshotLookup<'_> {
|
||||
}
|
||||
|
||||
type PeerGraph = Graph<PeerId, usize, Directed>;
|
||||
type PeerIdToNodeIdxMap = DashMap<PeerId, NodeIndex>;
|
||||
type PeerIdToNodeIdxMap = HashMap<PeerId, NodeIndex>;
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct OspfNextHopInfo {
|
||||
@@ -228,11 +229,11 @@ pub struct OspfRouteTable {
|
||||
impl OspfRouteTable {
|
||||
pub fn new() -> Self {
|
||||
OspfRouteTable {
|
||||
peer_infos: DashMap::new(),
|
||||
next_hop_map: DashMap::new(),
|
||||
suppressed_peer_ids: DashMap::new(),
|
||||
ipv4_peer_id_map: DashMap::new(),
|
||||
ipv6_peer_id_map: DashMap::new(),
|
||||
peer_infos: DashMap::with_shard_amount(small_map_shard_amount()),
|
||||
next_hop_map: DashMap::with_shard_amount(small_map_shard_amount()),
|
||||
suppressed_peer_ids: DashMap::with_shard_amount(small_map_shard_amount()),
|
||||
ipv4_peer_id_map: DashMap::with_shard_amount(small_map_shard_amount()),
|
||||
ipv6_peer_id_map: DashMap::with_shard_amount(small_map_shard_amount()),
|
||||
cidr_peer_id_map: ArcSwap::new(Arc::new(PrefixMap::new())),
|
||||
cidr_v6_peer_id_map: ArcSwap::new(Arc::new(PrefixMap::new())),
|
||||
next_hop_map_version: AtomicVersion::new(),
|
||||
@@ -301,7 +302,7 @@ impl OspfRouteTable {
|
||||
let mut graph: PeerGraph = PeerGraph::new();
|
||||
|
||||
let mut start_node_idx = None;
|
||||
let peer_id_to_node_index: PeerIdToNodeIdxMap = DashMap::new();
|
||||
let mut peer_id_to_node_index = PeerIdToNodeIdxMap::new();
|
||||
for entry in &snapshot.peer_infos {
|
||||
let peer_id = entry.peer_id;
|
||||
|
||||
@@ -321,12 +322,10 @@ impl OspfRouteTable {
|
||||
return (graph, NodeIndex::end());
|
||||
}
|
||||
|
||||
for item in peer_id_to_node_index.iter() {
|
||||
let src_peer_id = *item.key();
|
||||
for (&src_peer_id, src_node_idx) in &peer_id_to_node_index {
|
||||
if src_peer_id != my_peer_id && snapshot.suppressed_peer_ids.contains(&src_peer_id) {
|
||||
continue;
|
||||
}
|
||||
let src_node_idx = item.value();
|
||||
let connected_peers: BTreeSet<_> =
|
||||
lookup.connected_peers(src_peer_id).unwrap_or_default();
|
||||
|
||||
@@ -2035,9 +2034,9 @@ impl SyncRouteSession {
|
||||
SyncRouteSession {
|
||||
my_peer_id,
|
||||
dst_peer_id,
|
||||
dst_saved_peer_info_versions: DashMap::new(),
|
||||
dst_saved_conn_info_version: DashMap::new(),
|
||||
dst_saved_foreign_network_versions: DashMap::new(),
|
||||
dst_saved_peer_info_versions: DashMap::with_shard_amount(small_map_shard_amount()),
|
||||
dst_saved_conn_info_version: DashMap::with_shard_amount(small_map_shard_amount()),
|
||||
dst_saved_foreign_network_versions: DashMap::with_shard_amount(small_map_shard_amount()),
|
||||
|
||||
unreachable_peers_for_peer_info: parking_lot::Mutex::new(BTreeMap::new()),
|
||||
unreachable_peers_for_conn_info: parking_lot::Mutex::new(BTreeMap::new()),
|
||||
@@ -2412,7 +2411,7 @@ impl PeerRouteServiceImpl {
|
||||
my_peer_id,
|
||||
my_peer_route_id: rand::random(),
|
||||
context: context.clone(),
|
||||
sessions: DashMap::new(),
|
||||
sessions: DashMap::with_shard_amount(small_map_shard_amount()),
|
||||
stopped: AtomicBool::new(false),
|
||||
session_operation: AsyncRwLock::new(()),
|
||||
|
||||
@@ -2422,21 +2421,23 @@ impl PeerRouteServiceImpl {
|
||||
|
||||
route_table: OspfRouteTable::new(),
|
||||
route_table_with_cost: OspfRouteTable::new(),
|
||||
foreign_network_owner_map: DashMap::new(),
|
||||
foreign_network_my_peer_id_map: DashMap::new(),
|
||||
foreign_network_owner_map: DashMap::with_shard_amount(small_map_shard_amount()),
|
||||
foreign_network_my_peer_id_map: DashMap::with_shard_amount(small_map_shard_amount()),
|
||||
|
||||
synced_route_info: SyncedRouteInfo {
|
||||
default_easytier_version: context.easytier_version(),
|
||||
peer_infos: RwLock::new(OrderedHashMap::new()),
|
||||
raw_peer_infos: DashMap::new(),
|
||||
raw_peer_infos: DashMap::with_shard_amount(small_map_shard_amount()),
|
||||
conn_map: RwLock::new(OrderedHashMap::new()),
|
||||
foreign_network: DashMap::new(),
|
||||
group_trust_map: DashMap::new(),
|
||||
group_trust_map_cache: DashMap::new(),
|
||||
foreign_network: DashMap::with_shard_amount(small_map_shard_amount()),
|
||||
group_trust_map: DashMap::with_shard_amount(small_map_shard_amount()),
|
||||
group_trust_map_cache: DashMap::with_shard_amount(small_map_shard_amount()),
|
||||
group_trust_update_lock: parking_lot::Mutex::new(()),
|
||||
trusted_credential_pubkeys: RwLock::new(HashMap::new()),
|
||||
non_reusable_credential_owners: DashMap::new(),
|
||||
suppressed_non_reusable_credential_peers: DashMap::new(),
|
||||
non_reusable_credential_owners: DashMap::with_shard_amount(small_map_shard_amount()),
|
||||
suppressed_non_reusable_credential_peers: DashMap::with_shard_amount(
|
||||
small_map_shard_amount(),
|
||||
),
|
||||
version: AtomicVersion::new(),
|
||||
},
|
||||
public_ipv6_service: std::sync::Mutex::new(Weak::new()),
|
||||
@@ -4886,8 +4887,8 @@ mod tests {
|
||||
CoreNetworkIdentity::default()
|
||||
}
|
||||
|
||||
fn flags(&self) -> crate::proto::common::FlagsInConfig {
|
||||
crate::proto::common::FlagsInConfig {
|
||||
fn flags(&self) -> crate::proto::common::Flags {
|
||||
crate::proto::common::Flags {
|
||||
prefer_peer_relay: self.enabled.load(Ordering::Relaxed),
|
||||
..Default::default()
|
||||
}
|
||||
@@ -5012,7 +5013,7 @@ mod tests {
|
||||
}
|
||||
|
||||
fn test_peer_relay_service_impl(my_peer_id: PeerId) -> PeerRouteServiceImpl {
|
||||
let flags = crate::proto::common::FlagsInConfig {
|
||||
let flags = crate::proto::common::Flags {
|
||||
prefer_peer_relay: true,
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
use std::net::IpAddr;
|
||||
|
||||
use cidr::{Ipv4Inet, Ipv6Inet};
|
||||
use easytier_proto::common::{FlagsInConfig, SecureModeConfig};
|
||||
use easytier_proto::common::{Flags, SecureModeConfig};
|
||||
use hmac::Hmac;
|
||||
use sha2::Sha256;
|
||||
|
||||
@@ -58,7 +58,7 @@ fn ipv6_inet_to_config(value: Ipv6Inet) -> IpPrefix {
|
||||
#[derive(Debug, Clone)]
|
||||
pub(crate) struct NoopPeerContext {
|
||||
network_identity: NetworkIdentity,
|
||||
flags: FlagsInConfig,
|
||||
flags: Flags,
|
||||
secure_mode: Option<SecureModeConfig>,
|
||||
}
|
||||
|
||||
@@ -66,7 +66,7 @@ impl NoopPeerContext {
|
||||
pub(crate) fn new(network_identity: NetworkIdentity) -> Self {
|
||||
Self {
|
||||
network_identity,
|
||||
flags: FlagsInConfig::default(),
|
||||
flags: Flags::default(),
|
||||
secure_mode: None,
|
||||
}
|
||||
}
|
||||
@@ -77,7 +77,7 @@ impl NoopPeerContext {
|
||||
self
|
||||
}
|
||||
|
||||
pub(crate) fn with_flags(mut self, flags: FlagsInConfig) -> Self {
|
||||
pub(crate) fn with_flags(mut self, flags: Flags) -> Self {
|
||||
self.flags = flags;
|
||||
self
|
||||
}
|
||||
@@ -94,7 +94,7 @@ impl PeerContext for NoopPeerContext {
|
||||
self.network_identity.clone()
|
||||
}
|
||||
|
||||
fn flags(&self) -> FlagsInConfig {
|
||||
fn flags(&self) -> Flags {
|
||||
self.flags.clone()
|
||||
}
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ use dashmap::DashMap;
|
||||
use futures::future::BoxFuture;
|
||||
|
||||
use crate::config::PeerId;
|
||||
use crate::foundation::small_map_shard_amount;
|
||||
use crate::foundation::stats::{CounterHandle, LabelSet, LabelType, MetricName, StatsManager};
|
||||
use crate::packet::{PacketType, ZCPacket};
|
||||
use crate::peers::util::shrink_dashmap;
|
||||
@@ -88,7 +89,7 @@ impl LogicalTrafficMetrics {
|
||||
instance_bytes_metric,
|
||||
instance_packets_metric,
|
||||
label_kind,
|
||||
per_peer: DashMap::new(),
|
||||
per_peer: DashMap::with_shard_amount(small_map_shard_amount()),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -7,7 +7,8 @@ pub(crate) fn check_network_in_relay_whitelist(
|
||||
network_name: &str,
|
||||
) -> Result<(), anyhow::Error> {
|
||||
if relay_network_whitelist
|
||||
.split(' ')
|
||||
.split(|c: char| c.is_whitespace() || c == ',')
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(wildmatch::WildMatch::new)
|
||||
.any(|whitelist| whitelist.matches(network_name))
|
||||
{
|
||||
|
||||
@@ -14,6 +14,7 @@ use tokio::{sync::mpsc, task::JoinSet};
|
||||
use crate::{
|
||||
config::PeerId,
|
||||
foundation::{
|
||||
small_map_shard_amount,
|
||||
stats::{ArcRpcMetrics, RpcMetricLabels, RpcMetricsProvider},
|
||||
time::timeout,
|
||||
},
|
||||
@@ -109,8 +110,8 @@ impl Client {
|
||||
Self {
|
||||
mpsc: Mutex::new(MpscTunnel::new(ring_a, None)),
|
||||
transport: Mutex::new(MpscTunnel::new(ring_b, None)),
|
||||
inflight_requests: Arc::new(DashMap::new()),
|
||||
peer_info: Arc::new(DashMap::new()),
|
||||
inflight_requests: Arc::new(DashMap::with_shard_amount(small_map_shard_amount())),
|
||||
peer_info: Arc::new(DashMap::with_shard_amount(small_map_shard_amount())),
|
||||
tasks: Mutex::new(JoinSet::new()),
|
||||
metrics: None,
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ use tokio::task::JoinSet;
|
||||
|
||||
use crate::{
|
||||
foundation::{
|
||||
small_map_shard_amount,
|
||||
stats::{ArcRpcMetrics, RpcMetricLabels, RpcMetricsProvider},
|
||||
task::reap_joinset_background,
|
||||
},
|
||||
@@ -77,7 +78,7 @@ impl Server {
|
||||
tasks: Arc::new(Mutex::new(JoinSet::new())),
|
||||
handler_tasks: Arc::new(Mutex::new(JoinSet::new())),
|
||||
stopped: Arc::new(AtomicBool::new(false)),
|
||||
packet_mergers: Arc::new(DashMap::new()),
|
||||
packet_mergers: Arc::new(DashMap::with_shard_amount(small_map_shard_amount())),
|
||||
metrics: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ use std::sync::Arc;
|
||||
|
||||
use dashmap::DashMap;
|
||||
|
||||
use crate::foundation::small_map_shard_amount;
|
||||
use crate::proto::common::RpcDescriptor;
|
||||
use crate::proto::rpc_types;
|
||||
#[cfg(all(feature = "management-rpc", any(test, target_os = "wasi")))]
|
||||
@@ -86,7 +87,7 @@ impl Default for ServiceRegistry {
|
||||
impl ServiceRegistry {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
table: DashMap::new(),
|
||||
table: DashMap::with_shard_amount(small_map_shard_amount()),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -25,12 +25,13 @@ use super::{
|
||||
session::{
|
||||
ClassifiedUdpSessionAccept, ClassifiedUdpSessionAccepts, ClassifiedUdpSessionKey,
|
||||
ClassifiedUdpSessionRegistry, PendingUdpSessionConnect, PendingUdpSessionConnects,
|
||||
UdpConnectControl, UdpSession, UdpSessionClose, UdpSessionCodec, UdpSessionConnectError,
|
||||
UdpSessionConnectRequest, UdpSessionConnector, UdpSessionDatagram, UdpSessionEnqueuePolicy,
|
||||
UdpSessionKey, UdpSessionKind, UdpSessionLayerControl, UdpSessionProtocol,
|
||||
UdpSessionRegistry, close_all_classified_udp_sessions, close_all_udp_sessions,
|
||||
close_classified_udp_session, close_udp_session, create_udp_session_rings,
|
||||
dispatch_payload_to_session, udp_session_registry_entry,
|
||||
UdpConnectControl, UdpHolePunchObserverMap, UdpHolePunchObserverRegistry, UdpSession,
|
||||
UdpSessionClose, UdpSessionCodec, UdpSessionConnectError, UdpSessionConnectRequest,
|
||||
UdpSessionConnector, UdpSessionDatagram, UdpSessionEnqueuePolicy, UdpSessionKey,
|
||||
UdpSessionKind, UdpSessionLayerControl, UdpSessionProtocol, UdpSessionRegistry,
|
||||
close_all_classified_udp_sessions, close_all_udp_sessions, close_classified_udp_session,
|
||||
close_udp_session, create_udp_session_rings, dispatch_payload_to_session,
|
||||
udp_session_registry_entry,
|
||||
},
|
||||
virtual_socket::{
|
||||
MAX_UDP_SESSION_DATAGRAM_SIZE, NoopUdpSessionStunResponder, PreferredIpv6Source,
|
||||
@@ -49,6 +50,7 @@ pub struct UdpSessionLayer<S, R = NoopUdpSessionStunResponder> {
|
||||
classified_sessions: Arc<ClassifiedUdpSessionRegistry>,
|
||||
pub(super) classified_accepts: Arc<ClassifiedUdpSessionAccepts>,
|
||||
pub(super) pending_connects: Arc<PendingUdpSessionConnects>,
|
||||
hole_punch_observers: UdpHolePunchObserverRegistry,
|
||||
mux_accepted_rx: TokioMutex<mpsc::Receiver<UdpSession>>,
|
||||
_control_rx: TokioMutex<mpsc::Receiver<UdpSessionLayerControl>>,
|
||||
session_shutdown_tx: watch::Sender<bool>,
|
||||
@@ -90,15 +92,17 @@ where
|
||||
let classified_sessions = Arc::new(DashMap::new());
|
||||
let classified_accepts = create_classified_udp_session_accepts();
|
||||
let pending_connects = Arc::new(DashMap::new());
|
||||
let hole_punch_observers = Arc::new(DashMap::new());
|
||||
let (mux_accepted_tx, mux_accepted_rx) = mpsc::channel(UDP_SESSION_QUEUE_CAPACITY);
|
||||
let (control_tx, control_rx) = mpsc::channel(UDP_SESSION_QUEUE_CAPACITY);
|
||||
let (session_shutdown_tx, _) = watch::channel(false);
|
||||
let (session_shutdown_tx, session_shutdown_rx) = watch::channel(false);
|
||||
let recv_task = tokio::spawn(udp_session_layer_recv_task(
|
||||
socket.clone(),
|
||||
sessions.clone(),
|
||||
classified_sessions.clone(),
|
||||
classified_accepts.clone(),
|
||||
pending_connects.clone(),
|
||||
hole_punch_observers.clone(),
|
||||
mux_accepted_tx,
|
||||
control_tx,
|
||||
stun_responder.clone(),
|
||||
@@ -112,6 +116,10 @@ where
|
||||
classified_sessions,
|
||||
classified_accepts,
|
||||
pending_connects,
|
||||
hole_punch_observers: UdpHolePunchObserverRegistry {
|
||||
observers: hole_punch_observers,
|
||||
shutdown: session_shutdown_rx,
|
||||
},
|
||||
mux_accepted_rx: TokioMutex::new(mux_accepted_rx),
|
||||
_control_rx: TokioMutex::new(control_rx),
|
||||
session_shutdown_tx,
|
||||
@@ -123,6 +131,10 @@ where
|
||||
self.socket.local_addr()
|
||||
}
|
||||
|
||||
pub fn hole_punch_observers(&self) -> UdpHolePunchObserverRegistry {
|
||||
self.hole_punch_observers.clone()
|
||||
}
|
||||
|
||||
pub fn active_session_count(&self) -> usize {
|
||||
self.sessions.len()
|
||||
}
|
||||
@@ -355,6 +367,7 @@ impl<S, R> Drop for UdpSessionLayer<S, R> {
|
||||
fn drop(&mut self) {
|
||||
let _ = self.session_shutdown_tx.send(true);
|
||||
self.pending_connects.clear();
|
||||
self.hole_punch_observers.observers.clear();
|
||||
close_all_udp_sessions(&self.sessions);
|
||||
close_all_classified_udp_sessions(&self.classified_sessions);
|
||||
self.recv_task.abort();
|
||||
@@ -437,6 +450,7 @@ pub(super) async fn udp_session_layer_recv_task<S, R>(
|
||||
classified_sessions: Arc<ClassifiedUdpSessionRegistry>,
|
||||
classified_accepts: Arc<ClassifiedUdpSessionAccepts>,
|
||||
pending_connects: Arc<PendingUdpSessionConnects>,
|
||||
hole_punch_observers: Arc<UdpHolePunchObserverMap>,
|
||||
mux_accepted: mpsc::Sender<UdpSession>,
|
||||
control: mpsc::Sender<UdpSessionLayerControl>,
|
||||
stun_responder: Arc<R>,
|
||||
@@ -453,6 +467,7 @@ pub(super) async fn udp_session_layer_recv_task<S, R>(
|
||||
tracing::debug!(?err, "udp session recv loop stopped");
|
||||
let _ = session_shutdown_tx.send(true);
|
||||
pending_connects.clear();
|
||||
hole_punch_observers.clear();
|
||||
close_all_udp_sessions(&sessions);
|
||||
close_all_classified_udp_sessions(&classified_sessions);
|
||||
break;
|
||||
@@ -519,6 +534,7 @@ pub(super) async fn udp_session_layer_recv_task<S, R>(
|
||||
&socket,
|
||||
&sessions,
|
||||
&pending_connects,
|
||||
&hole_punch_observers,
|
||||
&mux_accepted,
|
||||
&control,
|
||||
&control_permits,
|
||||
@@ -570,6 +586,7 @@ fn dispatch_easy_tier_udp_datagram<S>(
|
||||
socket: &Arc<S>,
|
||||
sessions: &Arc<UdpSessionRegistry>,
|
||||
pending_connects: &Arc<PendingUdpSessionConnects>,
|
||||
hole_punch_observers: &Arc<UdpHolePunchObserverMap>,
|
||||
mux_accepted: &mpsc::Sender<UdpSession>,
|
||||
control: &mpsc::Sender<UdpSessionLayerControl>,
|
||||
control_permits: &Arc<Semaphore>,
|
||||
@@ -600,7 +617,20 @@ where
|
||||
dispatch_sack_packet(sessions, pending_connects, remote_addr, conn_id, &packet)
|
||||
}
|
||||
EasyTierUdpPacketKind::HolePunch => {
|
||||
dispatch_hole_punch_packet(pending_connects, remote_addr)
|
||||
let mut observed = false;
|
||||
if packet.udp_payload().len() == crate::packet::HOLE_PUNCH_PACKET_BODY_LEN as usize
|
||||
&& let Some(observer) = hole_punch_observers.get(&conn_id)
|
||||
{
|
||||
observer.source.send_if_modified(|source| {
|
||||
if source.is_some() {
|
||||
return false;
|
||||
}
|
||||
*source = Some(remote_addr);
|
||||
true
|
||||
});
|
||||
observed = true;
|
||||
}
|
||||
dispatch_hole_punch_packet(pending_connects, remote_addr) || observed
|
||||
}
|
||||
EasyTierUdpPacketKind::V4HolePunch => dispatch_v4_hole_punch_control(
|
||||
socket.clone(),
|
||||
|
||||
@@ -20,9 +20,10 @@ pub use packet::{
|
||||
parse_udp_session_datagram,
|
||||
};
|
||||
pub use session::{
|
||||
UdpSession, UdpSessionConnectError, UdpSessionConnectRequest, UdpSessionConnector,
|
||||
UdpSessionKind, UdpSessionLayerControl, UdpSessionListenRequest, UdpSessionListener,
|
||||
UdpSessionProtocol, UdpSessionRecvMeta, UdpSessionSocket,
|
||||
UdpHolePunchObserver, UdpHolePunchObserverRegistry, UdpSession, UdpSessionConnectError,
|
||||
UdpSessionConnectRequest, UdpSessionConnector, UdpSessionKind, UdpSessionLayerControl,
|
||||
UdpSessionListenRequest, UdpSessionListener, UdpSessionProtocol, UdpSessionRecvMeta,
|
||||
UdpSessionSocket,
|
||||
};
|
||||
pub(crate) use session::{
|
||||
UdpSessionCleanup, UdpSessionCodec, UdpSessionDatagram, UdpSessionOutbound,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
use std::{
|
||||
io,
|
||||
net::{IpAddr, SocketAddr, SocketAddrV4, SocketAddrV6},
|
||||
sync::{Arc, Mutex as StdMutex},
|
||||
sync::{Arc, Mutex as StdMutex, Weak},
|
||||
};
|
||||
|
||||
use async_trait::async_trait;
|
||||
@@ -15,6 +15,7 @@ use tokio::{
|
||||
};
|
||||
|
||||
use crate::{
|
||||
foundation::time::{Duration, Instant},
|
||||
packet::{UdpPacketType, ZCPacket, ZCPacketType},
|
||||
socket::ring::{RingSocket, RingSocketReceiver, RingSocketSendError, RingSocketSender},
|
||||
};
|
||||
@@ -201,6 +202,135 @@ pub(super) type ClassifiedUdpSessionRegistry =
|
||||
pub(super) type ClassifiedUdpSessionAccepts =
|
||||
DashMap<UdpSessionProtocol, Arc<ClassifiedUdpSessionAccept>>;
|
||||
pub(super) type PendingUdpSessionConnects = DashMap<u32, PendingUdpSessionConnect>;
|
||||
pub(super) type UdpHolePunchObserverMap = DashMap<u32, UdpHolePunchObserverEntry>;
|
||||
|
||||
#[derive(Debug)]
|
||||
pub(super) struct UdpHolePunchObserverEntry {
|
||||
pub(super) source: Arc<watch::Sender<Option<SocketAddr>>>,
|
||||
// Only unclaimed listener-selection registrations expire here. Once an RPC
|
||||
// takes the observer, its guard owns cleanup through completion/cancellation.
|
||||
expires_at: Option<Instant>,
|
||||
}
|
||||
|
||||
/// Registers observers on an existing UDP session layer without another receive task.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct UdpHolePunchObserverRegistry {
|
||||
pub(super) observers: Arc<UdpHolePunchObserverMap>,
|
||||
pub(super) shutdown: watch::Receiver<bool>,
|
||||
}
|
||||
|
||||
impl UdpHolePunchObserverRegistry {
|
||||
fn ensure_running(&self) -> io::Result<()> {
|
||||
if *self.shutdown.borrow() {
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::BrokenPipe,
|
||||
"UDP listener closed",
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Keep the first source even if the UDP packet arrives before the sending RPC.
|
||||
pub fn prepare(&self, transaction_id: u32, timeout: Duration) -> io::Result<()> {
|
||||
self.ensure_running()?;
|
||||
match self.observers.entry(transaction_id) {
|
||||
dashmap::mapref::entry::Entry::Vacant(entry) => {
|
||||
let (sender, _) = watch::channel(None);
|
||||
entry.insert(UdpHolePunchObserverEntry {
|
||||
source: Arc::new(sender),
|
||||
expires_at: Some(Instant::now() + timeout),
|
||||
});
|
||||
Ok(())
|
||||
}
|
||||
dashmap::mapref::entry::Entry::Occupied(_) => Err(io::Error::new(
|
||||
io::ErrorKind::AlreadyExists,
|
||||
"UDP hole-punch transaction already observed",
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn expire_prepared(&self) {
|
||||
let now = Instant::now();
|
||||
self.observers
|
||||
.retain(|_, entry| entry.expires_at.is_none_or(|deadline| deadline > now));
|
||||
}
|
||||
|
||||
pub fn observe(&self, transaction_id: u32) -> io::Result<UdpHolePunchObserver> {
|
||||
self.ensure_running()?;
|
||||
let sender = match self.observers.entry(transaction_id) {
|
||||
dashmap::mapref::entry::Entry::Occupied(mut entry) => {
|
||||
let Some(deadline) = entry.get_mut().expires_at.take() else {
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::AlreadyExists,
|
||||
"UDP hole-punch transaction already observed",
|
||||
));
|
||||
};
|
||||
if deadline <= Instant::now() {
|
||||
entry.remove();
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::TimedOut,
|
||||
"UDP hole-punch registration expired",
|
||||
));
|
||||
}
|
||||
entry.get().source.clone()
|
||||
}
|
||||
dashmap::mapref::entry::Entry::Vacant(entry) => {
|
||||
// Older clients select a listener without preparing a transaction.
|
||||
let (sender, _) = watch::channel(None);
|
||||
let sender = Arc::new(sender);
|
||||
entry.insert(UdpHolePunchObserverEntry {
|
||||
source: sender.clone(),
|
||||
expires_at: None,
|
||||
});
|
||||
sender
|
||||
}
|
||||
};
|
||||
Ok(UdpHolePunchObserver {
|
||||
transaction_id,
|
||||
observers: self.observers.clone(),
|
||||
sender: Arc::downgrade(&sender),
|
||||
source: sender.subscribe(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Observes the first valid source for one active hole-punch transaction.
|
||||
/// Dropping the observer unregisters it from the socket's receive loop.
|
||||
#[derive(Debug)]
|
||||
pub struct UdpHolePunchObserver {
|
||||
pub(super) transaction_id: u32,
|
||||
pub(super) observers: Arc<UdpHolePunchObserverMap>,
|
||||
pub(super) sender: Weak<watch::Sender<Option<SocketAddr>>>,
|
||||
pub(super) source: watch::Receiver<Option<SocketAddr>>,
|
||||
}
|
||||
|
||||
impl UdpHolePunchObserver {
|
||||
pub fn source_addr(&self) -> Option<SocketAddr> {
|
||||
*self.source.borrow()
|
||||
}
|
||||
|
||||
pub async fn wait_for_source_addr(&mut self) -> io::Result<SocketAddr> {
|
||||
loop {
|
||||
if let Some(addr) = self.source_addr() {
|
||||
return Ok(addr);
|
||||
}
|
||||
self.source.changed().await.map_err(|_| {
|
||||
io::Error::new(
|
||||
io::ErrorKind::UnexpectedEof,
|
||||
"UDP hole-punch observer closed",
|
||||
)
|
||||
})?;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for UdpHolePunchObserver {
|
||||
fn drop(&mut self) {
|
||||
self.observers.remove_if(&self.transaction_id, |_, entry| {
|
||||
self.sender.ptr_eq(&Arc::downgrade(&entry.source))
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
pub(super) struct ClassifiedUdpSessionKey {
|
||||
|
||||
@@ -477,6 +477,182 @@ impl AutoSackVirtualUdpSocket {
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn hole_punch_observer_matches_socket_transaction_and_packet_shape() {
|
||||
let local_addr = SocketAddr::from(([127, 0, 0, 1], 12000));
|
||||
let peer_addr = SocketAddr::from(([198, 51, 100, 1], 33140));
|
||||
let other_addr = SocketAddr::from(([198, 51, 100, 1], 33105));
|
||||
let socket = Arc::new(AutoSackVirtualUdpSocket::new(local_addr));
|
||||
let other_socket = Arc::new(AutoSackVirtualUdpSocket::new(local_addr));
|
||||
let layer = UdpSessionLayer::new(socket.clone());
|
||||
let other_layer = UdpSessionLayer::new(other_socket.clone());
|
||||
let mut observer = layer.hole_punch_observers().observe(7).unwrap();
|
||||
let other_observer = other_layer.hole_punch_observers().observe(7).unwrap();
|
||||
let mut concurrent_observer = layer.hole_punch_observers().observe(8).unwrap();
|
||||
|
||||
for (tid, body_len, source) in [
|
||||
(9, crate::packet::HOLE_PUNCH_PACKET_BODY_LEN, other_addr),
|
||||
(7, 32, other_addr),
|
||||
(8, crate::packet::HOLE_PUNCH_PACKET_BODY_LEN, other_addr),
|
||||
(7, crate::packet::HOLE_PUNCH_PACKET_BODY_LEN, peer_addr),
|
||||
(7, crate::packet::HOLE_PUNCH_PACKET_BODY_LEN, other_addr),
|
||||
] {
|
||||
socket.incoming.lock().unwrap().push_back((
|
||||
crate::packet::new_hole_punch_packet(tid, body_len)
|
||||
.into_bytes()
|
||||
.to_vec(),
|
||||
source,
|
||||
));
|
||||
}
|
||||
socket.incoming_notify.notify_one();
|
||||
|
||||
assert_eq!(
|
||||
tokio::time::timeout(Duration::from_secs(1), observer.wait_for_source_addr())
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap(),
|
||||
peer_addr,
|
||||
);
|
||||
assert_eq!(
|
||||
concurrent_observer.wait_for_source_addr().await.unwrap(),
|
||||
other_addr
|
||||
);
|
||||
assert_eq!(observer.source_addr(), Some(peer_addr));
|
||||
assert_eq!(other_observer.source_addr(), None);
|
||||
assert!(socket.sent().is_empty());
|
||||
|
||||
assert_eq!(
|
||||
layer.hole_punch_observers().observe(7).unwrap_err().kind(),
|
||||
io::ErrorKind::AlreadyExists
|
||||
);
|
||||
drop(observer);
|
||||
assert_eq!(
|
||||
layer
|
||||
.hole_punch_observers()
|
||||
.observe(7)
|
||||
.unwrap()
|
||||
.source_addr(),
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn prepared_hole_punch_keeps_source_until_rpc_claims_it() {
|
||||
let socket = Arc::new(AutoSackVirtualUdpSocket::new(SocketAddr::from((
|
||||
[127, 0, 0, 1],
|
||||
12000,
|
||||
))));
|
||||
let layer = UdpSessionLayer::new(socket.clone());
|
||||
let registry = layer.hole_punch_observers();
|
||||
registry.prepare(0, Duration::from_secs(30)).unwrap();
|
||||
let peer_addr = SocketAddr::from(([198, 51, 100, 1], 33140));
|
||||
socket.incoming.lock().unwrap().push_back((
|
||||
crate::packet::new_hole_punch_packet(0, crate::packet::HOLE_PUNCH_PACKET_BODY_LEN)
|
||||
.into_bytes()
|
||||
.to_vec(),
|
||||
peer_addr,
|
||||
));
|
||||
socket.incoming_notify.notify_one();
|
||||
tokio::time::timeout(Duration::from_secs(1), async {
|
||||
loop {
|
||||
if *registry.observers.get(&0).unwrap().source.borrow() == Some(peer_addr) {
|
||||
break;
|
||||
}
|
||||
tokio::task::yield_now().await;
|
||||
}
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let observer = registry.observe(0).unwrap();
|
||||
assert_eq!(observer.source_addr(), Some(peer_addr));
|
||||
assert_eq!(
|
||||
registry.observe(0).unwrap_err().kind(),
|
||||
io::ErrorKind::AlreadyExists
|
||||
);
|
||||
drop(observer);
|
||||
assert!(registry.observers.is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test(start_paused = true)]
|
||||
async fn prepared_hole_punch_expires_without_removing_active_rpc() {
|
||||
let socket = Arc::new(AutoSackVirtualUdpSocket::new(SocketAddr::from((
|
||||
[127, 0, 0, 1],
|
||||
12000,
|
||||
))));
|
||||
let layer = UdpSessionLayer::new(socket);
|
||||
let registry = layer.hole_punch_observers();
|
||||
registry.prepare(7, Duration::from_secs(1)).unwrap();
|
||||
registry.prepare(8, Duration::from_secs(1)).unwrap();
|
||||
registry.prepare(9, Duration::from_secs(1)).unwrap();
|
||||
let observer = registry.observe(8).unwrap();
|
||||
|
||||
tokio::time::advance(Duration::from_secs(1)).await;
|
||||
assert_eq!(
|
||||
registry.observe(7).unwrap_err().kind(),
|
||||
io::ErrorKind::TimedOut
|
||||
);
|
||||
registry.expire_prepared();
|
||||
assert!(!registry.observers.contains_key(&9));
|
||||
assert_eq!(
|
||||
registry.observe(8).unwrap_err().kind(),
|
||||
io::ErrorKind::AlreadyExists
|
||||
);
|
||||
registry.prepare(9, Duration::from_secs(1)).unwrap();
|
||||
drop(observer);
|
||||
assert!(!registry.observers.contains_key(&8));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn dropping_hole_punch_observer_preserves_udp_handshake_receive() {
|
||||
let socket = Arc::new(AutoSackVirtualUdpSocket::new(SocketAddr::from((
|
||||
[127, 0, 0, 1],
|
||||
12000,
|
||||
))));
|
||||
let layer = UdpSessionLayer::new(socket);
|
||||
let observer = layer.hole_punch_observers().observe(7).unwrap();
|
||||
drop(observer);
|
||||
let peer_addr = SocketAddr::from(([198, 51, 100, 1], 33140));
|
||||
let session = layer.connect(peer_addr).await.unwrap();
|
||||
assert_eq!(session.peer_addr().unwrap(), peer_addr);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn hole_punch_observer_registry_does_not_keep_listener_alive() {
|
||||
let socket = Arc::new(AutoSackVirtualUdpSocket::new(SocketAddr::from((
|
||||
[127, 0, 0, 1],
|
||||
12000,
|
||||
))));
|
||||
let layer = UdpSessionLayer::new(socket.clone());
|
||||
let registry = layer.hole_punch_observers();
|
||||
let mut observer = registry.observe(7).unwrap();
|
||||
registry.prepare(8, Duration::from_secs(30)).unwrap();
|
||||
drop(layer);
|
||||
assert_eq!(
|
||||
registry.observe(8).unwrap_err().kind(),
|
||||
io::ErrorKind::BrokenPipe
|
||||
);
|
||||
assert_eq!(
|
||||
registry
|
||||
.prepare(9, Duration::from_secs(30))
|
||||
.unwrap_err()
|
||||
.kind(),
|
||||
io::ErrorKind::BrokenPipe
|
||||
);
|
||||
assert_eq!(
|
||||
observer.wait_for_source_addr().await.unwrap_err().kind(),
|
||||
io::ErrorKind::UnexpectedEof
|
||||
);
|
||||
drop(observer);
|
||||
for _ in 0..10 {
|
||||
if Arc::strong_count(&socket) == 1 {
|
||||
return;
|
||||
}
|
||||
tokio::task::yield_now().await;
|
||||
}
|
||||
assert_eq!(Arc::strong_count(&socket), 1);
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl VirtualUdpSocket for AutoSackVirtualUdpSocket {
|
||||
fn local_addr(&self) -> io::Result<SocketAddr> {
|
||||
@@ -1301,6 +1477,7 @@ async fn udp_session_recv_loop_error_closes_registered_sessions() {
|
||||
classified_sessions.clone(),
|
||||
classified_accepts,
|
||||
pending_connects.clone(),
|
||||
Arc::new(DashMap::new()),
|
||||
mux_accepted_tx,
|
||||
control_tx,
|
||||
Arc::new(NoopUdpSessionStunResponder),
|
||||
@@ -1624,6 +1801,7 @@ async fn udp_session_recv_loop_does_not_wait_for_stun_responder() {
|
||||
classified_sessions,
|
||||
classified_accepts,
|
||||
pending_connects,
|
||||
Arc::new(DashMap::new()),
|
||||
mux_accepted_tx,
|
||||
control_tx,
|
||||
stun_responder.clone(),
|
||||
|
||||
@@ -15,23 +15,24 @@ impl<T: Buf> BufList<T> {
|
||||
}
|
||||
}
|
||||
|
||||
#[inline]
|
||||
#[inline(always)]
|
||||
pub fn push(&mut self, buf: T) {
|
||||
debug_assert!(buf.has_remaining());
|
||||
self.bufs.push_back(buf);
|
||||
if buf.has_remaining() {
|
||||
self.bufs.push_back(buf);
|
||||
}
|
||||
}
|
||||
|
||||
#[inline]
|
||||
#[inline(always)]
|
||||
pub fn pop(&mut self) -> Option<T> {
|
||||
self.bufs.pop_front()
|
||||
}
|
||||
|
||||
#[inline]
|
||||
#[inline(always)]
|
||||
pub fn len(&self) -> usize {
|
||||
self.bufs.len()
|
||||
}
|
||||
|
||||
#[inline]
|
||||
#[inline(always)]
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.bufs.is_empty()
|
||||
}
|
||||
@@ -39,17 +40,15 @@ impl<T: Buf> BufList<T> {
|
||||
|
||||
impl<T: Buf> Extend<T> for BufList<T> {
|
||||
fn extend<I: IntoIterator<Item = T>>(&mut self, iter: I) {
|
||||
self.bufs.extend(
|
||||
iter.into_iter()
|
||||
.inspect(|buf| debug_assert!(buf.has_remaining())),
|
||||
);
|
||||
self.bufs
|
||||
.extend(iter.into_iter().filter(Buf::has_remaining));
|
||||
}
|
||||
}
|
||||
|
||||
impl<T: Buf> Buf for BufList<T> {
|
||||
#[inline]
|
||||
fn remaining(&self) -> usize {
|
||||
self.bufs.iter().map(|buf| buf.remaining()).sum()
|
||||
self.bufs.iter().map(Buf::remaining).sum()
|
||||
}
|
||||
|
||||
#[inline]
|
||||
@@ -59,33 +58,30 @@ impl<T: Buf> Buf for BufList<T> {
|
||||
|
||||
#[inline]
|
||||
fn chunks_vectored<'t>(&'t self, dst: &mut [IoSlice<'t>]) -> usize {
|
||||
if dst.is_empty() {
|
||||
return 0;
|
||||
}
|
||||
let mut vecs = 0;
|
||||
|
||||
for buf in &self.bufs {
|
||||
vecs += buf.chunks_vectored(&mut dst[vecs..]);
|
||||
if vecs == dst.len() {
|
||||
let n = buf.chunks_vectored(&mut dst[vecs..]);
|
||||
vecs += n;
|
||||
|
||||
if dst[vecs - n..vecs].iter().map(|s| s.len()).sum::<usize>() < buf.remaining() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
vecs
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn advance(&mut self, mut cnt: usize) {
|
||||
while cnt > 0 {
|
||||
{
|
||||
let front = &mut self.bufs[0];
|
||||
let rem = front.remaining();
|
||||
if rem > cnt {
|
||||
front.advance(cnt);
|
||||
return;
|
||||
} else {
|
||||
front.advance(rem);
|
||||
cnt -= rem;
|
||||
}
|
||||
let front = &mut self.bufs[0];
|
||||
let rem = front.remaining();
|
||||
front.advance(cnt.min(rem));
|
||||
if rem > cnt {
|
||||
return;
|
||||
}
|
||||
cnt -= rem;
|
||||
self.bufs.pop_front();
|
||||
}
|
||||
}
|
||||
@@ -94,20 +90,21 @@ impl<T: Buf> Buf for BufList<T> {
|
||||
fn copy_to_bytes(&mut self, len: usize) -> Bytes {
|
||||
// Our inner buffer may have an optimized version of copy_to_bytes, and if the whole
|
||||
// request can be fulfilled by the front buffer, we can take advantage.
|
||||
match self.bufs.front_mut() {
|
||||
Some(front) if front.remaining() == len => {
|
||||
let b = front.copy_to_bytes(len);
|
||||
if let Some(front) = self.bufs.front_mut()
|
||||
&& let rem = front.remaining()
|
||||
&& len <= rem
|
||||
{
|
||||
let bytes = front.copy_to_bytes(len);
|
||||
if len == rem {
|
||||
self.bufs.pop_front();
|
||||
b
|
||||
}
|
||||
Some(front) if front.remaining() > len => front.copy_to_bytes(len),
|
||||
_ => {
|
||||
assert!(len <= self.remaining(), "`len` greater than remaining");
|
||||
let mut bm = BytesMut::with_capacity(len);
|
||||
bm.put(self.take(len));
|
||||
bm.freeze()
|
||||
}
|
||||
return bytes;
|
||||
}
|
||||
|
||||
assert!(len <= self.remaining());
|
||||
let mut bytes = BytesMut::with_capacity(len);
|
||||
bytes.put(self.take(len));
|
||||
bytes.freeze()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -125,4 +122,35 @@ mod tests {
|
||||
assert_eq!(&bytes[..], b"hello world");
|
||||
assert_eq!(list.remaining(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_chunks_vectored_partial_inner_buf() {
|
||||
#[derive(Debug)]
|
||||
struct PartialBuf(&'static [u8]);
|
||||
|
||||
impl Buf for PartialBuf {
|
||||
fn remaining(&self) -> usize {
|
||||
self.0.len()
|
||||
}
|
||||
|
||||
fn chunk(&self) -> &[u8] {
|
||||
&self.0[..self.0.len().min(1)]
|
||||
}
|
||||
|
||||
fn advance(&mut self, cnt: usize) {
|
||||
self.0 = &self.0[cnt..];
|
||||
}
|
||||
}
|
||||
|
||||
let mut list = BufList::new();
|
||||
list.push(PartialBuf(b"ab"));
|
||||
list.push(PartialBuf(b"c"));
|
||||
|
||||
let mut dst = [IoSlice::new(&[]); 3];
|
||||
|
||||
let n = list.chunks_vectored(&mut dst);
|
||||
|
||||
assert_eq!(n, 1);
|
||||
assert_eq!(&*dst[0], b"a");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use aes_gcm::{AeadInOut, Aes128Gcm, Aes256Gcm, Key, KeyInit};
|
||||
use aes_gcm::{AeadInPlace, Aes128Gcm, Aes256Gcm, Key, KeyInit};
|
||||
use rand::{RngCore, rngs::OsRng};
|
||||
use zerocopy::{AsBytes, FromBytes};
|
||||
|
||||
@@ -54,16 +54,16 @@ impl Encryptor for AesGcmCipher {
|
||||
let tag = aes_tail.tag.into();
|
||||
|
||||
let rs = match &self.cipher {
|
||||
AesGcmEnum::AES128GCM(aes_gcm) => aes_gcm.decrypt_inout_detached(
|
||||
AesGcmEnum::AES128GCM(aes_gcm) => aes_gcm.decrypt_in_place_detached(
|
||||
&nonce,
|
||||
&[],
|
||||
(&mut zc_packet.mut_payload()[..text_len]).into(),
|
||||
&mut zc_packet.mut_payload()[..text_len],
|
||||
&tag,
|
||||
),
|
||||
AesGcmEnum::AES256GCM(aes_gcm) => aes_gcm.decrypt_inout_detached(
|
||||
AesGcmEnum::AES256GCM(aes_gcm) => aes_gcm.decrypt_in_place_detached(
|
||||
&nonce,
|
||||
&[],
|
||||
(&mut zc_packet.mut_payload()[..text_len]).into(),
|
||||
&mut zc_packet.mut_payload()[..text_len],
|
||||
&tag,
|
||||
),
|
||||
};
|
||||
@@ -113,7 +113,7 @@ impl Encryptor for AesGcmCipher {
|
||||
nonce.into()
|
||||
});
|
||||
(
|
||||
aes_gcm.encrypt_inout_detached(&nonce, &[], zc_packet.mut_payload().into()),
|
||||
aes_gcm.encrypt_in_place_detached(&nonce, &[], zc_packet.mut_payload()),
|
||||
nonce,
|
||||
)
|
||||
}
|
||||
@@ -124,7 +124,7 @@ impl Encryptor for AesGcmCipher {
|
||||
nonce.into()
|
||||
});
|
||||
(
|
||||
aes_gcm.encrypt_inout_detached(&nonce, &[], zc_packet.mut_payload().into()),
|
||||
aes_gcm.encrypt_in_place_detached(&nonce, &[], zc_packet.mut_payload()),
|
||||
nonce,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use chacha20poly1305::{AeadInOut, ChaCha20Poly1305, Key, KeyInit};
|
||||
use chacha20poly1305::{AeadInPlace, ChaCha20Poly1305, Key, KeyInit};
|
||||
use rand::{RngCore, rngs::OsRng};
|
||||
use zerocopy::{AsBytes, FromBytes};
|
||||
|
||||
@@ -42,12 +42,7 @@ impl Encryptor for ChaCha20Cipher {
|
||||
let tag = tail.tag.into();
|
||||
|
||||
self.cipher
|
||||
.decrypt_inout_detached(
|
||||
&nonce,
|
||||
&[],
|
||||
(&mut zc_packet.mut_payload()[..text_len]).into(),
|
||||
&tag,
|
||||
)
|
||||
.decrypt_in_place_detached(&nonce, &[], &mut zc_packet.mut_payload()[..text_len], &tag)
|
||||
.map_err(|_| Error::DecryptionFailed)?;
|
||||
|
||||
let pm_header = zc_packet.mut_peer_manager_header().unwrap();
|
||||
@@ -89,7 +84,7 @@ impl Encryptor for ChaCha20Cipher {
|
||||
|
||||
let tag = self
|
||||
.cipher
|
||||
.encrypt_inout_detached(&nonce, &[], zc_packet.mut_payload().into())
|
||||
.encrypt_in_place_detached(&nonce, &[], zc_packet.mut_payload())
|
||||
.map_err(|_| Error::EncryptionFailed)?;
|
||||
|
||||
let tail = StandardAeadTail {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user