Compare commits

..
Author SHA1 Message Date
fanyang 30926aec55 feat(cli): support configurable local state directory
Add --state-dir and ET_STATE_DIR for persisted web-client machine IDs.

Fixes #2340
2026-10-09 18:29:50 +08:00
213 changed files with 4611 additions and 23350 deletions
-25
View File
@@ -3,28 +3,3 @@ 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
-82
View File
@@ -1,82 +0,0 @@
# 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.
-73
View File
@@ -1,73 +0,0 @@
# 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 构建的产物并推送镜像。
-204
View File
@@ -1,204 +0,0 @@
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/);
});
-70
View File
@@ -1,70 +0,0 @@
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;
}
};
-241
View File
@@ -1,241 +0,0 @@
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 };
-398
View File
@@ -1,398 +0,0 @@
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/);
});
@@ -1,25 +0,0 @@
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}.`);
}
};
-216
View File
@@ -1,216 +0,0 @@
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, &timestamp);
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, &param)) { 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
+17 -79
View File
@@ -2,11 +2,6 @@ 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:
@@ -14,8 +9,7 @@ on:
types: [ opened, synchronize, reopened, ready_for_review ]
concurrency:
# 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 }}
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
env:
@@ -37,17 +31,7 @@ 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: >-
${{
@@ -58,16 +42,15 @@ 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
@@ -78,54 +61,10 @@ 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' &&
(
needs.pre_job.outputs.full_build == 'true' ||
github.event_name != 'pull_request' ||
needs.pre_job.outputs.frontend_changed == 'true'
)
if: needs.pre_job.outputs.should_skip != 'true'
steps:
- uses: actions/checkout@v5
@@ -136,7 +75,6 @@ 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
@@ -144,7 +82,7 @@ jobs:
easytier-web/frontend/dist/*
build:
strategy:
fail-fast: false
fail-fast: true
matrix:
include:
- TARGET: x86_64-unknown-linux-musl
@@ -212,7 +150,7 @@ jobs:
needs:
- pre_job
- build_web
if: needs.pre_job.outputs.should_skip != 'true' && needs.pre_job.outputs.full_build == 'true'
if: needs.pre_job.outputs.should_skip != 'true'
steps:
- uses: actions/checkout@v5
@@ -230,8 +168,8 @@ jobs:
uses: ./.github/actions/prepare-build
with:
target: ${{ matrix.TARGET }}
gui: false
pnpm: false
gui: true
pnpm: true
token: ${{ secrets.GITHUB_TOKEN }}
- uses: Swatinem/rust-cache@v2
@@ -239,8 +177,8 @@ jobs:
# The prefix cache key, this can be changed to start a new cache manually.
# default: "v0-rust"
prefix-key: ""
shared-key: core-release-${{ matrix.TARGET }}
cache-targets: "true"
shared-key: "core-registry"
cache-targets: "false"
- uses: mlugg/setup-zig@v2
if: ${{ contains(matrix.OS, 'ubuntu') }}
@@ -348,7 +286,7 @@ jobs:
./artifacts/*
- name: Prepare SignPath test artifact
if: ${{ github.event_name == 'workflow_dispatch' && inputs.sign_windows && contains(matrix.TARGET, 'windows') }}
if: ${{ github.event_name == 'workflow_dispatch' && contains(matrix.TARGET, 'windows') }}
run: |
mkdir -p ./signpath-artifacts
cp \
@@ -359,7 +297,7 @@ jobs:
./signpath-artifacts/
- name: Upload unsigned SignPath test artifact
if: ${{ github.event_name == 'workflow_dispatch' && inputs.sign_windows && contains(matrix.TARGET, 'windows') }}
if: ${{ github.event_name == 'workflow_dispatch' && contains(matrix.TARGET, 'windows') }}
id: signpath-upload
uses: actions/upload-artifact@v5
with:
@@ -368,7 +306,7 @@ jobs:
if-no-files-found: error
- name: Submit SignPath test-signing request
if: ${{ github.event_name == 'workflow_dispatch' && inputs.sign_windows && contains(matrix.TARGET, 'windows') }}
if: ${{ github.event_name == 'workflow_dispatch' && contains(matrix.TARGET, 'windows') }}
uses: signpath/github-action-submit-signing-request@v2
with:
api-token: ${{ secrets.SIGNPATH_API_TOKEN }}
@@ -381,7 +319,7 @@ jobs:
output-artifact-directory: ./signpath-signed-artifacts
- name: Upload signed SignPath test artifact
if: ${{ github.event_name == 'workflow_dispatch' && inputs.sign_windows && contains(matrix.TARGET, 'windows') }}
if: ${{ github.event_name == 'workflow_dispatch' && contains(matrix.TARGET, 'windows') }}
uses: actions/upload-artifact@v5
with:
name: signpath-signed-${{ matrix.ARTIFACT_NAME }}
@@ -391,7 +329,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' && needs.pre_job.outputs.full_build == 'true' && !cancelled()
if: needs.pre_job.result == 'success' && needs.pre_job.outputs.should_skip != 'true' && !cancelled()
steps:
- name: Checkout Code
uses: actions/checkout@v5 # 必须先检出代码才能获取模块配置
@@ -433,9 +371,9 @@ jobs:
core-result:
runs-on: ubuntu-latest
needs: [ pre_job, check, build_web, build, build_magisk ]
if: ${{ always() }}
needs: [ pre_job, build_web, build, build_magisk ]
if: needs.pre_job.result == 'success' && needs.pre_job.outputs.should_skip != 'true' && !cancelled()
steps:
- name: Mark result as failed
if: contains(needs.*.result, 'failure') || contains(needs.*.result, 'cancelled')
if: contains(needs.*.result, 'failure')
run: exit 1
+9 -36
View File
@@ -1,7 +1,6 @@
name: EasyTier GUI
on:
workflow_dispatch:
push:
branches: ["develop", "main", "releases/**"]
pull_request:
@@ -9,8 +8,7 @@ on:
types: [opened, synchronize, reopened, ready_for_review]
concurrency:
# 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 }}
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
env:
@@ -34,7 +32,6 @@ 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: >-
${{
@@ -45,16 +42,15 @@ 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,29 +62,6 @@ 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
@@ -133,7 +106,7 @@ jobs:
GUI_TARGET: ${{ matrix.GUI_TARGET }}
OSS_BUCKET: ${{ secrets.ALIYUN_OSS_BUCKET }}
needs: pre_job
if: needs.pre_job.outputs.should_skip != 'true' && needs.pre_job.outputs.full_build == 'true'
if: needs.pre_job.outputs.should_skip != 'true'
steps:
- uses: actions/checkout@v5
@@ -147,7 +120,7 @@ jobs:
target: ${{ matrix.TARGET }}
gui: true
pnpm: true
pnpm-build-filter: easytier-gui...
pnpm-build-filter: ''
token: ${{ secrets.GITHUB_TOKEN }}
- uses: Swatinem/rust-cache@v2
@@ -155,8 +128,8 @@ jobs:
# The prefix cache key, this can be changed to start a new cache manually.
# default: "v0-rust"
prefix-key: ""
shared-key: gui-release-${{ matrix.GUI_TARGET }}
cache-targets: "true"
shared-key: "gui-registry"
cache-targets: "false"
- name: copy correct DLLs
if: ${{ contains(matrix.GUI_TARGET, 'windows') }}
@@ -289,9 +262,9 @@ jobs:
gui-result:
runs-on: ubuntu-latest
needs: [ pre_job, check, build-gui ]
if: ${{ always() }}
needs: [ pre_job, build-gui ]
if: needs.pre_job.result == 'success' && needs.pre_job.outputs.should_skip != 'true' && !cancelled()
steps:
- name: Mark result as failed
if: contains(needs.*.result, 'failure') || contains(needs.*.result, 'cancelled')
if: contains(needs.*.result, 'failure')
run: exit 1
+18 -22
View File
@@ -1,7 +1,6 @@
name: EasyTier Mobile
on:
workflow_dispatch:
push:
branches: ["develop", "main", "releases/**"]
pull_request:
@@ -9,8 +8,7 @@ on:
types: [opened, synchronize, reopened, ready_for_review]
concurrency:
# 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 }}
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
env:
@@ -34,7 +32,6 @@ 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: >-
${{
@@ -45,16 +42,15 @@ 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
@@ -70,11 +66,19 @@ jobs:
strategy:
fail-fast: true
matrix:
ARCH: ${{ fromJSON(needs.pre_job.outputs.full_build == 'true' && '["aarch64", "armv7", "i686", "x86_64"]' || '["aarch64"]') }}
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
runs-on: ubuntu-latest
env:
NAME: easytier
TARGET: ${{ matrix.ARCH == 'armv7' && 'armv7-linux-androideabi' || format('{0}-linux-android', matrix.ARCH) }}
TARGET: ${{ matrix.TARGET }}
ARCH: ${{ matrix.ARCH }}
OSS_BUCKET: ${{ secrets.ALIYUN_OSS_BUCKET }}
needs: pre_job
@@ -106,10 +110,10 @@ jobs:
- name: Prepare build environment
uses: ./.github/actions/prepare-build
with:
target: ${{ env.TARGET }}
target: ${{ matrix.TARGET }}
gui: false
pnpm: true
pnpm-build-filter: easytier-gui...
pnpm-build-filter: ''
token: ${{ secrets.GITHUB_TOKEN }}
- uses: Swatinem/rust-cache@v2
@@ -117,22 +121,15 @@ jobs:
# The prefix cache key, this can be changed to start a new cache manually.
# default: "v0-rust"
prefix-key: ""
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
shared-key: "gui-registry"
cache-targets: "false"
- 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/
@@ -147,7 +144,6 @@ 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 }}
@@ -157,8 +153,8 @@ jobs:
mobile-result:
runs-on: ubuntu-latest
needs: [ pre_job, build-mobile ]
if: ${{ always() }}
if: needs.pre_job.result == 'success' && needs.pre_job.outputs.should_skip != 'true' && !cancelled()
steps:
- name: Mark result as failed
if: contains(needs.*.result, 'failure') || contains(needs.*.result, 'cancelled')
if: contains(needs.*.result, 'failure')
run: exit 1
-104
View File
@@ -1,104 +0,0 @@
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' });
-28
View File
@@ -1,28 +0,0 @@
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,
});
-42
View File
@@ -1,42 +0,0 @@
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 }}
+24 -47
View File
@@ -80,62 +80,29 @@ 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: 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
sudo apt-get install -qqy \
pkg-config curl libgl1-mesa-dev expect llvm clang lldb lld
rustup component add rustfmt
cargo fmt --all --manifest-path \
easytier-contrib/easytier-ohrs/Cargo.toml -- --check
cargo test --locked --manifest-path \
easytier-contrib/easytier-ohrs/Cargo.toml \
-p easytier-ohos-core -p easytier-ohos-features \
--lib -- --test-threads=1
cargo check --locked --manifest-path \
easytier-contrib/easytier-ohrs/Cargo.toml \
-p easytier-ohrs --tests
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)
@@ -182,6 +149,17 @@ 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
@@ -189,7 +167,6 @@ 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 }}
-12
View File
@@ -27,7 +27,6 @@ on:
required: true
permissions:
actions: read
contents: write
jobs:
@@ -38,17 +37,6 @@ 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:
+28 -47
View File
@@ -1,16 +1,13 @@
name: EasyTier Test
on:
workflow_dispatch:
push:
branches: [ "develop", "main", "releases/**" ]
branches: [ "develop", "main" ]
pull_request:
branches: [ "develop", "main" ]
types: [ opened, synchronize, reopened, ready_for_review ]
concurrency:
# 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 }}
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
env:
@@ -24,48 +21,27 @@ 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' ||
(
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/')
github.event_name == 'pull_request' &&
steps.path_filter.outputs.relevant != 'true'
)
}}
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'
@@ -93,23 +69,16 @@ jobs:
uses: ./.github/actions/prepare-build
with:
gui: true
pnpm: ${{ needs.pre_job.outputs.full_build == 'true' }}
pnpm: 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() }}
@@ -120,7 +89,7 @@ jobs:
run: cargo clippy --all-targets --features full --all -- -D warnings
- name: Check features
if: ${{ !cancelled() && needs.pre_job.outputs.full_build == 'true' }}
if: ${{ !cancelled() }}
run: cargo hack check --package easytier --each-feature --exclude-features macos-ne --verbose
- name: Check WASI
@@ -148,8 +117,8 @@ jobs:
- name: Prepare build environment
uses: ./.github/actions/prepare-build
with:
gui: false
pnpm: false
gui: true
pnpm: true
token: ${{ secrets.GITHUB_TOKEN }}
- uses: Swatinem/rust-cache@v2
@@ -167,11 +136,23 @@ jobs:
path: tests.tar.zst
retention-days: 1
run-tests:
name: Run tests
test_matrix:
name: Test (${{ matrix.name }})
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
@@ -206,13 +187,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 --profile ci --no-fail-fast
cargo nextest run --archive-file tests.tar.zst ${{ matrix.opts }}
test:
runs-on: ubuntu-latest
needs: [ pre_job, ci-scripts, check, pre-test, run-tests ]
if: ${{ always() }}
needs: [ pre_job, check, test_matrix ]
if: needs.pre_job.result == 'success' && needs.pre_job.outputs.should_skip != 'true' && !cancelled()
steps:
- name: Mark result as failed
if: contains(needs.*.result, 'failure') || contains(needs.*.result, 'cancelled')
if: contains(needs.*.result, 'failure')
run: exit 1
-8
View File
@@ -91,14 +91,6 @@ 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
+2 -51
View File
@@ -2572,7 +2572,6 @@ dependencies = [
"pin-project-lite",
"pnet_datalink",
"prost 0.14.4",
"prost-types 0.14.4",
"quanta",
"quinn",
"quinn-proto",
@@ -2670,7 +2669,6 @@ dependencies = [
"hyper-util",
"idna",
"openssl",
"optionize",
"ordered_hash_map",
"parking_lot",
"percent-encoding",
@@ -2678,7 +2676,6 @@ 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",
@@ -2797,7 +2794,7 @@ dependencies = [
"flate2",
"gethostname",
"once_cell",
"prost-reflect 0.14.7",
"prost-reflect",
"rusqlite",
"serde",
"serde_json",
@@ -2845,13 +2842,11 @@ dependencies = [
"cidr",
"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",
@@ -2859,7 +2854,6 @@ dependencies = [
"serde",
"serde_json",
"sha2 0.10.9",
"strum 0.28.0",
"thiserror 2.0.20",
"tokio",
"url",
@@ -2923,7 +2917,6 @@ dependencies = [
"mimalloc",
"oauth2-reqwest",
"openidconnect",
"optionize",
"password-auth",
"prost 0.14.4",
"rand 0.8.8",
@@ -2942,7 +2935,6 @@ dependencies = [
"thunk-rs",
"tokio",
"tokio-util",
"toml 1.1.6+spec-1.1.0",
"tower",
"tower-http 0.7.1",
"tower-sessions",
@@ -6392,30 +6384,6 @@ 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"
@@ -7280,16 +7248,6 @@ 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"
@@ -8757,12 +8715,6 @@ 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"
@@ -10647,7 +10599,7 @@ checksum = "d2df906b07856748fa3f6e0ad0cbaa047052d4a7dd609e231c4f72cee8c36f31"
[[package]]
name = "tun-easytier"
version = "1.1.1"
source = "git+https://github.com/EasyTier/rust-tun?rev=5ba6be073e5b486b68c12f80fac5cfb50601f024#5ba6be073e5b486b68c12f80fac5cfb50601f024"
source = "git+https://github.com/EasyTier/rust-tun#12378839e7985283df0e4fb536b7137230356db5"
dependencies = [
"bytes",
"cfg-if",
@@ -10888,7 +10840,6 @@ dependencies = [
"js-sys",
"rand 0.10.2",
"serde_core",
"sha1_smol",
"wasm-bindgen",
]
-1
View File
@@ -22,7 +22,6 @@ 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"
+2 -9
View File
@@ -7,7 +7,7 @@
[![GitHub Core Actions](https://github.com/EasyTier/EasyTier/actions/workflows/core.yml/badge.svg)](https://github.com/EasyTier/EasyTier/actions/workflows/core.yml)
[![GitHub GUI Actions](https://github.com/EasyTier/EasyTier/actions/workflows/gui.yml/badge.svg)](https://github.com/EasyTier/EasyTier/actions/workflows/gui.yml)
[![GitHub Test Actions](https://github.com/EasyTier/EasyTier/actions/workflows/test.yml/badge.svg)](https://github.com/EasyTier/EasyTier/actions/workflows/test.yml)
[<img src="https://devin.ai/assets/askdeepwiki.png" alt="Ask DeepWiki" height="20"/>](https://deepwiki.com/EasyTier/EasyTier)
[![Ask DeepWiki](https://deepwiki.com/badge.svg)](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)** | **[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)**
📚 **[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)**
## Features
@@ -69,13 +69,6 @@ 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.
+2 -9
View File
@@ -7,7 +7,7 @@
[![GitHub Core Actions](https://github.com/EasyTier/EasyTier/actions/workflows/core.yml/badge.svg)](https://github.com/EasyTier/EasyTier/actions/workflows/core.yml)
[![GitHub GUI Actions](https://github.com/EasyTier/EasyTier/actions/workflows/gui.yml/badge.svg)](https://github.com/EasyTier/EasyTier/actions/workflows/gui.yml)
[![GitHub Test Actions](https://github.com/EasyTier/EasyTier/actions/workflows/test.yml/badge.svg)](https://github.com/EasyTier/EasyTier/actions/workflows/test.yml)
[<img src="https://devin.ai/assets/askdeepwiki.png" alt="Ask DeepWiki" height="20"/>](https://deepwiki.com/EasyTier/EasyTier)
[![Ask DeepWiki](https://deepwiki.com/badge.svg)](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)** | **[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)** | ❤️ **[赞助](#赞助)**
📚 **[完整文档](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)** | ❤️ **[赞助](#赞助)**
## 特性
@@ -69,13 +69,6 @@ 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 组网,功能范围与使用方式见其文档。
-118
View File
@@ -179,21 +179,6 @@ 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;
@@ -364,14 +349,6 @@ 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
@@ -413,79 +390,6 @@ 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
@@ -502,28 +406,6 @@ 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.
-55
View File
@@ -1,55 +0,0 @@
# 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.
+4 -21
View File
@@ -290,11 +290,7 @@ 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 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 UDP socket owns the destination flow leases it created;
- a direct or local stream reports its actual address and does not fabricate a
smoltcp address.
@@ -309,25 +305,12 @@ flow table using pointer equality.
Packet classification behavior must remain compatible:
- malformed or unmatched nonfragmented packets pass to the next pipeline;
- malformed or unmatched 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;
- 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.
- fragmented UDP response routing remains supported;
- a packet is consumed only when the owning flow exists.
`DataPlaneRuntime`, not the SOCKS5 Adapter, registers and owns the peer-packet
pipeline.
-1
View File
@@ -14,7 +14,6 @@ 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(&format!("instance not found: {inst_name}"));
set_error_msg("instance not found");
return -1;
}
Err(error) => {
@@ -32,10 +32,7 @@ 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(e) => {
set_error_msg(&format!("failed to set tun fd: {}", e));
-1
}
Err(_) => -1,
}
}
@@ -1,8 +1,6 @@
use easytier::proto::ALL_DESCRIPTOR_BYTES;
use once_cell::sync::Lazy;
use prost_reflect::{
Cardinality, DescriptorPool, FieldDescriptor, FileDescriptor, Kind, MessageDescriptor,
};
use prost_reflect::{Cardinality, DescriptorPool, FieldDescriptor, Kind, MessageDescriptor};
use serde::Serialize;
#[derive(Debug, Clone, Serialize)]
@@ -206,15 +204,7 @@ fn build_node(
}
}
/// 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 {
fn build_map_entry_node(message_desc: &MessageDescriptor) -> NetworkConfigSchema {
let key_field = message_desc.map_entry_key_field();
let value_field = message_desc.map_entry_value_field();
@@ -231,49 +221,35 @@ fn build_map_entry_node(
Vec::new(),
Vec::new(),
vec![
build_schema_field_node(&key_field, path),
build_schema_field_node(&value_field, path),
build_schema_field_node(&key_field),
build_schema_field_node(&value_field),
],
Vec::new(),
)
}
fn field_children(field: &FieldDescriptor, path: &mut ExpansionPath) -> Vec<NetworkConfigSchema> {
fn field_children(field: &FieldDescriptor) -> Vec<NetworkConfigSchema> {
if field.is_map()
&& let Kind::Message(message_desc) = field.kind()
{
return vec![build_map_entry_node(&message_desc, path)];
return vec![build_map_entry_node(&message_desc)];
}
match field.kind() {
Kind::Message(message_desc) => build_message_children(&message_desc, path),
Kind::Message(message_desc) => build_message_children(&message_desc),
_ => Vec::new(),
}
}
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_message_children(message_desc: &MessageDescriptor) -> Vec<NetworkConfigSchema> {
message_desc
.fields()
.filter(should_expose_field)
.map(|field| build_schema_field_node(&field))
.collect()
}
fn build_schema_field_node(
field: &FieldDescriptor,
path: &mut ExpansionPath,
) -> NetworkConfigSchema {
fn build_schema_field_node(field: &FieldDescriptor) -> NetworkConfigSchema {
build_node(
"field",
field.name().to_string(),
@@ -286,26 +262,17 @@ fn build_schema_field_node(
field_default_value_text(field),
enum_options(field.kind()),
build_validations(field),
field_children(field, path),
field_children(field),
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()
|| is_descriptor_schema(&message_desc.parent_file())
{
if full_name == NETWORK_CONFIG_MESSAGE_NAME || message_desc.is_map_entry() {
continue;
}
@@ -321,16 +288,12 @@ fn collect_definitions() -> Vec<NetworkConfigSchema> {
None,
Vec::new(),
Vec::new(),
build_message_children(&message_desc, &mut Vec::new()),
build_message_children(&message_desc),
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(),
@@ -366,7 +329,7 @@ fn build_network_config_schema() -> NetworkConfigSchema {
None,
Vec::new(),
Vec::new(),
build_message_children(&network_config, &mut Vec::new()),
build_message_children(&network_config),
collect_definitions(),
)
}
-5
View File
@@ -18,7 +18,6 @@ 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
@@ -122,13 +121,11 @@ endpoint-discovery = [
"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",
@@ -167,8 +164,6 @@ 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"] }
+55 -12
View File
@@ -4,10 +4,8 @@ use easytier_proto::api::manage::{
self, NetworkConfig, NetworkingMethod, PortForwardConfig as ApiPortForwardConfig,
};
use optionize::Optionizable as _;
use super::{
api_input::set_network_flags,
api_input::managed_credential_to_proto,
toml::{ConfigLoader as _, TomlConfig},
};
@@ -16,12 +14,6 @@ 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()
};
@@ -107,7 +99,9 @@ pub fn network_config_from_toml(config: &TomlConfig) -> NetworkConfig {
});
}
if let Some(routes) = config.get_routes() {
if let Some(routes) = config.get_routes()
&& !routes.is_empty()
{
result.enable_manual_routes = Some(true);
result.routes = routes.iter().map(ToString::to_string).collect();
}
@@ -131,12 +125,61 @@ pub fn network_config_from_toml(config: &TomlConfig) -> NetworkConfig {
result.managed_credentials = config
.get_managed_credentials()
.into_iter()
.map(|credential| credential.downgrade())
.map(managed_credential_to_proto)
.collect();
set_network_flags(&mut result, config.get_flags_patch());
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);
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
}
+283 -462
View File
@@ -3,20 +3,13 @@
use std::net::SocketAddr;
use anyhow::Context;
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 easytier_proto::api::manage;
use crate::config::{
MappedListenerPolicy, normalize_secure_mode_config,
toml::{
ConfigLoader, NetworkIdentity, PeerConfig, PortForwardConfig, TomlConfigLoader,
VpnPortalClientConfig, VpnPortalConfig,
ConfigLoader, ManagedCredentialConfig, NetworkIdentity, PeerConfig, PortForwardConfig,
TomlConfigLoader, VpnPortalClientConfig, VpnPortalConfig, gen_default_flags,
},
};
@@ -58,50 +51,32 @@ 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 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_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),
}
}
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(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),
}
}
pub trait NetworkConfigExt {
@@ -121,7 +96,6 @@ const FORM_MANAGED_TOML_FIELDS: &[&str] = &[
"ipv6_public_addr_auto",
"ipv6_public_addr_prefix",
"dhcp",
"dhcpv6",
"network_identity",
"listeners",
"mapped_listeners",
@@ -138,6 +112,47 @@ 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"))
@@ -169,22 +184,7 @@ pub(crate) fn merge_network_config_toml(
.remove("flags")
.and_then(|value| value.try_into().ok())
.unwrap_or_default();
// 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() {
for key in FORM_MANAGED_FLAG_FIELDS {
if let Some(value) = generated_flags.get(*key) {
merged_flags.insert((*key).to_owned(), value.clone());
} else {
@@ -251,18 +251,7 @@ 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(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_dhcp(self.dhcp.unwrap_or_default());
cfg.set_inst_name(self.network_name.clone().unwrap_or_default());
// The web UI does not expose credential inputs directly, but imported/saved
@@ -455,8 +444,8 @@ impl NetworkConfigExt for NetworkConfig {
cfg.set_managed_credentials(
self.managed_credentials
.iter()
.map(|credential| credential.clone().upgrade())
.collect::<Result<Vec<_>, _>>()?,
.map(managed_credential_from_proto)
.collect(),
);
if let Some(credential_secret) = credential_secret {
@@ -476,6 +465,19 @@ 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);
}
@@ -494,59 +496,145 @@ 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()));
}
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()
});
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);
Ok(cfg)
}
@@ -563,12 +651,6 @@ 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());
@@ -653,7 +735,9 @@ impl NetworkConfigExt for NetworkConfig {
});
}
if let Some(routes) = config.get_routes() {
if let Some(routes) = config.get_routes()
&& !routes.is_empty()
{
result.enable_manual_routes = Some(true);
result.routes = routes.iter().map(|r| r.to_string()).collect();
}
@@ -680,11 +764,65 @@ impl NetworkConfigExt for NetworkConfig {
result.managed_credentials = config
.get_managed_credentials()
.into_iter()
.map(|credential| credential.downgrade())
.map(managed_credential_to_proto)
.collect();
set_network_flags(&mut result, config.get_flags_patch());
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);
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)
}
}
@@ -694,7 +832,6 @@ mod tests {
#![allow(deprecated)]
use super::*;
use easytier_proto::common::Flags;
fn api_portal_config() -> manage::VpnPortalConfig {
manage::VpnPortalConfig {
@@ -716,219 +853,6 @@ 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 {
@@ -1045,46 +969,6 @@ 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() {
@@ -1122,74 +1006,11 @@ disable_p2p = true
Some("edited-network")
);
assert_eq!(merged["flags"]["default_protocol"].as_str(), Some("udp"));
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!(
!merged["flags"]
.as_table()
.unwrap()
.contains_key("disable_p2p")
);
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());
}
}
-96
View File
@@ -1,96 +0,0 @@
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))
}
+34 -45
View File
@@ -1,59 +1,48 @@
use serde::{Deserialize, Serialize};
use std::{fmt, str::FromStr};
use strum::VariantArray;
/// Stable configuration vocabulary for every known encryption algorithm.
#[derive(
Debug,
Default,
Clone,
Copy,
PartialEq,
Eq,
VariantArray,
strum::Display,
strum::EnumString,
strum::IntoStaticStr,
strum::AsRefStr,
Serialize,
Deserialize,
)]
#[strum(ascii_case_insensitive)]
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, VariantArray)]
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::*;
+17 -10
View File
@@ -10,15 +10,12 @@ 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] = &[
@@ -51,7 +48,6 @@ 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;
@@ -284,8 +280,6 @@ 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,
@@ -528,15 +522,28 @@ impl From<ForeignNetworkConfig> for pb::ForeignNetworkConfig {
impl From<pb::PeerPolicyConfig> for PeerPolicyConfig {
fn from(value: pb::PeerPolicyConfig) -> Self {
let mut config = Self::default();
config.load(value);
config
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),
}
}
}
impl From<PeerPolicyConfig> for pb::PeerPolicyConfig {
fn from(value: PeerPolicyConfig) -> Self {
value.downgrade()
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),
}
}
}
+4 -4
View File
@@ -6,7 +6,7 @@
use anyhow::Context as _;
use cidr::Ipv6Cidr;
use easytier_proto::common::{Flags, PeerFeatureFlag, SecureModeConfig, StunInfo};
use easytier_proto::common::{FlagsInConfig, 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: Flags,
pub flags: FlagsInConfig,
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: Flags) -> Self {
pub fn new(runtime: PeerRuntimeConfig, flags: FlagsInConfig) -> 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(),
},
Flags::default(),
FlagsInConfig::default(),
)
}
}
+1 -10
View File
@@ -3,7 +3,7 @@
use std::{collections::BTreeSet, sync::Arc};
use arc_swap::ArcSwap;
use cidr::{Ipv4Cidr, Ipv6Cidr};
use cidr::Ipv4Cidr;
use parking_lot::Mutex;
use serde::{Deserialize, Serialize};
@@ -16,12 +16,6 @@ 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>>,
@@ -36,9 +30,6 @@ 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(),
+235 -272
View File
@@ -6,24 +6,172 @@ use std::{
sync::{Arc, Mutex},
};
use super::{DhcpConfig, normalize_secure_mode_config};
use super::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::SecureModeConfig};
use crate::proto::{
acl::Acl,
common::{CompressionAlgoPb, SecureModeConfig},
};
pub const DEFAULT_ET_DNS_ZONE: &str = "et.net.";
pub use crate::proto::common::{Flags, FlagsPatch};
pub type Flags = crate::proto::common::FlagsInConfig;
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;
@@ -53,27 +201,8 @@ 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_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 get_dhcp(&self) -> bool;
fn set_dhcp(&self, dhcp: bool);
fn add_proxy_cidr(
&self,
@@ -102,12 +231,7 @@ 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>);
@@ -362,30 +486,19 @@ 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,
}
@@ -416,8 +529,7 @@ struct Config {
ipv6_public_addr_provider: Option<bool>,
ipv6_public_addr_auto: Option<bool>,
ipv6_public_addr_prefix: Option<String>,
dhcp: Option<DhcpConfig>,
dhcpv6: Option<DhcpConfig<cidr::Ipv6Cidr>>,
dhcp: Option<bool>,
network_identity: Option<NetworkIdentity>,
listeners: Option<Vec<url::Url>>,
mapped_listeners: Option<Vec<url::Url>>,
@@ -436,8 +548,10 @@ struct Config {
secure_mode: Option<SecureModeConfig>,
#[serde(default)]
flags: FlagsPatch,
flags: Option<serde_json::Map<String, serde_json::Value>>,
#[serde(skip)]
flags_struct: Option<Flags>,
acl: Option<Acl>,
@@ -517,6 +631,7 @@ 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
}
@@ -590,6 +705,10 @@ 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()
@@ -626,6 +745,17 @@ 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")]
@@ -745,22 +875,14 @@ impl ConfigLoader for TomlConfig {
prefix.map(|prefix| prefix.to_string());
}
fn get_dhcp_config(&self) -> DhcpConfig {
fn get_dhcp(&self) -> bool {
self.config.lock().unwrap().dhcp.unwrap_or_default()
}
fn set_dhcp_config(&self, dhcp: DhcpConfig) {
fn set_dhcp(&self, dhcp: bool) {
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,
@@ -897,27 +1019,16 @@ impl ConfigLoader for TomlConfig {
}
fn get_flags(&self) -> Flags {
Flags::resolve(self.get_flags_patch())
self.config
.lock()
.unwrap()
.flags_struct
.clone()
.unwrap_or_default()
}
fn set_flags(&self, flags: 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");
self.config.lock().unwrap().flags_struct = Some(flags);
}
fn get_exit_nodes(&self) -> Vec<IpAddr> {
@@ -1093,114 +1204,20 @@ pub type TomlConfigLoader = TomlConfig;
mod tests {
use super::*;
#[test]
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));
}
}
#[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();
let error =
TomlConfig::new_from_str_with_source("fixture.toml", "dhcp = \"yes\"").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())
);
}
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())
);
}
#[cfg(feature = "config-write")]
@@ -1389,9 +1406,37 @@ 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.
@@ -1441,91 +1486,13 @@ 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 = Flags::resolve(FlagsPatch::default());
let mut flags = gen_default_flags();
flags.dev_name = "et_test".to_string();
flags.enable_quic_proxy = true;
flags.disable_tcp_hole_punching = true;
@@ -2006,16 +1973,13 @@ mod diagnostic_compatibility_tests {
#[test]
fn non_ascii_before_typed_error_keeps_byte_location() {
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();
let error = TomlConfig::new_from_str("hostname = \"节点\"\ndhcp = \"yes\"")
.unwrap_err()
.to_string();
assert!(error.contains(&invalid_field));
assert!(error.contains('^'));
assert!(error.contains("invalid DHCP CIDR"));
}
assert!(error.contains("dhcp = \"yes\""));
assert!(error.contains('^'));
assert!(error.contains("invalid type: string"));
}
#[cfg(feature = "rich-config-errors")]
@@ -2032,7 +1996,7 @@ mod diagnostic_compatibility_tests {
}
#[test]
fn flags_parse_error_keeps_source_span_and_cause_chain() {
fn flags_conversion_error_keeps_source_and_cause_chain() {
let error = TomlConfig::new_from_str_with_source(
"flags-fixture.toml",
"[flags]\nsocket_mark = \"bad\"",
@@ -2041,13 +2005,12 @@ mod diagnostic_compatibility_tests {
let display = error.to_string();
assert!(display.contains("flags-fixture.toml"));
assert!(display.contains("failed to parse config TOML"));
assert!(display.contains("socket_mark = \"bad\""));
assert!(display.contains('^'));
assert!(display.contains("failed to load config"));
assert!(display.contains("failed to parse flags"));
assert!(
error
.chain()
.any(|cause| cause.downcast_ref::<toml::de::Error>().is_some())
.any(|cause| cause.to_string().contains("failed to parse flags"))
);
}
}
@@ -331,7 +331,6 @@ 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),
});
@@ -339,7 +338,6 @@ 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, VirtualUdpSocket},
socket::udp::{UdpBindOptions, VirtualUdpSocketFactory},
};
use super::{
@@ -62,29 +62,17 @@ 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(
@@ -108,12 +96,14 @@ 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(local_socket.as_ref(), &punch_packet, remote_mapped_addr).await?;
send_from_local(&udp_array, &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,
@@ -132,27 +122,33 @@ where
}
}));
// 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;
}
}
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));
}
_ = crate::foundation::time::sleep(std::time::Duration::from_millis(200)) => {
send_from_local(local_socket.as_ref(), &punch_packet, remote_mapped_addr).await?;
Err(e) => {
tracing::error!(?e, "failed to connect with socket");
}
}
}
@@ -161,20 +157,17 @@ where
Ok(None)
}
async fn send_from_local<S>(
socket: &S,
async fn send_from_local<R>(
udp_array: &UdpSocketArray<R>,
punch_packet: &[u8],
remote_mapped_addr: SocketAddr,
) -> UdpHolePunchClientResult<()>
where
S: VirtualUdpSocket,
R: VirtualUdpSocketFactory,
{
for _ in 0..3 {
socket
.send_to(punch_packet, remote_mapped_addr)
.await
.map_err(anyhow::Error::from)?;
}
udp_array
.send_with_all(punch_packet, remote_mapped_addr)
.await?;
Ok(())
}
@@ -360,7 +353,6 @@ where
SelectPunchListener {
force_new: false,
prefer_port_mapping: true,
transaction_id: None,
},
)
.await?;
@@ -622,10 +614,6 @@ 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]
@@ -634,35 +622,12 @@ mod tests {
Ok(self.local_addr)
}
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 send_to(&self, _data: &[u8], _addr: SocketAddr) -> io::Result<usize> {
Ok(0)
}
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;
}
async fn recv_from(&self, _buf: &mut [u8]) -> io::Result<(usize, SocketAddr)> {
std::future::pending().await
}
}
@@ -670,8 +635,6 @@ 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 {
@@ -680,8 +643,6 @@ 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,
}
}
}
@@ -693,25 +654,18 @@ 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);
let socket = Arc::new(MockSocket {
Ok(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], 40000)),
mapped_addr: SocketAddr::from(([203, 0, 113, 1], 10000)),
port_mapping_lease: None,
})
}
@@ -732,12 +686,10 @@ mod tests {
async fn connect_with_socket(
&self,
socket: Arc<Self::Socket>,
remote: SocketAddr,
_socket: Arc<Self::Socket>,
_remote: SocketAddr,
) -> anyhow::Result<UdpPunchSocket> {
let layer = Arc::new(crate::socket::udp::UdpSessionLayer::new(socket));
let session = layer.connect(remote).await?;
Ok(UdpPunchSocket::new(session, remote, layer))
unimplemented!("not used by cone client tests")
}
}
@@ -845,8 +797,6 @@ 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>>,
@@ -854,100 +804,9 @@ 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()),
@@ -973,16 +832,10 @@ 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,7 +56,6 @@ 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,
}
}
@@ -64,7 +63,6 @@ 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,
}
}
@@ -72,7 +70,6 @@ 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
@@ -86,7 +83,6 @@ fn select_listener_response_to_rpc(
) -> SelectPunchListenerResponse {
SelectPunchListenerResponse {
listener_mapped_addr: Some(response.listener_mapped_addr.into()),
transaction_id: response.transaction_id,
}
}
@@ -557,62 +553,8 @@ 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);
@@ -626,17 +568,14 @@ 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
);
@@ -644,7 +583,6 @@ 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,10 +17,7 @@ use crate::{
peers::PeerConnectionOrigin,
socket::{
ListenerConnectionCounter, SocketContext,
udp::{
UdpBindOptions, UdpHolePunchObserverRegistry, UdpSession, VirtualUdpSocket,
VirtualUdpSocketFactory,
},
udp::{UdpBindOptions, UdpSession, VirtualUdpSocket, VirtualUdpSocketFactory},
},
tunnel::Tunnel,
};
@@ -76,7 +73,6 @@ 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>,
@@ -92,13 +88,11 @@ 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)]
@@ -35,9 +35,6 @@ 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
@@ -269,16 +266,8 @@ 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,
})
}
@@ -287,12 +276,17 @@ where
request: SendPunchPacketCone,
) -> Result<(), UdpHolePunchSignalError> {
let _admission = self.admit().await?;
let (listener, observer) = self
let listener = self
.common
.observe_hole_punch(&request.listener_mapped_addr, request.transaction_id)
.find_listener(&request.listener_mapped_addr)
.await
.map_err(Self::anyhow_to_signal_error)?;
send_cone_hole_punch_packets(listener, &request, observer)
.ok_or_else(|| {
UdpHolePunchSignalError::RemoteRejected(
"send_punch_packet_for_cone failed to find listener".into(),
)
})?;
send_cone_hole_punch_packets(listener, &request)
.await
.map_err(Self::anyhow_to_signal_error)
}
@@ -392,7 +386,6 @@ 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 {
@@ -480,36 +473,6 @@ 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,
@@ -609,7 +572,6 @@ 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>>,
@@ -633,7 +595,6 @@ where
{
let UdpPunchListener {
socket,
hole_punch_observers,
mapped_addr,
conn_counter,
mut acceptor,
@@ -685,7 +646,6 @@ where
Self {
socket,
hole_punch_observers,
tasks: Mutex::new(tasks),
connection_tasks,
running,
@@ -766,7 +726,6 @@ 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,
@@ -779,47 +738,21 @@ where
);
}
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(), dest_addr).await {
tracing::error!(
?err,
?dest_addr,
"failed to send hole punch packet to dest addr"
);
}
}
};
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;
}
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");
}
} else {
interval.await;
}
crate::foundation::time::sleep(Duration::from_millis(request.packet_interval_ms as u64))
.await;
}
Ok(())
@@ -1038,8 +971,6 @@ 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]
@@ -1057,15 +988,8 @@ mod tests {
Ok(data.len())
}
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;
}
async fn recv_from(&self, _buf: &mut [u8]) -> io::Result<(usize, SocketAddr)> {
std::future::pending().await
}
}
@@ -1082,7 +1006,6 @@ mod tests {
struct MockAcceptor {
sockets: VecDeque<UdpPunchSocket>,
_layer: Arc<crate::socket::udp::UdpSessionLayer<MockSocket>>,
}
#[async_trait]
@@ -1116,8 +1039,6 @@ 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(),
}))
}
@@ -1209,22 +1130,16 @@ 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,
hole_punch_observers: layer.hole_punch_observers(),
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),
}),
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,
}
@@ -1291,8 +1206,6 @@ 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 =
@@ -1318,248 +1231,12 @@ 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)),
@@ -1570,12 +1247,7 @@ mod tests {
packet_interval_ms: 0,
};
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)
send_cone_hole_punch_packets(socket.clone(), &request)
.await
.unwrap();
@@ -1590,8 +1262,6 @@ 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)),
@@ -1602,12 +1272,7 @@ mod tests {
packet_interval_ms: 0,
};
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)
let err = send_cone_hole_punch_packets(socket.clone(), &request)
.await
.unwrap_err();
@@ -1621,8 +1286,6 @@ 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)),
@@ -1633,12 +1296,7 @@ mod tests {
packet_interval_ms: 0,
};
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)
send_cone_hole_punch_packets(socket.clone(), &request)
.await
.unwrap();
@@ -1652,8 +1310,6 @@ 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],
@@ -1679,8 +1335,6 @@ mod tests {
local_addr: SocketAddr::from(([127, 0, 0, 1], 10006)),
sent: tokio::sync::Mutex::new(Vec::new()),
fail_next_send: AtomicUsize::new(1),
incoming: StdMutex::new(VecDeque::new()),
incoming_notify: tokio::sync::Notify::new(),
});
let err = send_symmetric_hole_punch_packet(
+7 -8
View File
@@ -1381,14 +1381,13 @@ mod tests {
#[tokio::test]
async fn timeout_budget_reports_the_active_stage() {
let error = with_timeout_budget(
"connect",
Instant::now(),
Duration::from_millis(1),
std::future::pending::<anyhow::Result<()>>(),
)
.await
.unwrap_err();
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();
assert!(error.to_string().contains("connect timeout after"));
}
-12
View File
@@ -14,15 +14,3 @@ 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
}
+10 -17
View File
@@ -1,7 +1,7 @@
//! Shared data-plane flow registration and ownership.
use std::{
net::SocketAddr,
net::{IpAddr, SocketAddr},
sync::{
Arc,
atomic::{AtomicUsize, Ordering},
@@ -26,16 +26,6 @@ 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,
@@ -146,14 +136,16 @@ impl<V> FlowTable<V> {
self.entries.is_empty()
}
pub(super) fn registration(&self, entry: &FlowKey) -> Option<u64> {
self.entries.get(entry).map(|flow| flow.registration)
}
pub fn contains_key(&self, entry: &FlowKey) -> bool {
self.entries.contains_key(entry)
}
pub fn contains_destination_ip(&self, destination: IpAddr) -> bool {
self.entries
.iter()
.any(|entry| entry.key().dst.ip() == destination)
}
#[cfg(test)]
pub fn with_entry<R>(&self, entry: &FlowKey, f: impl FnOnce(&V) -> R) -> Option<R> {
self.entries.get(entry).map(|value| f(&value.value().value))
@@ -260,7 +252,7 @@ impl<V> FlowTable<V> {
fn increment_count(&self) -> FlowCountChange {
let previous = self
.count
.try_update(Ordering::AcqRel, Ordering::Acquire, |count| {
.fetch_update(Ordering::AcqRel, Ordering::Acquire, |count| {
count.checked_add(1)
})
.expect("flow count overflow");
@@ -302,7 +294,7 @@ impl<V> FlowTable<V> {
let previous = self
.count
.try_update(Ordering::AcqRel, Ordering::Acquire, |count| {
.fetch_update(Ordering::AcqRel, Ordering::Acquire, |count| {
count.checked_sub(delta)
})
.expect("flow count underflow");
@@ -384,6 +376,7 @@ 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);
+50 -74
View File
@@ -42,7 +42,7 @@ use crate::{
},
packet::{PacketType, ZCPacket},
peers::{
PeerPacketFilter, PeerPacketFilterResult,
PeerPacketFilter,
peer_manager::{PeerManagerCore, PipelineRegistrationGuard},
},
socket::{
@@ -52,7 +52,7 @@ use crate::{
VirtualTcpListener, VirtualTcpListenerFactory, VirtualTcpSocket,
VirtualTcpSocketFactory,
},
udp::VirtualUdpSocketFactory,
udp::{UdpBindOptions, VirtualUdpSocket, VirtualUdpSocketFactory},
},
};
@@ -69,9 +69,6 @@ mod tcp;
#[cfg(test)]
mod tests;
mod udp;
mod udp_fragments;
use udp_fragments::UdpFragments;
use self::{
deadline::{DataPlaneDeadline, DataPlaneIoDeadline},
@@ -172,6 +169,7 @@ pub(super) enum FlowData {
Udp,
}
const UDP_ENTRY: FlowKind = FlowKind::Udp;
const TCP_ENTRY: FlowKind = FlowKind::Tcp;
const TCP_LISTEN_ENTRY: FlowKind = FlowKind::TcpListen;
@@ -197,7 +195,6 @@ 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.
@@ -210,8 +207,8 @@ impl<H> PeerPacketFilter for DataPlaneRuntime<H>
where
H: VirtualTcpSocketFactory + VirtualTcpListenerFactory + VirtualUdpSocketFactory,
{
async fn try_process_packet_from_peer(&self, packet: ZCPacket) -> PeerPacketFilterResult {
if self.entries.is_idle() && !packet::is_udp_fragment(&packet) {
async fn try_process_packet_from_peer(&self, packet: ZCPacket) -> Option<ZCPacket> {
if self.entries.is_idle() {
if tracing::enabled!(tracing::Level::TRACE)
&& let Some(hdr) = packet.peer_manager_header()
&& matches!(
@@ -260,11 +257,11 @@ where
);
}
}
return PeerPacketFilterResult::Pass(packet);
return Some(packet);
}
let route = self.entries.route_peer_packet(&packet, true);
let (entry_key, tcp_flags) = match route {
PeerPacketRoute::Pass => return PeerPacketFilterResult::Pass(packet),
PeerPacketRoute::Pass => return Some(packet),
PeerPacketRoute::Unmatched { entry, tcp_flags } => {
tracing::trace!(
entry_key = ?entry,
@@ -274,20 +271,34 @@ where
entry_count = self.entries.count(),
"data plane has no flow for packet from peer"
);
return PeerPacketFilterResult::Pass(packet);
return Some(packet);
}
PeerPacketRoute::Deliver { entry, tcp_flags } => (entry, tcp_flags),
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 self.udp_fragments.lock().unwrap().process(
packet,
local_ip,
&self.entries,
&self.packet_sender,
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"
),
}
}
return Some(packet);
}
};
@@ -316,7 +327,7 @@ where
),
}
PeerPacketFilterResult::Consumed
None
}
}
@@ -348,7 +359,6 @@ 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,
@@ -382,21 +392,12 @@ 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 {
@@ -428,12 +429,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;
@@ -486,7 +487,6 @@ where
select! {
_ = peer_changes.changed() => {}
_ = fragment_gc.tick() => {}
_ = data_plane_consumers.changed() => {}
}
}
@@ -549,7 +549,6 @@ 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) {
@@ -944,44 +943,19 @@ 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?;
// 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 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);
let smol = generation
.while_open(deadline.run(smoltcp_net.udp_bind(bind_addr)))
.await?;
@@ -991,8 +965,10 @@ where
Ok(DataPlaneUdpSocket {
socket,
_bind_flow: bind_flow,
flows: self.entries.clone(),
routes: std::sync::Mutex::new(std::collections::HashMap::new()),
local_addr,
_reservation: reservation,
_data_plane_lease: data_plane_ref,
generation,
})
+46 -73
View File
@@ -4,7 +4,10 @@ use std::net::{IpAddr, Ipv4Addr, SocketAddr};
use smoltcp::wire::{IPV4_HEADER_LEN, IpProtocol, Ipv4Packet, TcpPacket, UdpPacket};
use crate::packet::{PacketType, ZCPacket};
use crate::{
gateway::proxy::ip_reassembler::IpReassembler,
packet::{PacketType, ZCPacket},
};
use super::flow::{FlowKey, FlowKind, FlowTable};
@@ -19,7 +22,7 @@ enum ClassifiedPeerPacket {
entry: FlowKey,
},
FragmentedUdp {
destination: Ipv4Addr,
source: Ipv4Addr,
},
Unsupported,
}
@@ -35,7 +38,8 @@ pub(crate) enum PeerPacketRoute {
tcp_flags: Option<u8>,
},
FragmentedUdp {
destination: Ipv4Addr,
source: Ipv4Addr,
mirror: bool,
},
}
fn classify_peer_ipv4_payload(payload: &[u8]) -> ClassifiedPeerPacket {
@@ -68,9 +72,9 @@ fn classify_peer_ipv4_payload(payload: &[u8]) -> ClassifiedPeerPacket {
}
}
IpProtocol::Udp => {
if ipv4.frag_offset() != 0 || ipv4.more_frags() {
if IpReassembler::is_packet_fragmented(&ipv4) {
return ClassifiedPeerPacket::FragmentedUdp {
destination: ipv4.dst_addr(),
source: ipv4.src_addr(),
};
}
let Ok(udp) = UdpPacket::new_checked(ipv4.payload()) else {
@@ -88,34 +92,6 @@ 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)
@@ -132,7 +108,18 @@ impl<V> FlowTable<V> {
packet: &ZCPacket,
allow_tcp_listen_fallback: bool,
) -> PeerPacketRoute {
if !is_data_plane_packet(packet) {
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 {
return PeerPacketRoute::Pass;
}
@@ -157,9 +144,12 @@ impl<V> FlowTable<V> {
};
(entry, Some(flags))
}
ClassifiedPeerPacket::Udp { entry } => (FlowKey::udp_bind(entry.src), None),
ClassifiedPeerPacket::FragmentedUdp { destination } => {
return PeerPacketRoute::FragmentedUdp { destination };
ClassifiedPeerPacket::Udp { entry } => (entry, None),
ClassifiedPeerPacket::FragmentedUdp { source } => {
return PeerPacketRoute::FragmentedUdp {
source,
mirror: self.contains_destination_ip(source.into()),
};
}
ClassifiedPeerPacket::Unsupported => return PeerPacketRoute::Pass,
};
@@ -244,7 +234,7 @@ mod tests {
assert_eq!(
classify_peer_ipv4_payload(&fragmented),
ClassifiedPeerPacket::FragmentedUdp {
destination: Ipv4Addr::new(10, 2, 2, 3),
source: Ipv4Addr::new(10, 1, 1, 2),
}
);
}
@@ -324,51 +314,34 @@ mod tests {
);
}
#[test]
fn flow_table_routes_udp_by_bound_port() {
fn flow_table_routes_fragmented_udp_by_source_ip() {
let mut packet = ipv4_packet(IpProtocol::Udp, 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);
Ipv4Packet::new_unchecked(&mut packet).set_frag_offset(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::Unmatched {
entry: entry.clone(),
tcp_flags: None
PeerPacketRoute::FragmentedUdp {
source: Ipv4Addr::new(10, 1, 1, 2),
mirror: false,
}
);
table.insert(entry.clone(), ());
table.insert(
FlowKey {
src: "10.2.2.3:4321".parse().unwrap(),
dst: "10.1.1.2:1234".parse().unwrap(),
kind: FlowKind::Udp,
},
(),
);
assert_eq!(
table.route_peer_ipv4_payload(&packet, false),
PeerPacketRoute::Deliver {
entry,
tcp_flags: None
PeerPacketRoute::FragmentedUdp {
source: Ipv4Addr::new(10, 1, 1, 2),
mirror: true,
}
);
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() {
+1 -9
View File
@@ -77,9 +77,7 @@ impl SmoltcpPlane {
tracing::debug!("smoltcp-to-peer bridge stopped");
});
// 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 interface_config = smoltcp::iface::Config::new(smoltcp::wire::HardwareAddress::Ip);
let net = Net::new(
device,
NetConfig::new(
@@ -119,12 +117,6 @@ 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()
}
+131 -363
View File
@@ -1,6 +1,6 @@
use std::net::{IpAddr, Ipv4Addr, SocketAddr};
use smoltcp::wire::{IpAddress, IpProtocol, Ipv4Packet, TcpPacket, UdpPacket};
use smoltcp::wire::{IpAddress, IpProtocol, Ipv4Packet, TcpPacket};
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use super::*;
@@ -36,7 +36,6 @@ 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),
@@ -197,45 +196,26 @@ fn build_tcp_packet(src: SocketAddr, dst: SocketAddr) -> Vec<u8> {
buf
}
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);
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];
fn build_udp_followup_fragment(src: Ipv4Addr, dst: Ipv4Addr) -> Vec<u8> {
let mut buf = vec![0u8; 28];
{
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));
let mut ip_packet = Ipv4Packet::new_unchecked(&mut buf);
ip_packet.set_version(4);
ip_packet.set_header_len(20);
ip_packet.set_total_len(28);
ip_packet.set_hop_limit(64);
ip_packet.set_next_header(IpProtocol::Udp);
ip_packet.set_frag_offset(8);
ip_packet.set_src_addr(src);
ip_packet.set_dst_addr(dst);
ip_packet
.payload_mut()
.copy_from_slice(&[0xde, 0xad, 0xbe, 0xef, 0xca, 0xfe, 0xba, 0xbe]);
ip_packet.fill_checksum();
}
[(&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
})
buf
}
#[tokio::test]
@@ -460,6 +440,18 @@ 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())
@@ -549,39 +541,37 @@ 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 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;
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;
}
}
// 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;
}
@@ -591,125 +581,60 @@ 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();
send_udp(&socket_a, &socket_b, &vec![0x5a; 65_507]).await;
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);
stop_data_plane_pair(&a, &b).await;
}
#[tokio::test]
async fn udp_fixed_ports_are_owned_by_each_instance_until_socket_drop() {
async fn udp_socket_drop_releases_every_destination_flow() {
let (a, b) = setup_data_plane_pair().await;
let timeout = Duration::from_secs(10);
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);
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);
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_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() {
async fn udp_socket_owns_a_host_port_reservation() {
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);
drop((fixed, socket));
gateway.stop_runtime().await;
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;
endpoint.peer_manager.clear_resources().await;
}
@@ -908,7 +833,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!(matches!(result, PeerPacketFilterResult::Consumed));
assert!(result.is_none());
let mut receiver = gateway.packet_recv.lock().await;
let received = receiver.try_recv().unwrap();
@@ -939,12 +864,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!(matches!(result, PeerPacketFilterResult::Pass(_)));
assert!(result.is_some());
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!(matches!(result, PeerPacketFilterResult::Pass(_)));
assert!(result.is_some());
let mut receiver = gateway.packet_recv.lock().await;
assert!(receiver.try_recv().is_err());
@@ -972,204 +897,47 @@ 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!(matches!(result, PeerPacketFilterResult::Pass(_)));
assert!(result.is_some());
let mut receiver = gateway.packet_recv.lock().await;
assert!(receiver.try_recv().is_err());
}
#[tokio::test]
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() {
async fn data_plane_mirrors_fragmented_udp_when_entry_matches() {
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());
});
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());
}
}
#[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(),
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());
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
);
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;
}
+28 -3
View File
@@ -1,13 +1,23 @@
//! UDP socket resources exposed by the data plane.
use std::{net::SocketAddr, sync::Arc};
use std::{
any::Any,
collections::{HashMap, hash_map::Entry},
net::SocketAddr,
sync::{Arc, Mutex},
};
use super::{DataPlaneIoGuard, DataPlaneLease, DataPlaneUdpIo, FlowData, FlowLease};
use super::{
DataPlaneIoGuard, DataPlaneLease, DataPlaneUdpIo, FlowData, FlowKey, FlowLease, FlowSet,
UDP_ENTRY,
};
pub struct DataPlaneUdpSocket {
pub(super) socket: Arc<DataPlaneUdpIo>,
pub(super) _bind_flow: FlowLease<FlowData>,
pub(super) flows: FlowSet,
pub(super) routes: Mutex<HashMap<SocketAddr, 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,
}
@@ -21,6 +31,21 @@ 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()),
@@ -1,381 +0,0 @@
//! 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);
}
}
}
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -27,6 +27,6 @@ pub use dataplane::{
DataPlaneTcpListener, DataPlaneTcpStream, DataPlaneUdpSocket,
};
#[cfg(feature = "proxy-smoltcp-stack")]
pub(crate) use port_forward::{PortForwardAdapter, PreparedPortForwards};
pub(crate) use port_forward::PortForwardAdapter;
#[cfg(feature = "proxy-smoltcp-stack")]
pub(crate) use socks5::Socks5GatewayAdapter;
+93 -308
View File
@@ -1,7 +1,6 @@
//! Host port-forward adapter backed by the data-plane runtime.
use std::{
collections::HashMap,
net::SocketAddr,
sync::{
Arc,
@@ -16,11 +15,11 @@ use quanta::Instant;
use tokio::{
select,
sync::{Mutex, OwnedMutexGuard, OwnedSemaphorePermit, Semaphore},
task::{JoinHandle, JoinSet},
task::JoinSet,
};
use tokio_util::{
sync::CancellationToken,
task::{AbortOnDropHandle, TaskTracker},
sync::{CancellationToken, DropGuard},
task::AbortOnDropHandle,
};
use crate::{
@@ -95,133 +94,24 @@ 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(&current, &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(&current, &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: Arc<Mutex<()>>,
operation: 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<()>>>,
listeners: std::sync::Mutex<HashMap<(String, SocketAddr), PortForwardListener>>,
cancel_tokens: Arc<DashMap<PortForwardConfig, DropGuard>>,
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: std::sync::Mutex<Option<DataPlaneConsumerLease>>,
consumer_lease: Mutex<Option<DataPlaneConsumerLease>>,
}
impl<H> PortForwardAdapter<H>
@@ -234,29 +124,27 @@ where
host: Arc<H>,
socket_context: SocketContext,
events: Arc<dyn CoreEventSink>,
runtime_handle: Option<tokio::runtime::Handle>,
) -> Arc<Self> {
Arc::new(Self {
operation: Arc::new(Mutex::new(())),
operation: 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())),
listeners: std::sync::Mutex::new(HashMap::new()),
cancel_tokens: Arc::new(DashMap::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: std::sync::Mutex::new(None),
consumer_lease: Mutex::new(None),
})
}
pub(crate) async fn start(self: &Arc<Self>) -> anyhow::Result<()> {
let operation = self.operation.clone().lock_owned().await;
pub(crate) async fn start(&self) -> anyhow::Result<()> {
let _operation = self.operation.lock().await;
if self.started.load(Ordering::Acquire) {
return Ok(());
}
@@ -272,157 +160,95 @@ where
.gateway
.port_forwards
.clone();
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();
if let Err(error) = self.apply_port_forwards(&cfgs).await {
self.stop_inner().await;
return Err(error);
}
self.started.store(true, Ordering::Release);
let _ = cleanup.await;
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.prepare_on_runtime(cfgs).await
}
async fn prepare_on_runtime(
self: &Arc<Self>,
cfgs: &[PortForwardConfig],
) -> anyhow::Result<Option<PreparedPortForwards<H>>> {
let operation = self.operation.clone().lock_owned().await;
pub(crate) async fn reload(&self, cfgs: &[PortForwardConfig]) -> anyhow::Result<()> {
let _operation = self.operation.lock().await;
if !self.started.load(Ordering::Acquire) {
return Ok(None);
return Ok(());
}
self.prepare_inner(cfgs, operation).await.map(Some)
self.apply_port_forwards(cfgs).await
}
async fn prepare_inner(
self: &Arc<Self>,
cfgs: &[PortForwardConfig],
operation: OwnedMutexGuard<()>,
) -> anyhow::Result<PreparedPortForwards<H>> {
let mut configs = HashMap::new();
async fn apply_port_forwards(&self, cfgs: &[PortForwardConfig]) -> anyhow::Result<()> {
for cfg in cfgs {
let mut cfg = cfg.clone();
cfg.proto.make_ascii_lowercase();
if !matches!(cfg.proto.as_str(), "tcp" | "udp") {
if !matches!(cfg.proto.to_lowercase().as_str(), "tcp" | "udp") {
anyhow::bail!(
"unsupported protocol: {}, only support udp / tcp",
cfg.proto
);
}
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
);
}
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 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();
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));
for cfg in cfgs {
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 !self.cancel_tokens.contains_key(cfg) {
self.add_port_forward(cfg.clone()).await?;
}
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));
}
Ok(PreparedPortForwards {
adapter: self.clone(),
operation,
configs,
sockets,
consumer_lease,
})
if cfgs.is_empty() {
self.consumer_lease.lock().await.take();
}
Ok(())
}
fn tcp_listen_options(&self, bind_addr: SocketAddr) -> TcpListenOptions {
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
)
}
}
self.events.emit(CoreEvent::GatewayPortForwardAdded(cfg));
Ok(())
}
async fn add_tcp_port_forward(&self, cfg: &PortForwardConfig) -> anyhow::Result<()> {
let (bind_addr, dst_addr) = (cfg.bind_addr, cfg.dst_addr);
let options = TcpListenOptions::port_forward(bind_addr);
let bind = options
.bind
.clone()
.with_context(self.socket_context.clone());
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);
let options = options.with_bind(bind);
let listener = self.host.bind_tcp(options.clone()).await?;
tracing::info!(?bind_addr, ?dst_addr, "TCP port-forward listener bound");
let cancel = CancellationToken::new();
let config = Arc::new(std::sync::Mutex::new(cfg.clone()));
let task_cancel = cancel.clone();
let task_config = config.clone();
self.cancel_tokens
.insert(cfg.clone(), cancel.clone().drop_guard());
let data_plane = self.data_plane.clone();
let connections = Arc::new(std::sync::Mutex::new(JoinSet::new()));
@@ -431,8 +257,7 @@ where
"TCP port-forward connections",
));
let host = self.host.clone();
let task = AbortOnDropHandle::new(tokio::spawn(async move {
let cancel = task_cancel;
self.tasks.lock().unwrap().spawn(async move {
let mut listener = Some(listener);
loop {
let Some((incoming, source_addr)) =
@@ -440,7 +265,6 @@ where
else {
break;
};
let dst_addr = task_config.lock().unwrap().dst_addr;
tracing::info!(
?bind_addr,
?source_addr,
@@ -475,27 +299,22 @@ where
?dst_addr,
"TCP port-forward listener task stopped"
);
}));
PortForwardListener {
config,
cancel,
task,
udp_tasks: TaskTracker::new(),
}
});
Ok(())
}
fn add_udp_port_forward(
&self,
cfg: &PortForwardConfig,
socket: Arc<<H as VirtualUdpSocketFactory>::Socket>,
) -> PortForwardListener {
let bind_addr = cfg.bind_addr;
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?;
let cancel = CancellationToken::new();
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();
self.cancel_tokens
.insert(cfg.clone(), cancel.clone().drop_guard());
let data_plane = self.data_plane.clone();
let host = self.host.clone();
@@ -504,8 +323,7 @@ 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();
let task = AbortOnDropHandle::new(tokio::spawn(async move {
let cancel = task_cancel;
self.tasks.lock().unwrap().spawn(async move {
let adapter = UdpFlowFactory {
data_plane,
host,
@@ -524,8 +342,6 @@ where
}
},
};
let forward = task_config.lock().unwrap().clone();
let dst_addr = forward.dst_addr;
let key = UdpClientKey {
client_addr,
forward: forward.clone(),
@@ -560,13 +376,6 @@ 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(),
@@ -581,7 +390,7 @@ where
let response_slot = slot;
response_tasks.insert(
key.clone(),
AbortOnDropHandle::new(response_tracker.spawn(async move {
AbortOnDropHandle::new(tokio::spawn(async move {
let _slot = response_slot;
let mut buf = vec![0u8; MAX_UDP_PAYLOAD_SIZE];
loop {
@@ -608,7 +417,6 @@ where
}
})),
);
drop(current);
drop(admission);
client
}
@@ -618,14 +426,9 @@ where
tracing::error!(?error, ?dst_addr, "send UDP data-plane packet failed");
}
}
}));
});
PortForwardListener {
config,
cancel,
task,
udp_tasks,
}
Ok(())
}
fn start_udp_reaper(&self) {
@@ -647,29 +450,14 @@ 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 = listeners.len(),
forward_count = self.cancel_tokens.len(),
"port-forward adapter stopping"
);
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.cancel_tokens.clear();
self.udp_response_tasks.clear();
self.udp_clients.clear();
for tracker in trackers {
tracker.close();
tracker.wait().await;
}
self.consumer_lease.lock().unwrap().take();
self.consumer_lease.lock().await.take();
let mut tasks = {
let mut tasks = self.tasks.lock().unwrap();
std::mem::replace(&mut *tasks, JoinSet::new())
@@ -833,9 +621,6 @@ where
}
}
#[cfg(test)]
mod reload_tests;
#[cfg(test)]
mod tests {
use std::{io, sync::atomic::AtomicUsize};
@@ -1,290 +0,0 @@
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, PeerPacketFilterResult,
PeerPacketFilter,
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) -> PeerPacketFilterResult {
async fn try_process_packet_from_peer(&self, packet: ZCPacket) -> Option<ZCPacket> {
let Some(service) = self.service.upgrade() else {
return PeerPacketFilterResult::Pass(packet);
return Some(packet);
};
service.handle_peer_packet(packet).await.into()
service.handle_peer_packet(packet).await
}
}
@@ -186,7 +186,7 @@ impl TcpProxyEngine {
for _ in 0..u16::MAX {
let translated_port = self
.next_translated_port
.try_update(Ordering::Relaxed, Ordering::Relaxed, |port| {
.fetch_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, PeerPacketFilterResult,
NicPacketFilter, PeerPacketFilter,
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) -> PeerPacketFilterResult {
async fn try_process_packet_from_peer(&self, packet: ZCPacket) -> Option<ZCPacket> {
let Some(service) = self.service.upgrade() else {
return PeerPacketFilterResult::Pass(packet);
return Some(packet);
};
service.handle_peer_packet(packet).await.into()
service.handle_peer_packet(packet).await
}
}
@@ -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) -> PeerPacketFilterResult {
async fn try_process_packet_from_peer(&self, packet: ZCPacket) -> Option<ZCPacket> {
let Some(service) = self.service.upgrade() else {
return PeerPacketFilterResult::Pass(packet);
return Some(packet);
};
service.handle_peer_packet(packet).await.into()
service.handle_peer_packet(packet).await
}
}
@@ -12,7 +12,7 @@ use crate::{
listener::RunningListenerRegistry,
packet::{PacketType, ZCPacket, ZCPacketType},
peers::{
PeerPacketFilter, PeerPacketFilterResult,
PeerPacketFilter,
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) -> PeerPacketFilterResult {
async fn try_process_packet_from_peer(&self, packet: ZCPacket) -> Option<ZCPacket> {
let Some(header) = packet.peer_manager_header() else {
return PeerPacketFilterResult::Pass(packet);
return Some(packet);
};
if header.packet_type != self.transport.incoming_packet_type(self.role) as u8 {
return PeerPacketFilterResult::Pass(packet);
return Some(packet);
}
let Some(engine) = self.engine.upgrade() else {
return PeerPacketFilterResult::Pass(packet);
return Some(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"
);
}
PeerPacketFilterResult::Consumed
None
}
}
@@ -797,10 +797,7 @@ mod tests {
let mut packet = ZCPacket::new_with_payload(b"payload");
packet.fill_peer_manager_hdr(7, 1, PacketType::KcpDst as u8);
assert!(matches!(
filter.try_process_packet_from_peer(packet).await,
PeerPacketFilterResult::Consumed
));
assert!(filter.try_process_packet_from_peer(packet).await.is_none());
assert_eq!(
*engine.injections.lock().unwrap(),
[(
@@ -814,10 +811,7 @@ 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!(matches!(
filter.try_process_packet_from_peer(packet).await,
PeerPacketFilterResult::Pass(_)
));
assert!(filter.try_process_packet_from_peer(packet).await.is_some());
}
#[test]
@@ -82,9 +82,6 @@ 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)
@@ -122,44 +119,18 @@ impl Net {
Net {
reactor: Arc::new(reactor),
ip_addr: config.ip_addr,
from_port: AtomicU16::new(Self::EPHEMERAL_PORT_START + 1),
from_port: AtomicU16::new(10001),
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
.try_update(Ordering::SeqCst, Ordering::SeqCst, |port| {
Some(Self::next_port(port))
.fetch_update(Ordering::SeqCst, Ordering::SeqCst, |x| {
Some(if x > 60000 { 10000 } else { x + 1 })
})
.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);
@@ -217,36 +188,3 @@ 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);
}
}
+7 -58
View File
@@ -6,12 +6,11 @@
//! accept, UDP sockets never receive, and DNS answers only literal IP hosts.
use std::{
collections::HashMap,
io,
net::{IpAddr, SocketAddr},
pin::Pin,
sync::{
Arc, Mutex,
Arc,
atomic::{AtomicUsize, Ordering},
},
task::{Context, Poll},
@@ -71,16 +70,11 @@ 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);
}
}
}
@@ -93,12 +87,6 @@ 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
}
}
@@ -120,8 +108,6 @@ impl VirtualUdpSocket for TestUdpSocket {
}
}
pub type TestTcpListeners = HashMap<SocketAddr, tokio::sync::mpsc::UnboundedSender<TestTcpSocket>>;
#[derive(Default)]
pub struct TestHost {
pub tcp_binds: AtomicUsize,
@@ -129,42 +115,21 @@ 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> {
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"),
if options.purpose != TcpSocketPurpose::ProxyNat {
anyhow::bail!("test host does not connect non-proxy TCP sockets");
}
.as_ref()
.ok_or_else(|| anyhow::anyhow!("test host TCP connections are disabled"))?;
let connections = self
.proxy_nat_connections
.as_ref()
.ok_or_else(|| anyhow::anyhow!("test host proxy NAT is disabled"))?;
let (socket, peer) = tokio::io::duplex(1024);
connections
.send((options.remote_addr, peer))
@@ -194,26 +159,10 @@ 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,7 +8,6 @@ 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");
@@ -29,7 +28,6 @@ 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(),
+8 -35
View File
@@ -210,20 +210,14 @@ impl CoreInstanceConfig {
network_name: network_name.clone(),
},
routes: RouteConfig {
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(),
}),
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(),
}),
proxy_networks: proxy_networks
.into_iter()
.map(|proxy| ProxyNetworkConfig {
@@ -302,9 +296,6 @@ 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 {
@@ -416,24 +407,6 @@ 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(
+15 -47
View File
@@ -6,8 +6,8 @@
use std::sync::{Arc, Weak};
#[cfg(any(feature = "dhcp-ipv4", feature = "dhcp-ipv6"))]
use crate::gateway::dhcp::{DhcpHost, DhcpRouteSource};
#[cfg(feature = "dhcp-ipv4")]
use crate::gateway::dhcp::{DhcpIpv4Host, DhcpIpv4RouteSource};
use super::{CoreInstance, CoreInstanceHost, CoreInstanceState};
@@ -66,16 +66,16 @@ where
}
}
#[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 {
#[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 {
return Ok(());
}
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
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
.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(any(feature = "dhcp-ipv4", feature = "dhcp-ipv6"))]
self.start_dhcp(dhcp_host).await?;
#[cfg(not(any(feature = "dhcp-ipv4", feature = "dhcp-ipv6")))]
#[cfg(feature = "dhcp-ipv4")]
self.start_dhcp_ipv4(dhcp_host).await?;
#[cfg(not(feature = "dhcp-ipv4"))]
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(any(feature = "dhcp-ipv4", feature = "dhcp-ipv6"))]
self.dhcp.stop().await;
#[cfg(feature = "dhcp-ipv4")]
self.dhcp_ipv4.stop().await;
#[cfg(feature = "proxy-cidr-monitor")]
self.proxy_cidr_monitor.stop().await;
if let Some(listener) = &self.listener {
@@ -216,25 +216,8 @@ 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 {
@@ -277,21 +260,6 @@ 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;
+3 -3
View File
@@ -479,9 +479,9 @@ where
}
pub fn data_plane_runtime_handle(&self, instance_id: &Uuid) -> Option<tokio::runtime::Handle> {
self.instance(*instance_id)?
.runtime_handle()
.or_else(|| self.runtime_handle.clone())
self.instance(*instance_id)?;
self.runtime_handle
.clone()
.or_else(|| tokio::runtime::Handle::try_current().ok())
}
+21 -59
View File
@@ -58,7 +58,7 @@ use crate::{
},
},
events::CoreEventSink,
gateway::dhcp::DhcpHost,
gateway::dhcp::DhcpIpv4Host,
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(any(feature = "dhcp-ipv4", feature = "dhcp-ipv6"))]
use crate::gateway::dhcp::DhcpRuntime;
#[cfg(feature = "dhcp-ipv4")]
use crate::gateway::dhcp::DhcpIpv4Runtime;
#[cfg(feature = "proxy-cidr-monitor")]
use crate::gateway::proxy::cidr_monitor::ProxyCidrMonitorRuntime;
#[cfg(feature = "proxy-packet")]
@@ -229,16 +229,10 @@ 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 DhcpHost>>>;
) -> anyhow::Result<Option<Arc<dyn DhcpIpv4Host>>>;
async fn shutdown(&self);
@@ -276,7 +270,7 @@ impl InstanceRuntimeHost for () {
async fn prepare(
&self,
_packet_plane: Arc<CorePacketPlane>,
) -> anyhow::Result<Option<Arc<dyn DhcpHost>>> {
) -> anyhow::Result<Option<Arc<dyn DhcpIpv4Host>>> {
Ok(None)
}
@@ -435,8 +429,8 @@ where
packet_proxy: Arc<CoreProxyModule<H>>,
#[cfg(feature = "proxy-cidr-monitor")]
proxy_cidr_monitor: ProxyCidrMonitorRuntime,
#[cfg(any(feature = "dhcp-ipv4", feature = "dhcp-ipv6"))]
dhcp: DhcpRuntime,
#[cfg(feature = "dhcp-ipv4")]
dhcp_ipv4: DhcpIpv4Runtime,
pub(super) packet_egress: Arc<dyn PacketEgressHost>,
pub(super) packet_receiver: Mutex<Option<HostPacketReceiver>>,
pub(super) peer_center: Arc<PeerCenterInstance>,
@@ -789,7 +783,6 @@ 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
@@ -884,8 +877,8 @@ where
packet_proxy,
#[cfg(feature = "proxy-cidr-monitor")]
proxy_cidr_monitor,
#[cfg(any(feature = "dhcp-ipv4", feature = "dhcp-ipv6"))]
dhcp: DhcpRuntime::new(),
#[cfg(feature = "dhcp-ipv4")]
dhcp_ipv4: DhcpIpv4Runtime::new(),
packet_egress,
packet_receiver: Mutex::new(Some(packet_rx)),
peer_center,
@@ -936,57 +929,26 @@ 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, 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?;
let published = self.peer_manager.update_runtime_config(config).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));
if let Some(cleanup) = cleanup {
let _ = cleanup.await;
}
#[cfg(feature = "proxy-smoltcp-stack")]
self.port_forward_adapter
.reload(
&self
.runtime_config
.snapshot()
.services
.gateway
.port_forwards,
)
.await?;
Ok(())
}
+5 -556
View File
@@ -628,57 +628,6 @@ 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();
@@ -884,88 +833,6 @@ 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() {
@@ -1465,7 +1332,7 @@ virtual_ip = "10.82.0.2/24"
async fn prepare(
&self,
_packet_plane: Arc<CorePacketPlane>,
) -> anyhow::Result<Option<Arc<dyn DhcpHost>>> {
) -> anyhow::Result<Option<Arc<dyn DhcpIpv4Host>>> {
Ok(None)
}
@@ -1499,7 +1366,7 @@ virtual_ip = "10.82.0.2/24"
async fn prepare(
&self,
_packet_plane: Arc<CorePacketPlane>,
) -> anyhow::Result<Option<Arc<dyn DhcpHost>>> {
) -> anyhow::Result<Option<Arc<dyn DhcpIpv4Host>>> {
anyhow::bail!("host prepare failed")
}
@@ -1610,7 +1477,7 @@ virtual_ip = "10.82.0.2/24"
async fn prepare(
&self,
_packet_plane: Arc<CorePacketPlane>,
) -> anyhow::Result<Option<Arc<dyn DhcpHost>>> {
) -> anyhow::Result<Option<Arc<dyn DhcpIpv4Host>>> {
self.prepare_started.notify_one();
self.prepare_release.notified().await;
Ok(None)
@@ -1646,162 +1513,6 @@ 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() {
@@ -1831,7 +1542,7 @@ virtual_ip = "10.82.0.2/24"
async fn prepare(
&self,
_packet_plane: Arc<CorePacketPlane>,
) -> anyhow::Result<Option<Arc<dyn DhcpHost>>> {
) -> anyhow::Result<Option<Arc<dyn DhcpIpv4Host>>> {
Ok(None)
}
@@ -2121,7 +1832,7 @@ virtual_ip = "10.82.0.2/24"
async fn prepare(
&self,
_packet_plane: Arc<CorePacketPlane>,
) -> anyhow::Result<Option<Arc<dyn DhcpHost>>> {
) -> anyhow::Result<Option<Arc<dyn DhcpIpv4Host>>> {
Ok(None)
}
@@ -2981,268 +2692,6 @@ 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,11 +5,10 @@ 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},
@@ -51,25 +50,9 @@ 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 {
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_port_forwards(&candidate, patch.port_forwards);
validate_persist_and_commit_candidate(instance, &config, &candidate, persistence).await?;
result?;
let result = patch_acl(&candidate, patch.acl);
validate_persist_and_commit_candidate(instance, &config, &candidate, persistence).await?;
@@ -79,7 +62,7 @@ where
validate_persist_and_commit_candidate(instance, &config, &candidate, persistence).await?;
result?;
let result = patch_routes(&candidate, patch.routes, patch.enable_manual_routes);
let result = patch_routes(&candidate, patch.routes);
validate_persist_and_commit_candidate(instance, &config, &candidate, persistence).await?;
result?;
@@ -107,16 +90,19 @@ where
{
candidate.set_ipv4(Some(ipv4.into()));
}
if let Some(ipv6) = patch.ipv6
&& !candidate.get_dhcpv6()
{
if let Some(ipv6) = patch.ipv6 {
candidate.set_ipv6(Some(ipv6.into()));
}
candidate.patch_flags(FlagsPatch {
disable_relay_data: patch.disable_relay_data,
prefer_peer_relay: patch.prefer_peer_relay,
..Default::default()
});
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);
}
if let Some(enabled) = patch.ipv6_public_addr_provider {
candidate.set_ipv6_public_addr_provider(enabled);
provider_config_changed = true;
@@ -202,8 +188,8 @@ where
let entries = managed
.entries
.iter()
.map(|credential| credential.clone().upgrade())
.collect::<Result<Vec<_>, _>>()?;
.map(managed_credential_from_proto)
.collect::<Vec<_>>();
let replacement = credential_manager
.validate_managed_credentials(&entries)
.map_err(anyhow::Error::msg)?;
@@ -471,25 +457,15 @@ fn patch_proxy_networks(
Ok(())
}
fn patch_routes(
config: &TomlConfig,
patches: Vec<RoutePatch>,
enable_manual_routes: Option<bool>,
) -> anyhow::Result<()> {
if patches.is_empty() && enable_manual_routes.is_none() {
fn patch_routes(config: &TomlConfig, patches: Vec<RoutePatch>) -> anyhow::Result<()> {
if patches.is_empty() {
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);
// 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),
);
config.set_routes((!current.is_empty()).then_some(current));
Ok(())
}
@@ -60,7 +60,6 @@ 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,7 +435,6 @@ fn build_heartbeat_request(
.map(Into::into)
.collect(),
support_heartbeat_policy: true,
support_manual_routes_patch: true,
}
}
@@ -749,8 +748,6 @@ mod tests {
vec![failed]
);
assert!(request.support_heartbeat_policy);
assert!(request.support_manual_routes_patch);
assert!(!HeartbeatRequest::default().support_manual_routes_patch);
}
#[test]
+1 -2
View File
@@ -17,7 +17,6 @@ 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},
@@ -58,7 +57,7 @@ impl Peer {
packet_recv_chan: PacketRecvChan,
context: ArcPeerContext,
) -> Self {
let conns: ConnMap = Arc::new(DashMap::with_shard_amount(small_map_shard_amount()));
let conns: ConnMap = Arc::new(DashMap::new());
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 -2
View File
@@ -11,7 +11,6 @@ use tokio::sync::RwLock;
use crate::{
config::PeerId,
foundation::small_map_shard_amount,
packet::ZCPacket,
peers::{
context::{ArcPeerContext, NetworkIdentity, PeerEvent},
@@ -54,7 +53,7 @@ impl PeerMap {
PeerMap {
context,
my_peer_id,
peer_map: DashMap::with_shard_amount(small_map_shard_amount()),
peer_map: DashMap::new(),
packet_send,
routes: RwLock::new(Vec::new()),
alive_client_urls: Arc::new(Mutex::new(HashMap::new())),
+21 -22
View File
@@ -13,7 +13,7 @@ use cidr::{Ipv4Cidr, Ipv4Inet, Ipv6Cidr, Ipv6Inet};
use dashmap::DashMap;
use easytier_proto::{
acl::Acl,
common::{Flags, PeerFeatureFlag, SecureModeConfig, StunInfo, TunnelInfo},
common::{FlagsInConfig, PeerFeatureFlag, SecureModeConfig, StunInfo, TunnelInfo},
peer_rpc::{PeerGroupInfo, TrustedCredentialPubkeyProof},
};
use hmac::{Hmac, Mac};
@@ -28,7 +28,6 @@ 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::{
@@ -72,7 +71,7 @@ pub struct PeerRuntimeSnapshotInput {
pub routes: RouteConfig,
pub network_identity: NetworkIdentity,
pub stun_info: StunInfo,
pub flags: Flags,
pub flags: FlagsInConfig,
pub secure_mode: Option<SecureModeConfig>,
pub host_routing: HostRoutingPolicy,
pub acl: Option<Acl>,
@@ -90,7 +89,7 @@ struct PeerTrafficLimits {
}
impl PeerTrafficLimits {
fn from_portable(runtime: &PeerRuntimeConfig, flags: &Flags) -> Self {
fn from_portable(runtime: &PeerRuntimeConfig, flags: &FlagsInConfig) -> Self {
let traffic = &runtime.core.traffic;
Self {
instance_recv_bps: Self::normalize(
@@ -439,7 +438,7 @@ pub(crate) struct TrustedKeyMapManager {
impl TrustedKeyMapManager {
pub fn new() -> Self {
Self {
network_trusted_keys: DashMap::with_shard_amount(small_map_shard_amount()),
network_trusted_keys: DashMap::new(),
}
}
@@ -528,7 +527,7 @@ pub(crate) struct PeerPacketPolicy {
}
impl PeerPacketPolicy {
fn from_flags(flags: &Flags) -> Self {
fn from_flags(flags: &FlagsInConfig) -> Self {
Self {
disable_relay_data: flags.disable_relay_data,
p2p_only: flags.p2p_only,
@@ -550,8 +549,8 @@ pub(crate) trait PeerContext: Send + Sync {
self.network_identity().network_name
}
fn flags(&self) -> Flags {
Flags::default()
fn flags(&self) -> FlagsInConfig {
FlagsInConfig::default()
}
fn packet_policy(&self) -> PeerPacketPolicy {
@@ -744,7 +743,7 @@ impl PeerContext for CorePeerContext {
self.snapshot().runtime.network_identity.clone()
}
fn flags(&self) -> Flags {
fn flags(&self) -> FlagsInConfig {
self.snapshot().flags.clone()
}
@@ -1083,7 +1082,7 @@ pub(crate) mod tests {
}
fn submitted_snapshot(hostname: &str, disable_relay_data: bool) -> PeerRuntimeSnapshot {
let mut flags = Flags::default();
let mut flags = FlagsInConfig::default();
flags.disable_relay_data = disable_relay_data;
PeerRuntimeSnapshot::new(
PeerRuntimeConfig {
@@ -1104,7 +1103,7 @@ pub(crate) mod tests {
)
}
fn host_snapshot_input(flags: Flags, acl: Option<Acl>) -> PeerRuntimeSnapshotInput {
fn host_snapshot_input(flags: FlagsInConfig, acl: Option<Acl>) -> PeerRuntimeSnapshotInput {
PeerRuntimeSnapshotInput {
node: NodeConfig {
peer_id: None,
@@ -1137,7 +1136,7 @@ pub(crate) mod tests {
#[test]
fn host_input_derives_peer_policy_features_and_traffic() {
let mut flags = Flags::default();
let mut flags = FlagsInConfig::default();
flags.disable_p2p = true;
flags.need_p2p = true;
flags.relay_all_peer_rpc = true;
@@ -1197,7 +1196,7 @@ pub(crate) mod tests {
}),
}),
};
let mut flags = Flags::default();
let mut flags = FlagsInConfig::default();
flags.relay_network_whitelist = "other-network".to_owned();
let snapshot = PeerRuntimeSnapshot::from_host_input(host_snapshot_input(flags, Some(acl)));
@@ -1275,7 +1274,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 = Flags::default();
let mut flags = FlagsInConfig::default();
flags.instance_recv_bps_limit = 1024;
flags.foreign_relay_bps_limit = 4096;
@@ -1288,7 +1287,7 @@ pub(crate) mod tests {
#[test]
fn legacy_traffic_limits_ignore_unlimited_sentinels() {
let runtime = PeerRuntimeSnapshot::default().runtime;
let mut flags = Flags::default();
let mut flags = FlagsInConfig::default();
flags.instance_recv_bps_limit = 1024;
flags.foreign_relay_bps_limit = u64::MAX;
@@ -1303,7 +1302,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 = Flags::default();
let mut flags = FlagsInConfig::default();
flags.instance_recv_bps_limit = 1024;
flags.foreign_relay_bps_limit = 2048;
@@ -1316,7 +1315,7 @@ pub(crate) mod tests {
fn portable_traffic_limits_default_to_unlimited() {
let runtime = PeerRuntimeSnapshot::default().runtime;
let snapshot = PeerRuntimeSnapshot::new(runtime, Flags::default());
let snapshot = PeerRuntimeSnapshot::new(runtime, FlagsInConfig::default());
assert_eq!(snapshot.traffic_limits(), PeerTrafficLimits::default());
}
@@ -1551,7 +1550,7 @@ pub(crate) mod tests {
local_exit_node_fallback: true,
},
};
let mut flags = Flags::default();
let mut flags = FlagsInConfig::default();
flags.p2p_only = true;
let acl = Acl {
acl_v1: Some(easytier_proto::acl::AclV1 {
@@ -1627,7 +1626,7 @@ pub(crate) mod tests {
live.tcp_nat_type = 4;
let context = core_owned_context(
runtime,
Flags::default(),
FlagsInConfig::default(),
Some(Arc::new(TestStunInfoSource(live.clone()))),
);
@@ -1637,7 +1636,7 @@ pub(crate) mod tests {
fn core_owned_context(
runtime: PeerRuntimeConfig,
flags: Flags,
flags: FlagsInConfig,
stun_info_source: Option<Arc<dyn PeerStunInfoSource>>,
) -> CorePeerContext {
core_owned_context_with_acl(runtime, flags, stun_info_source, None)
@@ -1645,7 +1644,7 @@ pub(crate) mod tests {
fn core_owned_context_with_acl(
runtime: PeerRuntimeConfig,
flags: Flags,
flags: FlagsInConfig,
stun_info_source: Option<Arc<dyn PeerStunInfoSource>>,
acl: Option<&Acl>,
) -> CorePeerContext {
@@ -1680,7 +1679,7 @@ pub(crate) mod tests {
secure_mode: None,
host_routing: HostRoutingPolicy::default(),
},
Flags::default(),
FlagsInConfig::default(),
None,
)
}
+37 -43
View File
@@ -5,14 +5,13 @@ pub(crate) mod client;
use std::sync::atomic::{AtomicBool, Ordering};
use std::{
collections::HashSet,
future,
sync::{Arc, Weak},
time::SystemTime,
};
use dashmap::DashMap;
use easytier_proto::common::Flags;
use dashmap::{DashMap, DashSet};
use easytier_proto::common::FlagsInConfig;
use guarden::defer;
use tokio::sync::{
Mutex, RwLock, RwLockReadGuard,
@@ -85,7 +84,7 @@ fn build_foreign_peer_context(
network: &NetworkIdentity,
parent_context: &Arc<CorePeerContext>,
relay_data: bool,
mut flags: Flags,
mut flags: FlagsInConfig,
) -> Arc<CorePeerContext> {
let parent_context_dyn: ArcPeerContext = parent_context.clone();
let parent_flags = parent_context_dyn.flags();
@@ -359,7 +358,7 @@ struct ForeignNetworkEntry {
traffic_metrics: Arc<TrafficMetricRecorder>,
event_handler_started: AtomicBool,
indexed_peers: parking_lot::Mutex<HashSet<PeerId>>,
indexed_peers: DashSet<PeerId>,
tasks: Mutex<JoinSet<()>>,
@@ -373,7 +372,7 @@ impl ForeignNetworkEntry {
my_peer_id: PeerId,
rpc_registrar: Arc<dyn ForeignNetworkRpcRegistrar>,
parent_context: Arc<CorePeerContext>,
foreign_context_default_flags: Flags,
foreign_context_default_flags: FlagsInConfig,
relay_data: bool,
peer_session_store: Arc<PeerSessionStore>,
pm_packet_sender: PacketRecvChan,
@@ -500,7 +499,7 @@ impl ForeignNetworkEntry {
traffic_metrics,
event_handler_started: AtomicBool::new(false),
indexed_peers: parking_lot::Mutex::new(HashSet::new()),
indexed_peers: DashSet::new(),
tasks: Mutex::new(JoinSet::new()),
@@ -625,7 +624,7 @@ impl Drop for ForeignNetworkEntry {
struct ForeignNetworkManagerData {
network_peer_maps: DashMap<String, Arc<ForeignNetworkEntry>>,
peer_network_map: DashMap<PeerId, HashSet<String>>,
peer_network_map: DashMap<PeerId, DashSet<String>>,
network_peer_last_update: DashMap<String, SystemTime>,
global_peer_map: Weak<PeerMap>,
lock: std::sync::Mutex<()>,
@@ -639,21 +638,19 @@ impl ForeignNetworkManagerData {
) {
let indexed_peers = entry
.indexed_peers
.lock()
.iter()
.copied()
.map(|peer_id| *peer_id)
.collect::<Vec<_>>();
for peer_id in indexed_peers {
self.peer_network_map
.remove_if_mut(&peer_id, |_, networks| {
networks.remove(network_name);
networks.is_empty()
});
self.peer_network_map.remove_if(&peer_id, |_, networks| {
networks.remove(network_name);
networks.is_empty()
});
}
entry.indexed_peers.lock().clear();
entry.indexed_peers.clear();
}
fn get_peer_network(&self, peer_id: PeerId) -> Option<HashSet<String>> {
fn get_peer_network(&self, peer_id: PeerId) -> Option<DashSet<String>> {
self.peer_network_map.get(&peer_id).map(|v| v.clone())
}
@@ -700,12 +697,11 @@ 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_mut(&peer_id, |_, networks| {
networks.remove(network_name);
networks.is_empty()
});
expected_entry.indexed_peers.lock().remove(&peer_id);
self.peer_network_map.remove_if(&peer_id, |_, networks| {
networks.remove(network_name);
networks.is_empty()
});
expected_entry.indexed_peers.remove(&peer_id);
}
shrink_dashmap(&self.peer_network_map, None);
shrink_dashmap(&self.network_peer_maps, None);
@@ -742,7 +738,7 @@ impl ForeignNetworkManagerData {
.entry(peer_id)
.or_default()
.insert(network_name.clone());
expected_entry.indexed_peers.lock().insert(peer_id);
expected_entry.indexed_peers.insert(peer_id);
self.network_peer_last_update
.insert(network_name.clone(), SystemTime::now());
true
@@ -807,7 +803,7 @@ impl ForeignNetworkManagerData {
.remove_if(network_name, |_, entry| {
Arc::ptr_eq(entry, &expected_entry)
&& entry.peer_map.is_empty()
&& entry.indexed_peers.lock().is_empty()
&& entry.indexed_peers.is_empty()
})
.is_some()
{
@@ -826,7 +822,7 @@ impl ForeignNetworkManagerData {
relay_data: bool,
rpc_registrar: Arc<dyn ForeignNetworkRpcRegistrar>,
parent_context: Arc<CorePeerContext>,
foreign_context_default_flags: Flags,
foreign_context_default_flags: FlagsInConfig,
peer_session_store: Arc<PeerSessionStore>,
pm_packet_sender: &PacketRecvChan,
) -> Option<(Arc<ForeignNetworkEntry>, bool)> {
@@ -878,7 +874,7 @@ enum ForeignNetworkManagerState {
pub(crate) struct ForeignNetworkManager {
rpc_registrar: Arc<dyn ForeignNetworkRpcRegistrar>,
parent_context: Arc<CorePeerContext>,
foreign_context_default_flags: Flags,
foreign_context_default_flags: FlagsInConfig,
peer_session_store: Arc<PeerSessionStore>,
packet_sender_to_mgr: PacketRecvChan,
@@ -916,7 +912,7 @@ impl ForeignNetworkManager {
pub fn new(
rpc_registrar: Arc<dyn ForeignNetworkRpcRegistrar>,
parent_context: Arc<CorePeerContext>,
foreign_context_default_flags: Flags,
foreign_context_default_flags: FlagsInConfig,
peer_session_store: Arc<PeerSessionStore>,
packet_sender_to_mgr: PacketRecvChan,
global_peer_map: Weak<PeerMap>,
@@ -1597,7 +1593,7 @@ mod tests {
};
use dashmap::DashMap;
use easytier_proto::common::{Flags, PeerFeatureFlag};
use easytier_proto::common::{FlagsInConfig, PeerFeatureFlag};
use super::{
ForeignNetworkEntry, ForeignNetworkManager, ForeignNetworkManagerData,
@@ -1642,7 +1638,7 @@ mod tests {
1,
Arc::new(()),
parent,
Flags::default(),
FlagsInConfig::default(),
true,
Arc::new(PeerSessionStore::new()),
packet_sender,
@@ -1673,10 +1669,10 @@ mod tests {
.entry(peer_id)
.or_default()
.insert(network_name.clone());
entry.indexed_peers.lock().insert(peer_id);
entry.indexed_peers.insert(peer_id);
}
data.peer_network_map
.get_mut(&20)
.get(&20)
.unwrap()
.insert(other_network.clone());
@@ -1701,11 +1697,11 @@ mod tests {
.get(&20)
.unwrap()
.iter()
.cloned()
.map(|network| network.clone())
.collect::<Vec<_>>(),
[other_network]
);
assert!(entry.indexed_peers.lock().is_empty());
assert!(entry.indexed_peers.is_empty());
assert!(!data.network_peer_last_update.contains_key(&network_name));
}
@@ -1734,7 +1730,7 @@ mod tests {
.entry(peer_id)
.or_default()
.insert(network_name.clone());
current_entry.indexed_peers.lock().insert(peer_id);
current_entry.indexed_peers.insert(peer_id);
}
assert!(
data.peer_network_map
@@ -1742,7 +1738,7 @@ mod tests {
.unwrap()
.contains(&network_name)
);
assert!(current_entry.indexed_peers.lock().contains(&10));
assert!(current_entry.indexed_peers.contains(&10));
data.remove_peer(10, &network_name, &Arc::downgrade(&old_entry));
assert!(
@@ -1763,7 +1759,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.lock().is_empty());
assert!(current_entry.indexed_peers.is_empty());
}
impl ForeignNetworkManager {
@@ -1829,7 +1825,7 @@ mod tests {
struct FeatureContext {
avoid_relay_data: AtomicBool,
flags: Flags,
flags: FlagsInConfig,
hostname: String,
}
@@ -1837,7 +1833,7 @@ mod tests {
fn new(avoid_relay_data: bool) -> Self {
Self {
avoid_relay_data: AtomicBool::new(avoid_relay_data),
flags: Flags::default(),
flags: FlagsInConfig::default(),
hostname: String::new(),
}
}
@@ -1855,7 +1851,7 @@ mod tests {
}
}
fn flags(&self) -> Flags {
fn flags(&self) -> FlagsInConfig {
self.flags.clone()
}
@@ -1877,8 +1873,6 @@ 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]
@@ -1949,7 +1943,7 @@ mod tests {
network_secret: Some("secret".to_owned()),
network_secret_digest: None,
};
let mut defaults = Flags::default();
let mut defaults = FlagsInConfig::default();
defaults.mtu = 1400;
defaults.relay_network_whitelist = "baseline".to_owned();
let foreign = build_foreign_peer_context(&network, &parent, false, defaults);
+2 -19
View File
@@ -159,28 +159,11 @@ 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) -> PeerPacketFilterResult {
PeerPacketFilterResult::Pass(zc_packet)
async fn try_process_packet_from_peer(&self, zc_packet: ZCPacket) -> Option<ZCPacket> {
Some(zc_packet)
}
}
+29 -107
View File
@@ -37,7 +37,7 @@ use crate::{
CompressorAlgo, PacketType, ZCPacket,
compressor::{Compressor as _, DefaultCompressor},
},
proto::common::{Flags, PeerFeatureFlag, StunInfo, Url as ProtoUrl},
proto::common::{FlagsInConfig, 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, PeerPacketFilterResult, PeerPacketIngress,
PeerPacketFilter, PeerPacketIngress,
acl::AclFilter,
conn::{
peer_conn::{PeerConn, PeerConnId},
@@ -98,7 +98,6 @@ 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,
@@ -214,14 +213,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: Flags,
pub foreign_context_default_flags: FlagsInConfig,
}
impl PortablePeerManagerConfig {
pub fn new(mut runtime: PeerRuntimeConfig) -> Self {
let policy = &runtime.core.peer_policy;
let traffic = &runtime.core.traffic;
let flags = Flags {
let flags = FlagsInConfig {
enable_encryption: policy.encryption_required,
encryption_algorithm: crate::config::EncryptionAlgorithm::default().to_string(),
disable_p2p: !policy.p2p_enabled,
@@ -284,9 +283,6 @@ 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<()> {
@@ -457,7 +453,7 @@ struct NicPacketProcessor {
#[async_trait::async_trait]
impl PeerPacketFilter for NicPacketProcessor {
async fn try_process_packet_from_peer(&self, packet: ZCPacket) -> PeerPacketFilterResult {
async fn try_process_packet_from_peer(&self, packet: ZCPacket) -> Option<ZCPacket> {
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() {
@@ -468,7 +464,7 @@ impl PeerPacketFilter for NicPacketProcessor {
compressed = hdr.is_compressed(),
"dropping packet before nic because it is not fully decoded"
);
return PeerPacketFilterResult::Consumed;
return None;
}
tracing::trace!(?packet, "send packet to nic channel");
if let Err(error) = self
@@ -480,9 +476,9 @@ impl PeerPacketFilter for NicPacketProcessor {
"dropping packet because nic channel cannot accept it"
);
}
PeerPacketFilterResult::Consumed
None
} else {
PeerPacketFilterResult::Pass(packet)
Some(packet)
}
}
}
@@ -493,16 +489,16 @@ struct PeerRpcPacketProcessor {
#[async_trait::async_trait]
impl PeerPacketFilter for PeerRpcPacketProcessor {
async fn try_process_packet_from_peer(&self, packet: ZCPacket) -> PeerPacketFilterResult {
async fn try_process_packet_from_peer(&self, packet: ZCPacket) -> Option<ZCPacket> {
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();
PeerPacketFilterResult::Consumed
None
} else {
PeerPacketFilterResult::Pass(packet)
Some(packet)
}
}
}
@@ -960,7 +956,7 @@ impl PeerManagerCore {
is_secure_mode_enabled: bool,
data_compress_algo: CompressorAlgo,
exit_nodes: Vec<IpAddr>,
foreign_context_default_flags: Flags,
foreign_context_default_flags: FlagsInConfig,
foreign_rpc_registrar: Arc<dyn ForeignNetworkRpcRegistrar>,
) -> Self {
let stats_manager = core_context.stats_manager();
@@ -1269,7 +1265,6 @@ 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(),
@@ -1336,16 +1331,6 @@ 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
@@ -1355,9 +1340,6 @@ 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
@@ -1370,7 +1352,7 @@ impl PeerManagerCore {
if refresh_acl_groups {
self.route.refresh_acl_groups().await;
}
Ok((published, committed))
Ok(published)
}
/// Keeps this manager's ACL rules and group assignments aligned with a
@@ -3193,33 +3175,25 @@ impl PeerPacketRouter {
return;
}
tracing::trace!(?ret, "try_process_packet_from_peer");
let mut processed = false;
let mut zc_packet = Some(ret);
tracing::trace!(?zc_packet, "try_process_packet_from_peer");
let pipelines = self.peer_packet_process_pipeline.load_full();
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;
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;
}
if zc_packet.is_none() {
processed = true;
break;
}
}
if !processed {
tracing::error!(?zc_packet, "unhandled packet");
}
}
tracing::error!(?packet, "unhandled packet");
}
}
@@ -3564,58 +3538,6 @@ 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 {
@@ -3685,7 +3607,7 @@ mod tests {
.await;
assert!(result.is_ok(), "a full host queue blocked the peer router");
assert!(matches!(result.unwrap(), PeerPacketFilterResult::Consumed));
assert!(result.unwrap().is_none());
assert_eq!(nic_receiver.try_recv().unwrap().payload(), b"queued");
}
+8 -9
View File
@@ -8,7 +8,6 @@ use tokio::sync::{Mutex, OwnedMutexGuard, oneshot};
use crate::{
config::PeerId,
foundation::small_map_shard_amount,
foundation::time::{Duration, timeout},
packet::{PacketType, ZCPacket},
peers::{
@@ -147,10 +146,10 @@ impl RelayPeerMap {
metric_network_name,
my_peer_id,
peer_session_store,
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()),
states: DashMap::new(),
pending_handshakes: DashMap::new(),
handshake_locks: DashMap::new(),
pending_packets: DashMap::new(),
is_secure_mode_enabled,
})
}
@@ -775,13 +774,13 @@ mod tests {
use super::*;
use crate::{
peers::context::{ArcPeerContext, NetworkIdentity, PeerContext},
proto::common::{Flags, SecureModeConfig},
proto::common::{FlagsInConfig, SecureModeConfig},
};
struct RelayTestContext {
network_identity: NetworkIdentity,
secure_mode: SecureModeConfig,
flags: Flags,
flags: FlagsInConfig,
}
impl PeerContext for RelayTestContext {
@@ -793,7 +792,7 @@ mod tests {
Some(self.secure_mode.clone())
}
fn flags(&self) -> Flags {
fn flags(&self) -> FlagsInConfig {
self.flags.clone()
}
}
@@ -867,7 +866,7 @@ mod tests {
local_private_key: Some(BASE64_STANDARD.encode(private.as_bytes())),
local_public_key: Some(BASE64_STANDARD.encode(public.as_bytes())),
},
flags: Flags {
flags: FlagsInConfig {
encryption_algorithm: "aes-gcm".to_owned(),
..Default::default()
},
@@ -34,7 +34,6 @@ use tokio::{
use crate::{
config::PeerId,
config::peers::PeerGroupIdentity,
foundation::small_map_shard_amount,
peers::{
PeerPacketFilter,
context::{
@@ -201,7 +200,7 @@ impl OspfRouteSnapshotLookup<'_> {
}
type PeerGraph = Graph<PeerId, usize, Directed>;
type PeerIdToNodeIdxMap = HashMap<PeerId, NodeIndex>;
type PeerIdToNodeIdxMap = DashMap<PeerId, NodeIndex>;
#[derive(Debug, Clone, Copy)]
pub struct OspfNextHopInfo {
@@ -229,11 +228,11 @@ pub struct OspfRouteTable {
impl OspfRouteTable {
pub fn new() -> Self {
OspfRouteTable {
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()),
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(),
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(),
@@ -302,7 +301,7 @@ impl OspfRouteTable {
let mut graph: PeerGraph = PeerGraph::new();
let mut start_node_idx = None;
let mut peer_id_to_node_index = PeerIdToNodeIdxMap::new();
let peer_id_to_node_index: PeerIdToNodeIdxMap = DashMap::new();
for entry in &snapshot.peer_infos {
let peer_id = entry.peer_id;
@@ -322,10 +321,12 @@ impl OspfRouteTable {
return (graph, NodeIndex::end());
}
for (&src_peer_id, src_node_idx) in &peer_id_to_node_index {
for item in peer_id_to_node_index.iter() {
let src_peer_id = *item.key();
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();
@@ -2034,9 +2035,9 @@ impl SyncRouteSession {
SyncRouteSession {
my_peer_id,
dst_peer_id,
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()),
dst_saved_peer_info_versions: DashMap::new(),
dst_saved_conn_info_version: DashMap::new(),
dst_saved_foreign_network_versions: DashMap::new(),
unreachable_peers_for_peer_info: parking_lot::Mutex::new(BTreeMap::new()),
unreachable_peers_for_conn_info: parking_lot::Mutex::new(BTreeMap::new()),
@@ -2411,7 +2412,7 @@ impl PeerRouteServiceImpl {
my_peer_id,
my_peer_route_id: rand::random(),
context: context.clone(),
sessions: DashMap::with_shard_amount(small_map_shard_amount()),
sessions: DashMap::new(),
stopped: AtomicBool::new(false),
session_operation: AsyncRwLock::new(()),
@@ -2421,23 +2422,21 @@ impl PeerRouteServiceImpl {
route_table: OspfRouteTable::new(),
route_table_with_cost: OspfRouteTable::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()),
foreign_network_owner_map: DashMap::new(),
foreign_network_my_peer_id_map: DashMap::new(),
synced_route_info: SyncedRouteInfo {
default_easytier_version: context.easytier_version(),
peer_infos: RwLock::new(OrderedHashMap::new()),
raw_peer_infos: DashMap::with_shard_amount(small_map_shard_amount()),
raw_peer_infos: DashMap::new(),
conn_map: RwLock::new(OrderedHashMap::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()),
foreign_network: DashMap::new(),
group_trust_map: DashMap::new(),
group_trust_map_cache: DashMap::new(),
group_trust_update_lock: parking_lot::Mutex::new(()),
trusted_credential_pubkeys: RwLock::new(HashMap::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(),
),
non_reusable_credential_owners: DashMap::new(),
suppressed_non_reusable_credential_peers: DashMap::new(),
version: AtomicVersion::new(),
},
public_ipv6_service: std::sync::Mutex::new(Weak::new()),
@@ -4887,8 +4886,8 @@ mod tests {
CoreNetworkIdentity::default()
}
fn flags(&self) -> crate::proto::common::Flags {
crate::proto::common::Flags {
fn flags(&self) -> crate::proto::common::FlagsInConfig {
crate::proto::common::FlagsInConfig {
prefer_peer_relay: self.enabled.load(Ordering::Relaxed),
..Default::default()
}
@@ -5013,7 +5012,7 @@ mod tests {
}
fn test_peer_relay_service_impl(my_peer_id: PeerId) -> PeerRouteServiceImpl {
let flags = crate::proto::common::Flags {
let flags = crate::proto::common::FlagsInConfig {
prefer_peer_relay: true,
..Default::default()
};
+5 -5
View File
@@ -6,7 +6,7 @@
use std::net::IpAddr;
use cidr::{Ipv4Inet, Ipv6Inet};
use easytier_proto::common::{Flags, SecureModeConfig};
use easytier_proto::common::{FlagsInConfig, 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: Flags,
flags: FlagsInConfig,
secure_mode: Option<SecureModeConfig>,
}
@@ -66,7 +66,7 @@ impl NoopPeerContext {
pub(crate) fn new(network_identity: NetworkIdentity) -> Self {
Self {
network_identity,
flags: Flags::default(),
flags: FlagsInConfig::default(),
secure_mode: None,
}
}
@@ -77,7 +77,7 @@ impl NoopPeerContext {
self
}
pub(crate) fn with_flags(mut self, flags: Flags) -> Self {
pub(crate) fn with_flags(mut self, flags: FlagsInConfig) -> Self {
self.flags = flags;
self
}
@@ -94,7 +94,7 @@ impl PeerContext for NoopPeerContext {
self.network_identity.clone()
}
fn flags(&self) -> Flags {
fn flags(&self) -> FlagsInConfig {
self.flags.clone()
}
+1 -2
View File
@@ -4,7 +4,6 @@ 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;
@@ -89,7 +88,7 @@ impl LogicalTrafficMetrics {
instance_bytes_metric,
instance_packets_metric,
label_kind,
per_peer: DashMap::with_shard_amount(small_map_shard_amount()),
per_peer: DashMap::new(),
}
}
+1 -2
View File
@@ -7,8 +7,7 @@ pub(crate) fn check_network_in_relay_whitelist(
network_name: &str,
) -> Result<(), anyhow::Error> {
if relay_network_whitelist
.split(|c: char| c.is_whitespace() || c == ',')
.filter(|s| !s.is_empty())
.split(' ')
.map(wildmatch::WildMatch::new)
.any(|whitelist| whitelist.matches(network_name))
{
+2 -3
View File
@@ -14,7 +14,6 @@ use tokio::{sync::mpsc, task::JoinSet};
use crate::{
config::PeerId,
foundation::{
small_map_shard_amount,
stats::{ArcRpcMetrics, RpcMetricLabels, RpcMetricsProvider},
time::timeout,
},
@@ -110,8 +109,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::with_shard_amount(small_map_shard_amount())),
peer_info: Arc::new(DashMap::with_shard_amount(small_map_shard_amount())),
inflight_requests: Arc::new(DashMap::new()),
peer_info: Arc::new(DashMap::new()),
tasks: Mutex::new(JoinSet::new()),
metrics: None,
}
+1 -2
View File
@@ -14,7 +14,6 @@ use tokio::task::JoinSet;
use crate::{
foundation::{
small_map_shard_amount,
stats::{ArcRpcMetrics, RpcMetricLabels, RpcMetricsProvider},
task::reap_joinset_background,
},
@@ -78,7 +77,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::with_shard_amount(small_map_shard_amount())),
packet_mergers: Arc::new(DashMap::new()),
metrics: None,
}
}
+1 -2
View File
@@ -2,7 +2,6 @@ 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")))]
@@ -87,7 +86,7 @@ impl Default for ServiceRegistry {
impl ServiceRegistry {
pub fn new() -> Self {
Self {
table: DashMap::with_shard_amount(small_map_shard_amount()),
table: DashMap::new(),
}
}
+8 -38
View File
@@ -25,13 +25,12 @@ use super::{
session::{
ClassifiedUdpSessionAccept, ClassifiedUdpSessionAccepts, ClassifiedUdpSessionKey,
ClassifiedUdpSessionRegistry, PendingUdpSessionConnect, PendingUdpSessionConnects,
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,
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,
},
virtual_socket::{
MAX_UDP_SESSION_DATAGRAM_SIZE, NoopUdpSessionStunResponder, PreferredIpv6Source,
@@ -50,7 +49,6 @@ 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>,
@@ -92,17 +90,15 @@ 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, session_shutdown_rx) = watch::channel(false);
let (session_shutdown_tx, _) = 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(),
@@ -116,10 +112,6 @@ 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,
@@ -131,10 +123,6 @@ 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()
}
@@ -367,7 +355,6 @@ 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();
@@ -450,7 +437,6 @@ 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>,
@@ -467,7 +453,6 @@ 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;
@@ -534,7 +519,6 @@ pub(super) async fn udp_session_layer_recv_task<S, R>(
&socket,
&sessions,
&pending_connects,
&hole_punch_observers,
&mux_accepted,
&control,
&control_permits,
@@ -586,7 +570,6 @@ 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>,
@@ -617,20 +600,7 @@ where
dispatch_sack_packet(sessions, pending_connects, remote_addr, conn_id, &packet)
}
EasyTierUdpPacketKind::HolePunch => {
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
dispatch_hole_punch_packet(pending_connects, remote_addr)
}
EasyTierUdpPacketKind::V4HolePunch => dispatch_v4_hole_punch_control(
socket.clone(),
+3 -4
View File
@@ -20,10 +20,9 @@ pub use packet::{
parse_udp_session_datagram,
};
pub use session::{
UdpHolePunchObserver, UdpHolePunchObserverRegistry, UdpSession, UdpSessionConnectError,
UdpSessionConnectRequest, UdpSessionConnector, UdpSessionKind, UdpSessionLayerControl,
UdpSessionListenRequest, UdpSessionListener, UdpSessionProtocol, UdpSessionRecvMeta,
UdpSessionSocket,
UdpSession, UdpSessionConnectError, UdpSessionConnectRequest, UdpSessionConnector,
UdpSessionKind, UdpSessionLayerControl, UdpSessionListenRequest, UdpSessionListener,
UdpSessionProtocol, UdpSessionRecvMeta, UdpSessionSocket,
};
pub(crate) use session::{
UdpSessionCleanup, UdpSessionCodec, UdpSessionDatagram, UdpSessionOutbound,
+1 -131
View File
@@ -1,7 +1,7 @@
use std::{
io,
net::{IpAddr, SocketAddr, SocketAddrV4, SocketAddrV6},
sync::{Arc, Mutex as StdMutex, Weak},
sync::{Arc, Mutex as StdMutex},
};
use async_trait::async_trait;
@@ -15,7 +15,6 @@ use tokio::{
};
use crate::{
foundation::time::{Duration, Instant},
packet::{UdpPacketType, ZCPacket, ZCPacketType},
socket::ring::{RingSocket, RingSocketReceiver, RingSocketSendError, RingSocketSender},
};
@@ -202,135 +201,6 @@ 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 {
-178
View File
@@ -477,182 +477,6 @@ 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> {
@@ -1477,7 +1301,6 @@ 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),
@@ -1801,7 +1624,6 @@ 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(),
+1 -1
View File
@@ -277,7 +277,7 @@ fn maybe_offload_aead(
pub(crate) fn validate_algorithm(algorithm: &str) -> Result<(), Error> {
let parsed = algorithm
.parse::<EncryptionAlgorithm>()
.map_err(|_| Error::InvalidAlgorithm(algorithm.to_owned()))?;
.map_err(|()| Error::InvalidAlgorithm(algorithm.to_owned()))?;
if algorithm_is_available(parsed) {
Ok(())
} else {
-7
View File
@@ -67,13 +67,6 @@ pub const GUEST_EXPORTS: &[&str] = &[
// WASI command initialization. This only binds the runtime; core lifecycle
// still starts through the instance exports below.
"_start",
// Module-wide logging; no instance handle is required. Levels 0..=5 are
// off, error, warn, info, debug, trace (matching api.logger.LogLevel).
// set_level(u32) -> i32 returns 0, or -3 without changing the level for
// invalid input. level() -> u32 defaults to info. _start is idempotent
// and does not reset the level. Output uses the WASI stdout sink.
"easytier_logger_set_level",
"easytier_logger_level",
// Guest-memory buffers.
"easytier_buffer_alloc",
"easytier_buffer_free",
-256
View File
@@ -1,256 +0,0 @@
//! Module-wide logging, initialized before any instance or WebClient is created.
use std::{
fmt::Write as _,
io::{self, Write},
sync::{
Arc, Mutex,
atomic::{AtomicU8, Ordering},
},
};
#[cfg(target_os = "wasi")]
use std::sync::OnceLock;
use tracing::{
Event, Metadata, Subscriber,
field::{Field, Visit},
level_filters::LevelFilter,
span::{Attributes, Id, Record},
subscriber::Interest,
};
// These values also match api.logger.LogLevel. Keep the basic WASI logger
// independent of the optional management protobuf API.
const LEVELS: [LevelFilter; 6] = [
LevelFilter::OFF,
LevelFilter::ERROR,
LevelFilter::WARN,
LevelFilter::INFO,
LevelFilter::DEBUG,
LevelFilter::TRACE,
];
struct Logger<W> {
level: AtomicU8,
writer: Mutex<W>,
}
impl<W> Logger<W> {
fn new(writer: W) -> Self {
Self {
level: AtomicU8::new(3),
writer: Mutex::new(writer),
}
}
fn set_level(&self, level: u32) -> bool {
if level >= LEVELS.len() as u32 {
return false;
}
self.level.store(level as u8, Ordering::Relaxed);
true
}
fn level(&self) -> u32 {
u32::from(self.level.load(Ordering::Relaxed))
}
}
impl<W: Write + Send + 'static> Subscriber for Logger<W> {
fn enabled(&self, metadata: &Metadata<'_>) -> bool {
metadata.is_event() && *metadata.level() <= LEVELS[self.level() as usize]
}
fn register_callsite(&self, metadata: &'static Metadata<'static>) -> Interest {
if metadata.is_event() {
Interest::sometimes()
} else {
Interest::never()
}
}
fn max_level_hint(&self) -> Option<LevelFilter> {
// The host can enable trace after a callsite has already been visited.
Some(LevelFilter::TRACE)
}
fn new_span(&self, _: &Attributes<'_>) -> Id {
Id::from_u64(1)
}
fn record(&self, _: &Id, _: &Record<'_>) {}
fn record_follows_from(&self, _: &Id, _: &Id) {}
fn enter(&self, _: &Id) {}
fn exit(&self, _: &Id) {}
fn event(&self, event: &Event<'_>) {
struct Fields(String);
impl Visit for Fields {
fn record_debug(&mut self, field: &Field, value: &dyn std::fmt::Debug) {
let _ = write!(self.0, " {}={value:?}", field.name());
}
}
let mut fields = Fields(String::new());
event.record(&mut fields);
// A host logging sink may fail; that must not trap the guest runtime.
let _ = writeln!(
self.writer.lock().unwrap(),
"[{}] {}{}",
event.metadata().level(),
event.metadata().target(),
fields.0,
);
}
}
#[cfg(target_os = "wasi")]
fn logger() -> &'static Arc<Logger<io::Stdout>> {
static LOGGER: OnceLock<Arc<Logger<io::Stdout>>> = OnceLock::new();
LOGGER.get_or_init(|| {
let logger = Arc::new(Logger::new(io::stdout()));
tracing::subscriber::set_global_default(logger.clone())
.expect("WASI runtime owns the tracing subscriber");
logger
})
}
#[cfg(target_os = "wasi")]
pub(super) fn init() {
logger();
}
/// Sets the module-wide level without requiring an instance handle.
/// Values 0..=5 mean off, error, warn, info, debug, and trace.
/// Invalid values return -3 and leave the current level unchanged.
#[cfg(target_os = "wasi")]
#[unsafe(no_mangle)]
pub extern "C" fn easytier_logger_set_level(level: u32) -> i32 {
if logger().set_level(level) { 0 } else { -3 }
}
/// Returns the module-wide level using the same values as the setter.
#[cfg(target_os = "wasi")]
#[unsafe(no_mangle)]
pub extern "C" fn easytier_logger_level() -> u32 {
logger().level()
}
#[cfg(feature = "management")]
impl<W: Write + Send + 'static> crate::management::LoggerControl for Logger<W> {
fn set_level(&self, name: &str) -> anyhow::Result<()> {
let filter = name.parse::<LevelFilter>()?;
let level = LEVELS
.iter()
.position(|level| *level == filter)
.expect("every tracing level has an ABI value");
self.set_level(level as u32);
Ok(())
}
fn level(&self) -> String {
LEVELS[self.level() as usize]
.to_string()
.to_ascii_lowercase()
}
}
#[cfg(all(target_os = "wasi", feature = "management"))]
pub(super) fn control() -> Arc<dyn crate::management::LoggerControl> {
logger().clone()
}
#[cfg(test)]
mod tests {
use super::*;
#[derive(Clone, Default)]
struct Output(Arc<Mutex<Vec<u8>>>);
impl Write for Output {
fn write(&mut self, bytes: &[u8]) -> io::Result<usize> {
self.0.lock().unwrap().extend_from_slice(bytes);
Ok(bytes.len())
}
fn flush(&mut self) -> io::Result<()> {
Ok(())
}
}
fn emit() {
tracing::error!(target: "logger_test", "error");
tracing::info!(target: "logger_test", "info");
tracing::trace!(target: "logger_test", "trace");
}
#[test]
fn changing_level_rechecks_existing_callsites() {
let output = Output::default();
let logger = Arc::new(Logger::new(output.clone()));
tracing::subscriber::with_default(logger.clone(), || {
for (level, expected) in [
(3, vec!["error", "info"]),
(5, vec!["error", "info", "trace"]),
(0, vec![]),
(3, vec!["error", "info"]),
] {
assert!(logger.set_level(level));
output.0.lock().unwrap().clear();
emit();
let bytes = output.0.lock().unwrap();
let text = std::str::from_utf8(&bytes).unwrap();
let messages = text
.lines()
.map(|line| line.split_once("message=").unwrap().1)
.collect::<Vec<_>>();
assert_eq!(messages, expected);
}
});
}
#[test]
fn invalid_level_preserves_current_filter() {
let logger = Logger::new(io::sink());
assert_eq!(logger.level(), 3);
for level in [6, 256, u32::MAX] {
assert!(!logger.set_level(level));
assert_eq!(logger.level(), 3);
}
}
#[test]
fn output_errors_do_not_panic() {
struct BrokenWriter;
impl Write for BrokenWriter {
fn write(&mut self, _: &[u8]) -> io::Result<usize> {
Err(io::Error::from(io::ErrorKind::BrokenPipe))
}
fn flush(&mut self) -> io::Result<()> {
Ok(())
}
}
tracing::subscriber::with_default(Logger::new(BrokenWriter), emit);
}
#[cfg(feature = "management")]
#[test]
fn management_control_uses_the_same_filter() {
let logger = Logger::new(io::sink());
let control: &dyn crate::management::LoggerControl = &logger;
control.set_level("trace").unwrap();
assert_eq!(logger.level(), 5);
assert!(logger.set_level(0));
assert_eq!(control.level(), "off");
assert!(control.set_level("invalid").is_err());
assert_eq!(logger.level(), 0);
}
}
-2
View File
@@ -12,8 +12,6 @@ pub mod abi;
pub mod adapter;
#[cfg(target_os = "wasi")]
pub(crate) mod imports;
#[cfg(any(test, target_os = "wasi"))]
mod logger;
#[cfg(target_os = "wasi")]
pub(crate) mod runtime;
#[cfg(any(test, target_os = "wasi"))]
+10 -8
View File
@@ -3,20 +3,24 @@
use crate::{
config::toml::TomlConfig,
connectivity::connector_host::HostConnectorEnvironmentSnapshot,
gateway::dhcp::{DhcpAddresses, DhcpApplyOutcome, DhcpHost},
gateway::dhcp::{DhcpIpv4ApplyOutcome, DhcpIpv4Host},
instance::{CorePacketPlane, InstanceRuntimeHost},
};
struct WasiInstanceRuntimeHost;
#[async_trait::async_trait]
impl DhcpHost for WasiInstanceRuntimeHost {
impl DhcpIpv4Host for WasiInstanceRuntimeHost {
fn take_interface_closed(&self) -> bool {
false
}
async fn apply_dhcp(&self, next: DhcpAddresses) -> DhcpApplyOutcome {
DhcpApplyOutcome::applied(next)
async fn apply_dhcp_ipv4(
&self,
_previous: Option<cidr::Ipv4Inet>,
next: Option<cidr::Ipv4Inet>,
) -> DhcpIpv4ApplyOutcome {
DhcpIpv4ApplyOutcome::applied(next)
}
}
@@ -25,7 +29,7 @@ impl InstanceRuntimeHost for WasiInstanceRuntimeHost {
async fn prepare(
&self,
_packet_plane: std::sync::Arc<CorePacketPlane>,
) -> anyhow::Result<Option<std::sync::Arc<dyn DhcpHost>>> {
) -> anyhow::Result<Option<std::sync::Arc<dyn DhcpIpv4Host>>> {
Ok(Some(std::sync::Arc::new(WasiInstanceRuntimeHost)))
}
@@ -681,9 +685,7 @@ mod abi {
#[unsafe(no_mangle)]
/// WASI command entrypoint used only to initialize the host runtime.
pub extern "C" fn _start() {
crate::wasi::logger::init();
}
pub extern "C" fn _start() {}
#[unsafe(no_mangle)]
/// Allocates a guest-owned ABI buffer and returns its linear-memory offset.
-15
View File
@@ -84,9 +84,6 @@ fn hosted_network_config(config: &NetworkConfig) -> NetworkConfig {
NetworkConfig {
instance_id: config.instance_id.clone(),
dhcp: config.dhcp,
dhcp_ipv4_cidr: config.dhcp_ipv4_cidr.clone(),
dhcpv6: config.dhcpv6,
dhcp_ipv6_cidr: config.dhcp_ipv6_cidr.clone(),
virtual_ipv4: config.virtual_ipv4.clone(),
network_length: config.network_length,
hostname: config.hostname.clone(),
@@ -260,12 +257,6 @@ impl WebClientBackend for WasiWebClientBackend {
fn register(&self, registry: &ServiceRegistry) {
registry.register(self.handler.clone(), "");
register_forwarded_instance_management_rpc(self.handler.clone(), registry);
registry.register(
crate::proto::api::logger::LoggerRpcServer::new(
crate::management::LoggerManagementRpc::new(crate::wasi::logger::control()),
),
"",
);
}
async fn instance_ids(&self) -> anyhow::Result<Vec<uuid::Uuid>> {
@@ -412,9 +403,6 @@ mod tests {
let original = NetworkConfig {
instance_id: Some(uuid::Uuid::new_v4().to_string()),
dhcp: Some(true),
dhcp_ipv4_cidr: Some("192.168.100.0/24".to_owned()),
dhcpv6: Some(true),
dhcp_ipv6_cidr: Some("fd12::/64".to_owned()),
network_name: Some("network".to_owned()),
network_secret: Some("secret".to_owned()),
networking_method: Some(NetworkingMethod::Manual as i32),
@@ -464,9 +452,6 @@ mod tests {
let hosted = hosted_network_config(&original);
assert_eq!(hosted.dhcp_ipv4_cidr, original.dhcp_ipv4_cidr);
assert_eq!(hosted.dhcpv6, original.dhcpv6);
assert_eq!(hosted.dhcp_ipv6_cidr, original.dhcp_ipv6_cidr);
assert_eq!(
hosted.peer_urls,
vec!["tcp://peer.example:11010".to_owned()]
-27
View File
@@ -66,33 +66,6 @@ instance, err := host.CreateInstanceTOML(ctx, "office", "", configTOML)
`Instance.ShowNodeInfo` returns this instance's virtual IPv4 address and
advertised hostname.
### Logging
Logging belongs to the Host and is independent of instance TOML and lifecycle.
Pass an `io.Writer` when creating the Host to receive guest logs; nil discards
them. The default level is `LogLevelInfo`. Change it before creating instances
or at runtime through the Host:
```go
host, err := corehost.New(ctx, corehost.Options{LogWriter: os.Stdout})
if err != nil {
return err
}
defer host.Close(ctx)
if err := host.SetLogLevel(ctx, corehost.LogLevelDebug); err != nil {
return err
}
```
`host.LogLevel(ctx)` reads the current level. `LogLevelOff` disables all logs.
The level applies to every instance and the WebClient in this Host; other
Hosts are independent. The WebClient's existing `LoggerRpc` controls the same
level. Creating or closing an instance never resets it.
Writes are synchronous, so the writer must not call back into this Host.
Writer errors do not interrupt guest execution, and the Host does not close
the writer.
### Web Client management
A host can also connect to an EasyTier Web configuration server. The embedded
-1
View File
@@ -19,7 +19,6 @@ const (
)
var managementProtoFiles = []string{
"annotations.proto",
"common.proto",
"error.proto",
"acl.proto",
Binary file not shown.
+2 -2
View File
@@ -3,6 +3,6 @@
package artifact
const (
Commit = "c51437c673a71f69b4b028c8d566a7b23b33e65c"
SHA256 = "17d6dd4561c1f1e818e7d70ed031c3cf8ea5dcfc1fd0461a6753f1f23f5fa6ab"
Commit = "599e4eacaa9c9a6f84b8d6439418af9d860f9aa3"
SHA256 = "f5cc76a30d4c6128f7ce77d494dd4e8130aa7e3ce9d5b9ede7604745064bed8d"
)

Some files were not shown because too many files have changed in this diff Show More