mirror of
https://github.com/EasyTier/EasyTier.git
synced 2026-09-20 11:22:39 +00:00
Compare commits
31
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0f2f019f16 | ||
|
|
86bf3df0fd | ||
|
|
daf30bff6e | ||
|
|
b5bc4a853d | ||
|
|
144e306562 | ||
|
|
ba86e47cc6 | ||
|
|
d742fa34aa | ||
|
|
800c840bb5 | ||
|
|
ca17e856a6 | ||
|
|
d87624b353 | ||
|
|
a7ab60e0e4 | ||
|
|
3d0d2bed9e | ||
|
|
7ef023fcdd | ||
|
|
69d476ad13 | ||
|
|
cf3fcb75b5 | ||
|
|
17195a5cc4 | ||
|
|
22bcfebe3e | ||
|
|
47a2fb19a6 | ||
|
|
62e43855cb | ||
|
|
5efbf4853a | ||
|
|
3e0bde68b8 | ||
|
|
bda06ebb61 | ||
|
|
be0859aca6 | ||
|
|
b2f1b37336 | ||
|
|
583fb45939 | ||
|
|
a343a907a9 | ||
|
|
b1ac64f40f | ||
|
|
122fb9b54e | ||
|
|
09780c1698 | ||
|
|
e533b48244 | ||
|
|
c0bd050742 |
@@ -1 +0,0 @@
|
||||
*.sh text eol=lf
|
||||
@@ -1,28 +0,0 @@
|
||||
# Security Policy
|
||||
|
||||
[简体中文](SECURITY.zh-CN.md) | English
|
||||
|
||||
## Supported Versions
|
||||
|
||||
Security updates are provided for the latest EasyTier release. Before reporting a vulnerability, please verify it against the latest release or the current `main` branch when practical.
|
||||
|
||||
| Version | Supported |
|
||||
| --- | --- |
|
||||
| Latest release | Yes |
|
||||
| Older releases | No |
|
||||
|
||||
## Reporting a Vulnerability
|
||||
|
||||
Please report suspected vulnerabilities through GitHub's [private vulnerability reporting form](https://github.com/EasyTier/EasyTier/security/advisories/new).
|
||||
|
||||
Do not disclose the vulnerability in a public issue, discussion, or pull request. A useful report should include:
|
||||
|
||||
- the affected EasyTier version or commit;
|
||||
- the affected platform and relevant configuration, with secrets removed;
|
||||
- a description of the vulnerability and its potential impact;
|
||||
- reproducible steps or a minimal proof of concept; and
|
||||
- any known mitigations or workarounds.
|
||||
|
||||
The maintainers will assess the report and coordinate remediation and disclosure through the private advisory. Please allow reasonable time for a fix before publishing details.
|
||||
|
||||
When investigating, avoid accessing or modifying other users' data, disrupting services, or violating applicable laws.
|
||||
@@ -1,28 +0,0 @@
|
||||
# 安全策略
|
||||
|
||||
简体中文 | [English](SECURITY.md)
|
||||
|
||||
## 支持的版本
|
||||
|
||||
EasyTier 仅为最新正式版本提供安全更新。报告漏洞前,请尽可能在最新正式版本或当前 `main` 分支上确认问题仍然存在。
|
||||
|
||||
| 版本 | 是否支持 |
|
||||
| --- | --- |
|
||||
| 最新正式版本 | 是 |
|
||||
| 更早版本 | 否 |
|
||||
|
||||
## 报告安全漏洞
|
||||
|
||||
请通过 GitHub 的[私有漏洞报告表单](https://github.com/EasyTier/EasyTier/security/advisories/new)报告疑似安全漏洞。
|
||||
|
||||
请勿在公开 Issue、Discussion 或 Pull Request 中披露漏洞。一份有效的报告应包括:
|
||||
|
||||
- 受影响的 EasyTier 版本或提交;
|
||||
- 受影响的平台和相关配置,请移除其中的密钥等敏感信息;
|
||||
- 漏洞说明及其潜在影响;
|
||||
- 可复现的步骤或最小概念验证;
|
||||
- 已知的缓解措施或临时解决方案。
|
||||
|
||||
维护者将评估报告,并通过私有安全公告协调修复和披露。请在公开漏洞详情前为修复预留合理时间。
|
||||
|
||||
调查漏洞时,请勿访问或修改其他用户的数据、干扰服务或违反适用的法律法规。
|
||||
+14
-91
@@ -1,7 +1,6 @@
|
||||
name: EasyTier Core
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
branches: [ "develop", "main", "releases/**" ]
|
||||
pull_request:
|
||||
@@ -22,45 +21,22 @@ defaults:
|
||||
|
||||
jobs:
|
||||
pre_job:
|
||||
# continue-on-error: true # Uncomment once integration is finished
|
||||
runs-on: ubuntu-latest
|
||||
if: github.event_name != 'pull_request' || !github.event.pull_request.draft
|
||||
env:
|
||||
RELEVANT_PATHS: >-
|
||||
["Cargo.toml", "Cargo.lock", "rust-toolchain.toml", ".cargo/**",
|
||||
"pnpm-lock.yaml", "pnpm-workspace.yaml", "package.json", "easytier/**",
|
||||
"easytier-core/**", "easytier-proto/**", "easytier-rpc-build/**",
|
||||
"easytier-web/**", "easytier-contrib/easytier-magisk/**",
|
||||
".github/workflows/core.yml", ".github/actions/**"]
|
||||
# Map a step output to a job output
|
||||
outputs:
|
||||
# Release builds must run even if they only change release metadata.
|
||||
should_skip: >-
|
||||
${{
|
||||
(
|
||||
steps.skip_check.outputs.should_skip == 'true' ||
|
||||
(
|
||||
github.event_name == 'pull_request' &&
|
||||
steps.path_filter.outputs.relevant != 'true'
|
||||
)
|
||||
) &&
|
||||
!startsWith(github.ref_name, 'releases/')
|
||||
}}
|
||||
# do not skip push on branch starts with releases/
|
||||
should_skip: ${{ steps.skip_check.outputs.should_skip == 'true' && !startsWith(github.ref_name, 'releases/') }}
|
||||
steps:
|
||||
- id: skip_check
|
||||
uses: fkirc/skip-duplicate-actions@v5
|
||||
with:
|
||||
# All of these options are optional, so you can remove them if you are happy with the defaults
|
||||
concurrent_skipping: 'same_content_newer'
|
||||
skip_after_successful_duplicate: 'true'
|
||||
cancel_others: 'true'
|
||||
paths: ${{ env.RELEVANT_PATHS }}
|
||||
|
||||
- id: path_filter
|
||||
if: >-
|
||||
github.event_name == 'pull_request' &&
|
||||
steps.skip_check.outputs.should_skip != 'true'
|
||||
uses: dorny/paths-filter@v4
|
||||
with:
|
||||
filters: |
|
||||
relevant: ${{ env.RELEVANT_PATHS }}
|
||||
paths: '["Cargo.toml", "Cargo.lock", "easytier/**", "easytier-core/**", "easytier-proto/**", ".github/workflows/core.yml", ".github/actions/**", "easytier-web/**"]'
|
||||
build_web:
|
||||
runs-on: ubuntu-latest
|
||||
needs: pre_job
|
||||
@@ -82,7 +58,7 @@ jobs:
|
||||
easytier-web/frontend/dist/*
|
||||
build:
|
||||
strategy:
|
||||
fail-fast: true
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- TARGET: x86_64-unknown-linux-musl
|
||||
@@ -189,10 +165,7 @@ jobs:
|
||||
- uses: taiki-e/install-action@v2
|
||||
if: ${{ contains(matrix.OS, 'ubuntu') }}
|
||||
with:
|
||||
# v0.23.3 emits -mcpu=generic+v6+strict_align for
|
||||
# arm-unknown-linux-musleabi, which zig 0.16.0 rejects;
|
||||
# unpin only together with a zig bump.
|
||||
tool: cargo-zigbuild@0.23.2
|
||||
tool: cargo-zigbuild
|
||||
|
||||
- name: Build
|
||||
if: ${{ !contains(matrix.TARGET, 'mips') }}
|
||||
@@ -285,47 +258,6 @@ jobs:
|
||||
path: |
|
||||
./artifacts/*
|
||||
|
||||
- name: Prepare SignPath test artifact
|
||||
if: ${{ github.event_name == 'workflow_dispatch' && contains(matrix.TARGET, 'windows') }}
|
||||
run: |
|
||||
mkdir -p ./signpath-artifacts
|
||||
cp \
|
||||
./artifacts/easytier-core.exe \
|
||||
./artifacts/easytier-cli.exe \
|
||||
./artifacts/easytier-web.exe \
|
||||
./artifacts/easytier-web-embed.exe \
|
||||
./signpath-artifacts/
|
||||
|
||||
- name: Upload unsigned SignPath test artifact
|
||||
if: ${{ github.event_name == 'workflow_dispatch' && contains(matrix.TARGET, 'windows') }}
|
||||
id: signpath-upload
|
||||
uses: actions/upload-artifact@v5
|
||||
with:
|
||||
name: signpath-unsigned-${{ matrix.ARTIFACT_NAME }}
|
||||
path: ./signpath-artifacts/*
|
||||
if-no-files-found: error
|
||||
|
||||
- name: Submit SignPath test-signing request
|
||||
if: ${{ github.event_name == 'workflow_dispatch' && contains(matrix.TARGET, 'windows') }}
|
||||
uses: signpath/github-action-submit-signing-request@v2
|
||||
with:
|
||||
api-token: ${{ secrets.SIGNPATH_API_TOKEN }}
|
||||
organization-id: 368cf38d-86d5-4bfe-a1fa-82a50dce7e9f
|
||||
project-slug: EasyTier
|
||||
signing-policy-slug: test-signing
|
||||
artifact-configuration-slug: initial
|
||||
github-artifact-id: ${{ steps.signpath-upload.outputs.artifact-id }}
|
||||
wait-for-completion: true
|
||||
output-artifact-directory: ./signpath-signed-artifacts
|
||||
|
||||
- name: Upload signed SignPath test artifact
|
||||
if: ${{ github.event_name == 'workflow_dispatch' && contains(matrix.TARGET, 'windows') }}
|
||||
uses: actions/upload-artifact@v5
|
||||
with:
|
||||
name: signpath-signed-${{ matrix.ARTIFACT_NAME }}
|
||||
path: ./signpath-signed-artifacts/*
|
||||
if-no-files-found: error
|
||||
|
||||
build_magisk:
|
||||
runs-on: ubuntu-latest
|
||||
needs: [ pre_job, build_web, build ]
|
||||
@@ -341,22 +273,13 @@ jobs:
|
||||
name: easytier-linux-aarch64
|
||||
path: ./downloaded-binaries/ # 独立目录避免冲突
|
||||
|
||||
- name: Download Magisk WebUI
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: easytier-web-dashboard
|
||||
path: ./downloaded-webui/
|
||||
|
||||
# 将二进制文件和 WebUI 复制到 Magisk 模块目录
|
||||
- name: Prepare Magisk module
|
||||
# 将二进制文件复制到 Magisk 模块目录
|
||||
- name: Prepare binaries
|
||||
run: |
|
||||
module_dir=./easytier-contrib/easytier-magisk
|
||||
mkdir -p "$module_dir/webroot"
|
||||
cp ./downloaded-binaries/easytier-core "$module_dir/"
|
||||
cp ./downloaded-binaries/easytier-cli "$module_dir/"
|
||||
cp ./downloaded-binaries/easytier-web "$module_dir/"
|
||||
cp -R ./downloaded-webui/config-generator/. "$module_dir/webroot/"
|
||||
test -f "$module_dir/webroot/index.html"
|
||||
mkdir -p ./easytier-contrib/easytier-magisk/
|
||||
cp ./downloaded-binaries/easytier-core ./easytier-contrib/easytier-magisk/
|
||||
cp ./downloaded-binaries/easytier-cli ./easytier-contrib/easytier-magisk/
|
||||
cp ./downloaded-binaries/easytier-web ./easytier-contrib/easytier-magisk/
|
||||
|
||||
# 上传生成的模块
|
||||
- name: Upload Magisk Module
|
||||
|
||||
@@ -11,7 +11,7 @@ on:
|
||||
image_tag:
|
||||
description: 'Tag for this image build'
|
||||
type: string
|
||||
default: 'v2.7.0'
|
||||
default: 'v2.6.4'
|
||||
required: true
|
||||
mark_latest:
|
||||
description: 'Mark this image as latest'
|
||||
|
||||
@@ -1,44 +0,0 @@
|
||||
name: EasyTier Go
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: ["develop", "main", "releases/**"]
|
||||
paths:
|
||||
- "easytier-go/**"
|
||||
- ".github/workflows/go.yml"
|
||||
pull_request:
|
||||
branches: ["develop", "main"]
|
||||
types: [opened, synchronize, reopened, ready_for_review]
|
||||
paths:
|
||||
- "easytier-go/**"
|
||||
- ".github/workflows/go.yml"
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
test:
|
||||
name: Test Go modules
|
||||
if: github.event_name != 'pull_request' || !github.event.pull_request.draft
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
|
||||
- uses: actions/setup-go@v6
|
||||
with:
|
||||
go-version: "1.25.x"
|
||||
cache-dependency-path: |
|
||||
easytier-go/go.sum
|
||||
easytier-go/examples/tun/go.sum
|
||||
|
||||
- name: Test main module
|
||||
working-directory: easytier-go
|
||||
run: go test ./...
|
||||
|
||||
- name: Test TUN example module
|
||||
working-directory: easytier-go/examples/tun
|
||||
run: go test ./...
|
||||
@@ -21,47 +21,21 @@ defaults:
|
||||
|
||||
jobs:
|
||||
pre_job:
|
||||
# continue-on-error: true # Uncomment once integration is finished
|
||||
runs-on: ubuntu-latest
|
||||
if: github.event_name != 'pull_request' || !github.event.pull_request.draft
|
||||
env:
|
||||
RELEVANT_PATHS: >-
|
||||
["Cargo.toml", "Cargo.lock", "rust-toolchain.toml", ".cargo/**",
|
||||
"pnpm-lock.yaml", "pnpm-workspace.yaml", "package.json", "easytier/**",
|
||||
"easytier-core/**", "easytier-proto/**", "easytier-rpc-build/**",
|
||||
"easytier-gui/**", "tauri-plugin-vpnservice/**",
|
||||
"easytier-web/frontend-lib/**", ".github/workflows/gui.yml",
|
||||
".github/actions/**"]
|
||||
# Map a step output to a job output
|
||||
outputs:
|
||||
# Release builds must run even if they only change release metadata.
|
||||
should_skip: >-
|
||||
${{
|
||||
(
|
||||
steps.skip_check.outputs.should_skip == 'true' ||
|
||||
(
|
||||
github.event_name == 'pull_request' &&
|
||||
steps.path_filter.outputs.relevant != 'true'
|
||||
)
|
||||
) &&
|
||||
!startsWith(github.ref_name, 'releases/')
|
||||
}}
|
||||
should_skip: ${{ steps.skip_check.outputs.should_skip == 'true' && !startsWith(github.ref_name, 'releases/') }}
|
||||
steps:
|
||||
- id: skip_check
|
||||
uses: fkirc/skip-duplicate-actions@v5
|
||||
with:
|
||||
# All of these options are optional, so you can remove them if you are happy with the defaults
|
||||
concurrent_skipping: 'same_content_newer'
|
||||
skip_after_successful_duplicate: 'true'
|
||||
cancel_others: 'true'
|
||||
paths: ${{ env.RELEVANT_PATHS }}
|
||||
|
||||
- id: path_filter
|
||||
if: >-
|
||||
github.event_name == 'pull_request' &&
|
||||
steps.skip_check.outputs.should_skip != 'true'
|
||||
uses: dorny/paths-filter@v4
|
||||
with:
|
||||
filters: |
|
||||
relevant: ${{ env.RELEVANT_PATHS }}
|
||||
|
||||
paths: '["Cargo.toml", "Cargo.lock", "easytier/**", "easytier-core/**", "easytier-gui/**", ".github/workflows/gui.yml", ".github/actions/**", "easytier-web/frontend-lib/**"]'
|
||||
build-gui:
|
||||
strategy:
|
||||
fail-fast: true
|
||||
|
||||
@@ -1,62 +0,0 @@
|
||||
name: EasyTier JavaScript Hosts
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: ["develop", "main", "releases/**"]
|
||||
paths:
|
||||
- "Cargo.toml"
|
||||
- "Cargo.lock"
|
||||
- "easytier-core/**"
|
||||
- "easytier-proto/**"
|
||||
- "easytier-js/**"
|
||||
- ".github/workflows/js.yml"
|
||||
pull_request:
|
||||
branches: ["develop", "main"]
|
||||
types: [opened, synchronize, reopened, ready_for_review]
|
||||
paths:
|
||||
- "Cargo.toml"
|
||||
- "Cargo.lock"
|
||||
- "easytier-core/**"
|
||||
- "easytier-proto/**"
|
||||
- "easytier-js/**"
|
||||
- ".github/workflows/js.yml"
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
check:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
|
||||
- uses: actions-rust-lang/setup-rust-toolchain@v1
|
||||
with:
|
||||
toolchain: 1.95
|
||||
target: wasm32-wasip1
|
||||
cache: false
|
||||
rustflags: ''
|
||||
|
||||
- uses: arduino/setup-protoc@v3
|
||||
with:
|
||||
version: '35.1'
|
||||
repo-token: ${{ github.token }}
|
||||
|
||||
- uses: actions/setup-node@v5
|
||||
with:
|
||||
node-version: 22
|
||||
|
||||
- uses: pnpm/action-setup@v5
|
||||
with:
|
||||
version: 10
|
||||
run_install: false
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm --dir easytier-js install --frozen-lockfile
|
||||
|
||||
- name: Build and test
|
||||
run: pnpm --dir easytier-js check
|
||||
@@ -21,47 +21,21 @@ defaults:
|
||||
|
||||
jobs:
|
||||
pre_job:
|
||||
# continue-on-error: true # Uncomment once integration is finished
|
||||
runs-on: ubuntu-latest
|
||||
if: github.event_name != 'pull_request' || !github.event.pull_request.draft
|
||||
env:
|
||||
RELEVANT_PATHS: >-
|
||||
["Cargo.toml", "Cargo.lock", "rust-toolchain.toml", ".cargo/**",
|
||||
"pnpm-lock.yaml", "pnpm-workspace.yaml", "package.json", "easytier/**",
|
||||
"easytier-core/**", "easytier-proto/**", "easytier-rpc-build/**",
|
||||
"easytier-gui/**", "tauri-plugin-vpnservice/**",
|
||||
"easytier-web/frontend-lib/**", ".github/workflows/mobile.yml",
|
||||
".github/actions/**"]
|
||||
# Map a step output to a job output
|
||||
outputs:
|
||||
# Release builds must run even if they only change release metadata.
|
||||
should_skip: >-
|
||||
${{
|
||||
(
|
||||
steps.skip_check.outputs.should_skip == 'true' ||
|
||||
(
|
||||
github.event_name == 'pull_request' &&
|
||||
steps.path_filter.outputs.relevant != 'true'
|
||||
)
|
||||
) &&
|
||||
!startsWith(github.ref_name, 'releases/')
|
||||
}}
|
||||
should_skip: ${{ steps.skip_check.outputs.should_skip == 'true' && !startsWith(github.ref_name, 'releases/') }}
|
||||
steps:
|
||||
- id: skip_check
|
||||
uses: fkirc/skip-duplicate-actions@v5
|
||||
with:
|
||||
# All of these options are optional, so you can remove them if you are happy with the defaults
|
||||
concurrent_skipping: 'same_content_newer'
|
||||
skip_after_successful_duplicate: 'true'
|
||||
cancel_others: 'true'
|
||||
paths: ${{ env.RELEVANT_PATHS }}
|
||||
|
||||
- id: path_filter
|
||||
if: >-
|
||||
github.event_name == 'pull_request' &&
|
||||
steps.skip_check.outputs.should_skip != 'true'
|
||||
uses: dorny/paths-filter@v4
|
||||
with:
|
||||
filters: |
|
||||
relevant: ${{ env.RELEVANT_PATHS }}
|
||||
|
||||
paths: '["Cargo.toml", "Cargo.lock", "easytier/**", "easytier-core/**", "easytier-gui/**", "tauri-plugin-vpnservice/**", ".github/workflows/mobile.yml", ".github/actions/**"]'
|
||||
build-mobile:
|
||||
strategy:
|
||||
fail-fast: true
|
||||
|
||||
@@ -9,18 +9,6 @@ on:
|
||||
pull_request:
|
||||
branches: [develop, main, "ohos/**"]
|
||||
types: [opened, synchronize, reopened, ready_for_review]
|
||||
paths:
|
||||
- "Cargo.toml"
|
||||
- "Cargo.lock"
|
||||
- "rust-toolchain.toml"
|
||||
- ".cargo/**"
|
||||
- "easytier/**"
|
||||
- "easytier-core/**"
|
||||
- "easytier-proto/**"
|
||||
- "easytier-rpc-build/**"
|
||||
- "easytier-contrib/easytier-ohrs/**"
|
||||
- ".github/workflows/ohos.yml"
|
||||
- ".github/actions/**"
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
publish:
|
||||
@@ -30,7 +18,6 @@ on:
|
||||
type: boolean
|
||||
|
||||
permissions:
|
||||
actions: write
|
||||
contents: read
|
||||
pull-requests: read
|
||||
|
||||
@@ -42,24 +29,9 @@ defaults:
|
||||
shell: bash
|
||||
|
||||
jobs:
|
||||
pre_job:
|
||||
runs-on: ubuntu-latest
|
||||
if: github.event_name != 'pull_request' || !github.event.pull_request.draft
|
||||
outputs:
|
||||
should_skip: ${{ steps.skip_check.outputs.should_skip == 'true' && github.ref_type != 'tag' && github.ref_name != 'main' && !startsWith(github.ref_name, 'releases/') }}
|
||||
steps:
|
||||
- id: skip_check
|
||||
uses: fkirc/skip-duplicate-actions@v5
|
||||
with:
|
||||
concurrent_skipping: 'same_content_newer'
|
||||
skip_after_successful_duplicate: 'true'
|
||||
cancel_others: 'true'
|
||||
paths: '["Cargo.toml", "Cargo.lock", "rust-toolchain.toml", ".cargo/**", "easytier/**", "easytier-core/**", "easytier-proto/**", "easytier-contrib/easytier-ohrs/**", ".github/workflows/ohos.yml", ".github/actions/**"]'
|
||||
|
||||
ohos:
|
||||
name: ohos
|
||||
needs: pre_job
|
||||
if: needs.pre_job.outputs.should_skip != 'true'
|
||||
if: github.event_name != 'pull_request' || !github.event.pull_request.draft
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
@@ -95,13 +67,6 @@ jobs:
|
||||
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')
|
||||
|
||||
@@ -18,7 +18,7 @@ on:
|
||||
version:
|
||||
description: 'Version for this release'
|
||||
type: string
|
||||
default: 'v2.7.0'
|
||||
default: 'v2.6.4'
|
||||
required: true
|
||||
make_latest:
|
||||
description: 'Mark this release as latest'
|
||||
|
||||
@@ -22,40 +22,19 @@ defaults:
|
||||
|
||||
jobs:
|
||||
pre_job:
|
||||
# continue-on-error: true # Uncomment once integration is finished
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
RELEVANT_PATHS: >-
|
||||
["Cargo.toml", "Cargo.lock", "rust-toolchain.toml", ".cargo/**",
|
||||
"pnpm-lock.yaml", "pnpm-workspace.yaml", "package.json", "easytier/**",
|
||||
"easytier-core/**", "easytier-proto/**", "easytier-rpc-build/**",
|
||||
"easytier-web/**", "easytier-gui/src-tauri/**",
|
||||
"tauri-plugin-vpnservice/**", "easytier-contrib/**",
|
||||
".github/workflows/test.yml", ".github/actions/**"]
|
||||
# Map a step output to a job output
|
||||
outputs:
|
||||
should_skip: >-
|
||||
${{
|
||||
steps.skip_check.outputs.should_skip == 'true' ||
|
||||
(
|
||||
github.event_name == 'pull_request' &&
|
||||
steps.path_filter.outputs.relevant != 'true'
|
||||
)
|
||||
}}
|
||||
should_skip: ${{ steps.skip_check.outputs.should_skip }}
|
||||
steps:
|
||||
- id: skip_check
|
||||
uses: fkirc/skip-duplicate-actions@v5
|
||||
with:
|
||||
# All of these options are optional, so you can remove them if you are happy with the defaults
|
||||
concurrent_skipping: 'never'
|
||||
skip_after_successful_duplicate: 'true'
|
||||
paths: ${{ env.RELEVANT_PATHS }}
|
||||
|
||||
- id: path_filter
|
||||
if: >-
|
||||
github.event_name == 'pull_request' &&
|
||||
steps.skip_check.outputs.should_skip != 'true'
|
||||
uses: dorny/paths-filter@v4
|
||||
with:
|
||||
filters: |
|
||||
relevant: ${{ env.RELEVANT_PATHS }}
|
||||
paths: '["Cargo.toml", "Cargo.lock", "easytier/**", "easytier-core/**", "easytier-proto/**", "easytier-web/**", "easytier-gui/src-tauri/**", "easytier-contrib/**", ".github/workflows/test.yml", ".github/actions/**"]'
|
||||
|
||||
check:
|
||||
name: Run linters & check
|
||||
@@ -75,7 +54,6 @@ jobs:
|
||||
- uses: actions-rust-lang/setup-rust-toolchain@v1
|
||||
with:
|
||||
components: rustfmt,clippy
|
||||
target: wasm32-wasip1
|
||||
rustflags: ''
|
||||
|
||||
- uses: taiki-e/install-action@cargo-hack
|
||||
@@ -92,12 +70,6 @@ jobs:
|
||||
if: ${{ !cancelled() }}
|
||||
run: cargo hack check --package easytier --each-feature --exclude-features macos-ne --verbose
|
||||
|
||||
- name: Check WASI
|
||||
if: ${{ !cancelled() }}
|
||||
run: >-
|
||||
cargo check --package easytier-core --lib --target wasm32-wasip1
|
||||
--features management-rpc,proxy-smoltcp-stack,ring-crypto,wasi-crypto-offload
|
||||
|
||||
- name: Check Cargo.lock is up to date
|
||||
if: ${{ !cancelled() }}
|
||||
run: |
|
||||
|
||||
-75
@@ -22,84 +22,9 @@ operation transition.
|
||||
Host capability operations use a separate seam. They turn Host readiness into
|
||||
Rust task wakeups and do not share the caller-to-core broker state machine.
|
||||
|
||||
## Credential grant
|
||||
|
||||
A credential grant contains the authorization constraints shared by generated,
|
||||
imported, managed, and attached-peer credentials: ACL groups, relay permission,
|
||||
allowed proxy CIDRs, and whether concurrent reuse is allowed. It does not own
|
||||
credential identity, key material, lifetime, persistence, or runtime ownership.
|
||||
Each credential intake path normalizes the grant before installing it.
|
||||
|
||||
## Peer Relay advertisement
|
||||
|
||||
A platform peer may prefer an eligible directly connected credential relay by
|
||||
omitting covered credential-leaf edges from only its own advertised OSPF
|
||||
connection row. Its local route calculation still uses the complete physical
|
||||
adjacency so direct-destination fallback remains available. Other peers'
|
||||
source-owned rows and versions are never rewritten, cached for promotion, or
|
||||
otherwise changed by this projection.
|
||||
|
||||
Before a graceful Instance stop, the owner publishes a new-version empty
|
||||
connection row while keeping its physical adjacencies available for route
|
||||
synchronization. It waits for the current direct route Sessions to acknowledge
|
||||
that withdrawal up to a bounded deadline, then continues shutdown. Abrupt
|
||||
process loss cannot publish this withdrawal and retains the normal route
|
||||
expiry behavior.
|
||||
|
||||
Relay eligibility comes from the transport-authenticated credential identity
|
||||
and grant, not self-reported route metadata. The advertisement Module does not
|
||||
support changing a credential's relay permission in place; such a permission
|
||||
change is a credential revocation and new authenticated Session.
|
||||
|
||||
## Attached peer
|
||||
|
||||
An attached peer is an ordinary `PeerManagerCore` connected to another
|
||||
`PeerManagerCore` through an authenticated in-process transport. Each
|
||||
authenticated portal client owns one complete peer manager. The managers are
|
||||
protocol peers; `attached` describes only the local transport and its trusted
|
||||
ingress provenance, not a parent/child peer role.
|
||||
|
||||
An attached peer owns one complete IPv4 CIDR (for example `10.144.0.5/16`).
|
||||
Its address and advertised network are independent of the network manager's
|
||||
own static or DHCP address. A VPN portal derives the attached peer route and
|
||||
the external client's allowed network from that single CIDR; it does not infer
|
||||
either value from the portal-hosting instance.
|
||||
|
||||
An external portal client uses that same IPv4 address on its native tunnel
|
||||
interface. The portal validates the source address and forwards IPv4 packets
|
||||
unchanged between the native tunnel and the attached peer; it does not assign
|
||||
a second tunnel-only address or perform address translation.
|
||||
|
||||
Each manager owns its ACL execution state, route service, RPC endpoint, secure
|
||||
sessions, packet processing, and lifecycle. Portal code supplies raw packets
|
||||
and peer configuration but does not build, reload, or coordinate ACL filters.
|
||||
|
||||
When the network manager uses Secure Mode, an attached peer authenticates as a
|
||||
credential peer. Its portal-owned, in-memory credential grant carries ACL
|
||||
groups and is revoked with the attached runtime; the peer never receives the
|
||||
network secret or ACL group secrets. A non-Secure-Mode network retains the
|
||||
legacy admin-attached identity for compatibility. A credential peer cannot host
|
||||
a portal because it cannot issue credential grants. Each live portal Session
|
||||
owns a fresh attached-peer identity, while the external client key remains
|
||||
stable across Sessions; a replacement Session must never reuse the previous
|
||||
non-reusable credential identity.
|
||||
|
||||
## Compact compatibility Host
|
||||
|
||||
A compact compatibility Host retains accepted values in the authoritative TOML
|
||||
model for management readback, while the shared host-aware normalization path
|
||||
omits capabilities that the compact runtime cannot execute. Omitted settings
|
||||
are silent no-ops and must not be advertised as live network capabilities.
|
||||
|
||||
## Web compatibility Host
|
||||
|
||||
The Web compatibility Host runs the portable EasyTier guest in JavaScript
|
||||
runtimes that provide WebAssembly JSPI. Its shared runtime Module owns guest
|
||||
lifecycle, Host capability operations, data-plane resources, and WebSocket
|
||||
message handling. Browser and Cloudflare Adapters own only the platform-specific
|
||||
way that WebSockets are dialed or accepted and the matching guest artifact.
|
||||
|
||||
The Browser Adapter is an outbound-only EasyTier instance with a smoltcp TCP
|
||||
data plane. The Cloudflare Adapter is an inbound-only relay hosted by one named
|
||||
Durable Object. Their public configuration exposes only capabilities each Host
|
||||
can execute; guest ABI details and serialized TOML remain internal.
|
||||
|
||||
Generated
+3160
-2850
File diff suppressed because it is too large
Load Diff
-71
@@ -10,7 +10,6 @@ members = [
|
||||
"easytier-contrib/easytier-ffi",
|
||||
"easytier-contrib/easytier-uptime",
|
||||
"easytier-contrib/easytier-android-jni",
|
||||
"easytier-contrib/easytier-ios",
|
||||
]
|
||||
default-members = ["easytier", "easytier-web"]
|
||||
exclude = [
|
||||
@@ -21,76 +20,6 @@ exclude = [
|
||||
edition = "2024"
|
||||
rust-version = "1.95"
|
||||
|
||||
[workspace.dependencies]
|
||||
# Shared versions and sources; members select features and optional dependencies.
|
||||
anyhow = "1.0"
|
||||
arc-swap = "1.9"
|
||||
async-trait = "0.1.92"
|
||||
atomic-shim = "0.2.0"
|
||||
auto_impl = "1.3.0"
|
||||
base64 = "0.23"
|
||||
bon = "3.10.1"
|
||||
bytecodec = "0.5.0"
|
||||
bytes = "1.12.1"
|
||||
chrono = "0.4.45"
|
||||
cidr = "0.3.2"
|
||||
clap = "4.6.7"
|
||||
crossbeam = "0.8.5"
|
||||
dashmap = "6.2.1"
|
||||
easytier = { version = "2.7.0", path = "easytier", default-features = false }
|
||||
easytier-core = { version = "2.7.0", path = "easytier-core", default-features = false }
|
||||
easytier-ffi = { path = "easytier-contrib/easytier-ffi", default-features = false }
|
||||
easytier-proto = { version = "2.7.0", path = "easytier-proto", default-features = false }
|
||||
futures = "0.3"
|
||||
guarden = "0.3"
|
||||
hmac = "0.13.0"
|
||||
http-body-util = "0.1"
|
||||
hyper = { version = "1", default-features = false }
|
||||
hyper-util = { version = "0.1", default-features = false }
|
||||
indoc = "2.0.7"
|
||||
log = "0.4"
|
||||
mimalloc = "*"
|
||||
once_cell = "1.21"
|
||||
parking_lot = "0.12.5"
|
||||
percent-encoding = "2.3.2"
|
||||
pin-project-lite = "0.2.17"
|
||||
prost = "0.14.4"
|
||||
prost-types = "0.14.4"
|
||||
quanta = "0.12"
|
||||
# rand 0.10 / x25519-dalek 3 require a coordinated RNG trait migration.
|
||||
rand = "0.8.8"
|
||||
reqwest = { version = "0.13.5", default-features = false }
|
||||
rust-i18n = "4"
|
||||
rustls = { version = "0.23.45", default-features = false }
|
||||
# tower-sessions-sqlx-store 0.15 requires SQLx 0.8; SeaORM 2 requires SQLx 0.9.
|
||||
sea-orm = "1.1.20"
|
||||
sea-orm-migration = "1.1.20"
|
||||
serde = "1.0.229"
|
||||
serde_json = "1.0"
|
||||
sha2 = "0.11.0"
|
||||
smoltcp = { version = "0.14.0", default-features = false }
|
||||
sqlx = "0.8.6"
|
||||
strum = "0.28.0"
|
||||
stun_codec = "0.4.0"
|
||||
sys-locale = "0.3"
|
||||
tauri = "2.11.5"
|
||||
tempfile = "3.27.0"
|
||||
thiserror = "2.0"
|
||||
thunk-rs = { git = "https://github.com/EasyTier/thunk.git", default-features = false }
|
||||
tokio = { version = "1.53", default-features = false }
|
||||
tokio-rustls = { version = "0.26", default-features = false }
|
||||
tokio-util = "0.7.19"
|
||||
toml = "1.1.6"
|
||||
tower-http = "0.7"
|
||||
tracing = "0.1"
|
||||
tracing-subscriber = { version = "0.3", default-features = false }
|
||||
url = "2.5"
|
||||
uuid = "1.26.1"
|
||||
winapi = "0.3.9"
|
||||
x25519-dalek = "2.0.1"
|
||||
# zerocopy 0.8 requires migrating the packet layout and casting APIs.
|
||||
zerocopy = "0.7.35"
|
||||
|
||||
[profile.dev]
|
||||
panic = "unwind"
|
||||
debug = 2
|
||||
|
||||
@@ -252,12 +252,8 @@ ios <-.-> nodea <--> nodeb <-.-> id1
|
||||
1. Start EasyTier with WireGuard portal enabled:
|
||||
|
||||
```bash
|
||||
# Register one WireGuard client as virtual peer 10.144.144.3
|
||||
sudo easytier-core -i 10.144.144.1 \
|
||||
--network-secret portal-secret \
|
||||
--vpn-portal wg://0.0.0.0:11013 \
|
||||
--vpn-portal-private-key "$(wg genkey)" \
|
||||
--vpn-portal-client phone=10.144.144.3
|
||||
# Listen on 0.0.0.0:11013 and use 10.14.14.0/24 subnet for WireGuard clients
|
||||
sudo easytier-core -i 10.144.144.1 --vpn-portal wg://0.0.0.0:11013/10.14.14.0/24
|
||||
```
|
||||
|
||||
2. Get WireGuard client configuration:
|
||||
@@ -267,10 +263,10 @@ sudo easytier-core -i 10.144.144.1 \
|
||||
easytier-cli vpn-portal
|
||||
```
|
||||
|
||||
3. In the output configuration, replace a wildcard `Peer.Endpoint` with the
|
||||
public IP/domain of your EasyTier node, then import it. `Interface.Address`
|
||||
is local to that WireGuard client and may be changed to any IPv4 address;
|
||||
EasyTier translates it to the registered virtual-peer address.
|
||||
3. In the output configuration:
|
||||
- Set `Interface.Address` to an available IP from the WireGuard subnet
|
||||
- Set `Peer.Endpoint` to the public IP/domain of your EasyTier node
|
||||
- Import the modified configuration into your WireGuard client
|
||||
|
||||
#### Self-Hosted Public Shared Node
|
||||
|
||||
@@ -300,12 +296,6 @@ sudo easytier-core --network-name mysharednode --network-secret mysharednode
|
||||
|
||||
EasyTier is released under the [LGPL-3.0](https://github.com/EasyTier/EasyTier/blob/main/LICENSE).
|
||||
|
||||
## Responsible Use
|
||||
|
||||
Use EasyTier only for lawful purposes and in compliance with applicable laws
|
||||
and regulations. You are responsible for ensuring that you are authorized to
|
||||
connect to and administer the networks and devices involved.
|
||||
|
||||
## Sponsor
|
||||
|
||||
CDN acceleration and security protection for this project are sponsored by Tencent EdgeOne.
|
||||
|
||||
+6
-13
@@ -250,12 +250,8 @@ ios <-.-> nodea <--> nodeb <-.-> id1
|
||||
1. 启动启用 WireGuard 门户的 EasyTier:
|
||||
|
||||
```bash
|
||||
# 将一个 WireGuard 客户端注册为虚拟 peer 10.144.144.3
|
||||
sudo easytier-core -i 10.144.144.1 \
|
||||
--network-secret portal-secret \
|
||||
--vpn-portal wg://0.0.0.0:11013 \
|
||||
--vpn-portal-private-key "$(wg genkey)" \
|
||||
--vpn-portal-client phone=10.144.144.3
|
||||
# 在 0.0.0.0:11013 上监听,并使用 10.14.14.0/24 子网作为 WireGuard 客户端
|
||||
sudo easytier-core -i 10.144.144.1 --vpn-portal wg://0.0.0.0:11013/10.14.14.0/24
|
||||
```
|
||||
|
||||
2. 获取 WireGuard 客户端配置:
|
||||
@@ -265,9 +261,10 @@ sudo easytier-core -i 10.144.144.1 \
|
||||
easytier-cli vpn-portal
|
||||
```
|
||||
|
||||
3. 如果输出配置中的 `Peer.Endpoint` 是通配地址,将其替换为 EasyTier
|
||||
节点的公网 IP/域名后即可导入。`Interface.Address` 只是客户端本地地址,
|
||||
可以改为任意 IPv4 地址;EasyTier 会把它转换成已注册的虚拟 peer 地址。
|
||||
3. 在输出配置中:
|
||||
- 将 `Interface.Address` 设置为 WireGuard 子网中的可用 IP
|
||||
- 将 `Peer.Endpoint` 设置为您的 EasyTier 节点的公网 IP/域名
|
||||
- 将修改后的配置导入到您的 WireGuard 客户端
|
||||
|
||||
#### 自建公共共享节点
|
||||
|
||||
@@ -299,10 +296,6 @@ sudo easytier-core --network-name mysharednode --network-secret mysharednode
|
||||
|
||||
EasyTier 在 [LGPL-3.0](https://github.com/EasyTier/EasyTier/blob/main/LICENSE) 许可下发布。
|
||||
|
||||
## 使用规范
|
||||
|
||||
请仅将 EasyTier 用于合法用途,并遵守适用的法律法规。使用者有责任确保其已获授权连接和管理相关网络与设备。
|
||||
|
||||
## 赞助
|
||||
|
||||
本项目的 CDN 加速和安全防护由腾讯云 EdgeOne 赞助。
|
||||
|
||||
@@ -184,27 +184,12 @@ wire codecs. It does not own socket I/O or connection policy.
|
||||
- DNS and DNS record resolution;
|
||||
- connector environment observations;
|
||||
- packet ingress and egress;
|
||||
- Host socket operation bridges and handle-based TCP/UDP/listener adapters;
|
||||
- host-owned, message-preserving tunnel endpoints.
|
||||
- Host socket operation bridges and handle-based TCP/UDP/listener adapters.
|
||||
|
||||
Core owns scheduling, backpressure, cancellation, UDP session state, and
|
||||
protocol state even when each actual operation crosses a Host Adapter. A Host
|
||||
Adapter owns the real resource and performs the OS operation.
|
||||
|
||||
For host-owned tunnels, ownership crosses the guest ABI only after a bounded
|
||||
Host listener queue accepts the tunnel. The queue is registered through
|
||||
`CoreHostAdapters` and consumed by the normal `CoreListenerRuntime`, so the
|
||||
tunnel still reaches `PeerAcceptedTunnelHandler` and cannot bypass peer
|
||||
handshake, admission, events, or routing policy. Each Host receive produces
|
||||
one complete `DummyTunnel` payload; no stream framing is added. The Host owns
|
||||
transport-specific message validation and maps a clean close to tunnel EOF.
|
||||
|
||||
Hosts without outbound sockets select `CoreConnectivityMode::InboundOnly` for
|
||||
one instance. That startup plan retains listeners and the peer/router while
|
||||
omitting STUN, outbound connectors, and hole punching. Cargo features only
|
||||
compile Host Adapters; they do not change `CoreInstance` fields, lifecycle, or
|
||||
management semantics.
|
||||
|
||||
The native `NativeHostRuntime` is process-wide and does not retain an instance
|
||||
`GlobalCtx`, namespace guard, socket mark, or connectivity state. Differences
|
||||
between instances travel in each request's `SocketContext`. A narrow
|
||||
@@ -295,11 +280,6 @@ Generated service descriptors and message types remain in `easytier-proto`.
|
||||
- VPN portal client/session policy;
|
||||
- UDP broadcast classification and rewrite policy.
|
||||
|
||||
Each VPN portal client is normalized to one attached-peer IPv4 CIDR. The
|
||||
portable gateway owns that client address and prefix; the hosting network
|
||||
manager's DHCP or static address is not a source of portal client routing
|
||||
facts.
|
||||
|
||||
TUN, raw sockets, transparent-destination lookup, concrete protocol engines,
|
||||
native DNS servers, namespace operations, and route application stay in native
|
||||
Adapters.
|
||||
|
||||
@@ -5,8 +5,9 @@
|
||||
Accepted.
|
||||
|
||||
This document is the implementation plan for restructuring the EasyTier data
|
||||
plane and exposing it through native FFI and the `easytier-go` module. It
|
||||
describes a target architecture, not the current implementation.
|
||||
plane and exposing it through native FFI and the standalone
|
||||
`easytier-go-host` project. It describes a target architecture, not the current
|
||||
implementation.
|
||||
|
||||
The implementation scope is:
|
||||
|
||||
@@ -15,7 +16,7 @@ The implementation scope is:
|
||||
backend;
|
||||
- `easytier-contrib/easytier-ffi`;
|
||||
- the WASI guest ABI implemented by `easytier-core`;
|
||||
- `easytier-go`;
|
||||
- `/data/project/easytier-go-host`;
|
||||
- TCP, UDP, and smoltcp data-plane paths;
|
||||
- moving KCP route selection and source-connection ownership below the
|
||||
`DataPlaneRuntime` Interface without making KCP portable.
|
||||
@@ -986,7 +987,7 @@ Repository: EasyTier.
|
||||
|
||||
### Phase 8: Go `coreabi` and engine
|
||||
|
||||
Repository: EasyTier (`easytier-go`).
|
||||
Repository: `easytier-go-host`.
|
||||
|
||||
- Add typed data-plane guest calls and wire codecs.
|
||||
- Extend the single driver with submit, cancel, close, and completion drain.
|
||||
@@ -995,7 +996,7 @@ Repository: EasyTier (`easytier-go`).
|
||||
|
||||
### Phase 9: Go standard network Adapters and artifact
|
||||
|
||||
Repository: EasyTier (`easytier-go`).
|
||||
Repository: `easytier-go-host`.
|
||||
|
||||
- Add `Dial`, `Listen`, and `ListenPacket`.
|
||||
- Implement TCP, UDP, deadlines, cancellation, close, and error mapping.
|
||||
@@ -1005,8 +1006,7 @@ Repository: EasyTier (`easytier-go`).
|
||||
- Run real two-instance TCP and UDP integration tests.
|
||||
|
||||
Each phase ends in a reviewable commit. Commit messages use a 72-column text
|
||||
width. The complete task receives one final review across both implementation
|
||||
areas.
|
||||
width. The complete task receives one final review across both repositories.
|
||||
The operation-broker commit may receive an additional high-risk incremental
|
||||
review because it contains concurrency logic.
|
||||
|
||||
|
||||
@@ -1,584 +0,0 @@
|
||||
# EasyTier Web Managed Config Incremental Sync Plan
|
||||
|
||||
## Status
|
||||
|
||||
- 状态:Implemented(核心协议、持久化与 Session 增量收敛)
|
||||
- 实施范围:EasyTier Web 的 HTTP 接收、校验、SQLite 持久化和 Session 运行态收敛
|
||||
- 上游依赖:后续由 Console 计算并发送 Patch
|
||||
- 兼容要求:保留现有 Full PUT
|
||||
|
||||
本文记录当前接收端方案。Session 合并已持久化 Patch 的 touched instance IDs,
|
||||
并在运行态收敛时读取这些实例的最新持久化状态。重启、通知丢失或无法安全判断
|
||||
实例 ownership 时沿用 Full reconcile。
|
||||
|
||||
## 1. 背景与结论
|
||||
|
||||
当前 `/validate-token` webhook 已经只交换 token、机器信息和 revision,不再
|
||||
携带完整 managed config 集合。剩余的大集合位于独立的配置发布路径:
|
||||
|
||||
```text
|
||||
PUT /api/internal/users/:user-id/machines/:machine-id/networks
|
||||
```
|
||||
|
||||
Console 每次发布都会向该路径发送完整 Exact Set。实例很多时,请求体、JSON
|
||||
解析、现有配置扫描和逐条 SQLite 写入都随实例总数增长。
|
||||
|
||||
第一阶段采用以下方案:
|
||||
|
||||
1. 保留 PUT,作为完整发布、首次同步和冲突恢复路径。
|
||||
2. 在同一路径增加 PATCH;普通变更只发送完整的单实例 upsert 和删除 ID。
|
||||
3. PATCH 使用 `expected_config_revision` 做 compare-and-swap(CAS)。
|
||||
4. Full/Patch 的配置变更与 revision 更新在一个 SQLite transaction 中提交。
|
||||
5. Patch 只查询和写入 touched instances,不扫描完整 Target。
|
||||
6. 写入成功后通知 Session 本次 expected、target 和 transaction 实际 touched
|
||||
instance IDs。
|
||||
7. Session 只合并 revision 连续的 touched IDs,并以 SQLite 当前状态为准增量
|
||||
收敛;可信 runtime base、通知链或 persisted target 无法证明连续时回退 Full。
|
||||
|
||||
普通变更的接收端成本由:
|
||||
|
||||
```text
|
||||
O(total instances)
|
||||
```
|
||||
|
||||
降为:
|
||||
|
||||
```text
|
||||
wire / JSON / persistence transaction / runtime config apply = O(changed instances)
|
||||
```
|
||||
|
||||
冷启动或 revision 冲突仍需要 `O(total)` 的 Full。这是没有可用基线时传递完整
|
||||
目标状态所必需的成本;如果 Full 超过安全的单请求上限,需另行设计 staged
|
||||
snapshot,而不是直接分页写入 live rows。
|
||||
|
||||
## 2. 目标与非目标
|
||||
|
||||
### 2.1 目标
|
||||
|
||||
1. 普通新增、更新和删除只传输、解析、查询并写入变化实例。
|
||||
2. Config rows 与 persisted revision 原子提交。
|
||||
3. Patch 可安全重试,并能确定性处理并发或乱序请求。
|
||||
4. 保持 user-owned 与 web-owned 配置的 ownership 规则。
|
||||
5. 保持 Full Exact Set 的删除和显式空集合语义。
|
||||
6. 为 Full 和 Patch 设置显式且可测试的容量限制。
|
||||
7. 先部署接收端,再允许 Console 使用 Patch。
|
||||
|
||||
### 2.2 非目标
|
||||
|
||||
1. 修改 `/validate-token` request/response。
|
||||
2. 在本阶段实现 Console 的 diff/cache 逻辑。
|
||||
3. 优化 Core heartbeat 中的完整运行实例上报。
|
||||
4. 实现 Full 分页、上传会话或持久化 delivery FSM。
|
||||
5. 让冷启动 Full 的成本低于 `O(total)`。
|
||||
|
||||
## 3. 必须保持的语义
|
||||
|
||||
### 3.1 Full Exact Set
|
||||
|
||||
Full 表示一个 `(user_id, machine_id)` 下全部期望的 web-owned configs:
|
||||
|
||||
- 请求中存在的实例应被创建或更新;
|
||||
- 已存在但请求中缺失的 web-owned 实例应被删除;
|
||||
- 空集合应删除该 Target 下全部 web-owned 实例;
|
||||
- user-owned 实例不能被覆盖或删除。
|
||||
|
||||
### 3.2 Patch
|
||||
|
||||
Patch 只描述从一个已知 revision 到另一个 revision 的变化:
|
||||
|
||||
- `upserts`:新增或变化实例的完整 config;
|
||||
- `delete_instance_ids`:从目标集合中删除的实例 ID;
|
||||
- `expected_config_revision`:receiver 必须已经处于的 base revision;
|
||||
- `config_revision`:提交完成后的 target revision。
|
||||
|
||||
Patch 不是独立的完整目标。当前 revision 与 expected revision 不一致时,必须
|
||||
返回冲突且不做任何写入。
|
||||
|
||||
### 3.3 Revision invariants
|
||||
|
||||
1. 一个 persisted revision 只对应与其一起提交的 web-owned projection。
|
||||
2. Config mutation 和 revision advancement 必须位于同一 transaction。
|
||||
3. Patch 只能应用在完全匹配的 expected revision 上。
|
||||
4. 当前 revision 已等于 target revision 时,返回幂等成功且不重复写入。
|
||||
5. Publisher 不得为不同目标状态复用同一个 target revision。
|
||||
6. 任何其他写路径只要改变 web-owned row,就必须在同一 transaction 中清除
|
||||
managed revision;否则未来 Patch 会基于错误的 base。
|
||||
7. Persisted revision 与 Session applied revision 保持为两个不同事实。HTTP
|
||||
成功只代表本地持久化完成,不代表 Core 已经应用。
|
||||
|
||||
## 4. HTTP contract
|
||||
|
||||
### 4.1 保留 Full PUT
|
||||
|
||||
路径不变:
|
||||
|
||||
```text
|
||||
PUT /api/internal/users/:user-id/machines/:machine-id/networks
|
||||
```
|
||||
|
||||
现有 JSON shape 保持兼容:
|
||||
|
||||
```json
|
||||
{
|
||||
"managed_network_configs": [
|
||||
{
|
||||
"instance_id": "11111111-1111-1111-1111-111111111111",
|
||||
"network_config": {}
|
||||
}
|
||||
],
|
||||
"config_revision": "target-revision",
|
||||
"expected_config_revision": "base-revision"
|
||||
}
|
||||
```
|
||||
|
||||
`expected_config_revision` 保持当前含义:
|
||||
|
||||
- 字段缺失:兼容旧调用者,不检查 base;
|
||||
- 空字符串:要求当前 persisted revision 不存在;
|
||||
- 非空字符串:要求当前 revision 与该值相等。
|
||||
|
||||
新 Console 必须发送 expected revision。省略 expected 的形式只用于旧版本兼容
|
||||
和明确的运维修复。
|
||||
|
||||
`config_revision` 的处理:
|
||||
|
||||
- 非空:配置与 target revision 原子提交;
|
||||
- 缺失:保留旧 Full 请求兼容,但清除已有 managed revision,因此该结果不能
|
||||
作为后续 Patch base;
|
||||
- 空字符串:拒绝为 400。
|
||||
|
||||
Revisioned Full 遇到 user-owned instance ID 冲突时整体失败。Legacy
|
||||
unrevisioned Full 保持当前兼容行为:跳过 user-owned row,且绝不覆盖它。
|
||||
|
||||
### 4.2 新增 Patch
|
||||
|
||||
同一资源增加:
|
||||
|
||||
```text
|
||||
PATCH /api/internal/users/:user-id/machines/:machine-id/networks
|
||||
```
|
||||
|
||||
请求格式:
|
||||
|
||||
```json
|
||||
{
|
||||
"upserts": [
|
||||
{
|
||||
"instance_id": "11111111-1111-1111-1111-111111111111",
|
||||
"network_config": {}
|
||||
}
|
||||
],
|
||||
"delete_instance_ids": [
|
||||
"22222222-2222-2222-2222-222222222222"
|
||||
],
|
||||
"config_revision": "target-revision",
|
||||
"expected_config_revision": "base-revision"
|
||||
}
|
||||
```
|
||||
|
||||
Patch contract:
|
||||
|
||||
1. 两个 revision 字段均必填、非空且不能相同。
|
||||
2. `upserts` 中的 instance ID 不得重复。
|
||||
3. `delete_instance_ids` 中的 ID 不得重复。
|
||||
4. 同一个 ID 不得同时出现在 upsert 和 delete 中。
|
||||
5. 每个 upsert 必须携带该实例的完整 `NetworkConfig`,不支持字段级 JSON
|
||||
Patch。
|
||||
6. `network_config` 内部的 instance ID 不受信任,receiver 使用 envelope 中的
|
||||
`instance_id` 进行归一化。
|
||||
7. 删除不存在的 ID 是幂等 no-op。
|
||||
8. Upsert 或 delete 碰到 user-owned row 时,整个 Patch 返回冲突且不写入。
|
||||
9. 不允许从“receiver revision 不存在”的未知状态直接 Patch;使用 Full 建立
|
||||
Exact Set 和首个 revision。
|
||||
10. 空 Patch 不能把 revision 改成另一个值;这通常表示 publisher revision
|
||||
计算错误,因此返回 400。
|
||||
|
||||
### 4.3 HTTP outcomes
|
||||
|
||||
| 条件 | HTTP | 语义 |
|
||||
| --- | ---: | --- |
|
||||
| Full/Patch 新提交成功 | 204 | Config 和 revision 已持久化 |
|
||||
| Target revision 已经存在 | 204 | 幂等成功,无 row mutation |
|
||||
| Expected revision 不匹配 | 409 | 零写入,调用者重新观察或发送 Full |
|
||||
| User-owned ownership 冲突 | 409 | 零写入,不能自动覆盖 |
|
||||
| 非法 ID、重复、交集或非法 config | 400 | 调用 contract 错误 |
|
||||
| 请求超过 byte limit | 413 | 未进入 reconciliation |
|
||||
| 条目数或单 config 超过限制 | 422 | 超出接收端容量 contract |
|
||||
| SQLite 错误 | 500 | Transaction rollback |
|
||||
|
||||
409 返回机器可读字段:revision 冲突为
|
||||
`code=managed_config_revision_conflict` 并在已知时带
|
||||
`current_config_revision`;ownership 冲突为
|
||||
`code=managed_config_ownership_conflict`。响应不得返回配置内容。日志不得记录
|
||||
token、secret 或完整 config JSON。
|
||||
|
||||
## 5. Receiver architecture
|
||||
|
||||
### 5.1 Module responsibilities
|
||||
|
||||
| Module | 本阶段职责 |
|
||||
| --- | --- |
|
||||
| Internal HTTP Adapter | 内部鉴权、body/count limit、DTO 解析、HTTP 状态映射 |
|
||||
| `ClientManager` | 解析 Target,调用 managed-config Interface,成功后通知 Session |
|
||||
| `client_manager::managed_config` | Full/Patch 规则、归一化、typed outcome |
|
||||
| `Db` Adapter | CAS、ownership fence、批量 mutation、revision transaction |
|
||||
| Session runtime reconciliation | 校验 applied/base/target fence,增量收敛 touched instances;断链时 Full |
|
||||
|
||||
HTTP Adapter 不实现 ownership、diff 或 transaction 逻辑。PUT 和 PATCH 共用
|
||||
managed-config Module,避免两套规则逐渐分叉。
|
||||
|
||||
### 5.2 Internal Interface
|
||||
|
||||
Module 接收两种 intent:
|
||||
|
||||
```text
|
||||
Full {
|
||||
desired_configs,
|
||||
target_revision: Option<Revision>,
|
||||
expected_revision: Any | Exact(Option<Revision>)
|
||||
}
|
||||
|
||||
Patch {
|
||||
upserts,
|
||||
delete_instance_ids,
|
||||
target_revision: Revision,
|
||||
expected_revision: Revision
|
||||
}
|
||||
```
|
||||
|
||||
返回 typed outcome:
|
||||
|
||||
```text
|
||||
Applied {
|
||||
previous_revision,
|
||||
target_revision
|
||||
}
|
||||
|
||||
AlreadyApplied {
|
||||
target_revision
|
||||
}
|
||||
|
||||
RevisionConflict {
|
||||
expected_revision,
|
||||
current_revision
|
||||
}
|
||||
|
||||
OwnershipConflict {
|
||||
instance_id
|
||||
}
|
||||
```
|
||||
|
||||
Validation error 与 database error 保持独立类型。HTTP handler 只负责将这些结果
|
||||
映射到 section 4.3 的状态码。
|
||||
|
||||
## 6. Receiver implementation
|
||||
|
||||
### 6.1 Validation and normalization
|
||||
|
||||
在打开 SQLite write transaction 之前完成:
|
||||
|
||||
- request byte/count/per-entry limit;
|
||||
- UUID、重复 ID 和 upsert/delete 交集校验;
|
||||
- config key 拼写归一化;
|
||||
- envelope instance ID 覆盖 nested identity;
|
||||
- `NetworkConfig` 反序列化。
|
||||
|
||||
这样非法大请求不会长时间占用 SQLite writer lock。Ownership 必须在 transaction
|
||||
内重新查询,因为 transaction 外的结果可能已过期。
|
||||
|
||||
当 request 带 target revision 时,可以先做一次 O(1) revision read;如果当前值
|
||||
已经等于 target,可直接返回 `AlreadyApplied`,避免完整 config 归一化。任何可能
|
||||
写入的请求仍必须在 transaction 内再次检查 revision。
|
||||
|
||||
### 6.2 Full transaction
|
||||
|
||||
在同一个 SQLite connection 上执行:
|
||||
|
||||
1. `BEGIN IMMEDIATE`。
|
||||
2. 读取 `(user_id, machine_id)` 当前 persisted revision。
|
||||
3. 若 supplied target 已经是 current,返回 `AlreadyApplied`。
|
||||
4. 检查 optional expected revision。
|
||||
5. 只读取现有 row 的 `(instance_id, source)`;不加载无关 config JSON。
|
||||
6. 执行 user-owned ownership fence。
|
||||
7. 批量 upsert 全部 desired web-owned rows。
|
||||
8. 计算并批量删除 `existing_web_ids - desired_ids`。
|
||||
9. 最后写入 supplied target revision;legacy unrevisioned Full 则删除旧 revision。
|
||||
10. Commit。
|
||||
|
||||
任一步骤失败都 rollback。Full 仍是 `O(total)`,但不会再逐条独立提交,也不会
|
||||
出现“部分 rows 已更新、revision 仍是旧值”的中间持久状态。
|
||||
|
||||
### 6.3 Patch transaction
|
||||
|
||||
在同一个 SQLite connection 上执行:
|
||||
|
||||
1. `BEGIN IMMEDIATE`。
|
||||
2. 读取 current revision。
|
||||
3. 如果 current 等于 target,返回 `AlreadyApplied`。
|
||||
4. 如果 current 不等于 expected,返回 `RevisionConflict`。
|
||||
5. 只查询 upsert/delete IDs 的 source。
|
||||
6. 任一 touched ID 属于 user 时,返回 `OwnershipConflict`。
|
||||
7. 批量 upsert changed configs。
|
||||
8. 批量删除 requested web-owned IDs。
|
||||
9. 最后写入 target revision。
|
||||
10. Commit。
|
||||
|
||||
Patch 禁止:
|
||||
|
||||
- list 全部 Target rows;
|
||||
- 重算完整 Target digest;
|
||||
- 根据 touched IDs 之外的数据做 stale-row scan。
|
||||
|
||||
因此其数据库工作量只随 `upserts + deletes` 增长。
|
||||
|
||||
### 6.4 Bounded batch SQL
|
||||
|
||||
批量操作不构造无限长 SQL。根据 SQLite bind-variable limit 选取固定 batch size,
|
||||
并在同一个 transaction 内分批执行:
|
||||
|
||||
- multi-row `INSERT ... ON CONFLICT DO UPDATE`;
|
||||
- 带 `source = web` 条件的 batch delete;
|
||||
- 只返回 instance ID/source 的 ownership query。
|
||||
|
||||
Patch statement 数量应为 `O(ceil(delta / batch_size))`;Full 为
|
||||
`O(ceil(total / batch_size))`。每个 accepted request 只有一个 transaction 和
|
||||
一次 revision 写入。
|
||||
|
||||
### 6.5 Alternate-write revision invalidation
|
||||
|
||||
现有其他路径可能 save、delete、disable 或改变 web-owned row。若这些路径修改
|
||||
rows 后仍保留旧 managed revision,Patch CAS 会把错误状态当作正确 base。
|
||||
|
||||
因此所有 config mutation Adapter 必须遵守:
|
||||
|
||||
1. 判断 mutation 是否改变 web-owned row;
|
||||
2. 在一个 transaction 中执行 mutation;
|
||||
3. 在 commit 前删除该 Target 的 managed revision。
|
||||
|
||||
Managed Full/Patch 在同一 transaction 内先完成 mutation,最后写入新的 target
|
||||
revision。只影响 user-owned rows 的操作不清除 managed revision。
|
||||
|
||||
本方案不在 `/validate-token` 读取 revision 时重算完整 digest,否则周期性验证会
|
||||
重新变成 `O(total)`。Revision 完整性由所有写入 Adapter 局部维护。
|
||||
|
||||
### 6.6 Locking, cancellation and notification
|
||||
|
||||
现有 per-target process-local lock 可以保留,用于减少同进程的重复工作,但它不
|
||||
承担正确性。正确性由 SQLite transaction 和 CAS 提供。
|
||||
|
||||
- Transaction 内不执行 Session RPC、网络请求或无关 async 工作。
|
||||
- HTTP future 在 commit 前取消时,transaction drop 必须 rollback。
|
||||
- Commit 后即使 response 或 notification 丢失,persisted state 仍然有效;调用者
|
||||
用同一 target retry 会得到幂等成功。
|
||||
- 只有带 target revision 的 `Applied` 才通知匹配的 live Session;
|
||||
`AlreadyApplied`、legacy unrevisioned Full、conflict 和失败不重复通知。
|
||||
- Notification 必须发生在 commit 之后。
|
||||
- Full notification 将 pending reconcile hint 提升为 Full,触发完整收敛。
|
||||
- Patch notification 携带 expected revision、target revision、upsert IDs 和本次
|
||||
transaction 实际接受删除的 web-owned IDs。请求删除但数据库原本不存在的 ID
|
||||
仍是 no-op,不能借机删除 Core 中同 ID 的 user-owned 实例。
|
||||
- Session 将尚未应用的 Patch touched IDs 合并为一个 Dirty set,并始终以 SQLite
|
||||
最新 revision 下的 rows 为准。它不重放历史 Patch,也不维护 Patch queue 或
|
||||
delivery FSM。只有 incoming expected 等于 pending target 的通知才能合并;Dirty
|
||||
hint 保留最早 expected 和最新 target。乱序、不连续或无法证明顺序的通知将 hint
|
||||
提升为 Full。多个连续 Patch 积压时,旧 round 由 runtime epoch 拦截,下一 round
|
||||
直接收敛到最新 target。
|
||||
- Session 分开记录对外报告的 applied revision 和内部可信的 runtime base。开始任何
|
||||
runtime side effect 前清除 applied;Patch round 的 side effects 完全包含在 Dirty
|
||||
set 中,因此失败或被新通知拦截时仍保留最早 runtime base,以便按最新持久化状态
|
||||
重试 Dirty set。Full round、direct mutation、授权失败或 Session ownership 中断会
|
||||
清除 runtime base。
|
||||
- 只有可信 runtime base 等于 Dirty 最早 expected,并且 SQLite persisted revision
|
||||
等于 Dirty 最新 target 时,才允许增量 round。重连后 runtime base 未知、通知
|
||||
丢失,或 SQLite 已经提交了更靠后的 revision 而通知尚未送达时都回退 Full,避免
|
||||
不完整的 Dirty set 把完整 target revision 误标为已应用。
|
||||
- 增量 round 逐个读取 Dirty set 中的最新 row。仍然存在且启用的 web-owned row
|
||||
使用其最新 config;已经删除的 row 进入 delete set;遇到 disabled 或非 web-owned
|
||||
row 时回退 Full,以保留 ownership 规则。完成前再次校验 persisted target
|
||||
revision;只有全部 touched instances 成功且 target 仍相同,才推进 applied
|
||||
revision。
|
||||
- 任何通过 EasyTier Web mutation route 直接 Run、Save、Delete 或切换实例状态的
|
||||
操作在执行前和结束后(包括部分 side effect 后返回错误)都清除 Session applied
|
||||
revision、可信 runtime base 与 pending hint,增加运行配置 cache epoch,并唤醒一次 Full
|
||||
reconcile。旧 round 只有 epoch 仍匹配时才能推进 applied revision;新一轮不得
|
||||
信任 mutation 前缓存的 runtime config。否则 runtime-only mutation 或 Core 成功、
|
||||
SQLite 失败的复合 mutation 可能在 persisted revision 不变时破坏 Patch base 的
|
||||
完整性。
|
||||
|
||||
## 7. Capacity contract
|
||||
|
||||
当前 route 没有显式 body limit,Axum `Json` 使用依赖版本的默认 2 MiB 限制。
|
||||
生产容量不应依赖框架隐式默认值。
|
||||
|
||||
本阶段定义并测试四个独立限制:
|
||||
|
||||
- decoded request 最大 bytes;
|
||||
- Full entries / Patch upserts 最大数量;
|
||||
- Patch deletes 最大数量;
|
||||
- 单个 `network_config` 最大 bytes。
|
||||
|
||||
限制只应用于 internal managed-config route,不提高其他 public route 的 limit。
|
||||
具体默认值不能拍脑袋确定:先采集 1k/10k representative configs 的 encoded
|
||||
size 和 peak memory,再选择有明确 headroom 的默认值及硬上限。
|
||||
|
||||
提高 Full limit 只是确保 fallback 覆盖已支持的生产规模,不是稳态优化。请求压缩
|
||||
同样只能降低 wire bytes,不能降低 JSON materialization 和 SQLite 工作量,因此
|
||||
不作为 Patch 的前置条件。
|
||||
|
||||
## 8. Failure and recovery
|
||||
|
||||
| Failure | Receiver state | Caller action |
|
||||
| --- | --- | --- |
|
||||
| Invalid payload | Unchanged | 修复请求,不重试相同 payload |
|
||||
| Capacity exceeded | Unchanged | 使用较小 Patch;Full 需检查支持规模 |
|
||||
| Revision conflict | Unchanged | 重新观察;有 base 时重算 Patch,否则 Full |
|
||||
| Ownership conflict | Unchanged | 解决 ownership,不能自动覆盖 |
|
||||
| SQLite error before commit | Rolled back | 从相同 observed revision 重试 |
|
||||
| Response lost after commit | Target committed | 同一 target retry,幂等成功 |
|
||||
| Process exits before revisioned Session notify | Target committed | 现有 revision reconciliation 恢复 |
|
||||
| Alternate web-row mutation | Revision atomically cleared | 下一次观察触发 Full 修复 |
|
||||
| Console cache loss | Receiver unchanged | Console 发布 Full |
|
||||
|
||||
Receiver 不保存 Patch delivery ledger。Publisher 根据自己的完整目标和 receiver
|
||||
当前 revision 重算 Patch 或选择 Full。
|
||||
|
||||
## 9. Rollout and rollback
|
||||
|
||||
### 9.1 Receiver-first rollout
|
||||
|
||||
1. 为现有 Full 行为增加 characterization tests。
|
||||
2. 将 Full rows/revision 改为一个 atomic transaction。
|
||||
3. 为 alternate web-row mutation 增加 revision invalidation。
|
||||
4. 增加 PATCH、typed conflict、capacity limits 和 metrics。
|
||||
5. 在 Console 仍只发送 PUT 时部署到全部 EasyTier Web 实例。
|
||||
6. 完成旧 Console PUT、新 Console PUT/PATCH contract 测试。
|
||||
7. 最后启用 Console Patch 发布。
|
||||
|
||||
Patch capability 不通过 `/validate-token` 协商。部署顺序就是 compatibility gate;
|
||||
这样不会把配置能力重新耦合回鉴权 Interface。
|
||||
|
||||
Console 遇到 409 可以 re-observe 后发送 Full。它不能把 404、401 或 malformed
|
||||
response 当作旧 receiver 并静默换一种 mutation contract;出现 404 表示接收端
|
||||
部署门禁未满足。
|
||||
|
||||
### 9.2 Rollback
|
||||
|
||||
- Console 尚未发送 Patch 时,EasyTier Web 可正常回滚。
|
||||
- Console 已发送 Patch 后,先回滚 Console,使调用恢复为 PUT,再回滚 Web。
|
||||
- PUT 在整个发布周期保持兼容。
|
||||
- Patch 和 Full 写入相同 rows/revision,不需要格式级数据迁移。
|
||||
|
||||
本方案不新增 persistent table。现有 Target/instance unique index 应覆盖 touched-ID
|
||||
查询;若实现时需要新 index,必须先用实际 SQLite query plan 证明。
|
||||
|
||||
## 10. Verification
|
||||
|
||||
### 10.1 Contract tests
|
||||
|
||||
- 现有 Full JSON 继续接受。
|
||||
- 空 Full 删除所有 web-owned rows,保留 user-owned rows。
|
||||
- Patch add/update/delete 与等价 Full 得到相同最终 projection。
|
||||
- Duplicate/overlap/invalid config 返回 400 且零写入。
|
||||
- Patch 缺少 revision 返回 400。
|
||||
- Revision conflict 返回 409 和 current revision,不返回 config。
|
||||
- Byte/count/per-entry limits 分别有确定性测试。
|
||||
|
||||
### 10.2 Transaction and ownership tests
|
||||
|
||||
- 在 upsert 后、delete 后、revision write 前注入错误,rows/revision 全部 rollback。
|
||||
- Revisioned Full/Patch 的 user-owned collision 整体 rollback。
|
||||
- 删除不存在的 ID 幂等成功。
|
||||
- 两个 target 从同一 base 并发时,一个成功、一个 409。
|
||||
- 相同 target retry 只有第一次写入,第二次为 no-op success。
|
||||
- Alternate save/delete/disable web row 与 revision invalidation 原子提交。
|
||||
- User-owned-only mutation 不清除 managed revision。
|
||||
- 数据库重连后,任一 persisted revision 都对应完整一致的 rows。
|
||||
|
||||
### 10.3 Scale tests
|
||||
|
||||
至少使用 1k 和 10k representative entries:
|
||||
|
||||
- 单实例 Patch 的 decoded bytes、row reads、writes 和 statement count 不随 Target
|
||||
总实例数增长;
|
||||
- Patch 不执行 list-all query;
|
||||
- Full 使用 bounded batches 和一个 transaction;
|
||||
- Revision read 保持 O(1);
|
||||
- 超限 Full 稳定返回 413/422,而不是耗尽进程内存;
|
||||
- 并发请求无 deadlock,且 CAS 结果确定。
|
||||
|
||||
Session 测试还必须验证:连续 Patch 的 Dirty IDs 会合并且保留最早 expected;未知
|
||||
或不匹配的 runtime base、乱序/不连续通知使用 Full;增量 round 读取最新 row;已经
|
||||
删除的 web-owned row 只删除对应 Dirty ID;Full notification 覆盖 Dirty hint;目标
|
||||
revision 已变化或 touched runtime apply 失败时不推进 applied revision;直接 runtime
|
||||
mutation 使 revision 与运行配置 cache 同时失效。运行态 Config Get/Patch/Run/Delete
|
||||
数量应随 touched instances 增长。为确认运行实例身份而进行的一次 list/meta RPC
|
||||
可以保留,它不发送或重写所有实例配置。
|
||||
|
||||
## 11. Observability
|
||||
|
||||
每个请求记录结构化字段,但不记录 config 内容:
|
||||
|
||||
- mode:`full` / `patch`;
|
||||
- user/machine scope;
|
||||
- request bytes;
|
||||
- desired/upsert/delete count;
|
||||
- normalization、target-lock wait、transaction duration;
|
||||
- SQL statement/batch count;
|
||||
- result:applied、already-applied、revision-conflict、ownership-conflict、
|
||||
invalid、oversized、database-error;
|
||||
- Session notification 是否发送。
|
||||
|
||||
Rollout acceptance:
|
||||
|
||||
- Console 启用后 Patch 占普通变更的绝大多数;
|
||||
- 单实例变化的 request size 与 SQLite cost 与单实例成比例;
|
||||
- conflict rate 可解释且稳定;
|
||||
- 支持规模内的 Full 没有 413/422;
|
||||
- validate-token latency 不随 Target 实例数增长。
|
||||
|
||||
## 12. 后续优化
|
||||
|
||||
### 12.1 Session runtime delta apply(已实现)
|
||||
|
||||
Patch commit outcome 已携带 expected、target 和 transaction 实际 touched IDs。
|
||||
Session 只合并 expected/target 连续的 Dirty IDs,并在每一轮从 SQLite 读取最新
|
||||
target revision 对应的当前 rows;因此正常积压只增加 Dirty set,不需要保留中间
|
||||
revision 的 Patch queue。可信 runtime base 必须等于 Dirty 最早 expected;Patch
|
||||
side effect 失败可保留该 base 重试,通知丢失、乱序、进程重启或新 Session 尚无
|
||||
runtime base 时回退 Full。Full
|
||||
notification、disabled row 或 ownership 无法证明时也回退 Full。
|
||||
|
||||
### 12.2 Chunked Full
|
||||
|
||||
不能把 Full Exact Set 直接分页写入 live rows:接收端无法在中间页判断哪些旧
|
||||
实例最终应删除,crash 也会暴露半套目标。
|
||||
|
||||
如果测量证明单请求 Full 无法覆盖必须支持的冷恢复规模,需要单独设计带
|
||||
snapshot ID、staging rows、expiry、finalize 和 atomic swap 的协议。在出现数据
|
||||
证明前不新增该状态机。
|
||||
|
||||
## 13. Implementation files and checklist
|
||||
|
||||
主要涉及:
|
||||
|
||||
- `easytier-web/src/restful/network.rs`
|
||||
- `easytier-web/src/client_manager/mod.rs`
|
||||
- `easytier-web/src/client_manager/managed_config.rs`
|
||||
- `easytier-web/src/db/mod.rs`
|
||||
- 对应 contract、database 和 managed-config tests
|
||||
|
||||
完成条件:
|
||||
|
||||
- [x] 现有 Full compatibility tests 固定。
|
||||
- [x] Full config rows 与 revision 原子提交。
|
||||
- [x] Alternate web-owned mutations 原子清除 revision。
|
||||
- [x] PATCH contract 和 typed 409 实现。
|
||||
- [x] Patch 只查询、写入 touched IDs。
|
||||
- [ ] Bulk SQL 遵守 tested bind-count bound。
|
||||
- [ ] Route byte/count/per-entry limits 有文档和测试。
|
||||
- [x] User-owned rows 不能被 Full/Patch 覆盖或删除。
|
||||
- [x] Empty Full 语义保持。
|
||||
- [x] Applied/AlreadyApplied/conflict 的通知行为符合设计。
|
||||
- [x] Session 在 revision 连续时只收敛 touched instances,断链时使用 Full。
|
||||
- [ ] 1k/10k scale 与 concurrent CAS tests 通过。
|
||||
- [ ] Receiver-first compatibility matrix 通过。
|
||||
@@ -1,470 +0,0 @@
|
||||
# KCP 控制报文可靠性与旧版兼容设计
|
||||
|
||||
日期:2026-09-14。
|
||||
|
||||
状态:已按本文实现并提交;当前实现、验收结果及仍未定位的异常见
|
||||
[实现与验证记录](kcp-control-reliability-validation-2026-09-14.md)。
|
||||
本文保留设计要求,具体通过范围以验证记录为准。
|
||||
|
||||
## 1. 背景与问题边界
|
||||
|
||||
此前已修复 TCP 代理任一方向 EOF 导致双向转发提前退出、KCP accept
|
||||
通知队列满后连接交接丢失、接收缓冲排空与提前 FIN 的处理,以及握手前
|
||||
心跳触发 RST 的竞态。具体版本及结果见
|
||||
[半关闭验证记录](tcp-proxy-half-close-validation-2026-09-14.md)。
|
||||
|
||||
仍有两个问题:
|
||||
|
||||
1. 已证实的握手最终 ACK 丢失:源端收到 SYNACK 后认为连接建立,目标端
|
||||
仍停留在 SynReceived;服务端先发 greeting 的业务因此超时。
|
||||
2. 一次未主动注入丢包的反向半关闭超时:发送端出现输出队列 Full,FIN
|
||||
已进入端点输出队列,但没有证据证明它何时到达对端。失败日志未记录
|
||||
客户端已收字节数,尚不能确定缺少的是数据、EOF,还是两者。后续
|
||||
TRACE 重跑通过,不足以消除或解释原失败。
|
||||
|
||||
KCP 数据传输自身的确认重传没有覆盖外层 SYN、SYNACK、握手确认和 FIN。
|
||||
这是连接状态机的局部协议设计问题。修复需要明确控制报文的重复处理、
|
||||
完成条件和状态寿命,无需重做 KCP 数据传输或 EasyTier 路由架构。
|
||||
|
||||
本设计解决已证实的握手恢复缺口,并为 FIN 增加可靠确认。同时继续调查
|
||||
第二项异常;不能将新增 FIN 重传直接当作该异常已经修复的证明。
|
||||
|
||||
## 2. 目标、范围与不变量
|
||||
|
||||
目标:
|
||||
|
||||
- 新旧节点双向正常互通,滚动升级不得要求所有节点同时升级。
|
||||
- 新节点之间,控制报文在重试预算内丢失、重复或乱序时能够恢复。
|
||||
- 连接只交接一次,应用数据不重复交付,EOF 不越过已接收数据。
|
||||
- 半关闭只关闭一个发送方向,另一方向仍可长期传输。
|
||||
- 无法恢复时有界结束并报告错误,不能用正常 EOF 掩盖失败。
|
||||
- 关闭记录、待发送控制报文和重试任务均有明确生命周期。
|
||||
|
||||
范围:kcp-sys 的报文定义、连接状态机、端点调度与清理、必要的流错误
|
||||
传播,以及 EasyTier 的依赖 pin、集成测试和验证记录。
|
||||
|
||||
不新增路由能力公告、PeerFeatureFlag 字段、配置开关或应用层请求重试;
|
||||
不修改 TCP flow-key 修复、QUIC 协议、KCP 数据分段和拥塞算法。保留现有
|
||||
connect API、调用方超时和业务测试的 5 秒 socket 超时。
|
||||
|
||||
协议不变量:
|
||||
|
||||
1. 模式由本连接握手确定,交付应用后不能切换。
|
||||
2. 握手状态变化和是否产生应答是两件事;可靠模式允许状态不变但应答。
|
||||
3. 已经进入应用交接路径的连接,重复控制包不能再次 accept。
|
||||
4. FIN_ACK 只确认对方 FIN,不表示自己也关闭发送方向。
|
||||
5. 只有正常收到对端 FIN 且排空接收数据,才能向应用报告正常 EOF。
|
||||
6. FIN 确认成功后,FIN 重试期限不能限制正常半关闭连接的寿命。
|
||||
7. 重复包不能延长握手、FIN 重试和最终关闭记录的固定截止时间。
|
||||
|
||||
## 3. 版本基线与兼容证据
|
||||
|
||||
| 用途 | kcp-sys revision |
|
||||
| --- | --- |
|
||||
| 旧协议基线 | `d7427c22d764deb1860a7d37acc446ed5033464c` |
|
||||
| 本设计实施基线,已含前述局部修复 | `b37ee660fb70bb6d816fb8bbc08b140e55e7218b` |
|
||||
|
||||
源码检查确认:
|
||||
|
||||
- 旧 header 含三个 u32、一个 flags 字节及一个 `rsv` 保留字节,总长
|
||||
14 字节;旧实现不读取 `rsv`。
|
||||
- 旧 SYNACK 使用新建且清零的 header,不回显 SYN 的 `rsv`。
|
||||
- 旧 FSM 收到重复 SYN 时可能构造 RST,但状态不变;外层
|
||||
`KcpConnectionState::handle_packet` 丢弃状态未变化时的输出,
|
||||
因此已检查的旧端点不会实际发送该 RST。
|
||||
- `rsv` 未被读取、状态不变时抑制输出的行为,在已检查历史
|
||||
`a5f4b4e`、`37f653c`、`0f2fea1`、`9ce5c08`、`0f0a055`、
|
||||
`71eff18`、`d7427c2` 中一致。
|
||||
|
||||
这些是源码与历史检查,不是上述每个 revision 的运行测试。正式兼容
|
||||
声明必须对应实际测试的 EasyTier 版本及其锁定依赖,不能扩展为所有
|
||||
未检查的历史版本或第三方修改版本都已验证。
|
||||
|
||||
实现基线源码:
|
||||
[报文定义](https://github.com/EasyTier/kcp-sys/blob/b37ee660fb70bb6d816fb8bbc08b140e55e7218b/src/packet_def.rs)、
|
||||
[状态机](https://github.com/EasyTier/kcp-sys/blob/b37ee660fb70bb6d816fb8bbc08b140e55e7218b/src/state.rs)、
|
||||
[端点](https://github.com/EasyTier/kcp-sys/blob/b37ee660fb70bb6d816fb8bbc08b140e55e7218b/src/endpoint.rs)。
|
||||
|
||||
## 4. 报文格式与连接内协商
|
||||
|
||||
### 4.1 字段定义
|
||||
|
||||
保持 14 字节 header、连接标识字段、已有 flags 和 KCP DATA payload
|
||||
布局不变。使用已有 `rsv` 字节:
|
||||
|
||||
| 值 | 定义 |
|
||||
| --- | --- |
|
||||
| `0` | 旧协议,后文称 legacy |
|
||||
| `1` | 本文可靠控制协议,后文称可靠模式 |
|
||||
|
||||
新增 FIN_ACK 使用 flags 当前未分配的最高位 `0x80`。它是独立报文,
|
||||
flags 必须恰好为 FIN_ACK,`rsv=1`,payload 为空。不能用 FIN|ACK
|
||||
表示关闭确认,因为旧端会把 FIN 位解释为对端关闭发送方向。
|
||||
|
||||
可靠模式报文约束:
|
||||
|
||||
| 报文 | flags | payload |
|
||||
| --- | --- | --- |
|
||||
| SYN | SYN | 原有连接元数据,重传保持完全相同 |
|
||||
| SYNACK | SYN\|ACK | 空 |
|
||||
| 最终握手确认 | ACK\|DATA | 空 |
|
||||
| 数据及 KCP 内部确认 | ACK\|DATA | 原有 KCP payload |
|
||||
| 关闭发送方向 | FIN | 空 |
|
||||
| 关闭确认 | FIN_ACK (`0x80`) | 空 |
|
||||
| 重置、心跳 | 原有 RST、PING、PING\|PONG | 沿用现有定义 |
|
||||
|
||||
可靠模式报文均带 `rsv=1`。legacy 的后续报文仍使用现有格式和 `rsv=0`;
|
||||
新源端最初提出协商的 SYN 是可能发给旧目标端的唯一保留字节扩展。
|
||||
|
||||
只有匹配连接标识、当前握手阶段和合法 flags/payload 的 SYNACK,
|
||||
才可以确认模式。不能通过 PING/PONG 确认:旧端的心跳应答会复制输入
|
||||
报文,可能保留它并不理解的 `rsv`。
|
||||
|
||||
### 4.2 协商步骤
|
||||
|
||||
1. 新源端发送 SYN,`rsv=1`,进入尚未确认模式的 SynSent。
|
||||
2. 新目标端收到 SYN:`rsv=1` 时回复 SYNACK `rsv=1`,记录可靠模式
|
||||
候选状态;`rsv=0` 时从头使用 legacy,回复 SYNACK `rsv=0`。
|
||||
3. 旧目标端忽略 `rsv`,自然回复 SYNACK `rsv=0`。新源端收到它后,
|
||||
在交付应用前确定 legacy,最终 ACK 及后续报文都使用旧模式。
|
||||
4. 新源端收到合法 SYNACK `rsv=1` 后确认可靠模式,发送最终 ACK
|
||||
`rsv=1`。目标端收到匹配模式的确认后完成握手并登记待 accept。
|
||||
5. 源端继续在收到合法 SYNACK 后完成 connect,不增加第四次握手或
|
||||
额外 RTT。最终 ACK 丢失后的恢复规则见第 5 节。
|
||||
|
||||
未识别的 SYN `rsv` 值不得被当作可靠能力,可按不支持扩展响应 legacy
|
||||
SYNACK。新源端只接受它实际支持的 SYNACK 模式 `0` 或 `1`;未知值
|
||||
报告协议错误,不启用增强行为。重复 SYN 不允许更改已有连接的模式或
|
||||
连接元数据。已协商可靠模式后,模式不匹配的控制包不得触发降级。
|
||||
|
||||
节点重启或降级后的新连接重新协商;不缓存按节点永久生效的协议模式。
|
||||
不承诺跨进程重启维持既有连接。
|
||||
|
||||
### 4.3 初始 SYN 与旧版兼容
|
||||
|
||||
首个 SYN 或 SYNACK 丢失时,源端尚不可能拿到协商确认。因此 SynSent
|
||||
期间允许在原截止时间内重传完全相同的 SYN,不以前置能力公告或已收到
|
||||
SYNACK 为条件。
|
||||
|
||||
在已检查的旧端点中,重复 SYN 不引出 RST,但也不会让旧目标端重发
|
||||
SYNACK。所以新源端→旧目标端仍保留旧目标端 SYNACK 丢失时的限制。
|
||||
这一行为必须由真实旧依赖运行测试确认,不能只测试重写的 legacy 模型。
|
||||
|
||||
新目标端对旧源端保持 legacy 行为,不主动开启 SYNACK 重传。
|
||||
legacy 分支继续保留“状态不变则抑制 FSM 输出”的现有行为;不得将
|
||||
可靠模式的应答修复无条件套用,否则会改变旧连接的 RST 行为。
|
||||
|
||||
## 5. 可靠握手与重复报文
|
||||
|
||||
源端在 SynSent 重传 SYN,目标端在可靠 SynReceived 重传 SYNACK。
|
||||
所有重传使用原连接 ID,不创建额外连接或重试业务请求。
|
||||
|
||||
| 当前状态 | 输入 | 动作 |
|
||||
| --- | --- | --- |
|
||||
| 无状态 | 合法 SYN | 保存模式与连接元数据,建立 SynReceived,回复 SYNACK |
|
||||
| SynReceived | 相同 SYN | 重新回复 SYNACK,不替换状态或延长截止时间 |
|
||||
| SynReceived | 匹配的 ACK\|DATA | 完成握手,登记待 accept 一次,停止 SYNACK 定时重传 |
|
||||
| SynSent | 合法 SYNACK | 确定模式,发送最终 ACK,完成 connect |
|
||||
| Established 或半关闭 | 重复 SYNACK | 回复最终 ACK,不重新创建流、不改变关闭状态 |
|
||||
| 已握手、尚未最终释放 | 相同 SYN | 可重发原 SYNACK,不重复 accept、不恢复已关闭方向 |
|
||||
| 已握手 | 重复最终 ACK | 幂等处理,不重复交接连接 |
|
||||
|
||||
ACK\|DATA 带有效 KCP payload 时仍按 KCP 处理。若它同时完成握手,
|
||||
不得为了修复握手而重复交付 payload;必须验证 accept 前到达数据时,
|
||||
现有 KCP 重传和接收路径最终完整交付字节。
|
||||
|
||||
最终 ACK 丢失的正常恢复:
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant S as 源端
|
||||
participant D as 目标端
|
||||
S->>D: SYN
|
||||
D->>S: SYNACK
|
||||
S--xD: 最终 ACK 丢失
|
||||
Note over S: connect 已返回
|
||||
Note over D: 保持 SynReceived
|
||||
D->>S: 重传 SYNACK
|
||||
S->>D: 重发最终 ACK
|
||||
Note over D: 登记待 accept 一次
|
||||
```
|
||||
|
||||
### 5.1 最终 ACK 丢失后立即 FIN
|
||||
|
||||
空流的发送缓冲本来就是空的,connect 返回后可以立即 shutdown;不能
|
||||
假设 FIN 一定晚于最终 ACK 到达。
|
||||
|
||||
可靠 SynReceived 收到匹配模式和连接标识的合法 FIN 时,将它视为
|
||||
隐含的握手确认,同时执行:
|
||||
|
||||
1. 完成握手并停止 SYNACK 重传。
|
||||
2. 登记待 accept 一次,保存对端发送方向已关闭。
|
||||
3. 回复 FIN_ACK;accept 创建流时继承对端关闭状态。
|
||||
4. 接收排空后报告 EOF,本端仍可回复数据。
|
||||
|
||||
不能先进入 Closed 再返回 RST,也不能要求应用先发送非空数据才能
|
||||
完成握手。该规则仅用于可靠模式。
|
||||
|
||||
## 6. FIN、确认与双向关闭
|
||||
|
||||
### 6.1 本端关闭发送方向
|
||||
|
||||
保留现有顺序:应用关闭发送队列,发送任务把排队数据交给 KCP,并等待
|
||||
`waitsnd == 0`,然后进入 FIN 待确认阶段。重试预算从这一阶段开始计算,
|
||||
不能从应用开始传输或尚未排空数据时开始计算。
|
||||
|
||||
FIN 发送入队成功不代表对端已收到。记录待确认状态、下一次重传时间和
|
||||
固定截止时间,直到收到有效 FIN_ACK 或连接明确失败。收到对端 FIN
|
||||
本身不等同于本端 FIN 已被确认。
|
||||
|
||||
保留现有 AsyncWrite shutdown 的本地关闭语义,不将其返回成功解释为
|
||||
对端已确认。关闭确认和最终回收由端点负责;后续超时通过仍存活的流和
|
||||
诊断状态表达,不能变成伪造的正常 EOF。
|
||||
|
||||
### 6.2 收到 FIN 或 FIN_ACK
|
||||
|
||||
- 收到 FIN:记录对端发送方向已关闭,并安排 FIN_ACK。接收任务仍需
|
||||
把已确认接收的数据交给应用,排空后才产生 EOF。
|
||||
- FIN_ACK 可以先于应用读完数据发送;它确认端点收到了 FIN,不确认
|
||||
应用已经消费数据。因此接收状态和缓冲生命周期不能随确认提前删除。
|
||||
- 重复 FIN:重复应答 FIN_ACK,不重复产生应用 EOF,不重置连接。
|
||||
- 收到 FIN_ACK:仅当本端确实已经发送 FIN,且模式、连接标识、flags
|
||||
和 payload 全部合法时,停止本端 FIN 重传。
|
||||
- 提前、重复或与当前阶段无关的 FIN_ACK 不推进状态、不关闭写方向。
|
||||
|
||||
发送和接收两个方向分别记录完成情况。现有 Established、LocalClosed、
|
||||
PeerClosed、Closed 不足以单独表达“本端 FIN 已发但未确认”,应在现有
|
||||
连接状态内补足该信息,不另建一套连接所有权或通用重试框架。
|
||||
|
||||
### 6.3 正常回收与短暂关闭记录
|
||||
|
||||
可靠模式最终回收分两步:
|
||||
|
||||
1. 本端 FIN 已确认、对端 FIN 已收到,并且接收任务排空后,释放 KCP
|
||||
数据对象、应用通道和数据任务;在原 state_map 保留最小关闭记录。
|
||||
2. 固定保留期结束后删除该记录。重复报文不延长保留期。
|
||||
|
||||
关闭记录仅保存原连接 ID、协议模式、正常关闭原因、回复重复 FIN 所需
|
||||
信息和过期时间。它继续回复 FIN_ACK;合法在途 PING/PONG 可以忽略,
|
||||
不得因现有 Closed 心跳路径而回复 RST。迟到 SYN/SYNACK 不得重新
|
||||
accept、创建数据对象或恢复连接;已经完成的数据包可以丢弃。
|
||||
|
||||
双方同时关闭时,即使只有一方收到 FIN_ACK,先完成的一方也要依靠该
|
||||
记录继续回答另一方的 FIN 重传。不能沿用“Closed 且 recv_done 就立即
|
||||
删除所有状态”的判断。
|
||||
|
||||
legacy 继续使用原有回收规则。可靠模式的短暂记录会使状态表在有限
|
||||
时间内多保留条目,容量验收必须单独计算,不能继续要求所有内部状态
|
||||
在原 15 秒观察点清零。
|
||||
|
||||
## 7. 定时、队列与失败路径
|
||||
|
||||
### 7.1 拟定默认参数
|
||||
|
||||
以下为实现初值,需由定点丢包与延迟测试验证后固定;调整须在验证记录
|
||||
说明,不能为了让测试通过而延长业务超时。
|
||||
|
||||
| 参数 | 初值 / 规则 |
|
||||
| --- | --- |
|
||||
| SYN、SYNACK、FIN 首次重传间隔 | 200 ms |
|
||||
| 后续重传 | 指数退避,单次间隔上限 2 s |
|
||||
| 源端握手期限 | 调用方现有 `connect(timeout_dur)`,不延长 |
|
||||
| 可靠目标端握手期限 | 首次收到 SYN 后 60 s,不由心跳或重复包续期 |
|
||||
| FIN 待确认期限 | 进入 FIN 阶段后 60 s |
|
||||
| 正常关闭记录保留期 | 65 s,覆盖 FIN 重试窗口并留调度余量 |
|
||||
|
||||
目标端和 FIN 的 60 秒预算是端点控制状态的资源边界,不替代应用更短的
|
||||
超时。网络永久不可达时,正确结果是可诊断的超时和最终清理。
|
||||
|
||||
关闭记录保留期必须不少于另一端允许的最大 FIN 重试窗口加余量,因此
|
||||
FIN 期限不能变成各端任意可调且互不知情的参数。若后续版本改变这些
|
||||
上界,需要重新审查版本间兼容性。
|
||||
|
||||
FIN 已确认后立即解除 FIN 重试期限;正常半关闭继续按既有连接存活规则
|
||||
处理。仅本端 FIN 未确认才受该期限限制,反向有数据或 PONG 也不能使
|
||||
未确认 FIN 无限续期。
|
||||
|
||||
### 7.2 端点调度与输出队列
|
||||
|
||||
使用单个端点控制调度任务和现有 state_map;不为每次重传创建任务,不
|
||||
扩容或改成无界队列。按最近的控制截止时间唤醒,新状态通过通知更新
|
||||
调度;没有待处理控制状态时,避免对全部空闲连接进行高频扫描。
|
||||
|
||||
状态记录是控制报文待处理事实的来源。输出队列暂满时保留该事实,稍后
|
||||
再尝试;不能像原 accept 通知一样丢失后就再无恢复机会。
|
||||
|
||||
- 用短临界区和非阻塞入队,禁止持有 state_map/conn_map 锁跨 await。
|
||||
- 每次实际入队前重新检查状态、连接标识和期限,防止陈旧重传在取消或
|
||||
回收后继续发送。回收不能与入队检查形成复活旧连接的竞态。
|
||||
- SYN/SYNACK/FIN 的重传受各自固定预算限制。重复请求所需的 ACK 或
|
||||
FIN_ACK 应答可合并为待发状态,不能累计无界报文列表。
|
||||
- 应答暂时无法入队时由调度器继续尝试;应答不会获得独立的无限寿命。
|
||||
合并应答记录从首次待发起最多保留 60 秒,重复请求不续期;正常关闭
|
||||
记录中的应答还受该记录更早的截止时间限制。入队成功即撤销待发标记。
|
||||
单纯的应答待发过期只清除该应答,不因此关闭仍合法存活的连接;请求
|
||||
发送方最终依靠自身握手或 FIN 重试期限判定成功或失败。
|
||||
- 队列满不能阻塞整个控制调度任务,也不能阻止它处理其他连接的截止
|
||||
时间。不得把“尝试入队成功”统计成“对端确认成功”。
|
||||
|
||||
### 7.3 取消、错误与连接 ID
|
||||
|
||||
- connect future 取消或超时:撤销该连接的重试与本地所有权;目标端
|
||||
未完成握手的状态按其固定期限退出,重复包不能延长。
|
||||
- 有效 RST、端点销毁或 FIN 重试耗尽:停止控制重试、唤醒等待任务,
|
||||
保留错误与正常 EOF 的区别。只发送必要的现有重置通知,不建立 RST
|
||||
确认和重试协议。
|
||||
- 清理失败连接不能重新产生待 accept;迟到的控制响应不能恢复已取消
|
||||
的 connect future。
|
||||
- ConnId 分配必须避开所有仍有效的状态,包括正常关闭记录。回绕不能
|
||||
复用仍在保留期内的 ID;会话标识变化后,旧报文不得作用于新会话。
|
||||
- 保留期之后无限迟到的包不在可靠恢复窗口内。不得通过永久保存关闭
|
||||
记录来试图覆盖无界的网络延迟。
|
||||
|
||||
## 8. 兼容性矩阵与降级
|
||||
|
||||
| 源端 | 目标端 | 预期模式 | 必须满足 |
|
||||
| --- | --- | --- | --- |
|
||||
| 旧 | 旧 | legacy | 对照基线,保留既有行为 |
|
||||
| 新 | 旧 | SYNACK `rsv=0` 确定 legacy | 正常互通,不发 FIN_ACK,不要求目标支持新确认 |
|
||||
| 旧 | 新 | 从 SYN `rsv=0` 确定 legacy | 正常互通,不主动开启新 SYNACK/FIN 重传语义 |
|
||||
| 新 | 新 | 本连接握手确认可靠模式 | 握手与关闭在重试预算内恢复 |
|
||||
|
||||
兼容的含义是旧节点仍可互通,且混合组合不因新代码发生行为退化。它不
|
||||
意味着旧目标端自动获得 accept 修复,或旧连接获得其未实现的 ACK/FIN
|
||||
恢复能力。必须把遗留失败与新增失败分别记录。
|
||||
|
||||
发布或回退影响后续新连接的协商。协议模式固定在连接内,禁止基于后续
|
||||
路由公告变化对活跃连接进行升级或降级。旧可执行程序重启后不承担继续
|
||||
解释原进程可靠模式连接的义务。
|
||||
|
||||
## 9. 实施拆分
|
||||
|
||||
实施使用独立协议修复分支,以 `b37ee660` 及 EasyTier 当前已验证修复
|
||||
为基线,不将协议改动混入原 TCP flow-key patch。
|
||||
|
||||
1. **先建立兼容测试夹具。** 使用实际旧依赖和当前基线端点,确认 `rsv`
|
||||
处理、旧 SYNACK、重复 SYN 和旧模式输出门控。明确失败基线。
|
||||
2. **实现协商及状态处理。** 增加模式、合法报文校验、可靠模式幂等应答,
|
||||
保留 legacy 路径。补上最终 ACK 丢失加立即 FIN 的交叉状态。
|
||||
3. **实现统一控制调度及可靠关闭。** 补足握手/FIN 期限、FIN_ACK、正常
|
||||
关闭记录、队列暂满、取消和错误清理。
|
||||
4. **完成依赖验证后接入 EasyTier。** 更新依赖 pin 与锁文件,运行真实
|
||||
代理路径、混合版本和跨平台验证,提交可追溯记录。
|
||||
|
||||
协商声明代表完整可靠控制协议,不能在只实现握手恢复、尚未实现 FIN_ACK
|
||||
时对外声明 `rsv=1`。中间提交可以用于审阅,但仅完整实现且验证通过的
|
||||
依赖版本可被 EasyTier 发布使用。
|
||||
|
||||
代码主要落点为 kcp-sys 的 `packet_def.rs`、`state.rs`、`endpoint.rs`,
|
||||
按实际错误传播需要调整 `stream.rs`;不预先拆出通用协议框架。EasyTier
|
||||
预计只需依赖更新、测试和记录,不需要修改 protobuf 或节点能力公告。
|
||||
|
||||
## 10. 验证与验收标准
|
||||
|
||||
### 10.1 确定性协议测试
|
||||
|
||||
在端点输入/输出之间设置测试用报文过滤器,按连接 ID、报文类型和次数
|
||||
精确丢弃、延迟或重复报文。可使用可控时钟测试期限,不依赖随机 netem
|
||||
才能触发边界,也不通过扩大生产超时使测试通过。
|
||||
|
||||
| 场景 | 验收断言 |
|
||||
| --- | --- |
|
||||
| 分别丢首个 SYN、SYNACK、最终 ACK | 新新组合恢复,同一连接只交接一次 |
|
||||
| 连续丢多次控制包后恢复链路 | 截止时间内恢复,重试次数与退避符合预期 |
|
||||
| 最终 ACK 丢失后空流立即 FIN | 目标完成握手、返回 EOF、仍能回复数据,无 RST |
|
||||
| 最终 ACK 丢失后首个 DATA 到达 | 正确完成握手,应用字节完整且不重复 |
|
||||
| 重复 SYN/SYNACK/ACK,含半关闭阶段 | 不重复创建连接,不恢复已经关闭的方向 |
|
||||
| 丢单向 FIN 或 FIN_ACK | 重传恢复;确认不关闭另一方向 |
|
||||
| 双方同时 FIN,单侧或双侧确认丢失 | 正确排空,正常关闭记录继续应答,最终回收 |
|
||||
| FIN 先于 accept 或 connect 返回 | 空请求、空响应均正确得到 EOF |
|
||||
| 接收缓冲尚未被应用读完 | FIN_ACK 不导致缓冲和数据任务提前释放 |
|
||||
| FIN 已确认后持续半关闭超过 60 s | 反向仍可传输,不受 FIN 期限误杀 |
|
||||
| 正常关闭记录收到重复 FIN、旧心跳 | 应答或忽略,无 RST,不延长固定保留期 |
|
||||
| SYN/FIN 永久丢失、控制输出队列长时间满 | 到期退出,无无限任务或状态残留 |
|
||||
| 队列临时满后恢复 | 待发事实不丢失,其他连接和清理继续运行 |
|
||||
| connect 取消、RST、端点销毁与重试并发 | 不复活旧状态,等待者正确结束,无伪 EOF |
|
||||
| ConnId 回绕、会话变化、迟到报文 | 不命中仍在保留期的旧 ID,不污染新会话 |
|
||||
|
||||
### 10.2 真实旧版兼容测试
|
||||
|
||||
测试夹具必须运行旧依赖的真实 endpoint,不能只在新实现上设置 legacy
|
||||
标志代替旧版本。首次验收覆盖 `d7427c2`、`b37ee660` 与新实现的组合,
|
||||
并记录 EasyTier 发布验证实际选用的旧二进制版本和哈希。
|
||||
|
||||
除四种组合的双向数据与关闭外,额外断言:
|
||||
|
||||
- 旧端收到 SYN `rsv=1` 仍按旧协议回复 SYNACK `rsv=0`。
|
||||
- 重复 SYN 不引出混合连接新增 RST;legacy 输出门控保持原有行为。
|
||||
- 混合组合捕获不到 FIN_ACK;旧端不必识别任何新控制语义。
|
||||
- PING/PONG、错误连接 ID、非法 flags 或未知 SYNACK 模式不能确认能力。
|
||||
- 新连接在节点升级、回退后重新选择正确模式,不沿用节点级缓存。
|
||||
- 旧版原有半关闭或丢包失败作为对照保留,不将它们写成新协议已通过。
|
||||
|
||||
### 10.3 EasyTier 流量与平台验证
|
||||
|
||||
Linux 需要 root 的测试在现有 `rust` 容器中运行。复用正常 target,权限
|
||||
问题通过修复所有权解决,不另开编译目录。
|
||||
|
||||
- 完整三节点组合及 ACL、配置更新、端口转发、断连测试。
|
||||
- TCP/KCP/QUIC × 内核/smoltcp 六种模式,检查未修改协议没有退化。
|
||||
- 每组合 300 次短连接、16 并发、5 秒 socket 超时;KCP 每栈追加三轮,
|
||||
与已有每版本 2,400 次 KCP 结果对照。
|
||||
- 空请求、256 KiB 请求后 EOF、1 MiB 响应,以及服务端先半关闭后客户端
|
||||
才发送 256 KiB 的反向场景,均逐字节核对。
|
||||
- `netem delay 10ms 3ms loss 1%` 下持续双连接与短连接;定点控制包丢失
|
||||
由协议测试证明,随机丢包实验用于验证整体行为。
|
||||
- 新旧两方向真实二进制互通;无丢包与丢包结果分别记录。
|
||||
- Linux、macOS、Windows 原生依赖测试及网关测试;格式与严格 Clippy。
|
||||
缺少组件、未运行的项目不得记录为通过。
|
||||
|
||||
### 10.4 未解释的反向超时
|
||||
|
||||
复现脚本必须记录超时时已收到的字节数、是否收到 EOF、源端口、连接 ID,
|
||||
并在双方记录 FIN/FIN_ACK 的入队、发送和接收时间。补足原失败缺少的
|
||||
证据,区分数据缺失、关闭通知缺失、队列延迟和状态处理错误。
|
||||
|
||||
恢复实际链路的 FIN 丢失,只能证明这一类注入故障已修复;仍需解释原
|
||||
异常或明确保留未定位项。不得用重跑通过覆盖原始失败。
|
||||
|
||||
### 10.5 资源与交付门槛
|
||||
|
||||
分别统计:应用代理连接、KCP 数据对象、半开握手、未确认 FIN、正常关闭
|
||||
记录、FD 和 RSS。保留期内的轻量记录属于设计成本,不能混同于活跃连接
|
||||
泄漏;其数量约受每秒关闭连接数乘以保留期约束,需实测内存成本。
|
||||
|
||||
静止并超过握手/FIN 期限及关闭记录保留期后,所有应回收状态必须消失。
|
||||
使用重复负载周期检查资源是否持续累积,并检查端点空闲及大量并发时
|
||||
控制调度的 CPU 成本。不能仅凭一次 FD 回到基线宣布不存在泄漏。
|
||||
|
||||
交付必须满足:确定性恢复用例通过;实际旧版兼容用例没有新增失败;
|
||||
错误路径有界清理;原有矩阵没有新退化;所有异常如实保留。完整最终
|
||||
代码 diff 由独立子代理审查,只处理高置信度真实缺陷。验证记录绑定
|
||||
提交、依赖 revision、二进制哈希、准确命令及原始日志。
|
||||
|
||||
## 11. 当前进度与证据
|
||||
|
||||
已完成:连接级版本协商、握手与 FIN 恢复、FIN_ACK、重复控制处理、
|
||||
正常关闭记录、队列饱和处理与取消清理。kcp-sys 最终提交为
|
||||
`3ef5c4161faf99940f3ed51efd43cef0cbc02b4f`,EasyTier 最终依赖接入为
|
||||
`ee02b8f7`。首次验收已测试真实旧实现 `d7427c2` 和 `b37ee660`。
|
||||
后续测试整理仅保留 `d7427c2` 长期基线;当前维护版本与依赖 pin 见
|
||||
[实现与验证记录](kcp-control-reliability-validation-2026-09-14.md)。
|
||||
|
||||
三平台原生依赖测试、Linux 完整矩阵及实际流量的本轮结果单独记录,
|
||||
不沿用上一轮局部修复的通过数。最终代码审查没有 blocker / major,
|
||||
一项首次 SYNACK 调度竞态 minor 按用户规则记录待办。
|
||||
|
||||
仍需保留的边界:原反向超时没有足够证据做最终归因;混合版本保留
|
||||
legacy 的控制恢复限制;有限负载和应用级资源采样不能代替生产规模
|
||||
长期内存、CPU 和容量验证。所有实测异常及未执行项见关联验证记录。
|
||||
|
||||
此前调查原始产物位于:
|
||||
|
||||
```text
|
||||
/data/project/proxy-close-validation-20260914/
|
||||
```
|
||||
|
||||
关键证据:`remaining-loss-handshake.md`、`loss-handshake-evidence.log`、
|
||||
`delivery-reverse-timeout.md`、`fin-send-path-old-new.txt`、
|
||||
`delivery-manifest.json`、`traffic-summary.md`。上述绝对路径是本机
|
||||
调查产物位置;仓库读者可通过关联验证记录了解结论与证据限制。
|
||||
@@ -1,296 +0,0 @@
|
||||
# KCP 控制报文可靠性实现与验证(2026-09-14)
|
||||
|
||||
本文记录
|
||||
[协议设计](kcp-control-reliability-design-2026-09-14.md)
|
||||
的实现与验证,独立于此前的 TCP flow-key 和半关闭局部修复。
|
||||
|
||||
## 兼容测试维护整理
|
||||
|
||||
当前维护版本为 kcp-sys `268533568d734ae89dc89603078da3ca522effe1`。
|
||||
保留 `d7427c22` 作为长期兼容基线,移除中间版本 `b37ee660` 的
|
||||
`kcp-sys-baseline` dev-dependency 与三项重复用例;单次使用的宏展开为
|
||||
普通测试函数。四项旧版兼容测试继续覆盖能力协商、双向数据、半关闭及
|
||||
重复 SYN;15 项 library 与 12 项协议回归不变。
|
||||
|
||||
整理后 Linux 共 31/31 测试通过,格式与严格 Clippy 通过。EasyTier
|
||||
同步依赖 pin 与锁文件,并通过 `cargo +1.95 check --locked -p easytier
|
||||
--features full`。此次只整理测试与测试依赖,协议源码未改变。
|
||||
历史三平台 34/34 和全部流量结果仍属于下述 `3ef5c416` 实现验证,
|
||||
没有重写为整理后版本的执行结果。中间基线作为调查证据保留在本文。
|
||||
本次日志位于 `/data/project/kcp-compat-cleanup-validation/`。
|
||||
|
||||
## 版本与实现
|
||||
|
||||
- kcp-sys 基线:`b37ee660fb70bb6d816fb8bbc08b140e55e7218b`。
|
||||
- kcp-sys 初版实现:`c84733d4479b40a299d51f4c5b8bb02ccacadc68`。
|
||||
- kcp-sys 最终实现:`3ef5c4161faf99940f3ed51efd43cef0cbc02b4f`,补齐
|
||||
可靠模式下未知连接 RST 输出队列饱和时的非阻塞处理。
|
||||
- EasyTier 基线:`851e7523`;接入提交:`4fedbdd1`、`ee02b8f7`,
|
||||
仅修改依赖 pin 和 Cargo.lock。
|
||||
- 最终真实流量二进制:`final-easytier-core`,SHA-256:
|
||||
`962f2480cb611ceb6cab293560d0dfb337218594cdc298a08f0e83801a2d17b8`。
|
||||
它在接入提交前构建,源码及依赖内容与该提交一致;识别产物以哈希和
|
||||
依赖 revision 为准,不单凭内嵌的 EasyTier git 版本字符串。
|
||||
|
||||
依赖已发布到 `EasyTier/kcp-sys` 的独立分支
|
||||
`fix/control-reliability-20260914`,远端引用核对为上述最终 revision。
|
||||
EasyTier 任务分支为 `fix/kcp-control-reliability`。
|
||||
|
||||
实现保留 14 字节 header,通过 SYN/SYNACK 的 `rsv` 协商可靠控制模式。
|
||||
收到旧 SYNACK 后固定使用 legacy,旧源端连接新目标端也从头使用 legacy。
|
||||
只有协商为可靠模式的连接使用独立 FIN_ACK 和新增恢复逻辑。
|
||||
|
||||
可靠模式在原连接 ID 上恢复 SYN、SYNACK 和 FIN;重复握手不重复交接。
|
||||
最终 ACK 丢失后的空 FIN 可以同时确认握手并报告对端半关闭,反向仍能
|
||||
发送数据。FIN_ACK 只确认收到 FIN,不关闭本端发送方向,也不提前释放
|
||||
尚未被应用读取的接收数据。
|
||||
|
||||
控制请求和待发送应答在原连接状态中保存,由端点统一调度。输出队列
|
||||
暂满不会丢失待发事实或延长固定期限;取消 connect 同步撤销本地状态。
|
||||
状态检查、控制入队和清理保持一致锁序,不持锁跨 await。
|
||||
|
||||
初始重传间隔 200 ms、指数退避至 2 s;源端使用调用方连接超时,目标端
|
||||
握手及 FIN 待确认期限为 60 s。正常双向关闭且接收排空后释放数据对象,
|
||||
原状态表保留 65 s 的关闭记录,继续应答重复 FIN,并避免迟到心跳引出
|
||||
RST。FIN 一旦确认,正常半关闭不受 FIN 重试期限限制。
|
||||
|
||||
## 自动化结果
|
||||
|
||||
| 验证 | 结果 |
|
||||
| --- | --- |
|
||||
| Linux kcp-sys | 15 library + 12 协议回归 + 7 真实旧依赖兼容,34/34 |
|
||||
| macOS kcp-sys | 相同 34/34,两个 example target 通过 |
|
||||
| Windows kcp-sys | 相同 34/34,两个 example target 通过 |
|
||||
| Linux 两个 example target、格式、严格 Clippy | 通过 |
|
||||
| macOS 格式、严格 Clippy | 通过 |
|
||||
| Windows 格式、Clippy | 所选 stable 缺少组件,未执行 |
|
||||
| Linux 原生网关测试 | 200/200 |
|
||||
| macOS、Windows 原生网关测试 | 各 200/200 |
|
||||
| EasyTier 完整三节点及补充集成测试 | 276/276,834.639 s |
|
||||
| EasyTier Linux 严格 Clippy | 通过 |
|
||||
|
||||
macOS 首次获取依赖遇到 GitHub TLS 错误,随后导入本机真实 Git 对象与
|
||||
checkout,在依赖 revision 不变的情况下离线测试;没有改成 path 依赖,
|
||||
也没有用新实现替换旧依赖。Windows 原始日志为 UTF-16LE,另保存 UTF-8
|
||||
副本。两平台复用原 target,没有另开编译目录绕过权限问题。
|
||||
|
||||
协议修改集中在 kcp-sys,EasyTier core 源码没有变化。原生网关验证检查
|
||||
与现有相同 core 源码的兼容行为,不能替代上面的真实新旧 KCP 端点测试。
|
||||
远端应用 manifest 仍锁定旧依赖 `d7427c2`,网关命令只选择 easytier-core;
|
||||
关键转发文件 `tcp_proxy_service.rs` 哈希与本地一致。因此不能将这组
|
||||
网关测试描述为完整原生应用已经接入最终新依赖。
|
||||
|
||||
## 失败到通过的对照
|
||||
|
||||
- 丢弃第一份最终握手 ACK:基线客户端 connect 返回,服务端 accept
|
||||
超过 5 s 仍未完成;实现后约 0.25 s 恢复,并成功发送服务端 greeting。
|
||||
- 新源端连接真实旧依赖:基线仅因 SYN 尚未声明协商能力而未达到新协议
|
||||
测试要求;实现后 SYN 提出 `rsv=1`、旧 SYNACK 回复 `rsv=0`,后续
|
||||
数据与关闭均走 legacy。该项验证协商功能,不把它描述为旧版互通 bug。
|
||||
- 原有 RST 单测曾在新连接上注入 `rsv=0` 的合成 RST,因可靠模式拒绝
|
||||
不匹配模式而超时;测试改为注入该连接实际协商版本的 RST 后通过。
|
||||
模式不匹配的报文不能用于证明正常 RST 错误传播失败。
|
||||
- 新增双向大缓冲夹具最初单次写入超过既有 KCP send 的分片限制,出现
|
||||
`Err(-2)`。调整为与原有测试一致的 16 KiB 分块,仍验证双向各
|
||||
200 KiB 总数据和原 5 s 期限;本补丁没有修改既有单次大写入限制。
|
||||
|
||||
- 最终补查发现初版 `c84733d` 对未知可靠 FIN 的 RST 仍使用阻塞发送。
|
||||
填满输出队列后,新 SYN 不能在 100 ms 内进入状态表;改为可靠模式
|
||||
的无状态应答使用 try_send 后通过。需要重试的有状态控制仍由状态表
|
||||
保管;legacy 发送路径不变。
|
||||
|
||||
原始失败日志保留,未使用扩大业务超时或重跑通过覆盖失败记录。
|
||||
|
||||
## 协议与生命周期覆盖
|
||||
|
||||
12 项公有 API 协议回归使用真实端点及按报文类型过滤的链路:
|
||||
|
||||
- 连续丢 SYN、连续丢 SYNACK、丢最终 ACK 后的服务端 greeting。
|
||||
- 所有空最终 ACK 均丢失,空 FIN 直接完成握手,EOF 后仍可回复。
|
||||
- 单向 FIN 或 FIN_ACK 丢失,另一方向仍可传输。
|
||||
- 双向 200 KiB 缓冲、双方首个关闭确认丢失、延后读取与排空。
|
||||
- 半关闭后重复 SYN/SYNACK/ACK;握手 ACK 丢失时 DATA 乱序、重复。
|
||||
- FIN 确认后推进 61 s,再恢复实际时钟,仍可反向传输。
|
||||
- PONG 保留 `rsv=1`、非法 SYNACK flags、非空 SYNACK 均不能确认模式。
|
||||
- 未知 SYNACK version 返回 `InvalidProtocolVersion`,不启用新模式。
|
||||
|
||||
15 项 library 测试包含原有 10 项以及五项生命周期与队列测试:取消 connect
|
||||
立即释放状态;重复 SYN 与满输出队列不延长半开期限;未确认 FIN 超时
|
||||
唤醒读端并释放状态;正常关闭记录应答迟到包、不引出 RST、不被延长,
|
||||
保留期间跳过相同 ConnId,期满后删除旧记录而不影响新连接;满输出
|
||||
队列下未知可靠 FIN 不能阻止后续 SYN 进入握手。
|
||||
|
||||
可控时钟只用于测试期限,不修改生产时间常量。数据测试仍通过实际 KCP
|
||||
发送、接收及 AsyncRead/AsyncWrite 路径。
|
||||
|
||||
## 真实旧依赖兼容
|
||||
|
||||
dev-dependency 固定并实际运行 `d7427c2`、`b37ee660` 两个历史实现,
|
||||
没有用新代码上的 legacy 开关模拟旧端。
|
||||
|
||||
7 项测试覆盖旧 SYN 处理探针和两个基线的新旧双向连接、256 KiB 双向
|
||||
数据、半关闭、重复 SYN。报文捕获断言:新源初始 SYN 可为 `rsv=1`,
|
||||
其余混合连接报文均为 `rsv=0`;没有 FIN_ACK、新增 RST 或重复 accept。
|
||||
|
||||
`d7427c2` 原有正常关闭被报告为 BrokenPipe 的行为仍在对照中保留。
|
||||
兼容意味着旧节点仍可互通,不意味着它自动获得新协议的恢复能力。
|
||||
|
||||
## 实际代理流量
|
||||
|
||||
实验使用独立 namespace,底层 UDP,
|
||||
覆盖 TCP/KCP/QUIC 与内核/smoltcp 六种模式。固定每轮 300 次短连接、
|
||||
16 并发、5 s socket 超时,逐字节校验大小请求、纯空请求及反向半关闭。
|
||||
|
||||
验证分为两个阶段;不将初版流量统计冒充最终版本结果。
|
||||
|
||||
### 初版 c84733d
|
||||
|
||||
二进制 `protocol-easytier-core` 的 SHA-256 为
|
||||
`f41ff98762ed1ce8691d8f83b8c9e47acc4548c76432a17cd2daba36e2cc7b80`。
|
||||
|
||||
- 六模式主矩阵:1,800 次短连接、12 次大小请求半关闭、6 次反向
|
||||
半关闭、192 次纯空请求,全部通过。
|
||||
- KCP 每栈追加三轮,合并主矩阵共 2,400 次 KCP 短连接,全部通过。
|
||||
- `netem delay 10ms 3ms loss 1%` 六模式:1,800 次短连接、12 次大小
|
||||
半关闭、6 次反向、192 次纯空及每组合 15 s 双连接持续流量,全部通过。
|
||||
- 新旧双方向各六模式:3,600 次短连接、24 次大小半关闭、384 次纯空
|
||||
和每组合 3 s 持续流量通过;**反向半关闭为 11/12,存在一次失败**。
|
||||
|
||||
旧端为依赖 b37 的 `delivery-easytier-core`,SHA-256 为
|
||||
`4635810ac9a536258f9b7ff606e5e1a5eb0243cb0f187825ba6f721d92dfc4fe`。
|
||||
失败发生在新 source → 旧 destination 的 KCP/smoltcp,连接
|
||||
`conv=3560055726`、源端口 `40748`:客户端已经收到完整 1 MiB,随后
|
||||
等待 EOF 超过 5 s;因此未进入后发 256 KiB 阶段。旧目标端在
|
||||
10:11:08.953 进入 LocalClosed,直到客户端超时才见客户端方向关闭。
|
||||
DEBUG 不能确定旧端 FIN 后续的发送、到达或处理点,不能直接定性为
|
||||
FIN 丢失,也不能把混合版本测试写成全部通过。
|
||||
|
||||
另一次有界 KCP/kernel 丢包 TRACE 测试中,300 次短连接及全部半关闭
|
||||
通过。实际捕获的控制事件全部使用 version 1:连接 `1468864199` 的
|
||||
重复 SYNACK 相隔约 200 ms,目标随后收到 ACK,业务完成;连接
|
||||
`1468864161` 的重复 FIN 后收到 FIN_ACK。它们证明实际链路使用了
|
||||
新控制恢复路径;单轮成功不代表任意丢包条件下都能成功。
|
||||
|
||||
### 最终 3ef5c416
|
||||
|
||||
- 六模式主矩阵全部通过:1,800 次短连接、12 次大小半关闭、6 次反向
|
||||
半关闭、192 次纯空请求。
|
||||
- 六模式随机丢包全部通过:相同流量规模,另每组合 15 s 双连接持续
|
||||
逐字节回显。原 5 s socket 超时不变,没有添加应用重试。
|
||||
- 混合版本无丢包,两连接方向 × 两个 KCP 栈全部通过:1,200 次短连接、
|
||||
8 次大小半关闭、4 次反向及 128 次纯空请求。该轮通过不覆盖初版
|
||||
混合实验中的 EOF 超时记录。
|
||||
- KCP 每栈追加三轮全部通过;合并主矩阵为 **2,400/2,400** 次 KCP
|
||||
短连接;追加轮次的 12 次大小、6 次反向和 192 次纯空也全部通过。
|
||||
混合丢包仍走 legacy,下表为单轮**失败数**,不重跑取最好结果。
|
||||
每组合为 300 短连接、2 次大小半关闭、1 次反向、32 次纯空和 3 s 持续流量。
|
||||
|
||||
| 连接方向/栈 | 短连接失败 | 大小失败 | 反向失败 | 纯空失败 | 持续失败 |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| b37 → 最终新 / kernel | 6 | 0 | 0 | 16 | 0 |
|
||||
| b37 → 最终新 / smoltcp | 2 | 0 | 0 | 13 | 0 |
|
||||
| 最终新 → b37 / kernel | 7 | 1 | 0 | 16 | 0 |
|
||||
| 最终新 → b37 / smoltcp | 1 | 0 | 0 | 14 | 0 |
|
||||
|
||||
唯一大小用例失败停在 greeting 阶段,5 s 超时;纯空失败为 response
|
||||
阶段收到空 EOF。为判断兼容回退,另跑一次相同条件、相同业务参数的
|
||||
b37 → b37 旧旧对照:kernel 短连接失败 2/300、纯空失败 12/32;
|
||||
smoltcp 分别为 0/300、16/32。两栈的大小、反向与持续项目均通过。
|
||||
|
||||
旧旧 TRACE 确认了一条失败链:kernel 的 `conv=3046346801`、空请求
|
||||
`id=3`、源端口 `35958`,目标在 10:30:44.112 先收到 FIN,直接
|
||||
Closed 并发送 RST;10:30:44.115 才收到最终 ACK|DATA。源在 .113
|
||||
收到 RST,客户端约 37 ms 后得到零字节 EOF。`src/state.rs` 的
|
||||
SynReceived + FIN → Closed/RST 路径本次没有修改;可靠模式单独支持
|
||||
FIN 完成握手,legacy 按已批准设计保留原行为。
|
||||
|
||||
这条时序证明上述失效路径在旧旧连接也存在,不能由随机样本的失败率
|
||||
断言所有混合版本失败均已归因或已经排除一切回退。混合实验原 DEBUG
|
||||
不足以逐包归因全部失败。兼容保证旧节点可按原协议互通;控制恢复能力
|
||||
需要两端都协商为 version 1,混合部署仍不能获得这一保证。
|
||||
|
||||
最终丢包轮次直接启用 KCP TRACE:kernel 捕获 5 个成功连接收到重复
|
||||
SYNACK、4 个收到重复 FIN;smoltcp 分别为 2 个、5 个。捕获的控制
|
||||
报文均使用 version 1。详见 `final-control-recovery-*-summary.json`
|
||||
及对应 `evidence.log`;这份证据直接绑定最终二进制。
|
||||
|
||||
资源观察区分应用代理条目与内部 KCP 状态。CLI 不直接暴露依赖内部
|
||||
关闭记录,不能用 CLI 条目清零证明内部记录已经删除;内部期限由
|
||||
生命周期测试验证。实际流量保存起始、结束、15 s、80 s 的 FD/RSS 与
|
||||
应用代理条目。65 s 关闭记录属于设计成本,不能沿用旧的 15 s 内部
|
||||
状态必须清零的断言。
|
||||
|
||||
最终每栈三轮连续负载的资源结果如下。a 为 source,b 为 destination。
|
||||
该追加轮次的 FD 全程未增长;应用 proxy 条目在 15 s 与 80 s 均为零。
|
||||
|
||||
| 栈/节点 | FD 前后 | RSS 前 → 80 s(KiB) | 负载 CPU 秒 / 墙钟秒 | 静置 15–80 s 单核 CPU |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| kernel/a | 15 → 15 | 46,944 → 50,792 | 2.20 / 2.697 | 0.29% |
|
||||
| kernel/b | 15 → 15 | 46,580 → 49,344 | 1.12 / 2.694 | 0.31% |
|
||||
| smoltcp/a | 14 → 14 | 46,784 → 50,352 | 4.39 / 5.378 | 0.49% |
|
||||
| smoltcp/b | 14 → 14 | 46,024 → 49,060 | 2.32 / 5.368 | 0.48% |
|
||||
|
||||
CPU 来自 `/proc/PID/stat` 的 user/system ticks 与单调时钟差,包含
|
||||
路由、心跳、代理、日志等全部进程工作。产物为 debug 构建,表中负载
|
||||
CPU 也不是纯协议开销或吞吐基准。RSS 未回到起点,无法由这些采样区分
|
||||
分配器缓存与其他长期对象,也不能推算每条关闭记录的精确内存成本。
|
||||
内部对象期限由单测验证,生产规模的逐对象内存与长期容量验证仍未完成。
|
||||
主矩阵 kernel/b 在 15 s 采样曾由 15 个 FD 暂升为 16,80 s 恢复为 15;
|
||||
该瞬时变化同样保留在原始报告,未作为持续增长处理。
|
||||
原始记录为 `final-kcp-repeat-kcp-*-resources.json`,换算另存
|
||||
`final-resource-summary.json`。
|
||||
|
||||
## 审查、复现与限制
|
||||
|
||||
初轮独立子代理只读审查 `b37ee660..c84733d`,没有高置信度缺陷发现。
|
||||
最终由新子代理审查完整 `b37ee660..3ef5c416` 及 EasyTier 最终 pin,
|
||||
未发现 blocker / major;重点检查旧版输出规则、握手与关闭交叉状态、
|
||||
队列、取消清理和锁序。
|
||||
|
||||
最终审查记录一项 **minor / high confidence** 待办:
|
||||
`kcp-sys/src/endpoint.rs:922` 新 SYN 路径先 notify 再插入连接状态,
|
||||
多线程时可能先消费通知并漏过首次 SYNACK 调度。正常源端约 200 ms 后
|
||||
重发 SYN 即可恢复;若后续 SYN 未到达,则等待约 10 s 周期扫描。状态
|
||||
不会丢失,也不会永久阻塞,但特别短的 connect 期限可能超时。按用户
|
||||
minor 默认记录的规则保留;后续最小改动是将通知放到状态插入之后。
|
||||
|
||||
主要命令:
|
||||
|
||||
```sh
|
||||
# kcp-sys 工作树
|
||||
cargo test --all-targets
|
||||
cargo fmt --all -- --check
|
||||
cargo clippy --all-targets -- -D warnings
|
||||
|
||||
# EasyTier,Linux root 集成测试在 rust 容器内执行
|
||||
cargo +1.95 test --locked -p easytier-core \
|
||||
--features proxy-smoltcp-stack --lib gateway::
|
||||
cargo +1.95 nextest run --locked -p easytier --features full --lib \
|
||||
-E 'test(subnet_proxy_three_node_test) | test(subnet_proxy_half_close_test) | test(acl_rule_test_inbound) | test(acl_rule_test_subnet_proxy) | test(proxy_three_node_disconnect_test) | test(config_patch_test) | test(port_forward_with_inbound_default_drop_acl_test)' \
|
||||
--test-threads 1 --no-fail-fast
|
||||
cargo +1.95 clippy --locked -p easytier --features full \
|
||||
--lib --tests -- -D warnings
|
||||
```
|
||||
|
||||
本机原始日志、脚本、JSON 与二进制:
|
||||
|
||||
```text
|
||||
/data/project/kcp-control-validation-20260914/
|
||||
```
|
||||
|
||||
主要证据为 `final-ack-red.log`、`final-ack-green.log`、
|
||||
`final-dependency-tests.log`、`final-dependency-clippy.log`、
|
||||
`unknown-close-full-output-red.log`、`legacy-final.log`、
|
||||
`negotiation-lifetime-regressions.log`、`final-native-*-tests.log`、
|
||||
`linux-gateway.log`、`integration-final.log`、`app-final-clippy.log`。
|
||||
`integration-protocol.log` 是初版的中断轮次,切换最终实现后重新执行完整
|
||||
矩阵,不计入最终通过数。
|
||||
流量的准确 argv、产物 hash 和逐连接结果另存该目录。完整流量汇总为
|
||||
`traffic-summary.md`,混合丢包对照为 `mixed-loss-analysis.md`,
|
||||
旧旧逐包证据为 `legacy-fin-before-ack-evidence.log` 与
|
||||
`legacy-fin-before-ack-client.json`。测试环境清理记录为 `cleanup.json`。
|
||||
|
||||
此前单次反向超时的具体丢包点仍不能由旧 DEBUG 日志倒推出。定点 FIN
|
||||
丢失测试证明该类失效现在能够恢复,不能因此改写原事故的根因结论。
|
||||
Windows/macOS 原生 TUN 端到端、生产规模长期负载、吞吐与容量上限仍
|
||||
不是这些有限测试能够证明的事项。
|
||||
@@ -1,84 +0,0 @@
|
||||
# Host socket protection
|
||||
|
||||
VPN bypass is a socket-creation requirement, not an operation on a socket that
|
||||
core has already connected. Core and WASI guests never need an OS file descriptor.
|
||||
|
||||
## Portable requests
|
||||
|
||||
`TcpBindOptions::need_protect` and `UdpBindOptions::need_protect` travel through the
|
||||
existing connect/bind operations. A host requiring VPN bypass must acknowledge
|
||||
successful protection before connect, bind/listen, or publishing the socket.
|
||||
Failure or cancellation fails creation and discards the owned socket; emitting
|
||||
an event alone is not acknowledgement. Platforms without VPN bypass can treat
|
||||
the requirement as a no-op.
|
||||
|
||||
- TCP connect constructors request protection, including egress for proxies.
|
||||
- TCP transport/hole-punch listeners request protection. Hosts retain this flag
|
||||
on the listener and protect accepted children before handing them to core.
|
||||
- Local ProxyNat/SOCKS/port-forward/port-lease listeners do not request it.
|
||||
- UDP transport, candidates, listeners, NAT egress and STUN request protection;
|
||||
HolePunchControl, local SOCKS/port-forward and port-lease sockets do not.
|
||||
- Low-level TCP/UDP bind options default to protection, including deserialization
|
||||
of options without `need_protect`. The named local/TUN-facing constructors set
|
||||
`false` explicitly. The UDP default keeps its historical purpose label for
|
||||
socket setup; only the named `hole_punch_control()` constructor opts out.
|
||||
- `with_bind()` replaces the **entire** bind object. A local listener's replacement
|
||||
must retain its opt-out rather than inherit the protected default. A native
|
||||
adapter must honor explicit `false`, not silently change it based on `purpose`.
|
||||
- DNS and source-route queries are already host-owned operations. A bypass-enabled
|
||||
host must protect their underlying sockets before querying/probing, including
|
||||
DNS TCP fallback, rather than letting system DNS silently bypass this contract.
|
||||
|
||||
## TUN-facing ingress and port forwarding
|
||||
|
||||
The extra `force_smoltcp` wildcard port-forward ingress listener has been removed.
|
||||
Normal TUN-backed ingress is delivered to the existing unprotected native listener;
|
||||
its accepted sockets remain unprotected so overlay replies can return through TUN.
|
||||
The physical/underlay egress socket is protected independently. A separate
|
||||
DataPlane listener must not mask broken host/TUN routing in this path.
|
||||
|
||||
This does not remove the existing public DataPlane listener APIs, the generic
|
||||
no-TUN smoltcp TCP proxy, or `force_smoltcp` itself. Those have other uses. Whether
|
||||
Android subnet proxy works without forced smoltcp needs actual platform regression
|
||||
testing; socket-creation unit tests alone do not establish that result.
|
||||
|
||||
## Native integration and HarmonyOS
|
||||
|
||||
The native `easytier` adapter implements the creation requirement using an async
|
||||
`NativeSocketProtector` callback. This is a native implementation detail, not a
|
||||
new portable or WASI ABI. It takes only the native handle; the creation options
|
||||
select policy, without a second purpose enum. Namespace switching is confined to synchronous socket
|
||||
creation and never held across the callback's await.
|
||||
|
||||
The existing native `bind` builder is async and shared by TCP/UDP creation. Its
|
||||
legacy direct-call default remains unprotected; portable factories explicitly
|
||||
pass their core bind options (default protected). Callers must await `.call()`.
|
||||
TCP listeners reuse TCP socket creation then listen, instead of duplicating the
|
||||
setup. Existing legacy WebSocket direct-call policy is unchanged.
|
||||
|
||||
The HarmonyOS broker wakes the already-pending request consumer with `Notify`
|
||||
(no polling timer). It keeps a duplicate FD alive until ArkTS completes
|
||||
`VpnConnection.protect(fd)` and returns its ACK. The waiting creation future is
|
||||
woken immediately by the oneshot acknowledgement. Failure stays fail-closed;
|
||||
shutdown retains dispatched FDs for late ACKs to prevent FD reuse races.
|
||||
The ArkTS request shape is unchanged; its diagnostic `purpose` string is now
|
||||
the generic `"socket"`. Neither ACK routing nor protection policy uses that label.
|
||||
|
||||
This guarantees ordering, not a wall-clock real-time bound: OS/ArkTS scheduling
|
||||
can still delay protection. Such a delay keeps the socket unconnected; it must
|
||||
never allow the first SYN/query to race ahead of protection.
|
||||
|
||||
## WASI option format
|
||||
|
||||
The `easytier_host` import names and function signatures are unchanged. TCP
|
||||
connect, TCP listen and UDP bind use option document **version 3** (previously 2):
|
||||
one `u8` boolean `need_protect` is inserted immediately after the existing purpose
|
||||
byte and before the optional bind-device field. All other field encodings and
|
||||
purpose values are unchanged. The document version lets older hosts reject
|
||||
unsupported options instead of silently ignoring protection. DNS, environment,
|
||||
instance-config and data-plane layouts/versions are unchanged.
|
||||
|
||||
An embedding host must update its versioned option decoder and honor the flag
|
||||
inside its existing creation implementation. The external host implementation
|
||||
is not in this repository: building the guest proves propagation/compatibility
|
||||
of imports, not that every external host has implemented platform protection.
|
||||
@@ -1,137 +0,0 @@
|
||||
# TCP proxy flow-key 验证记录(2026-09-13)
|
||||
|
||||
本次验证没有发现只在修复版本出现的行为退化。同源端口、不同目标的
|
||||
并发连接在六种代理模式下均由父提交的超时变为成功。验证中仍有半关闭
|
||||
失败和 KCP 突发短连接超时,父提交也存在这些现象,不能将结果描述为
|
||||
所有场景均无异常。
|
||||
|
||||
## 版本与范围
|
||||
|
||||
- 修复版本:`eb83655958be932d6de34e090dc361b6f3ba3393`。
|
||||
- 父提交:`e0bdb516b6dc8a654940efbe12960dbfa846f424`。
|
||||
- 本次提交仅新增测试与记录,生产代码保持上述修复版本的内容。
|
||||
- Linux 测试在现有 `rust` 容器内运行,Rust 1.93.1。
|
||||
- macOS arm64、Windows x64 原生测试使用 Rust 1.95.0。
|
||||
|
||||
父提交使用独立 worktree,但复用当前 worktree 的 `target`。第一次父提交
|
||||
网关测试意外复用了修复版本的构建缓存,结果已排除。清理相应 package
|
||||
的构建产物后重新编译,确认父提交网关测试为 189 项,修复版本原有
|
||||
192 项。真实流量实验还通过 CLI 核对两端实际运行的版本号。
|
||||
|
||||
## 自动化验证
|
||||
|
||||
| 验证 | 结果 |
|
||||
| --- | --- |
|
||||
| 修复版本完整 `subnet_proxy_three_node_test` 矩阵 | 256/256,714.800 秒 |
|
||||
| ACL、端口转发 ACL、配置更新、代理断连 | 父提交与修复版本各 14/14 |
|
||||
| 父提交 Linux 网关测试 | 189/189 |
|
||||
| 新增测试后的 Linux 网关测试 | 198/198 |
|
||||
| 新增测试后的 macOS 原生网关测试 | 198/198 |
|
||||
| 新增测试后的 Windows 原生网关测试 | 198/198 |
|
||||
|
||||
完整三节点矩阵覆盖 TUN/no-TUN、普通/公共中继、源端 KCP/QUIC 开关、
|
||||
目标端 KCP/QUIC 开关及对应输入禁用组合。每个组合检查映射子网地址、
|
||||
真实子网地址和节点虚拟地址上的 ICMP、TCP、UDP。
|
||||
|
||||
命令(Linux 需在容器内运行):
|
||||
|
||||
```sh
|
||||
cargo nextest run -p easytier --features full --lib \
|
||||
subnet_proxy_three_node_test --test-threads 1 --no-fail-fast
|
||||
|
||||
cargo test -p easytier-core --features proxy-smoltcp-stack --lib gateway::
|
||||
|
||||
cargo fmt --all -- --check
|
||||
|
||||
cargo clippy -p easytier-core \
|
||||
--features proxy-smoltcp-stack,ring-crypto --lib --tests -- -D warnings
|
||||
```
|
||||
|
||||
格式检查和上述严格 Clippy 检查均通过。仅启用 `proxy-smoltcp-stack`
|
||||
时,严格 Clippy 被未修改的 `tunnel/encrypt/mod.rs` 中
|
||||
`assert_interoperable` 未使用警告阻断;增加 `ring-crypto` 会启用调用
|
||||
该函数的现有后端互操作测试。本次没有修改或屏蔽该警告。
|
||||
|
||||
14 项补充集成测试来自以下测试组,使用各版本独立保存的测试二进制,
|
||||
逐项 `--exact` 执行,避免不同进程同时操作相同的测试 network namespace:
|
||||
|
||||
- `acl_rule_test_inbound`:4 项。
|
||||
- `acl_rule_test_subnet_proxy`:4 项。
|
||||
- `port_forward_with_inbound_default_drop_acl_test`:3 项。
|
||||
- `config_patch_test`:1 项。
|
||||
- `proxy_three_node_disconnect_test`:2 项。
|
||||
|
||||
三节点矩阵与流量实验在新增测试前完成;新增测试不改变生产代码,随后
|
||||
在三平台重新执行完整网关测试。macOS、Windows 的结果不包含原生 TUN
|
||||
端到端验证。
|
||||
|
||||
## 新增回归测试
|
||||
|
||||
测试位于 `easytier-core/src/gateway/proxy/tcp_proxy_engine.rs`:
|
||||
|
||||
1. SYN 在 accept 前后重传,均保留转换端口,且不会重复 accept。
|
||||
2. 旧连接处于 ClosingSrc、ClosingDst、Closed 时,被同一流的新连接
|
||||
替换;旧连接清理不会删除新映射,反向地址、端口和校验和仍正确。
|
||||
3. 过期 SYN 同时释放两个索引,已 accept 的连接不受 SYN 超时清理影响。
|
||||
4. 转换端口计数器回绕后跳过零、已占用端口和监听端口。
|
||||
5. 实际填满同一源 IP 的 65,534 个转换端口后,新 SYN 被丢弃;其他源
|
||||
IP 仍可建立映射,释放一个条目后该端口可复用,clear 后也可重新分配。
|
||||
6. 八个线程同时处理同一流,仅建立一个映射并成功 accept 一次。
|
||||
|
||||
端口池测试验证实际容量和恢复行为,没有将 debug 构建的耗时用作生产
|
||||
性能阈值,也没有通过延长超时或添加重试改变被测行为。
|
||||
|
||||
## 真实流量对照
|
||||
|
||||
使用独立的 `flow_val_a`、`flow_val_b` namespace,物理链路为一对 veth。
|
||||
虚拟地址为 `10.251.92.1/24` 和 `10.251.92.2/24`;目标端将
|
||||
`192.0.2.0/24` 映射到 `198.18.0.0/24`。两个 TCP 服务监听真实地址
|
||||
`.10`、`.11` 的 23456 端口,返回服务地址标记,并逐字节回显、校验数据。
|
||||
底层隧道使用 UDP,两个节点均使用四线程运行时。
|
||||
|
||||
每个版本分别验证普通 TCP、KCP、QUIC 与内核/smoltcp 的六种组合。
|
||||
|
||||
| 场景 | 父提交 | 修复版本 |
|
||||
| --- | --- | --- |
|
||||
| 源 IP/端口相同、两个不同目标的并发连接,各进行 30 轮回显 | 6/6 超时 | 6/6 通过 |
|
||||
| 同一四元组 RST 后重连,30 次,间隔 20ms | 6/6 通过 | 6/6 通过 |
|
||||
| 普通 TCP、QUIC 的短连接,各 300 次、16 并发 | 各组合 300/300 | 各组合 300/300 |
|
||||
| KCP 内核模式短连接,300 次、16 并发 | 4 次超时 | 4 次超时 |
|
||||
| KCP smoltcp 模式短连接,300 次、16 并发 | 12 次超时 | 11 次超时 |
|
||||
| 关闭客户端写端后等待服务端响应 | 6/6 无响应数据 | 6/6 无响应数据 |
|
||||
|
||||
各组合完成流量后等待 15 秒。修复版本两端的代理条目均回收为零,FD
|
||||
数量回到接近起始水平(采样期间 RPC 连接带来约一个 FD 的差异)。这只能
|
||||
证明本次有限负载下的回收行为,不能证明长期内存占用没有增长。
|
||||
|
||||
KCP 的超时阈值为 5 秒,尚未定位这些突发短连接超时的根因;两版都有
|
||||
失败不等于已证明每次失败属于同一根因。半关闭实验的服务端在 EOF 后
|
||||
返回响应;未修改的 `copy_bidirectional_no_shutdown` 在任一方向结束
|
||||
后即退出转发,与两版都丢失该响应的现象一致。本次不修复这两类问题。
|
||||
|
||||
## 丢包与混合版本
|
||||
|
||||
- 在客户端 veth 出方向施加 `netem delay 10ms 3ms loss 1%`。
|
||||
- 两版各六种模式,每个组合保持两个连接持续回显至少 15 秒,所有数据
|
||||
均通过逐字节校验;共 12/12 通过。
|
||||
- 无 netem 时,旧源端/新目标端、新源端/旧目标端,各覆盖六种模式。
|
||||
两个连接持续回显至少 2 秒,共 12/12 通过。
|
||||
- 混合版本验证使用不同客户端源端口,验证正常互通;它不意味着仍运行
|
||||
旧代理引擎的一端也获得了同源端口冲突修复。
|
||||
|
||||
## 证据与限制
|
||||
|
||||
本机原始日志、流量脚本、各阶段 JSON、二进制与 SHA-256 清单保存在:
|
||||
|
||||
```text
|
||||
/data/project/tcp-flow-validation-20260913/
|
||||
```
|
||||
|
||||
主要记录为 `manifest.json`、`current-matrix-summary.log`、
|
||||
`*-extra.json`、`traffic-matrix.json`、`loss-interop.json` 和
|
||||
`current-final*gateway.log`。矩阵记录是最终摘要,不是完整逐项日志。
|
||||
实验进程、独立 namespace 和 netem 均已清理。
|
||||
|
||||
未覆盖 Windows/macOS 原生 TUN 路径、生产防火墙/conntrack 规则兼容性、
|
||||
长期运行、吞吐回归基准,以及接近容量极限时的真实内核连接负载。
|
||||
端口耗尽已在引擎级验证,但不能替代上述生产容量测试。
|
||||
@@ -1,178 +0,0 @@
|
||||
# TCP 代理半关闭与 KCP 短连接修复验证(2026-09-14)
|
||||
|
||||
本次修复上一轮验证发现的半关闭失败、无丢包 KCP 突发连接交接丢失,
|
||||
以及跨平台验证暴露的握手前心跳竞态。这些是转发与连接生命周期的局部
|
||||
逻辑问题;没有改变 KCP 报文格式,也没有增加重试或放宽业务超时。
|
||||
丢包网络下另有握手最终 ACK 丢失的问题,本次未修复,见下文。
|
||||
|
||||
## 版本与根因
|
||||
|
||||
- EasyTier 基线:`24572b49`;生产修复:`03fa375e`、`fd6e2a3e`。
|
||||
- kcp-sys 基线:`d7427c22d764deb1860a7d37acc446ed5033464c`。
|
||||
- 最终依赖:`b37ee660fb70bb6d816fb8bbc08b140e55e7218b`,
|
||||
`Cargo.toml` 和 `Cargo.lock` 均锁定此 Git revision。
|
||||
已发布到 kcp-sys 的 `fix/accept-half-close-20260914` 专用分支,
|
||||
便于其他机器获取锁定的依赖;未合并主分支。
|
||||
- 旧版真实流量对照:`eb836559`,已包含 flow-key 修复,尚无本次修复。
|
||||
- 最终流量二进制:`delivery-easytier-core`,SHA-256:
|
||||
`4635810ac9a536258f9b7ff606e5e1a5eb0243cb0f187825ba6f721d92dfc4fe`。
|
||||
构建内容与最终生产代码一致;构建发生在依赖 pin 提交前,内嵌版本号
|
||||
不用于区分本次产物,以 SHA-256 和依赖 revision 为准。
|
||||
|
||||
修复内容:
|
||||
|
||||
1. 原转发函数在任一方向 EOF 后退出,取消另一方向,服务端在请求 EOF
|
||||
后返回的响应因此丢失。改用 Tokio 双向拷贝传播写端关闭,继续转发
|
||||
反向数据,直到双向完成或发生错误。
|
||||
2. KCP 容量为 4 的 accept 通知队列满时,已建立连接失去交接机会。
|
||||
在连接状态中保留待 accept 标记,队列为空时领取待交接连接;保留
|
||||
原有有界通知队列和串行领取,防止重复交接。
|
||||
3. KCP 将正常关闭当作读错误,并可能在 FIN 后丢弃已经确认接收、尚未
|
||||
交给应用的数据。现在先排空接收数据再返回 EOF,RST 和端点销毁仍
|
||||
返回错误;双向关闭清理等待接收任务完成。EOF 判断和过期清理的
|
||||
状态读取顺序也一并修正,避免并发数据到达或心跳更新被旧判断覆盖。
|
||||
4. FIN 可以先于 accept 或 connect 返回到达。创建流时继承该关闭状态,
|
||||
使纯空请求、纯空响应也能得到 EOF。半关闭连接继续响应心跳。
|
||||
5. Windows 原生测试捕获到 PING 先于 SYN 到达,对端因连接未知返回
|
||||
RST;旧依赖也复现相同时序。周期心跳现在只覆盖已建立和半关闭状态。
|
||||
|
||||
## 回归覆盖与自动化结果
|
||||
|
||||
| 验证 | 结果 |
|
||||
| --- | --- |
|
||||
| Linux、macOS arm64、Windows x64 原生网关测试 | 各 200/200 |
|
||||
| 最终 kcp-sys 三平台原生测试 | 各 10/10,两个 example target 通过 |
|
||||
| 最终完整三节点矩阵及补充集成测试 | 276/276,848.628 秒 |
|
||||
| EasyTier 格式检查、Linux 严格 Clippy | 通过 |
|
||||
| kcp-sys Linux/macOS 格式检查、严格 Clippy | 通过 |
|
||||
|
||||
网关新增两个测试,分别从两端先关闭写方向;使用空请求、32 KiB 请求
|
||||
及 64 KiB 响应,接收方等到 EOF 才返回响应。旧转发函数两项均在 5 秒
|
||||
超时,修复后通过。原生网关测试不依赖应用层 KCP,心跳修复没有改变
|
||||
这些测试的生产代码。
|
||||
|
||||
新增三节点半关闭测试覆盖 TCP/KCP/QUIC × 内核/smoltcp 六种模式,
|
||||
各检查两个关闭方向、空请求和 64 KiB 请求、128 KiB 响应。完整集成
|
||||
集合共 276 项:原有三节点矩阵 256 项、新增 6 项、ACL/配置更新/
|
||||
端口转发/断连 14 项,均在已有 `rust` 容器中串行运行。
|
||||
|
||||
依赖回归覆盖:32 次握手全部完成后才 accept;200 KiB 缓冲数据后的
|
||||
EOF/响应;双向关闭后跨越清理周期再读数据;半关闭心跳;RST/端点销毁
|
||||
读错误;FIN 先于 accept、FIN 先于 connect 返回;各握手阶段的心跳
|
||||
筛选。通知饱和测试在旧实现第五次 accept 超时;提前 FIN 测试在修复
|
||||
前等待 EOF 超时。排空测试使用多工作线程运行时,并做过额外重复验证。
|
||||
|
||||
主要命令(Linux 容器内,Rust 1.95):
|
||||
|
||||
```sh
|
||||
cargo +1.95 test --locked -p easytier-core \
|
||||
--features proxy-smoltcp-stack --lib gateway::
|
||||
cargo +1.95 nextest run --locked -p easytier --features full --lib \
|
||||
-E 'test(subnet_proxy_three_node_test) | test(subnet_proxy_half_close_test) | test(acl_rule_test_inbound) | test(acl_rule_test_subnet_proxy) | test(proxy_three_node_disconnect_test) | test(config_patch_test) | test(port_forward_with_inbound_default_drop_acl_test)' \
|
||||
--test-threads 1 --no-fail-fast
|
||||
cargo fmt --all -- --check
|
||||
cargo +1.95 clippy --locked -p easytier --features full \
|
||||
--lib --tests -- -D warnings
|
||||
```
|
||||
|
||||
网关严格 Clippy 另以 `proxy-smoltcp-stack,ring-crypto` 通过;原因见上一轮
|
||||
记录中的既有未使用警告说明。Windows 所选 stable 工具链缺少格式检查
|
||||
组件,未将 Windows 格式或 Clippy 计为通过。
|
||||
|
||||
## 最终二进制真实流量
|
||||
|
||||
普通 TCP、KCP、QUIC 与内核/smoltcp 六种组合,底层 UDP;每组合 300 次
|
||||
短连接、16 并发、5 秒 socket 超时,检查 greeting 和逐字节回显。
|
||||
|
||||
- 六模式主矩阵:1,800/1,800 次短连接通过。
|
||||
- 请求 EOF 后返回响应:小/大请求共 12/12,通过;大请求 256 KiB,
|
||||
响应 1 MiB + 4 字节。
|
||||
- 服务端先关闭写端,客户端读到 EOF 后再发 256 KiB:6/6,通过,
|
||||
服务端实际校验接收数据。
|
||||
- connect 后立即关闭写端,不发送模式字节、不等待 greeting:每模式
|
||||
32 次、16 并发,共 192/192 次纯空请求通过。
|
||||
- KCP 每栈额外三轮,与主矩阵合计 2,400/2,400 次短连接通过。追加
|
||||
测试的大小半关闭及 192 次纯空请求通过,6 次反向半关闭中 1 次超时,
|
||||
因此不能将所有追加半关闭计为通过,具体证据见下文。
|
||||
- 最终版本在 `delay 10ms 3ms loss 1%` 下,六模式各两个连接持续
|
||||
15 秒逐字节回显、12 次大小半关闭及 6 次反向半关闭通过。KCP 短连接
|
||||
仍有内核模式 5/300、smoltcp 模式 8/300 次 greeting 超时,其他
|
||||
四种模式零失败;没有为每次超时单独抓包证明原因相同。
|
||||
- 新旧两方向 × 六模式,共 12 组合正常持续回显通过。旧源端→新目标端
|
||||
1,800 次短连接全通过;新源端→旧目标端 KCP 内核模式 12/300、
|
||||
smoltcp 模式 9/300 次超时,其他四种模式零失败。
|
||||
|
||||
旧版同样的无丢包 KCP 2,400 次短连接有 78 次 greeting 超时;旧版六种
|
||||
模式在请求 EOF 后均没有响应,且出现过 QUIC 请求数据被截断。
|
||||
|
||||
最终版本 KCP 主矩阵后,两种栈源端各 335 条 Closed 代理记录均在
|
||||
15 秒观察点清零;目标端记录为零。两端 FD 数保持内核模式 15、smoltcp
|
||||
模式 14。RSS 较起始保留约 3.2–4.4 MiB 增长,单次有限负载不能证明
|
||||
或排除长期内存泄漏,也不能将代理条目清零等同于所有内部对象回收。
|
||||
|
||||
## 仍然存在的丢包握手问题与升级限制
|
||||
|
||||
在源端施加 `netem delay 10ms 3ms loss 1%` 后,中间修复版本
|
||||
(SHA-256 `2e78ecf…`)KCP 仍有 3/600 次 greeting 超时。额外有界
|
||||
TRACE 重跑捕获 3 个连接:源端收到 SYN|ACK,发出空 ACK|DATA 后即
|
||||
返回已建立;目标端未收到最终 ACK,直到 5 秒后的 FIN 才返回 RST。
|
||||
这与 accept 通知饱和不同,目标端尚未完成握手。
|
||||
|
||||
该控制报文可靠性问题需要单独设计握手恢复、重传及过期清理的一致性,
|
||||
不能仅延长超时或重试应用请求。本次没有声称消除了所有 KCP 超时。
|
||||
具体连接号和双方日志行见 `remaining-loss-handshake.md`、
|
||||
`loss-handshake-evidence.log`。
|
||||
|
||||
混合版本正常回显通过并不意味着旧端获得修复。旧 KCP 目标端仍保留
|
||||
accept 缺陷;KCP 的完整半关闭能力需要两端升级。没有修改报文格式。
|
||||
|
||||
追加无 netem 的 KCP/kernel 反向半关闭中,连接 `3969305677` 在等
|
||||
服务端数据和 EOF 时超时;服务端没有收到客户端原定在 EOF 后发送的
|
||||
256 KiB 数据。发送端同一连接出现 780 次数据输出队列 Full,随后在
|
||||
08:36:47.247 UTC 发送缓冲排空,FIN 成功进入输出队列;接收端直到
|
||||
5 秒业务超时都未报告对端关闭,08:37:01.030 才报告关闭或重置信号。
|
||||
该 debug 日志行也可能由 RST 触发,不能据此认定迟到的是 FIN,更不能
|
||||
将数据队列 Full 直接解释为 FIN 被该队列丢弃。现有 FIN 发送路径没有
|
||||
确认重传;这一单次失败的具体丢包点尚未证实,保留为未解决的验证异常。
|
||||
同场景有界 TRACE 重跑 3 轮,900 次短连接、3 次反向半关闭均通过,
|
||||
抓包确认成功轮次收到 FIN 并完成反向数据,未复现原失败;不能以重跑
|
||||
通过覆盖原失败。旧依赖与最终依赖的发送排空后入队 FIN 路径相同,
|
||||
对照保存在 `fin-send-path-old-new.txt`。
|
||||
|
||||
异常轮次的 15 秒资源观察点仍有 1 条 Closed 代理记录,无 Connecting/
|
||||
Connected,FD 已回到起始值;因此仅主矩阵可以报告该观察点全部清零。
|
||||
定向 TRACE 三轮的 1,005 条 Closed 记录在 15 秒观察点全部清零。
|
||||
|
||||
## 验证中的异常与证据
|
||||
|
||||
首轮未完成的集成运行中,端口转发 ACL case 3 曾在连接本地监听端口时
|
||||
出现一次 ConnectionRefused;随后完整一轮 276 项通过,同项也通过。
|
||||
旧版单次及额外十次均通过,因此没有证据把这次失败断言为旧版必现问题,
|
||||
也未修改无关的端口转发代码。新增半关闭测试的早期夹具曾只等待路由
|
||||
公告而未等待实际 TUN 路由可用,已补足就绪检查,并用有界 try_join
|
||||
及时报告连接错误;这不计入生产代码的 red/green 对照。
|
||||
|
||||
Windows 测试早期表现为挂起,原因是测试 join 在 connect 失败后仍等待
|
||||
accept。限定握手等待后暴露出上述 PING/RST/SYN 竞态,旧依赖和修复前
|
||||
分支均有 TRACE 证据;最终原生测试通过。完整 diff 和后续心跳增量均
|
||||
经过独立子代理审查,没有 high-confidence 的新缺陷发现。
|
||||
|
||||
原始脚本、日志、逐连接 JSON、二进制和哈希保存在本机:
|
||||
|
||||
```text
|
||||
/data/project/proxy-close-validation-20260914/
|
||||
```
|
||||
|
||||
主要文件:`delivery-manifest.json`、`delivery-integration.log`、`delivery-clippy.log`、
|
||||
`final-core-gateway.log`、`macos-core.log`、`windows-core.log`、
|
||||
`mac-kcp-heartbeat-tests.log`、`final-tests.log`(Windows KCP)、
|
||||
`delivery-summary.json`、`delivery-kcp-repeat-summary.json`、
|
||||
`delivery-*-resources.json`、`traffic-summary.md`、`traffic-binaries.json`、
|
||||
`delivery-commands.json`、`artifacts-manifest.json`、`delivery-reverse-timeout.md`、
|
||||
`relay-red.log`、`windows-kcp-trace-fail.txt`、
|
||||
`windows-kcp-baseline-startup.txt`。Windows 部分原始日志为 UTF-16LE。
|
||||
Linux kcp-sys 测试、格式、Clippy 及依赖测试 red/green 对照为子代理
|
||||
执行结果,未单独落盘原始命令日志;最终 10 项测试耗时 11.03 秒。
|
||||
|
||||
未覆盖 Windows/macOS 原生 TUN 端到端路径、生产规模长时间运行、吞吐
|
||||
基准及容量极限负载。流量实验仅使用并清理自己的 namespace 和进程。
|
||||
@@ -7,11 +7,11 @@ edition.workspace = true
|
||||
crate-type = ["cdylib"]
|
||||
|
||||
[dependencies]
|
||||
# JNI 0.22 changes native entry points to EnvUnowned and Env.
|
||||
jni = "0.21.1"
|
||||
once_cell.workspace = true
|
||||
log.workspace = true
|
||||
android_logger = "0.15"
|
||||
serde_json.workspace = true
|
||||
easytier = { workspace = true, default-features = true }
|
||||
easytier-ffi.workspace = true
|
||||
jni = "0.21"
|
||||
once_cell = "1.18.0"
|
||||
log = "0.4"
|
||||
android_logger = "0.13"
|
||||
serde = { version = "1.0.220", features = ["derive"] }
|
||||
serde_json = "1.0"
|
||||
easytier = { path = "../../easytier" }
|
||||
easytier-ffi = { path = "../easytier-ffi", default-features = false }
|
||||
|
||||
@@ -73,14 +73,6 @@ object EasyTierJNI {
|
||||
*/
|
||||
@JvmStatic external fun retainNetworkInstance(instanceNames: Array<String>?): Int
|
||||
|
||||
/**
|
||||
* 停止指定的网络实例,其他实例不受影响
|
||||
* @param instanceName 要停止的实例名称,不存在时为 no-op
|
||||
* @return 0 表示成功,-1 表示失败
|
||||
* @throws RuntimeException 当操作失败时抛出异常
|
||||
*/
|
||||
@JvmStatic external fun deleteNetworkInstance(instanceName: String): Int
|
||||
|
||||
/**
|
||||
* 收集网络信息
|
||||
* @param maxLength 最大返回条目数
|
||||
|
||||
@@ -9,7 +9,6 @@
|
||||
//! - `parseConfig(config)`: validate TOML config text.
|
||||
//! - `runNetworkInstance(config)`: start a local network instance.
|
||||
//! - `retainNetworkInstance(instanceNames)`: retain named instances and stop the rest.
|
||||
//! - `deleteNetworkInstance(instanceName)`: stop exactly one named instance.
|
||||
//! - `listInstances()`: return running instance names and IDs as JSON.
|
||||
//! - `collectNetworkInfos()`: return running instance info as a JSON string.
|
||||
//! - `callJsonRpc(...)`: call an exposed EasyTier RPC service with JSON payload.
|
||||
@@ -107,23 +106,6 @@ pub extern "system" fn Java_com_easytier_jni_EasyTierJNI_retainNetworkInstance(
|
||||
network_api::retain_network_instance_jni(env, class, instance_names)
|
||||
}
|
||||
|
||||
/// Stop exactly one named network instance without affecting other instances.
|
||||
///
|
||||
/// Java signature:
|
||||
/// `EasyTierJNI.deleteNetworkInstance(instanceName: String): Int`
|
||||
///
|
||||
/// An unknown name is a no-op. On failure this returns `-1` and throws
|
||||
/// `RuntimeException`.
|
||||
#[unsafe(no_mangle)]
|
||||
pub extern "system" fn Java_com_easytier_jni_EasyTierJNI_deleteNetworkInstance(
|
||||
env: JNIEnv,
|
||||
class: JClass,
|
||||
instance_name: JString,
|
||||
) -> jint {
|
||||
logger::init();
|
||||
network_api::delete_network_instance_jni(env, class, instance_name)
|
||||
}
|
||||
|
||||
/// Collect running network instance information.
|
||||
///
|
||||
/// Java signature:
|
||||
|
||||
@@ -2,8 +2,8 @@ use std::{ffi::CStr, ptr};
|
||||
|
||||
use easytier::proto::api::manage::{NetworkInstanceRunningInfo, NetworkInstanceRunningInfoMap};
|
||||
use easytier_ffi::{
|
||||
KeyValuePair, collect_network_infos, delete_network_instance, free_string, list_instance,
|
||||
parse_config, retain_network_instance, run_network_instance, set_tun_fd,
|
||||
KeyValuePair, collect_network_infos, free_string, list_instance, parse_config,
|
||||
retain_network_instance, run_network_instance, set_tun_fd,
|
||||
};
|
||||
use jni::JNIEnv;
|
||||
use jni::objects::{JClass, JObjectArray, JString};
|
||||
@@ -76,30 +76,6 @@ pub(crate) fn run_network_instance_jni(mut env: JNIEnv, _class: JClass, config:
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn delete_network_instance_jni(
|
||||
mut env: JNIEnv,
|
||||
_class: JClass,
|
||||
instance_name: JString,
|
||||
) -> jint {
|
||||
let instance_name = match jstring_to_cstring(&mut env, &instance_name) {
|
||||
Ok(name) => name,
|
||||
Err(error) => {
|
||||
throw_exception(&mut env, &format!("Invalid instance name: {error}"));
|
||||
return -1;
|
||||
}
|
||||
};
|
||||
let instance_names = [instance_name.as_ptr()];
|
||||
unsafe {
|
||||
let result = delete_network_instance(instance_names.as_ptr(), instance_names.len());
|
||||
if result != 0
|
||||
&& let Some(error) = get_last_error()
|
||||
{
|
||||
throw_exception(&mut env, &error);
|
||||
}
|
||||
result
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn retain_network_instance_jni(
|
||||
mut env: JNIEnv,
|
||||
_class: JClass,
|
||||
|
||||
@@ -13,26 +13,22 @@ 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"] }
|
||||
easytier-core = { workspace = true, default-features = true }
|
||||
easytier = { path = "../../easytier", features = ["tracing-log"] }
|
||||
easytier-core = { path = "../../easytier-core" }
|
||||
|
||||
once_cell.workspace = true
|
||||
tokio = { workspace = true, default-features = true, features = ["rt-multi-thread", "io-util", "time", "sync", "macros"] }
|
||||
async-trait.workspace = true
|
||||
log.workspace = true
|
||||
once_cell = "1.18.0"
|
||||
tokio = { version = "1", features = ["rt-multi-thread", "io-util", "time", "sync", "macros"] }
|
||||
async-trait = "0.1"
|
||||
log = "0.4"
|
||||
|
||||
serde_json.workspace = true
|
||||
uuid.workspace = true
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
uuid = "1.17.0"
|
||||
|
||||
[build-dependencies]
|
||||
thunk-rs = { workspace = true, features = [
|
||||
thunk-rs = { git = "https://github.com/easytier/thunk.git", default-features = false, features = [
|
||||
"win7",
|
||||
] }
|
||||
|
||||
[package.metadata.cargo-machete]
|
||||
ignored = [
|
||||
# Windows-only build.rs setup calls thunk::thunk().
|
||||
"thunk-rs",
|
||||
]
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
use std::ffi::{CString, c_char, c_int};
|
||||
|
||||
#[cfg(any(
|
||||
target_os = "android",
|
||||
target_os = "ios",
|
||||
all(target_os = "macos", feature = "macos-ne"),
|
||||
target_env = "ohos"
|
||||
))]
|
||||
use easytier::common::config::ConfigLoader as _;
|
||||
use easytier::common::config::{ConfigFileControl, TomlConfigLoader};
|
||||
|
||||
use crate::{
|
||||
@@ -9,6 +16,28 @@ use crate::{
|
||||
types::KeyValuePair,
|
||||
};
|
||||
|
||||
#[cfg(any(
|
||||
target_os = "android",
|
||||
target_os = "ios",
|
||||
all(target_os = "macos", feature = "macos-ne"),
|
||||
target_env = "ohos"
|
||||
))]
|
||||
fn mobile_tun_sources_for_legacy_set_tun_fd(inst_id: uuid::Uuid) -> Result<(), String> {
|
||||
let config = ffi_context()
|
||||
.manager
|
||||
.config(inst_id)
|
||||
.ok_or_else(|| format!("instance config unavailable: {inst_id}"))?;
|
||||
let flags = config.get_flags();
|
||||
if flags.dev_name.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
Err(format!(
|
||||
"set_tun_fd legacy API cannot attach shared mobile TUN dev_name={} without tun sources",
|
||||
flags.dev_name
|
||||
))
|
||||
}
|
||||
|
||||
/// # Safety
|
||||
/// Set the tun fd
|
||||
pub(crate) unsafe fn set_tun_fd(inst_name: *const c_char, fd: c_int) -> c_int {
|
||||
@@ -21,7 +50,7 @@ pub(crate) unsafe fn set_tun_fd(inst_name: *const c_char, fd: c_int) -> c_int {
|
||||
let inst_id = match resolve_instance_id_by_name(&inst_name) {
|
||||
Ok(Some(instance_id)) => instance_id,
|
||||
Ok(None) => {
|
||||
set_error_msg("instance not found");
|
||||
set_error_msg(&format!("instance not found: {inst_name}"));
|
||||
return -1;
|
||||
}
|
||||
Err(error) => {
|
||||
@@ -30,9 +59,23 @@ pub(crate) unsafe fn set_tun_fd(inst_name: *const c_char, fd: c_int) -> c_int {
|
||||
}
|
||||
};
|
||||
|
||||
#[cfg(any(
|
||||
target_os = "android",
|
||||
target_os = "ios",
|
||||
all(target_os = "macos", feature = "macos-ne"),
|
||||
target_env = "ohos"
|
||||
))]
|
||||
if let Err(error) = mobile_tun_sources_for_legacy_set_tun_fd(inst_id) {
|
||||
set_error_msg(&error);
|
||||
return -1;
|
||||
}
|
||||
|
||||
match ffi_context().manager.attach_tun_fd(inst_id, fd) {
|
||||
Ok(_) => 0,
|
||||
Err(_) => -1,
|
||||
Err(e) => {
|
||||
set_error_msg(&format!("failed to set tun fd: {}", e));
|
||||
-1
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,23 +0,0 @@
|
||||
[package]
|
||||
name = "easytier-ios"
|
||||
version = "0.1.0"
|
||||
edition.workspace = true
|
||||
|
||||
[lib]
|
||||
crate-type = ["staticlib", "rlib"]
|
||||
|
||||
[dependencies]
|
||||
serde_json.workspace = true
|
||||
tracing.workspace = true
|
||||
tracing-subscriber = { workspace = true, default-features = true, features = ["env-filter"] }
|
||||
easytier-ffi = { workspace = true, features = [
|
||||
"c-abi",
|
||||
] }
|
||||
|
||||
[dev-dependencies]
|
||||
uuid.workspace = true
|
||||
easytier-core = { workspace = true, default-features = true }
|
||||
easytier-ffi = { workspace = true, features = [
|
||||
"c-abi",
|
||||
"ffi-dataplane",
|
||||
] }
|
||||
@@ -1,70 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# Build the easytier-ios static library slices for the Flutter iOS client.
|
||||
#
|
||||
# This script only runs on macOS: it needs the Apple SDK (aarch64-apple-ios*,
|
||||
# x86_64-apple-ios targets) plus `lipo`. Run it from the EasyTier repository
|
||||
# root or from this crate directory.
|
||||
#
|
||||
# rustup target add aarch64-apple-ios aarch64-apple-ios-sim x86_64-apple-ios
|
||||
# ./build-xcframework.sh
|
||||
#
|
||||
# Output (workspace target directory + ./xcframework/sim):
|
||||
# target/aarch64-apple-ios/release/libeasytier_ios.a (device)
|
||||
# xcframework/sim/libeasytier_ios.a (simulator, lipo merged)
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
CRATE_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
# The crate lives in a workspace; build artifacts land in the workspace root
|
||||
# target directory regardless of the current directory.
|
||||
WORKSPACE_ROOT="$(cd "${CRATE_DIR}/../.." && pwd)"
|
||||
TARGET_DIR="${WORKSPACE_ROOT}/target"
|
||||
OUT_DIR="${CRATE_DIR}/xcframework"
|
||||
|
||||
if [[ "$(uname)" != "Darwin" ]]; then
|
||||
echo "error: build-xcframework.sh must run on macOS (needs Apple SDK, lipo)" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
cd "${WORKSPACE_ROOT}"
|
||||
|
||||
# The Rust iOS targets emit a `___chkstk_darwin` stack-probe call but do not
|
||||
# link the compiler-rt archive that provides it. Point the linker at the
|
||||
# matching device or simulator archive shipped inside the Xcode toolchain.
|
||||
CLANG_BIN="$(xcrun --find clang)" # .../Toolchains/XcodeDefault.xctoolchain/usr/bin/clang
|
||||
TOOLCHAIN_USR="${CLANG_BIN%/bin/clang}" # .../XcodeDefault.xctoolchain/usr
|
||||
CLANG_RT_DIR="$(cd "${TOOLCHAIN_USR}/lib/clang" && cd "$(ls | sort -V | tail -1)/lib/darwin" && pwd)"
|
||||
CLANG_RT_RUSTFLAGS="${RUSTFLAGS:-} -C link-arg=-L${CLANG_RT_DIR}"
|
||||
echo "==> using libclang_rt from ${CLANG_RT_DIR}"
|
||||
|
||||
# kcp-sys's bindgen rejects the `-sim` in the aarch64-apple-ios-sim target
|
||||
# triple; give bindgen an explicit simulator target so the C bindings build.
|
||||
SIM_SDK="$(xcrun --sdk iphonesimulator --show-sdk-path)"
|
||||
|
||||
echo "==> building aarch64-apple-ios (device)"
|
||||
RUSTFLAGS="${CLANG_RT_RUSTFLAGS} -C link-arg=-lclang_rt.ios" \
|
||||
cargo build -p easytier-ios --release --target aarch64-apple-ios
|
||||
|
||||
echo "==> building aarch64-apple-ios-sim (Apple Silicon simulator)"
|
||||
BINDGEN_EXTRA_CLANG_ARGS="--target=arm64-apple-ios17.0-simulator -isysroot ${SIM_SDK}" \
|
||||
RUSTFLAGS="${CLANG_RT_RUSTFLAGS} -C link-arg=-lclang_rt.iossim" \
|
||||
cargo build -p easytier-ios --release --target aarch64-apple-ios-sim
|
||||
|
||||
echo "==> building x86_64-apple-ios (Intel simulator)"
|
||||
BINDGEN_EXTRA_CLANG_ARGS="--target=x86_64-apple-ios17.0-simulator -isysroot ${SIM_SDK}" \
|
||||
RUSTFLAGS="${CLANG_RT_RUSTFLAGS} -C link-arg=-lclang_rt.iossim" \
|
||||
cargo build -p easytier-ios --release --target x86_64-apple-ios
|
||||
|
||||
rm -rf "${OUT_DIR}"
|
||||
mkdir -p "${OUT_DIR}/sim"
|
||||
|
||||
echo "==> lipo: merge simulator slices"
|
||||
lipo -create \
|
||||
"${TARGET_DIR}/aarch64-apple-ios-sim/release/libeasytier_ios.a" \
|
||||
"${TARGET_DIR}/x86_64-apple-ios/release/libeasytier_ios.a" \
|
||||
-output "${OUT_DIR}/sim/libeasytier_ios.a"
|
||||
|
||||
echo "==> done:"
|
||||
echo " device: ${TARGET_DIR}/aarch64-apple-ios/release/libeasytier_ios.a"
|
||||
echo " simulator: ${OUT_DIR}/sim/libeasytier_ios.a"
|
||||
@@ -1,158 +0,0 @@
|
||||
/**
|
||||
* @file easytier-ios.h
|
||||
* @brief iOS-facing C ABI for EasyTier.
|
||||
*
|
||||
* This library embeds EasyTier into an iOS app without a TUN device or
|
||||
* NEPacketTunnel: it manages EasyTier instances and bridges to the EasyTier
|
||||
* management RPC surface. Loopback port forwarding into the virtual network
|
||||
* is configured through easytier_ios_call_json_rpc() with
|
||||
* api.config.ConfigRpcService/PatchConfig port-forward patches; there is no
|
||||
* built-in forwarder.
|
||||
*
|
||||
* Error handling: functions returning `int` return 0 on success and -1 on
|
||||
* failure; functions returning `char *` return NULL on failure. Call
|
||||
* easytier_ios_last_error() on the same thread to retrieve details.
|
||||
*
|
||||
* Threading: all functions are safe to call from any thread. The last-error
|
||||
* buffer is thread-local, so query it on the thread that received the
|
||||
* failure.
|
||||
*/
|
||||
|
||||
#ifndef EASYTIER_IOS_H
|
||||
#define EASYTIER_IOS_H
|
||||
|
||||
#include <stdint.h>
|
||||
|
||||
#ifdef __cplusplus
|
||||
extern "C" {
|
||||
#endif
|
||||
|
||||
/**
|
||||
* @brief Configure persistent EasyTier diagnostic logging.
|
||||
*
|
||||
* Enabling writes targeted connection trace/debug events into rotating log
|
||||
* files in `directory`; disabling turns the filter off and flushes output.
|
||||
*
|
||||
* @param directory UTF-8 directory path. Required when enabling; ignored when
|
||||
* disabling.
|
||||
* @param enabled Non-zero to enable, zero to disable.
|
||||
* @return 0 on success, -1 on failure.
|
||||
*/
|
||||
int easytier_ios_configure_diagnostic_logging(const char *directory,
|
||||
int enabled);
|
||||
|
||||
/**
|
||||
* @brief Append a host lifecycle or network-path marker to the active log.
|
||||
*
|
||||
* This is a no-op while diagnostic logging is disabled.
|
||||
*
|
||||
* @param message Non-null NUL-terminated UTF-8 event text.
|
||||
* @return 0 on success, -1 on failure.
|
||||
*/
|
||||
int easytier_ios_append_diagnostic_event(const char *message);
|
||||
|
||||
/** @brief Flush diagnostic log output. */
|
||||
int easytier_ios_flush_diagnostic_logging(void);
|
||||
|
||||
/** @brief Delete all diagnostic log content and reopen the active log. */
|
||||
int easytier_ios_clear_diagnostic_logs(void);
|
||||
|
||||
/**
|
||||
* @brief Start one EasyTier network instance from a TOML config string.
|
||||
*
|
||||
* The config's `instance_name` must be unique among instances started
|
||||
* through this library.
|
||||
*
|
||||
* @param toml Non-null pointer to a NUL-terminated UTF-8 TOML config string.
|
||||
* @return 0 on success, -1 on failure.
|
||||
*/
|
||||
int easytier_ios_run_instance(const char *toml);
|
||||
|
||||
/**
|
||||
* @brief Keep the named instances and stop all others.
|
||||
*
|
||||
* @param names_json Null, empty, or a NUL-terminated JSON array of instance
|
||||
* name strings. Null / empty / `[]` stops every running
|
||||
* instance.
|
||||
* @return 0 on success, -1 on failure.
|
||||
*/
|
||||
int easytier_ios_retain_instances(const char *names_json);
|
||||
|
||||
/**
|
||||
* @brief Stop exactly one named instance without affecting other instances.
|
||||
*
|
||||
* An unknown name is a no-op.
|
||||
*
|
||||
* @param instance_name Non-null NUL-terminated instance name.
|
||||
* @return 0 on success, -1 on failure.
|
||||
*/
|
||||
int easytier_ios_delete_instance(const char *instance_name);
|
||||
|
||||
/**
|
||||
* @brief Collect running instance information as a JSON object.
|
||||
*
|
||||
* The result maps each instance name to its running info JSON object.
|
||||
*
|
||||
* @param max_length Maximum number of instances to report.
|
||||
* @return A newly allocated NUL-terminated JSON string on success, NULL on
|
||||
* failure.
|
||||
*
|
||||
* @ownership The caller owns the returned string and must release it with
|
||||
* easytier_ios_free_string().
|
||||
*/
|
||||
char *easytier_ios_collect_network_infos(int max_length);
|
||||
|
||||
/**
|
||||
* @brief Call an exposed EasyTier management RPC method using protobuf JSON.
|
||||
*
|
||||
* `service_name` is the protobuf service name (e.g.
|
||||
* "api.config.ConfigRpcService"), `method_name` the RPC method name (e.g.
|
||||
* "PatchConfig"). `payload_json` must contain the protobuf JSON request,
|
||||
* including any `instance` selector required by the target RPC.
|
||||
*
|
||||
* Port forwarding into the virtual network is driven through this bridge
|
||||
* with api.config.ConfigRpcService/PatchConfig port-forward patches.
|
||||
*
|
||||
* @param service_name Non-null NUL-terminated RPC service name.
|
||||
* @param method_name Non-null NUL-terminated RPC method name.
|
||||
* @param payload_json Non-null NUL-terminated protobuf JSON request body.
|
||||
* @return A newly allocated NUL-terminated JSON response string on success,
|
||||
* NULL on failure.
|
||||
*
|
||||
* @ownership The caller owns the returned string and must release it with
|
||||
* easytier_ios_free_string().
|
||||
*/
|
||||
char *easytier_ios_call_json_rpc(const char *service_name,
|
||||
const char *method_name,
|
||||
const char *payload_json);
|
||||
|
||||
/**
|
||||
* @brief Return the last error message on this thread.
|
||||
*
|
||||
* Combines wrapper-side errors recorded by this library with the
|
||||
* easytier-ffi last FFI error.
|
||||
*
|
||||
* @return A newly allocated NUL-terminated string, or NULL when there is no
|
||||
* recorded error.
|
||||
*
|
||||
* @ownership The caller owns the returned string and must release it with
|
||||
* easytier_ios_free_string().
|
||||
*/
|
||||
char *easytier_ios_last_error(void);
|
||||
|
||||
/**
|
||||
* @brief Release a string returned by this library.
|
||||
*
|
||||
* Use this for strings returned by easytier_ios_collect_network_infos(),
|
||||
* easytier_ios_call_json_rpc() and easytier_ios_last_error(). Passing NULL
|
||||
* is a no-op. The string must not be used after this call.
|
||||
*
|
||||
* @param s NULL, or a string previously returned by this library.
|
||||
*/
|
||||
void easytier_ios_free_string(char *s);
|
||||
|
||||
#ifdef __cplusplus
|
||||
}
|
||||
#endif
|
||||
|
||||
#endif /* EASYTIER_IOS_H */
|
||||
@@ -1,308 +0,0 @@
|
||||
use std::{
|
||||
fs::{self, File, OpenOptions},
|
||||
io::{self, Write},
|
||||
path::{Path, PathBuf},
|
||||
sync::{Arc, Mutex},
|
||||
};
|
||||
|
||||
use tracing_subscriber::fmt::MakeWriter;
|
||||
|
||||
pub(crate) const MAX_LOG_BYTES: u64 = 5 * 1024 * 1024;
|
||||
pub(crate) const MAX_LOG_FILES: usize = 4;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub(crate) struct DiagnosticMakeWriter {
|
||||
inner: Arc<Mutex<RotatingLog>>,
|
||||
}
|
||||
|
||||
impl DiagnosticMakeWriter {
|
||||
pub(crate) fn new(directory: &Path) -> io::Result<Self> {
|
||||
Ok(Self {
|
||||
inner: Arc::new(Mutex::new(RotatingLog::open(directory)?)),
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn set_directory(&self, directory: &Path) -> io::Result<()> {
|
||||
self.lock()?.set_directory(directory)
|
||||
}
|
||||
|
||||
pub(crate) fn clear(&self) -> io::Result<()> {
|
||||
self.lock()?.clear()
|
||||
}
|
||||
|
||||
pub(crate) fn flush(&self) -> io::Result<()> {
|
||||
self.lock()?.flush()
|
||||
}
|
||||
|
||||
fn lock(&self) -> io::Result<std::sync::MutexGuard<'_, RotatingLog>> {
|
||||
self.inner
|
||||
.lock()
|
||||
.map_err(|_| io::Error::other("diagnostic log lock poisoned"))
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a> MakeWriter<'a> for DiagnosticMakeWriter {
|
||||
type Writer = BufferedEventWriter;
|
||||
|
||||
fn make_writer(&'a self) -> Self::Writer {
|
||||
BufferedEventWriter {
|
||||
target: self.clone(),
|
||||
buffer: Vec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) struct BufferedEventWriter {
|
||||
target: DiagnosticMakeWriter,
|
||||
buffer: Vec<u8>,
|
||||
}
|
||||
|
||||
impl BufferedEventWriter {
|
||||
fn commit(&mut self) -> io::Result<()> {
|
||||
if self.buffer.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
let buffer = std::mem::take(&mut self.buffer);
|
||||
self.target.lock()?.write_event(&buffer)
|
||||
}
|
||||
}
|
||||
|
||||
impl Write for BufferedEventWriter {
|
||||
fn write(&mut self, buffer: &[u8]) -> io::Result<usize> {
|
||||
self.buffer.extend_from_slice(buffer);
|
||||
Ok(buffer.len())
|
||||
}
|
||||
|
||||
fn flush(&mut self) -> io::Result<()> {
|
||||
self.commit()
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for BufferedEventWriter {
|
||||
fn drop(&mut self) {
|
||||
let _ = self.commit();
|
||||
}
|
||||
}
|
||||
|
||||
struct RotatingLog {
|
||||
directory: PathBuf,
|
||||
active: Option<File>,
|
||||
active_bytes: u64,
|
||||
}
|
||||
|
||||
impl RotatingLog {
|
||||
fn open(directory: &Path) -> io::Result<Self> {
|
||||
fs::create_dir_all(directory)?;
|
||||
let mut log = Self {
|
||||
directory: directory.to_owned(),
|
||||
active: None,
|
||||
active_bytes: 0,
|
||||
};
|
||||
log.truncate_oversized_files()?;
|
||||
log.open_active()?;
|
||||
Ok(log)
|
||||
}
|
||||
|
||||
fn set_directory(&mut self, directory: &Path) -> io::Result<()> {
|
||||
if self.directory == directory && self.active.is_some() {
|
||||
return Ok(());
|
||||
}
|
||||
self.flush()?;
|
||||
self.active = None;
|
||||
self.directory = directory.to_owned();
|
||||
fs::create_dir_all(directory)?;
|
||||
self.truncate_oversized_files()?;
|
||||
self.open_active()
|
||||
}
|
||||
|
||||
fn active_path(&self) -> PathBuf {
|
||||
self.directory.join("easytier.log")
|
||||
}
|
||||
|
||||
fn rotated_path(&self, index: usize) -> PathBuf {
|
||||
self.directory.join(format!("easytier.{index}.log"))
|
||||
}
|
||||
|
||||
fn truncate_oversized_files(&self) -> io::Result<()> {
|
||||
let paths = std::iter::once(self.active_path())
|
||||
.chain((1..MAX_LOG_FILES).map(|index| self.rotated_path(index)));
|
||||
for path in paths {
|
||||
if path
|
||||
.metadata()
|
||||
.is_ok_and(|metadata| metadata.len() > MAX_LOG_BYTES)
|
||||
{
|
||||
OpenOptions::new()
|
||||
.write(true)
|
||||
.open(path)?
|
||||
.set_len(MAX_LOG_BYTES)?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn open_active(&mut self) -> io::Result<()> {
|
||||
let path = self.active_path();
|
||||
let file = OpenOptions::new().create(true).append(true).open(&path)?;
|
||||
self.active_bytes = file.metadata()?.len();
|
||||
self.active = Some(file);
|
||||
if self.active_bytes >= MAX_LOG_BYTES {
|
||||
self.rotate()?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn write_event(&mut self, event: &[u8]) -> io::Result<()> {
|
||||
if event.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
if self.active_bytes > 0
|
||||
&& self.active_bytes.saturating_add(event.len() as u64) > MAX_LOG_BYTES
|
||||
{
|
||||
self.rotate()?;
|
||||
}
|
||||
let remaining = MAX_LOG_BYTES.saturating_sub(self.active_bytes) as usize;
|
||||
let event = &event[..event.len().min(remaining)];
|
||||
if let Some(active) = self.active.as_mut() {
|
||||
active.write_all(event)?;
|
||||
self.active_bytes += event.len() as u64;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn rotate(&mut self) -> io::Result<()> {
|
||||
self.flush()?;
|
||||
self.active = None;
|
||||
|
||||
let oldest = self.rotated_path(MAX_LOG_FILES - 1);
|
||||
if oldest.exists() {
|
||||
fs::remove_file(oldest)?;
|
||||
}
|
||||
for index in (1..MAX_LOG_FILES - 1).rev() {
|
||||
let source = self.rotated_path(index);
|
||||
if source.exists() {
|
||||
fs::rename(source, self.rotated_path(index + 1))?;
|
||||
}
|
||||
}
|
||||
let active = self.active_path();
|
||||
if active.exists() {
|
||||
fs::rename(active, self.rotated_path(1))?;
|
||||
}
|
||||
self.active_bytes = 0;
|
||||
self.active = Some(
|
||||
OpenOptions::new()
|
||||
.create(true)
|
||||
.append(true)
|
||||
.open(self.active_path())?,
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn clear(&mut self) -> io::Result<()> {
|
||||
self.flush()?;
|
||||
self.active = None;
|
||||
for index in 1..MAX_LOG_FILES {
|
||||
let path = self.rotated_path(index);
|
||||
if path.exists() {
|
||||
fs::remove_file(path)?;
|
||||
}
|
||||
}
|
||||
let active = self.active_path();
|
||||
if active.exists() {
|
||||
fs::remove_file(&active)?;
|
||||
}
|
||||
self.active_bytes = 0;
|
||||
self.active = Some(OpenOptions::new().create(true).append(true).open(active)?);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn flush(&mut self) -> io::Result<()> {
|
||||
match self.active.as_mut() {
|
||||
Some(active) => active.flush(),
|
||||
None => Ok(()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
struct TempDir(PathBuf);
|
||||
|
||||
impl TempDir {
|
||||
fn new(name: &str) -> Self {
|
||||
let unique = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_nanos();
|
||||
let path = std::env::temp_dir().join(format!("easytier-ios-{name}-{unique}"));
|
||||
fs::create_dir_all(&path).unwrap();
|
||||
Self(path)
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for TempDir {
|
||||
fn drop(&mut self) {
|
||||
let _ = fs::remove_dir_all(&self.0);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rotates_without_exceeding_file_limit() {
|
||||
let directory = TempDir::new("rotation");
|
||||
let mut log = RotatingLog::open(&directory.0).unwrap();
|
||||
let event = vec![b'x'; (MAX_LOG_BYTES / 2 + 1) as usize];
|
||||
|
||||
for _ in 0..6 {
|
||||
log.write_event(&event).unwrap();
|
||||
}
|
||||
log.flush().unwrap();
|
||||
|
||||
let files = fs::read_dir(&directory.0)
|
||||
.unwrap()
|
||||
.collect::<Result<Vec<_>, _>>()
|
||||
.unwrap();
|
||||
assert_eq!(files.len(), MAX_LOG_FILES);
|
||||
assert!(
|
||||
files
|
||||
.iter()
|
||||
.all(|entry| entry.metadata().unwrap().len() <= MAX_LOG_BYTES)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clear_removes_rotated_content_and_keeps_active_file_writable() {
|
||||
let directory = TempDir::new("clear");
|
||||
let mut log = RotatingLog::open(&directory.0).unwrap();
|
||||
let event = vec![b'x'; (MAX_LOG_BYTES / 2 + 1) as usize];
|
||||
log.write_event(&event).unwrap();
|
||||
log.write_event(&event).unwrap();
|
||||
|
||||
log.clear().unwrap();
|
||||
log.write_event(b"after clear\n").unwrap();
|
||||
log.flush().unwrap();
|
||||
|
||||
assert_eq!(fs::read(log.active_path()).unwrap(), b"after clear\n");
|
||||
assert!(!log.rotated_path(1).exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn opening_truncates_oversized_known_files() {
|
||||
let directory = TempDir::new("oversized");
|
||||
for name in ["easytier.log", "easytier.1.log"] {
|
||||
let file = File::create(directory.0.join(name)).unwrap();
|
||||
file.set_len(MAX_LOG_BYTES + 1).unwrap();
|
||||
}
|
||||
|
||||
let log = RotatingLog::open(&directory.0).unwrap();
|
||||
|
||||
for index in 1..MAX_LOG_FILES {
|
||||
let path = log.rotated_path(index);
|
||||
if path.exists() {
|
||||
assert!(path.metadata().unwrap().len() <= MAX_LOG_BYTES);
|
||||
}
|
||||
}
|
||||
assert!(log.active_path().metadata().unwrap().len() <= MAX_LOG_BYTES);
|
||||
}
|
||||
}
|
||||
@@ -1,70 +0,0 @@
|
||||
use std::{
|
||||
cell::RefCell,
|
||||
ffi::{CStr, CString, c_char},
|
||||
ptr,
|
||||
};
|
||||
|
||||
thread_local! {
|
||||
// Thread-local last error for the easytier-ios C ABI. Wrapper-side
|
||||
// argument/JSON failures are recorded here; easytier-ffi records
|
||||
// instance/RPC failures in its own buffer. `last_error` merges both.
|
||||
static LAST_ERROR: RefCell<Vec<u8>> = const { RefCell::new(Vec::new()) };
|
||||
}
|
||||
|
||||
pub(crate) fn set_error(message: &str) {
|
||||
LAST_ERROR.with(|cell| {
|
||||
let mut buffer = cell.borrow_mut();
|
||||
buffer.clear();
|
||||
buffer.extend_from_slice(message.as_bytes());
|
||||
});
|
||||
}
|
||||
|
||||
pub(crate) fn clear_error() {
|
||||
LAST_ERROR.with(|cell| cell.borrow_mut().clear());
|
||||
}
|
||||
|
||||
fn thread_local_error() -> Option<String> {
|
||||
LAST_ERROR.with(|cell| {
|
||||
let buffer = cell.borrow();
|
||||
if buffer.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(String::from_utf8_lossy(&buffer).into_owned())
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn ffi_error() -> Option<String> {
|
||||
unsafe {
|
||||
let mut error_ptr: *const c_char = ptr::null();
|
||||
easytier_ffi::get_error_msg(&mut error_ptr);
|
||||
if error_ptr.is_null() {
|
||||
None
|
||||
} else {
|
||||
let error_str = CStr::from_ptr(error_ptr).to_string_lossy().into_owned();
|
||||
easytier_ffi::free_string(error_ptr);
|
||||
Some(error_str)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Merge both error layers: this wrapper's own thread-local buffer and
|
||||
/// easytier-ffi's last FFI error.
|
||||
pub(crate) fn last_error() -> Option<String> {
|
||||
match (ffi_error(), thread_local_error()) {
|
||||
(Some(ffi_error), Some(local_error)) => Some(format!("{local_error}; {ffi_error}")),
|
||||
(Some(ffi_error), None) => Some(ffi_error),
|
||||
(None, Some(local_error)) => Some(local_error),
|
||||
(None, None) => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Copy the merged last error into a newly allocated C string (null when
|
||||
/// there is no error). The caller owns the result and must release it with
|
||||
/// `easytier_ios_free_string`.
|
||||
pub(crate) fn last_error_raw() -> *mut c_char {
|
||||
match last_error().and_then(|message| CString::new(message).ok()) {
|
||||
Some(message) => message.into_raw(),
|
||||
None => ptr::null_mut(),
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,11 +0,0 @@
|
||||
use std::ffi::CString;
|
||||
|
||||
/// Build a NUL-terminated C string from a Rust string for FFI calls.
|
||||
pub(crate) fn cstring_for(value: &str, what: &str) -> std::io::Result<CString> {
|
||||
CString::new(value).map_err(|_| {
|
||||
std::io::Error::new(
|
||||
std::io::ErrorKind::InvalidInput,
|
||||
format!("{what} contains a null byte"),
|
||||
)
|
||||
})
|
||||
}
|
||||
@@ -1,23 +1,6 @@
|
||||
# EasyTier Magisk 模块
|
||||
# easytier_magisk版模块
|
||||
magisk安装后重启
|
||||
|
||||
安装模块并重启后,EasyTier 会读取
|
||||
`/data/adb/modules/easytier_magisk/config/config.toml` 启动。
|
||||
|
||||
## WebUI
|
||||
|
||||
KernelSU 及支持模块 WebUI 的分支管理器可直接从模块详情页打开配置
|
||||
界面。Magisk 用户可安装兼容 KernelSU 模块 WebUI 的独立管理器后打开。
|
||||
|
||||
WebUI 使用 EasyTier 自身的配置模型加载和校验配置。点击“保存并重启”后,
|
||||
模块会先验证配置,原子替换 `config.toml`,再重启正在运行的 EasyTier
|
||||
进程。校验失败时原配置保持不变。
|
||||
|
||||
如果 `config/command_args` 存在,模块处于启动参数模式并会忽略
|
||||
`config.toml`。WebUI 会显示提示并禁止保存;删除 `command_args` 后即可
|
||||
使用 WebUI 管理配置。
|
||||
|
||||
## 手动配置
|
||||
|
||||
也可以直接编辑
|
||||
`/data/adb/modules/easytier_magisk/config/config.toml`。保存后在模块管理器中
|
||||
禁用 EasyTier,等待 10 秒后重新启用即可让配置生效。
|
||||
目录位置:/data/adb/modules/easytier_magisk
|
||||
配置文件位置://data/adb/modules/easytier_magisk/config/config.toml
|
||||
修改config.conf即可,修改后配置文件后去magisk app重新开关模块即可生效
|
||||
|
||||
@@ -1,32 +1,25 @@
|
||||
#!/bin/sh
|
||||
WEBUI_SOURCE="../../easytier-web/config-generator/dist"
|
||||
|
||||
if [ -f "${WEBUI_SOURCE}/index.html" ]; then
|
||||
rm -rf ./webroot
|
||||
mkdir -p ./webroot
|
||||
cp -R "${WEBUI_SOURCE}/." ./webroot/
|
||||
elif [ ! -f "./webroot/index.html" ]; then
|
||||
echo "Error: WebUI 构建产物不存在,请先运行 pnpm --dir ../../easytier-web/config-generator build."
|
||||
exit 1
|
||||
fi
|
||||
version=$(cat module.prop | grep 'version=' | awk -F '=' '{print $2}' | sed 's/ (.*//')
|
||||
|
||||
version=$(grep '^version =' ../../easytier/Cargo.toml | cut -d '"' -f 2)
|
||||
version='v'$(grep '^version =' ../../easytier/Cargo.toml | cut -d '"' -f 2)
|
||||
|
||||
if [ -z "$version" ]; then
|
||||
echo "Error: 版本号不存在."
|
||||
exit 1
|
||||
fi
|
||||
version="v${version}"
|
||||
|
||||
filename="easytier_magisk_${version}.zip"
|
||||
echo "${version}"
|
||||
echo $version
|
||||
|
||||
if [ ! -f "./easytier-core" ] || [ ! -f "./easytier-cli" ] || [ ! -f "./easytier-web" ]; then
|
||||
wget -O "easytier_last.zip" "https://github.com/EasyTier/EasyTier/releases/download/${version}/easytier-linux-aarch64-${version}.zip"
|
||||
|
||||
if [ -f "./easytier-core" ] && [ -f "./easytier-cli" ] && [ -f "./easytier-web" ]; then
|
||||
zip -r -o -X "$filename" ./ -x '.git/*' -x '.github/*' -x 'folder/*' -x 'build.sh' -x 'magisk_update.json'
|
||||
else
|
||||
wget -O "easytier_last.zip" https://github.com/EasyTier/EasyTier/releases/download/"$version"/easytier-linux-aarch64-"$version".zip
|
||||
unzip -o easytier_last.zip -d ./
|
||||
mv ./easytier-linux-aarch64/* ./
|
||||
rm -rf ./easytier_last.zip
|
||||
rm -rf ./easytier-linux-aarch64
|
||||
fi
|
||||
|
||||
zip -r -o -X "${filename}" ./ -x '.git/*' -x '.github/*' -x 'folder/*' -x 'build.sh' -x 'magisk_update.json'
|
||||
zip -r -o -X "$filename" ./ -x '.git/*' -x '.github/*' -x 'folder/*' -x 'build.sh' -x 'magisk_update.json'
|
||||
fi
|
||||
@@ -16,7 +16,7 @@ rpc_portal = "0.0.0.0:15888"
|
||||
network_name = "default"
|
||||
network_secret = ""
|
||||
|
||||
#[[peer]]
|
||||
[[peer]]
|
||||
#uri = "协议://中转ip:端口"
|
||||
|
||||
[flags]
|
||||
@@ -29,7 +29,7 @@ latency_first = false
|
||||
enable_exit_node = false
|
||||
no_tun = false
|
||||
use_smoltcp = false
|
||||
relay_network_whitelist = "*"
|
||||
foreign_network_whitelist = "*"
|
||||
disable_p2p = false
|
||||
relay_all_peer_rpc = false
|
||||
disable_udp_hole_punching = false
|
||||
|
||||
@@ -11,7 +11,6 @@ ui_print "EasyTier 安装位置:/data/adb/modules/easytier_magisk"
|
||||
ui_print "配置文件位置:/data/adb/modules/easytier_magisk/config/config.toml"
|
||||
ui_print "如需使用启动参数模式,请将 /data/adb/modules/easytier_magisk/config/command_args_sample 重命名为 command_args,并修改其中的内容"
|
||||
ui_print "config 目录中存在 command_args 文件时,模块会自动忽略 config.toml 文件"
|
||||
ui_print "支持模块 WebUI 的管理器可直接打开配置界面;Magisk 用户可配合 KsuWebUI 使用"
|
||||
ui_print "----------------------------------"
|
||||
ui_print "注意!启动参数文件中不能存在 \" 和 ',配置文件则没有这个限制"
|
||||
ui_print "----------------------------------"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
id=easytier_magisk
|
||||
name=EasyTier_Magisk
|
||||
version=v2.7.0
|
||||
version=v2.6.4
|
||||
versionCode=1
|
||||
author=EasyTier
|
||||
description=easytier magisk module @EasyTier(https://github.com/EasyTier/EasyTier)
|
||||
|
||||
@@ -1,77 +0,0 @@
|
||||
#!/system/bin/sh
|
||||
|
||||
MODDIR=$(CDPATH= cd "${0%/*}" 2>/dev/null && pwd -P) || {
|
||||
echo "Failed to resolve module directory" >&2
|
||||
exit 1
|
||||
}
|
||||
CONFIG_FILE="${MODDIR}/config/config.toml"
|
||||
COMMAND_ARGS="${MODDIR}/config/command_args"
|
||||
EASYTIER="${MODDIR}/easytier-core"
|
||||
|
||||
fail() {
|
||||
echo "$1" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
running_pids() {
|
||||
for process_dir in /proc/[0-9]*; do
|
||||
[ "$(readlink "${process_dir}/exe" 2>/dev/null)" = "${EASYTIER}" ] || continue
|
||||
printf '%s\n' "${process_dir#/proc/}"
|
||||
done
|
||||
}
|
||||
|
||||
is_running() {
|
||||
[ -n "$(running_pids)" ]
|
||||
}
|
||||
|
||||
case "$1" in
|
||||
status)
|
||||
if is_running; then
|
||||
echo "running"
|
||||
else
|
||||
echo "stopped"
|
||||
fi
|
||||
;;
|
||||
config-mode)
|
||||
if [ -f "${COMMAND_ARGS}" ]; then
|
||||
echo "command-args"
|
||||
else
|
||||
echo "toml"
|
||||
fi
|
||||
;;
|
||||
read-config)
|
||||
[ -f "${CONFIG_FILE}" ] || fail "Configuration file not found: ${CONFIG_FILE}"
|
||||
cat "${CONFIG_FILE}"
|
||||
;;
|
||||
save-and-restart)
|
||||
[ -f "${COMMAND_ARGS}" ] && fail "command_args is active; config.toml would be ignored"
|
||||
[ -n "$2" ] || fail "Missing base64-encoded configuration"
|
||||
[ -x "${EASYTIER}" ] || fail "easytier-core is not executable"
|
||||
|
||||
TEMP_FILE="${CONFIG_FILE}.webui.$$"
|
||||
trap 'rm -f "${TEMP_FILE}"' EXIT HUP INT TERM
|
||||
umask 077
|
||||
|
||||
printf '%s' "$2" | base64 -d > "${TEMP_FILE}" \
|
||||
|| fail "Failed to decode configuration"
|
||||
|
||||
VALIDATION_OUTPUT=$("${EASYTIER}" --check-config -c "${TEMP_FILE}" 2>&1)
|
||||
if [ $? -ne 0 ]; then
|
||||
fail "${VALIDATION_OUTPUT:-Configuration validation failed}"
|
||||
fi
|
||||
|
||||
chmod 0644 "${TEMP_FILE}"
|
||||
mv -f "${TEMP_FILE}" "${CONFIG_FILE}" \
|
||||
|| fail "Failed to replace configuration"
|
||||
trap - EXIT HUP INT TERM
|
||||
|
||||
RUNNING_PIDS=$(running_pids)
|
||||
for pid in ${RUNNING_PIDS}; do
|
||||
kill "${pid}" || fail "Failed to restart EasyTier"
|
||||
done
|
||||
echo "saved"
|
||||
;;
|
||||
*)
|
||||
fail "Usage: $0 {status|config-mode|read-config|save-and-restart BASE64}"
|
||||
;;
|
||||
esac
|
||||
@@ -1,15 +1,15 @@
|
||||
[package]
|
||||
name = "easytier-mini"
|
||||
description = "Minimal native EasyTier node with TCP/UDP tunnels, TUN and UDP hole punching."
|
||||
version = "2.7.0"
|
||||
version = "2.6.4"
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
license-file = "../../LICENSE"
|
||||
build = "build.rs"
|
||||
|
||||
[dependencies]
|
||||
anyhow.workspace = true
|
||||
easytier = { workspace = true, features = [
|
||||
anyhow = "1.0"
|
||||
easytier = { path = "../../easytier", version = "2.6.4", default-features = false, features = [
|
||||
"aes-gcm",
|
||||
"dhcp-ipv4",
|
||||
"logging",
|
||||
@@ -18,4 +18,4 @@ easytier = { workspace = true, features = [
|
||||
"tun",
|
||||
"web-client",
|
||||
] }
|
||||
tokio = { workspace = true, features = ["macros", "rt", "signal"] }
|
||||
tokio = { version = "1", default-features = false, features = ["macros", "rt", "signal"] }
|
||||
|
||||
+1783
-1661
File diff suppressed because it is too large
Load Diff
@@ -1,10 +1,3 @@
|
||||
[workspace]
|
||||
members = [
|
||||
"crates/easytier-ohos-features",
|
||||
"crates/easytier-ohos-core",
|
||||
]
|
||||
resolver = "2"
|
||||
|
||||
[package]
|
||||
name = "easytier-ohrs"
|
||||
version = "0.1.0"
|
||||
@@ -16,16 +9,17 @@ crate-type=["cdylib"]
|
||||
[dependencies]
|
||||
anyhow = "1.0"
|
||||
async-trait = "0.1"
|
||||
base64 = "0.22"
|
||||
bytes = "1.5"
|
||||
easytier-core = { path = "../../easytier-core", default-features = false }
|
||||
easytier-ohos-features = { path = "crates/easytier-ohos-features" }
|
||||
easytier-ohos-core = { path = "crates/easytier-ohos-core" }
|
||||
easytier-proto = { path = "../../easytier-proto", default-features = false, features = [
|
||||
"api",
|
||||
"core",
|
||||
"json-rpc",
|
||||
] }
|
||||
flate2 = "1.1"
|
||||
futures = "0.3"
|
||||
gethostname = "1.1"
|
||||
easytier = { path = "../../easytier" }
|
||||
napi-derive-ohos = "1.1"
|
||||
napi-ohos = { version = "1.1", default-features = false, features = [
|
||||
@@ -40,11 +34,15 @@ napi-ohos = { version = "1.1", default-features = false, features = [
|
||||
"tokio_io_util",
|
||||
"deferred_trace",
|
||||
"napi8",
|
||||
"node_version_detect",
|
||||
"web_stream",
|
||||
] }
|
||||
once_cell = "1.21.3"
|
||||
ipnet = "2.10"
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
serde_json = "1.0.125"
|
||||
prost-reflect = { version = "0.14.5", default-features = false, features = ["derive"] }
|
||||
rusqlite = { version = "0.32", features = ["bundled"] }
|
||||
tracing-subscriber = "0.3.19"
|
||||
tracing-core = "0.1.33"
|
||||
tracing = "0.1.41"
|
||||
|
||||
@@ -1,29 +0,0 @@
|
||||
# HarmonyOS Rust package boundaries
|
||||
|
||||
`easytier-ohrs` keeps the single `.so`/HAR and N-API compatibility surface consumed by ArkTS, but its Rust implementation is split by responsibility:
|
||||
|
||||
- **`easytier-ohos-core`** owns the process Tokio runtime, `NativeInstanceManager`, runtime-state projections, kernel socket protocol DTOs, TUN route aggregation, and the platform handshake used to protect individual transport sockets. Code that starts, stops, observes, or translates EasyTier runtime state belongs here.
|
||||
- **`easytier-ohos-features`** owns configuration persistence and migration, SQLite metadata/field storage, schema reflection, validation, import/export, and share links. Code that remains meaningful without a running EasyTier instance belongs here.
|
||||
- **`easytier-ohrs`** is the platform facade. It owns N-API exports, HarmonyOS platform logging and nearby-management adapters, and the small amount of orchestration that passes a validated feature configuration into the kernel package.
|
||||
|
||||
## Dependency direction
|
||||
|
||||
```text
|
||||
ArkTS/HAR
|
||||
|
|
||||
easytier-ohrs (N-API facade)
|
||||
| |
|
||||
v v
|
||||
easytier-ohos-core easytier-ohos-features
|
||||
```
|
||||
|
||||
The facade passes owned EasyTier configuration values into the kernel package when runtime state must be projected. The kernel and feature packages do not depend on each other, so another HarmonyOS application can reuse the kernel integration without pulling in this client's SQLite repository, migrations, schema UI metadata, or share-link services.
|
||||
|
||||
## Boundary rules
|
||||
|
||||
1. SQLite, schema reflection, import/export, and share-link code must not enter `easytier-ohos-core`.
|
||||
2. Tokio runtime ownership, instance lifecycle, TUN attachment, kernel protocol, and runtime-state conversion must not enter `easytier-ohos-features`.
|
||||
3. New ArkTS exports remain in the outer `easytier-ohrs` facade so the HAR continues to expose one stable native module.
|
||||
4. Cross-package values should be owned DTOs/snapshots; feature code must not receive runtime-manager handles.
|
||||
5. Kernel code must not read the feature package's repository or global storage state; the facade supplies the validated runtime values it needs.
|
||||
6. The split is semantic and architectural. It is not presented as a configuration-page frame-time optimization.
|
||||
@@ -1,17 +0,0 @@
|
||||
[package]
|
||||
name = "easytier-ohos-core"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
description = "HarmonyOS-side EasyTier runtime, instance lifecycle and kernel interaction state"
|
||||
publish = false
|
||||
|
||||
[dependencies]
|
||||
async-trait = "0.1"
|
||||
easytier = { path = "../../../../easytier" }
|
||||
easytier-core = { path = "../../../../easytier-core", default-features = false }
|
||||
ipnet = "2.10"
|
||||
once_cell = "1.21.3"
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
serde_json = "1.0.125"
|
||||
tokio = { version = "1", features = ["rt", "rt-multi-thread", "sync", "time"] }
|
||||
url = "2.5"
|
||||
@@ -1,56 +0,0 @@
|
||||
pub mod protocol;
|
||||
pub mod routing;
|
||||
pub mod runtime;
|
||||
pub mod socket_protection;
|
||||
|
||||
use easytier::instance::factory::{NativeInstanceManager, native_instance_manager_with_runtime};
|
||||
use once_cell::sync::Lazy;
|
||||
use std::sync::Arc;
|
||||
use tokio::runtime::{Builder, Runtime};
|
||||
|
||||
/// The single Tokio runtime that owns HarmonyOS kernel and web-client work.
|
||||
pub static ASYNC_RUNTIME: Lazy<Runtime> = Lazy::new(|| {
|
||||
Builder::new_multi_thread()
|
||||
.enable_all()
|
||||
.build()
|
||||
.expect("tokio runtime for easytier-ohos-core")
|
||||
});
|
||||
|
||||
/// Process-wide EasyTier instance manager. Keeping it in the kernel crate prevents feature/storage
|
||||
/// code from acquiring lifecycle ownership.
|
||||
pub static INSTANCE_MANAGER: Lazy<Arc<NativeInstanceManager>> = Lazy::new(|| {
|
||||
Arc::new(native_instance_manager_with_runtime(
|
||||
ASYNC_RUNTIME.handle().clone(),
|
||||
))
|
||||
});
|
||||
|
||||
#[cfg(test)]
|
||||
mod architecture_tests {
|
||||
fn assert_no_napi_annotations(path: &std::path::Path) {
|
||||
for entry in std::fs::read_dir(path).expect("read source directory") {
|
||||
let path = entry.expect("read source entry").path();
|
||||
if path.is_dir() {
|
||||
assert_no_napi_annotations(&path);
|
||||
} else if path.extension().is_some_and(|extension| extension == "rs") {
|
||||
let source = std::fs::read_to_string(&path).expect("read Rust source");
|
||||
let marker = ["#[", "napi"].concat();
|
||||
assert!(!source.contains(&marker), "N-API annotation in {path:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inner_crate_has_no_napi_registration_dependency() {
|
||||
let manifest = include_str!("../Cargo.toml");
|
||||
let runtime_dependency = ["napi", "ohos"].join("-");
|
||||
let derive_dependency = ["napi", "derive", "ohos"].join("-");
|
||||
assert!(!manifest.contains(&runtime_dependency));
|
||||
assert!(!manifest.contains(&derive_dependency));
|
||||
assert!(!manifest.contains("easytier-ohos-features"));
|
||||
assert_no_napi_annotations(
|
||||
std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("src")
|
||||
.as_path(),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
pub mod state;
|
||||
@@ -1 +0,0 @@
|
||||
pub mod runtime_state;
|
||||
@@ -1,255 +0,0 @@
|
||||
use std::{
|
||||
collections::{HashMap, VecDeque},
|
||||
io,
|
||||
os::fd::{AsRawFd, BorrowedFd, OwnedFd},
|
||||
sync::{Arc, Mutex},
|
||||
};
|
||||
|
||||
use async_trait::async_trait;
|
||||
use easytier::socket_protector::{NativeSocketProtector, set_native_socket_protector};
|
||||
use once_cell::sync::Lazy;
|
||||
use tokio::sync::{Notify, oneshot};
|
||||
|
||||
const MAX_PENDING_SOCKET_PROTECTIONS: usize = 128;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct SocketProtectionRequest {
|
||||
pub request_id: u64,
|
||||
pub socket_fd: i32,
|
||||
pub purpose: String,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct SocketProtectionState {
|
||||
enabled: bool,
|
||||
next_request_id: u64,
|
||||
queued: VecDeque<SocketProtectionRequest>,
|
||||
pending: HashMap<u64, PendingSocketProtection>,
|
||||
}
|
||||
|
||||
struct PendingSocketProtection {
|
||||
completion: oneshot::Sender<io::Result<()>>,
|
||||
_socket: OwnedFd,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct SocketProtectionManager {
|
||||
state: Mutex<SocketProtectionState>,
|
||||
request_ready: Notify,
|
||||
}
|
||||
|
||||
pub static SOCKET_PROTECTION_MANAGER: Lazy<Arc<SocketProtectionManager>> =
|
||||
Lazy::new(|| Arc::new(SocketProtectionManager::default()));
|
||||
|
||||
impl SocketProtectionManager {
|
||||
fn enable(&self) {
|
||||
let mut state = self
|
||||
.state
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
state.enabled = true;
|
||||
}
|
||||
|
||||
fn disable(&self) {
|
||||
let queued = {
|
||||
let mut state = self
|
||||
.state
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
state.enabled = false;
|
||||
let queued_ids = state
|
||||
.queued
|
||||
.drain(..)
|
||||
.map(|request| request.request_id)
|
||||
.collect::<Vec<_>>();
|
||||
queued_ids
|
||||
.into_iter()
|
||||
.filter_map(|request_id| state.pending.remove(&request_id))
|
||||
.map(|pending| pending.completion)
|
||||
.collect::<Vec<_>>()
|
||||
};
|
||||
self.request_ready.notify_waiters();
|
||||
for sender in queued {
|
||||
let _ = sender.send(Err(io::Error::new(
|
||||
io::ErrorKind::Interrupted,
|
||||
"native socket protection stopped",
|
||||
)));
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn next_request(&self) -> Option<SocketProtectionRequest> {
|
||||
loop {
|
||||
let notified = self.request_ready.notified();
|
||||
{
|
||||
let mut state = self
|
||||
.state
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
if let Some(request) = state.queued.pop_front() {
|
||||
return Some(request);
|
||||
}
|
||||
if !state.enabled {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
notified.await;
|
||||
}
|
||||
}
|
||||
|
||||
pub fn complete_request(&self, request_id: u64, success: bool, error: Option<String>) -> bool {
|
||||
let pending = self
|
||||
.state
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner)
|
||||
.pending
|
||||
.remove(&request_id);
|
||||
let Some(pending) = pending else {
|
||||
return false;
|
||||
};
|
||||
let result = if success {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(io::Error::other(error.unwrap_or_else(|| {
|
||||
"native socket protection failed".to_string()
|
||||
})))
|
||||
};
|
||||
pending.completion.send(result).is_ok()
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl NativeSocketProtector for SocketProtectionManager {
|
||||
async fn protect(&self, socket_handle: u64) -> io::Result<()> {
|
||||
let socket_fd = i32::try_from(socket_handle).map_err(|_| {
|
||||
io::Error::new(
|
||||
io::ErrorKind::InvalidInput,
|
||||
format!("socket handle {socket_handle} does not fit a HarmonyOS fd"),
|
||||
)
|
||||
})?;
|
||||
// Keep a duplicate alive across the ArkTS Promise. Socket options set
|
||||
// through it affect the same socket, while cancellation cannot turn the
|
||||
// request into a stale, reused descriptor.
|
||||
let protected_socket = unsafe { BorrowedFd::borrow_raw(socket_fd) }.try_clone_to_owned()?;
|
||||
let protected_fd = protected_socket.as_raw_fd();
|
||||
let (sender, receiver) = oneshot::channel();
|
||||
let request_id = {
|
||||
let mut state = self
|
||||
.state
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
if !state.enabled {
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::NotConnected,
|
||||
"native socket protection is not active",
|
||||
));
|
||||
}
|
||||
if state.pending.len() >= MAX_PENDING_SOCKET_PROTECTIONS {
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::WouldBlock,
|
||||
"too many pending native socket protection requests",
|
||||
));
|
||||
}
|
||||
state.next_request_id = state.next_request_id.wrapping_add(1).max(1);
|
||||
let request_id = state.next_request_id;
|
||||
state.queued.push_back(SocketProtectionRequest {
|
||||
request_id,
|
||||
socket_fd: protected_fd,
|
||||
// Keep the existing ArkTS request shape without native policy labels.
|
||||
purpose: "socket".to_owned(),
|
||||
});
|
||||
state.pending.insert(
|
||||
request_id,
|
||||
PendingSocketProtection {
|
||||
completion: sender,
|
||||
_socket: protected_socket,
|
||||
},
|
||||
);
|
||||
request_id
|
||||
};
|
||||
self.request_ready.notify_one();
|
||||
receiver.await.map_err(|_| {
|
||||
io::Error::new(
|
||||
io::ErrorKind::Interrupted,
|
||||
format!("socket protection request {request_id} was cancelled"),
|
||||
)
|
||||
})?
|
||||
}
|
||||
}
|
||||
|
||||
pub fn enable_socket_protection() -> bool {
|
||||
SOCKET_PROTECTION_MANAGER.enable();
|
||||
set_native_socket_protector(Some(SOCKET_PROTECTION_MANAGER.clone()));
|
||||
true
|
||||
}
|
||||
|
||||
pub fn disable_socket_protection() -> bool {
|
||||
set_native_socket_protector(None);
|
||||
SOCKET_PROTECTION_MANAGER.disable();
|
||||
true
|
||||
}
|
||||
|
||||
pub fn fail_socket_protection() -> bool {
|
||||
// Keep the disabled manager installed so an unexpected ArkTS pump failure
|
||||
// remains fail-closed for every subsequently created transport socket.
|
||||
SOCKET_PROTECTION_MANAGER.disable();
|
||||
true
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use easytier::socket_protector::NativeSocketProtector;
|
||||
use std::os::fd::AsRawFd;
|
||||
|
||||
#[test]
|
||||
fn transport_socket_waits_for_platform_completion() {
|
||||
let runtime = tokio::runtime::Builder::new_current_thread()
|
||||
.enable_all()
|
||||
.build()
|
||||
.unwrap();
|
||||
runtime.block_on(async {
|
||||
let manager = Arc::new(SocketProtectionManager::default());
|
||||
manager.enable();
|
||||
let socket = std::net::UdpSocket::bind("127.0.0.1:0").unwrap();
|
||||
let socket_fd = socket.as_raw_fd();
|
||||
let task = tokio::spawn({
|
||||
let manager = manager.clone();
|
||||
async move { manager.protect(socket_fd as u64).await }
|
||||
});
|
||||
let request = manager.next_request().await.unwrap();
|
||||
assert_ne!(request.socket_fd, socket_fd);
|
||||
assert!(manager.complete_request(request.request_id, true, None));
|
||||
task.await.unwrap().unwrap();
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dispatched_socket_is_retained_during_shutdown() {
|
||||
let runtime = tokio::runtime::Builder::new_current_thread()
|
||||
.enable_all()
|
||||
.build()
|
||||
.unwrap();
|
||||
runtime.block_on(async {
|
||||
let manager = Arc::new(SocketProtectionManager::default());
|
||||
manager.enable();
|
||||
let socket = std::net::UdpSocket::bind("127.0.0.1:0").unwrap();
|
||||
let socket_fd = socket.as_raw_fd();
|
||||
let task = tokio::spawn({
|
||||
let manager = manager.clone();
|
||||
async move { manager.protect(socket_fd as u64).await }
|
||||
});
|
||||
let request = manager.next_request().await.unwrap();
|
||||
|
||||
manager.disable();
|
||||
tokio::task::yield_now().await;
|
||||
assert!(!task.is_finished());
|
||||
|
||||
assert!(manager.complete_request(
|
||||
request.request_id,
|
||||
false,
|
||||
Some("shutdown".to_string()),
|
||||
));
|
||||
assert!(task.await.unwrap().is_err());
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
[package]
|
||||
name = "easytier-ohos-features"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
description = "HarmonyOS-side EasyTier configuration, schema, persistence and sharing features"
|
||||
publish = false
|
||||
|
||||
[dependencies]
|
||||
base64 = "0.22"
|
||||
easytier = { path = "../../../../easytier" }
|
||||
flate2 = "1.1"
|
||||
gethostname = "1.1"
|
||||
once_cell = "1.21.3"
|
||||
prost-reflect = { version = "0.14.5", default-features = false, features = ["derive"] }
|
||||
rusqlite = { version = "0.32", features = ["bundled"] }
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
serde_json = "1.0.125"
|
||||
tracing = "0.1.41"
|
||||
url = "2.5"
|
||||
uuid = { version = "1.5.0", features = ["v4", "fast-rng", "macro-diagnostics", "serde"] }
|
||||
@@ -1,4 +0,0 @@
|
||||
pub mod repository;
|
||||
pub mod services;
|
||||
pub mod storage;
|
||||
pub mod types;
|
||||
-2
@@ -1,2 +0,0 @@
|
||||
pub mod schema_service;
|
||||
pub mod share_link_service;
|
||||
@@ -1 +0,0 @@
|
||||
pub mod config_meta;
|
||||
@@ -1 +0,0 @@
|
||||
pub mod stored_config;
|
||||
@@ -1,81 +0,0 @@
|
||||
use std::sync::OnceLock;
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
pub struct FeatureLogSink {
|
||||
pub enabled: fn(i32) -> bool,
|
||||
pub emit: fn(i32, &str, &str),
|
||||
}
|
||||
|
||||
static FEATURE_LOG_SINK: OnceLock<FeatureLogSink> = OnceLock::new();
|
||||
|
||||
/// Installs the outer HAR facade's log sink without coupling this feature crate to N-API setup.
|
||||
pub fn install_log_sink(sink: FeatureLogSink) {
|
||||
let _ = FEATURE_LOG_SINK.set(sink);
|
||||
}
|
||||
|
||||
#[doc(hidden)]
|
||||
pub fn log_enabled(level: i32) -> bool {
|
||||
FEATURE_LOG_SINK
|
||||
.get()
|
||||
.map(|sink| (sink.enabled)(level))
|
||||
.unwrap_or(true)
|
||||
}
|
||||
|
||||
#[doc(hidden)]
|
||||
pub fn emit_log(level: i32, message: String) {
|
||||
if let Some(sink) = FEATURE_LOG_SINK.get() {
|
||||
(sink.emit)(level, "RustOhrs", &message);
|
||||
return;
|
||||
}
|
||||
match level {
|
||||
5 => tracing::error!(target: "easytier_ohrs", "{message}"),
|
||||
4 => tracing::info!(target: "easytier_ohrs", "{message}"),
|
||||
_ => tracing::debug!(target: "easytier_ohrs", "{message}"),
|
||||
}
|
||||
}
|
||||
|
||||
macro_rules! ohrs_log_error {
|
||||
($($arg:tt)*) => {{
|
||||
$crate::emit_log(5, std::format!($($arg)*));
|
||||
}};
|
||||
}
|
||||
|
||||
macro_rules! ohrs_log_debug {
|
||||
($($arg:tt)*) => {{
|
||||
if $crate::log_enabled(3) {
|
||||
$crate::emit_log(3, std::format!($($arg)*));
|
||||
}
|
||||
}};
|
||||
}
|
||||
|
||||
pub mod config;
|
||||
|
||||
#[cfg(test)]
|
||||
mod architecture_tests {
|
||||
fn assert_no_napi_annotations(path: &std::path::Path) {
|
||||
for entry in std::fs::read_dir(path).expect("read source directory") {
|
||||
let path = entry.expect("read source entry").path();
|
||||
if path.is_dir() {
|
||||
assert_no_napi_annotations(&path);
|
||||
} else if path.extension().is_some_and(|extension| extension == "rs") {
|
||||
let source = std::fs::read_to_string(&path).expect("read Rust source");
|
||||
let marker = ["#[", "napi"].concat();
|
||||
assert!(!source.contains(&marker), "N-API annotation in {path:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inner_crate_has_no_napi_registration_dependency() {
|
||||
let manifest = include_str!("../Cargo.toml");
|
||||
let runtime_dependency = ["napi", "ohos"].join("-");
|
||||
let derive_dependency = ["napi", "derive", "ohos"].join("-");
|
||||
assert!(!manifest.contains(&runtime_dependency));
|
||||
assert!(!manifest.contains(&derive_dependency));
|
||||
assert_no_napi_annotations(
|
||||
std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("src")
|
||||
.as_path(),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
pub(crate) mod repository;
|
||||
pub(crate) mod services;
|
||||
pub(crate) mod storage;
|
||||
pub(crate) mod types;
|
||||
@@ -0,0 +1,2 @@
|
||||
pub(crate) mod schema_service;
|
||||
pub(crate) mod share_link_service;
|
||||
+6
-16
@@ -1,15 +1,18 @@
|
||||
use easytier::proto::ALL_DESCRIPTOR_BYTES;
|
||||
use napi_derive_ohos::napi;
|
||||
use once_cell::sync::Lazy;
|
||||
use prost_reflect::{Cardinality, DescriptorPool, FieldDescriptor, Kind, MessageDescriptor};
|
||||
use serde::Serialize;
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[napi(object)]
|
||||
pub struct FieldOption {
|
||||
pub label: String,
|
||||
pub value: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[napi(object)]
|
||||
pub struct ValidationRule {
|
||||
pub rule_type: String,
|
||||
pub arg: String,
|
||||
@@ -17,6 +20,7 @@ pub struct ValidationRule {
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[napi(object)]
|
||||
pub struct NetworkConfigSchema {
|
||||
pub node_kind: String,
|
||||
pub name: String,
|
||||
@@ -34,6 +38,7 @@ pub struct NetworkConfigSchema {
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[napi(object)]
|
||||
pub struct ConfigFieldMapping {
|
||||
pub field_name: String,
|
||||
pub field_number: i32,
|
||||
@@ -114,10 +119,7 @@ fn enum_options(kind: Kind) -> Vec<FieldOption> {
|
||||
.values()
|
||||
.map(|value| FieldOption {
|
||||
label: value.name().to_string(),
|
||||
// Protobuf JSON uses enum names rather than numeric wire values.
|
||||
// Returning the number made ArkTS write `1`, while NetworkConfig
|
||||
// deserialization expects a name such as `"None"`.
|
||||
value: value.name().to_string(),
|
||||
value: value.number().to_string(),
|
||||
})
|
||||
.collect(),
|
||||
_ => Vec::new(),
|
||||
@@ -408,17 +410,5 @@ mod tests {
|
||||
.iter()
|
||||
.any(|option| option.label == "PublicServer")
|
||||
);
|
||||
|
||||
let data_compress_algo = schema
|
||||
.children
|
||||
.iter()
|
||||
.find(|field| field.name == "data_compress_algo")
|
||||
.expect("data_compress_algo field");
|
||||
let none = data_compress_algo
|
||||
.enum_options
|
||||
.iter()
|
||||
.find(|option| option.label == "None")
|
||||
.expect("compression None option");
|
||||
assert_eq!(none.value, "None");
|
||||
}
|
||||
}
|
||||
+5
-6
@@ -162,7 +162,7 @@ pub fn import_config_share_link(
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::config::repository::{create_config_record, init_config_store};
|
||||
use crate::config_repo::{create_config_record, init_config_store};
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
fn test_root() -> String {
|
||||
@@ -178,21 +178,20 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn share_link_roundtrip_works() {
|
||||
const CONFIG_ID: &str = "00000000-0000-0000-0000-000000000003";
|
||||
assert!(init_config_store(test_root()));
|
||||
create_config_record(CONFIG_ID.to_string(), "share-demo".to_string())
|
||||
create_config_record("cfg-share".to_string(), "share-demo".to_string())
|
||||
.expect("create config");
|
||||
|
||||
let link = build_config_share_link(CONFIG_ID, None, true).expect("share link");
|
||||
let link = build_config_share_link("cfg-share", None, true).expect("share link");
|
||||
let payload = parse_config_share_link(&link).expect("parse link");
|
||||
let config =
|
||||
serde_json::from_str::<NetworkConfig>(&payload.config_json).expect("config json");
|
||||
|
||||
assert!(payload.only_start);
|
||||
assert_eq!(payload.display_name.as_deref(), Some("share-demo"));
|
||||
assert_ne!(config.instance_id.as_deref(), Some(CONFIG_ID));
|
||||
assert_ne!(config.instance_id.as_deref(), Some("cfg-share"));
|
||||
|
||||
let imported_id = import_config_share_link(&link, None).expect("import link");
|
||||
assert_ne!(imported_id, CONFIG_ID);
|
||||
assert_ne!(imported_id, "cfg-share");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
pub(crate) mod config_meta;
|
||||
@@ -0,0 +1 @@
|
||||
pub(crate) mod stored_config;
|
||||
+16
@@ -1,7 +1,9 @@
|
||||
use napi_derive_ohos::napi;
|
||||
use serde::Serialize;
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[napi(object)]
|
||||
pub struct StoredConfigMeta {
|
||||
pub config_id: String,
|
||||
pub display_name: String,
|
||||
@@ -13,6 +15,7 @@ pub struct StoredConfigMeta {
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[napi(object)]
|
||||
pub struct StoredConfigRecord {
|
||||
pub meta: StoredConfigMeta,
|
||||
pub config_json: String,
|
||||
@@ -20,18 +23,21 @@ pub struct StoredConfigRecord {
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[napi(object)]
|
||||
pub struct StoredConfigList {
|
||||
pub configs: Vec<StoredConfigMeta>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[napi(object)]
|
||||
pub struct ExportTomlResult {
|
||||
pub toml_text: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[napi(object)]
|
||||
pub struct SharedConfigLinkPayload {
|
||||
pub config_json: String,
|
||||
pub display_name: Option<String>,
|
||||
@@ -39,6 +45,15 @@ pub struct SharedConfigLinkPayload {
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[napi(object)]
|
||||
pub struct LocalSocketSyncMessage {
|
||||
pub message_type: String,
|
||||
pub payload_json: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[napi(object)]
|
||||
pub struct KeyValuePair {
|
||||
pub key: String,
|
||||
pub value: String,
|
||||
@@ -46,6 +61,7 @@ pub struct KeyValuePair {
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[napi(object)]
|
||||
pub struct SnapshotImportResult {
|
||||
pub ok: bool,
|
||||
pub error_code: String,
|
||||
+61
-39
@@ -4,9 +4,7 @@ use crate::config::storage::config_meta::{
|
||||
reset_config_meta_store, upsert_config_meta_in_tx,
|
||||
};
|
||||
use crate::config::types::stored_config::{ExportTomlResult, StoredConfigRecord};
|
||||
use easytier::common::config::{NetworkConfigExt, TomlConfigLoader};
|
||||
use easytier::proto::api::manage::NetworkConfig;
|
||||
use easytier::proto::common::CompressionAlgoPb;
|
||||
use once_cell::sync::Lazy;
|
||||
use rusqlite::params;
|
||||
use serde_json::Value;
|
||||
@@ -18,16 +16,16 @@ use std::time::Instant;
|
||||
static CONFIG_ROOT_DIR: Mutex<Option<PathBuf>> = Mutex::new(None);
|
||||
static RUNTIME_CONFIG_SNAPSHOTS: Lazy<Mutex<HashMap<String, RuntimeConfigSnapshot>>> =
|
||||
Lazy::new(|| Mutex::new(HashMap::new()));
|
||||
pub const CONFIG_DIR_NAME: &str = "easytier-configs";
|
||||
pub const KERNEL_SOCKET_FILE_NAME: &str = "easytier-kernel.sock";
|
||||
pub(crate) const CONFIG_DIR_NAME: &str = "easytier-configs";
|
||||
pub(crate) const KERNEL_SOCKET_FILE_NAME: &str = "easytier-kernel.sock";
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct RuntimeConfigSnapshot {
|
||||
pub(crate) struct RuntimeConfigSnapshot {
|
||||
pub display_name: String,
|
||||
pub config: NetworkConfig,
|
||||
}
|
||||
|
||||
pub fn cache_runtime_config_snapshot(
|
||||
pub(crate) fn cache_runtime_config_snapshot(
|
||||
config_id: String,
|
||||
display_name: String,
|
||||
config: NetworkConfig,
|
||||
@@ -43,27 +41,42 @@ pub fn cache_runtime_config_snapshot(
|
||||
}
|
||||
}
|
||||
|
||||
pub fn clear_runtime_config_snapshot(config_id: &str) {
|
||||
pub(crate) fn clear_runtime_config_snapshot(config_id: &str) {
|
||||
if let Ok(mut guard) = RUNTIME_CONFIG_SNAPSHOTS.lock() {
|
||||
guard.remove(config_id);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get_runtime_config_snapshot(config_id: &str) -> Option<RuntimeConfigSnapshot> {
|
||||
pub(crate) fn get_runtime_config_snapshot(config_id: &str) -> Option<RuntimeConfigSnapshot> {
|
||||
RUNTIME_CONFIG_SNAPSHOTS
|
||||
.lock()
|
||||
.ok()
|
||||
.and_then(|guard| guard.get(config_id).cloned())
|
||||
}
|
||||
|
||||
pub fn config_root_dir() -> Option<PathBuf> {
|
||||
pub(crate) fn get_runtime_config_route_overrides(config_id: &str) -> (Vec<String>, Vec<String>) {
|
||||
RUNTIME_CONFIG_SNAPSHOTS
|
||||
.lock()
|
||||
.ok()
|
||||
.and_then(|guard| {
|
||||
guard.get(config_id).map(|snapshot| {
|
||||
(
|
||||
snapshot.config.routes.clone(),
|
||||
snapshot.config.proxy_cidrs.clone(),
|
||||
)
|
||||
})
|
||||
})
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
pub(crate) fn config_root_dir() -> Option<PathBuf> {
|
||||
CONFIG_ROOT_DIR
|
||||
.lock()
|
||||
.ok()
|
||||
.and_then(|guard| guard.as_ref().cloned())
|
||||
}
|
||||
|
||||
pub fn kernel_socket_path() -> Option<PathBuf> {
|
||||
pub(crate) fn kernel_socket_path() -> Option<PathBuf> {
|
||||
config_root_dir().map(|root| root.join(KERNEL_SOCKET_FILE_NAME))
|
||||
}
|
||||
|
||||
@@ -267,9 +280,7 @@ pub fn set_config_field_value(config_id: &str, field: &str, json_value: &str) ->
|
||||
}
|
||||
|
||||
pub fn get_default_config_json() -> Option<String> {
|
||||
let mut config = NetworkConfig::new_from_config(TomlConfigLoader::default()).ok()?;
|
||||
config.data_compress_algo = Some(CompressionAlgoPb::None as i32);
|
||||
serde_json::to_string(&config).ok()
|
||||
crate::build_default_network_config_json().ok()
|
||||
}
|
||||
|
||||
pub fn create_config_record(config_id: String, display_name: String) -> Option<StoredConfigRecord> {
|
||||
@@ -281,6 +292,24 @@ pub fn create_config_record(config_id: String, display_name: String) -> Option<S
|
||||
save_config_record(config_id, display_name, normalized_json)
|
||||
}
|
||||
|
||||
pub fn start_kernel_with_config_id(config_id: &str) -> bool {
|
||||
if validation::validate_config_id(config_id).is_err() {
|
||||
return false;
|
||||
}
|
||||
let raw = match load_config_json(config_id) {
|
||||
Some(raw) => raw,
|
||||
None => return false,
|
||||
};
|
||||
let display_name = get_config_meta(config_id)
|
||||
.map(|meta| meta.display_name)
|
||||
.unwrap_or_else(|| config_id.to_string());
|
||||
let started = crate::run_network_instance_from_json(&raw);
|
||||
if started && let Ok(config) = serde_json::from_str::<NetworkConfig>(&raw) {
|
||||
cache_runtime_config_snapshot(config_id.to_string(), display_name, config);
|
||||
}
|
||||
started
|
||||
}
|
||||
|
||||
pub fn list_config_meta_json() -> String {
|
||||
serde_json::to_string(&list_config_meta_entries().configs).unwrap_or_else(|_| "[]".to_string())
|
||||
}
|
||||
@@ -350,28 +379,23 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn save_get_export_delete_roundtrip() {
|
||||
const CONFIG_ID: &str = "00000000-0000-0000-0000-000000000001";
|
||||
let root = test_root();
|
||||
assert!(init_config_store(root.clone()));
|
||||
|
||||
let config_json = get_default_config_json().expect("default config");
|
||||
let saved = save_config_record(
|
||||
CONFIG_ID.to_string(),
|
||||
"test-config".to_string(),
|
||||
config_json,
|
||||
)
|
||||
.expect("save config");
|
||||
let config_json = crate::build_default_network_config_json().expect("default config");
|
||||
let saved = save_config_record("cfg-1".to_string(), "test-config".to_string(), config_json)
|
||||
.expect("save config");
|
||||
|
||||
assert_eq!(saved.meta.config_id, CONFIG_ID);
|
||||
assert_eq!(saved.meta.config_id, "cfg-1");
|
||||
assert_eq!(saved.meta.display_name, "test-config");
|
||||
|
||||
let loaded = get_config_record(CONFIG_ID).expect("load config");
|
||||
let loaded = get_config_record("cfg-1").expect("load config");
|
||||
assert_eq!(loaded.meta.display_name, "test-config");
|
||||
assert!(loaded.config_json.contains(CONFIG_ID));
|
||||
assert!(loaded.config_json.contains("cfg-1"));
|
||||
|
||||
let legacy_json_path = PathBuf::from(&root)
|
||||
.join(CONFIG_DIR_NAME)
|
||||
.join(format!("{CONFIG_ID}.json"));
|
||||
.join("cfg-1.json");
|
||||
assert!(
|
||||
!legacy_json_path.exists(),
|
||||
"config should no longer be persisted as a per-config json file"
|
||||
@@ -381,54 +405,52 @@ mod tests {
|
||||
let field_count: i64 = conn
|
||||
.query_row(
|
||||
"SELECT COUNT(*) FROM stored_config_fields WHERE config_id = ?1",
|
||||
params![CONFIG_ID],
|
||||
params!["cfg-1"],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.expect("count config fields");
|
||||
drop(conn);
|
||||
assert!(field_count > 0, "config fields should be stored in sqlite");
|
||||
|
||||
let exported = export_config_toml(CONFIG_ID).expect("export toml");
|
||||
let exported = export_config_toml("cfg-1").expect("export toml");
|
||||
assert!(exported.toml_text.contains("instance_id"));
|
||||
|
||||
assert!(delete_config_record(CONFIG_ID));
|
||||
assert!(get_config_record(CONFIG_ID).is_none());
|
||||
assert!(delete_config_record("cfg-1"));
|
||||
assert!(get_config_record("cfg-1").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn set_config_field_updates_only_requested_top_level_field() {
|
||||
const CONFIG_ID: &str = "00000000-0000-0000-0000-000000000002";
|
||||
let root = test_root();
|
||||
assert!(init_config_store(root));
|
||||
|
||||
let config_json = get_default_config_json().expect("default config");
|
||||
let config_json = crate::build_default_network_config_json().expect("default config");
|
||||
save_config_record(
|
||||
CONFIG_ID.to_string(),
|
||||
"cfg-field".to_string(),
|
||||
"field-config".to_string(),
|
||||
config_json,
|
||||
)
|
||||
.expect("save config");
|
||||
|
||||
let before_network_name = get_config_field_value(CONFIG_ID, "network_name");
|
||||
let before_instance_id = get_config_field_value(CONFIG_ID, "instance_id")
|
||||
let before_network_name = get_config_field_value("cfg-field", "network_name");
|
||||
let before_instance_id = get_config_field_value("cfg-field", "instance_id")
|
||||
.expect("instance id field should exist");
|
||||
|
||||
assert!(set_config_field_value(
|
||||
CONFIG_ID,
|
||||
"cfg-field",
|
||||
"network_name",
|
||||
"\"changed-network\""
|
||||
));
|
||||
|
||||
assert_eq!(
|
||||
get_config_field_value(CONFIG_ID, "network_name"),
|
||||
get_config_field_value("cfg-field", "network_name"),
|
||||
Some("\"changed-network\"".to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
get_config_field_value(CONFIG_ID, "instance_id"),
|
||||
get_config_field_value("cfg-field", "instance_id"),
|
||||
Some(before_instance_id)
|
||||
);
|
||||
assert_ne!(
|
||||
get_config_field_value(CONFIG_ID, "network_name"),
|
||||
get_config_field_value("cfg-field", "network_name"),
|
||||
before_network_name
|
||||
);
|
||||
}
|
||||
@@ -2,10 +2,6 @@ use crate::config;
|
||||
use crate::config::types::stored_config::SnapshotImportResult;
|
||||
|
||||
pub(crate) fn init_config_store(root_dir: String) -> bool {
|
||||
easytier_ohos_features::install_log_sink(easytier_ohos_features::FeatureLogSink {
|
||||
enabled: crate::feature_log_enabled,
|
||||
emit: crate::feature_log_sink,
|
||||
});
|
||||
config::repository::init_config_store(root_dir)
|
||||
}
|
||||
|
||||
|
||||
@@ -157,10 +157,19 @@ pub(crate) fn collect_runtime_state() -> RuntimeAggregateState {
|
||||
.as_ref()
|
||||
.map(|snapshot| snapshot.display_name.clone())
|
||||
.unwrap_or_else(|| config_id.clone());
|
||||
let magic_dns_enabled = snapshot
|
||||
.as_ref()
|
||||
.and_then(|snapshot| snapshot.config.enable_magic_dns)
|
||||
.unwrap_or(false);
|
||||
let need_exit_node = snapshot
|
||||
.as_ref()
|
||||
.map(|snapshot| !snapshot.config.exit_nodes.is_empty())
|
||||
.unwrap_or(false);
|
||||
instances.push(runtime_instance_from_running_info(
|
||||
config_id,
|
||||
display_name,
|
||||
snapshot.map(|snapshot| snapshot.config),
|
||||
magic_dns_enabled,
|
||||
need_exit_node,
|
||||
info,
|
||||
));
|
||||
} else if let Some(snapshot) = get_runtime_config_snapshot(&config_id) {
|
||||
@@ -186,7 +195,6 @@ pub(crate) fn collect_runtime_state() -> RuntimeAggregateState {
|
||||
events: Vec::new(),
|
||||
routes: Vec::new(),
|
||||
peers: Vec::new(),
|
||||
manual_routes: Vec::new(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
mod protocol;
|
||||
mod routing;
|
||||
mod socket_server;
|
||||
|
||||
pub(crate) use easytier_ohos_core::routing::aggregate_requested_tun_routes;
|
||||
pub(crate) use routing::aggregate_requested_tun_routes;
|
||||
pub use socket_server::{start_local_socket_server, stop_local_socket_server};
|
||||
|
||||
+6
-50
@@ -1,17 +1,11 @@
|
||||
use crate::config::types::stored_config::LocalSocketSyncMessage;
|
||||
use serde::Serialize;
|
||||
use std::io::{Error, ErrorKind, Write};
|
||||
use std::os::unix::net::UnixStream;
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct LocalSocketSyncMessage {
|
||||
pub message_type: String,
|
||||
pub payload_json: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct TunRequestPayload {
|
||||
pub(crate) struct TunRequestPayload {
|
||||
pub config_id: String,
|
||||
pub instance_id: String,
|
||||
pub display_name: String,
|
||||
@@ -22,7 +16,7 @@ pub struct TunRequestPayload {
|
||||
pub need_exit_node: bool,
|
||||
}
|
||||
|
||||
pub fn send_local_socket_message(
|
||||
pub(crate) fn send_local_socket_message(
|
||||
stream: &mut UnixStream,
|
||||
message_type: &str,
|
||||
payload_json: String,
|
||||
@@ -45,7 +39,7 @@ fn shrink_clients_if_sparse(clients: &mut Vec<UnixStream>) {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn broadcast_local_socket_message(
|
||||
pub(crate) fn broadcast_local_socket_message(
|
||||
clients: &mut Vec<UnixStream>,
|
||||
message_type: &str,
|
||||
payload_json: &str,
|
||||
@@ -63,7 +57,7 @@ pub fn broadcast_local_socket_message(
|
||||
delivered
|
||||
}
|
||||
|
||||
pub fn send_local_socket_json_payload_message(
|
||||
pub(crate) fn send_local_socket_json_payload_message(
|
||||
stream: &mut UnixStream,
|
||||
message_type: &str,
|
||||
payload_json: &str,
|
||||
@@ -80,7 +74,7 @@ pub fn send_local_socket_json_payload_message(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn broadcast_local_socket_json_payload_message(
|
||||
pub(crate) fn broadcast_local_socket_json_payload_message(
|
||||
clients: &mut Vec<UnixStream>,
|
||||
message_type: &str,
|
||||
payload_json: &str,
|
||||
@@ -97,41 +91,3 @@ pub fn broadcast_local_socket_json_payload_message(
|
||||
*clients = active_clients;
|
||||
delivered
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{broadcast_local_socket_message, send_local_socket_message};
|
||||
use std::io::Read;
|
||||
use std::os::unix::net::UnixStream;
|
||||
|
||||
#[test]
|
||||
fn local_socket_message_uses_camel_case_newline_frame() {
|
||||
let (mut sender, mut receiver) = UnixStream::pair().expect("socket pair");
|
||||
send_local_socket_message(&mut sender, "runtimeState", "{\"ok\":true}".to_string())
|
||||
.expect("send frame");
|
||||
sender
|
||||
.shutdown(std::net::Shutdown::Write)
|
||||
.expect("shutdown");
|
||||
|
||||
let mut raw = String::new();
|
||||
receiver.read_to_string(&mut raw).expect("read frame");
|
||||
assert_eq!(
|
||||
raw,
|
||||
"{\"messageType\":\"runtimeState\",\"payloadJson\":\"{\\\"ok\\\":true}\"}\n"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn broadcast_removes_disconnected_clients() {
|
||||
let (sender, receiver) = UnixStream::pair().expect("socket pair");
|
||||
drop(receiver);
|
||||
let mut clients = vec![sender];
|
||||
|
||||
assert!(!broadcast_local_socket_message(
|
||||
&mut clients,
|
||||
"runtimeState",
|
||||
"{}"
|
||||
));
|
||||
assert!(clients.is_empty());
|
||||
}
|
||||
}
|
||||
+7
-65
@@ -1,3 +1,4 @@
|
||||
use crate::config::repository::get_runtime_config_route_overrides;
|
||||
use crate::runtime::state::runtime_state::RuntimeInstanceState;
|
||||
use ipnet::IpNet;
|
||||
use std::collections::HashSet;
|
||||
@@ -53,11 +54,13 @@ fn simplify_routes(routes: Vec<String>) -> Vec<String> {
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn aggregate_tun_routes(instance: &RuntimeInstanceState) -> Vec<String> {
|
||||
pub(crate) fn aggregate_tun_routes(instance: &RuntimeInstanceState) -> Vec<String> {
|
||||
let virtual_ipv4_cidr = instance
|
||||
.my_node_info
|
||||
.as_ref()
|
||||
.and_then(|info| info.virtual_ipv4_cidr.clone());
|
||||
let (manual_routes, config_proxy_cidrs) =
|
||||
get_runtime_config_route_overrides(&instance.config_id);
|
||||
let runtime_proxy_cidrs = instance
|
||||
.routes
|
||||
.iter()
|
||||
@@ -69,15 +72,13 @@ pub fn aggregate_tun_routes(instance: &RuntimeInstanceState) -> Vec<String> {
|
||||
raw_routes.push(cidr);
|
||||
}
|
||||
|
||||
raw_routes.extend(instance.manual_routes.iter().cloned());
|
||||
// Local proxy CIDRs are advertisements for networks reached through this
|
||||
// node. Installing them into the same local TUN would recapture the proxy's
|
||||
// own destination sockets instead of using the physical network.
|
||||
raw_routes.extend(manual_routes.iter().cloned());
|
||||
raw_routes.extend(config_proxy_cidrs.iter().cloned());
|
||||
raw_routes.extend(runtime_proxy_cidrs.iter().cloned());
|
||||
simplify_routes(raw_routes)
|
||||
}
|
||||
|
||||
pub fn aggregate_requested_tun_routes(instances: &[RuntimeInstanceState]) -> Vec<String> {
|
||||
pub(crate) fn aggregate_requested_tun_routes(instances: &[RuntimeInstanceState]) -> Vec<String> {
|
||||
let mut aggregated_routes = Vec::new();
|
||||
let mut seen_routes = HashSet::new();
|
||||
for instance in instances.iter().filter(|instance| instance.tun_required) {
|
||||
@@ -89,62 +90,3 @@ pub fn aggregate_requested_tun_routes(instances: &[RuntimeInstanceState]) -> Vec
|
||||
}
|
||||
aggregated_routes
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{aggregate_tun_routes, simplify_routes};
|
||||
use crate::runtime::state::runtime_state::{RouteView, runtime_instance_from_config_snapshot};
|
||||
use easytier::proto::api::manage::NetworkConfig;
|
||||
|
||||
#[test]
|
||||
fn simplify_routes_normalizes_deduplicates_and_removes_subnets() {
|
||||
let routes = simplify_routes(vec![
|
||||
"10.0.0.7".to_string(),
|
||||
"10.0.0.0/24".to_string(),
|
||||
"10.0.0.42/32->peer-a".to_string(),
|
||||
"2001:db8::1".to_string(),
|
||||
"2001:db8::/64".to_string(),
|
||||
]);
|
||||
|
||||
assert_eq!(routes, vec!["10.0.0.0/24", "2001:db8::/64"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn local_proxy_cidr_is_not_installed_in_tun_routes() {
|
||||
let mut instance = runtime_instance_from_config_snapshot(
|
||||
"routing-test".to_string(),
|
||||
"test".to_string(),
|
||||
NetworkConfig {
|
||||
virtual_ipv4: Some("10.144.144.1".to_string()),
|
||||
network_length: Some(24),
|
||||
routes: vec!["172.16.0.0/16".to_string()],
|
||||
proxy_cidrs: vec!["192.168.1.0/24".to_string()],
|
||||
..Default::default()
|
||||
},
|
||||
true,
|
||||
);
|
||||
instance.routes.push(RouteView {
|
||||
peer_id: 2,
|
||||
hostname: None,
|
||||
ipv4: Some("10.144.144.2".to_string()),
|
||||
ipv4_cidr: Some("10.144.144.2/24".to_string()),
|
||||
ipv6_cidr: None,
|
||||
proxy_cidrs: vec!["10.20.0.0/16".to_string()],
|
||||
next_hop_peer_id: Some(2),
|
||||
cost: Some(1),
|
||||
path_latency: None,
|
||||
udp_nat_type: None,
|
||||
tcp_nat_type: None,
|
||||
inst_id: None,
|
||||
version: None,
|
||||
is_public_server: None,
|
||||
});
|
||||
|
||||
let routes = aggregate_tun_routes(&instance);
|
||||
|
||||
assert!(routes.contains(&"10.144.144.0/24".to_string()));
|
||||
assert!(routes.contains(&"172.16.0.0/16".to_string()));
|
||||
assert!(routes.contains(&"10.20.0.0/16".to_string()));
|
||||
assert!(!routes.contains(&"192.168.1.0/24".to_string()));
|
||||
}
|
||||
}
|
||||
@@ -1,15 +1,15 @@
|
||||
use super::protocol::{
|
||||
TunRequestPayload, broadcast_local_socket_json_payload_message, broadcast_local_socket_message,
|
||||
};
|
||||
use crate::collect_runtime_state_inner;
|
||||
use crate::config::repository::kernel_socket_path;
|
||||
use crate::kernel_bridge::routing::aggregate_tun_routes;
|
||||
use crate::runtime::state::runtime_state::{
|
||||
PeerConnInfo as RuntimePeerConnInfo, RuntimeAggregateState, peer_conn_to_view,
|
||||
};
|
||||
use crate::{ASYNC_RUNTIME, INSTANCE_MANAGER};
|
||||
use easytier::common::global_ctx::{EventBusSubscriber, GlobalCtxEvent};
|
||||
use easytier::instance::factory::subscribe_native_instance_event;
|
||||
use easytier_ohos_core::protocol::{
|
||||
TunRequestPayload, broadcast_local_socket_json_payload_message, broadcast_local_socket_message,
|
||||
};
|
||||
use easytier_ohos_core::routing::aggregate_tun_routes;
|
||||
use once_cell::sync::Lazy;
|
||||
use serde::Serialize;
|
||||
use std::collections::{HashMap, HashSet};
|
||||
|
||||
@@ -22,14 +22,28 @@ macro_rules! ohrs_log_info {
|
||||
}};
|
||||
}
|
||||
|
||||
macro_rules! ohrs_log_debug {
|
||||
($($arg:tt)*) => {{
|
||||
if $crate::platform::logging::log_manager::app_log_enabled(3) {
|
||||
$crate::platform::logging::log_manager::record_app_log(
|
||||
3,
|
||||
"RustOhrs",
|
||||
&std::format!($($arg)*),
|
||||
);
|
||||
}
|
||||
}};
|
||||
}
|
||||
|
||||
mod config;
|
||||
mod exports;
|
||||
mod kernel_bridge;
|
||||
mod napi_types;
|
||||
mod nearby_management;
|
||||
mod platform;
|
||||
mod runtime;
|
||||
|
||||
use config::repository::cache_runtime_config_snapshot;
|
||||
use config::repository::{cache_runtime_config_snapshot, start_kernel_with_config_id};
|
||||
use config::services::schema_service::{
|
||||
ConfigFieldMapping, NetworkConfigSchema,
|
||||
get_network_config_field_mappings as build_network_config_field_mappings,
|
||||
get_network_config_schema as build_network_config_schema,
|
||||
};
|
||||
@@ -39,42 +53,42 @@ use config::services::share_link_service::{
|
||||
parse_config_share_link as parse_config_share_link_inner,
|
||||
};
|
||||
use config::storage::config_meta::get_config_display_name;
|
||||
use config::types::stored_config::{KeyValuePair, SharedConfigLinkPayload, SnapshotImportResult};
|
||||
use easytier::common::config::NetworkConfigExt;
|
||||
use easytier::common::constants::EASYTIER_VERSION;
|
||||
use easytier::common::{
|
||||
MachineIdOptions,
|
||||
config::{ConfigLoader, TomlConfigLoader},
|
||||
};
|
||||
use easytier::instance::factory::{NativeInstanceManager, native_instance_manager_with_runtime};
|
||||
use easytier::proto::api::manage::NetworkConfig;
|
||||
use easytier::proto::api::manage::NetworkingMethod;
|
||||
use easytier::web_client::{WebClient, WebClientHooks, run_web_client};
|
||||
use easytier_ohos_core::runtime;
|
||||
use easytier_ohos_core::{ASYNC_RUNTIME, INSTANCE_MANAGER};
|
||||
use easytier_ohos_features::config;
|
||||
use kernel_bridge::{
|
||||
start_local_socket_server as start_local_socket_server_inner,
|
||||
stop_local_socket_server as stop_local_socket_server_inner,
|
||||
};
|
||||
use napi_derive_ohos::napi;
|
||||
use napi_ohos::bindgen_prelude::Uint8Array;
|
||||
use napi_types::{
|
||||
ConfigFieldMapping, KeyValuePair, NetworkConfigSchema, SharedConfigLinkPayload,
|
||||
SnapshotImportResult, SocketProtectionRequest,
|
||||
};
|
||||
use runtime::state::runtime_state::{RuntimeAggregateState, RuntimeInstanceState};
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::format;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use tokio::runtime::{Builder, Runtime};
|
||||
use uuid::Uuid;
|
||||
|
||||
pub(crate) fn feature_log_sink(level: i32, target: &str, message: &str) {
|
||||
platform::logging::log_manager::record_app_log(level, target, message);
|
||||
}
|
||||
|
||||
pub(crate) fn feature_log_enabled(level: i32) -> bool {
|
||||
platform::logging::log_manager::app_log_enabled(level)
|
||||
}
|
||||
|
||||
static ASYNC_RUNTIME: once_cell::sync::Lazy<Runtime> = once_cell::sync::Lazy::new(|| {
|
||||
Builder::new_multi_thread()
|
||||
.enable_all()
|
||||
.build()
|
||||
.expect("tokio runtime for easytier-ohrs")
|
||||
});
|
||||
pub(crate) static INSTANCE_MANAGER: once_cell::sync::Lazy<Arc<NativeInstanceManager>> =
|
||||
once_cell::sync::Lazy::new(|| {
|
||||
Arc::new(native_instance_manager_with_runtime(
|
||||
ASYNC_RUNTIME.handle().clone(),
|
||||
))
|
||||
});
|
||||
static WEB_CLIENTS: once_cell::sync::Lazy<Mutex<HashMap<String, ManagedWebClient>>> =
|
||||
once_cell::sync::Lazy::new(|| Mutex::new(HashMap::new()));
|
||||
const PRO_CONFIG_SERVER_CLIENT_ID: &str = "__easytier_pro_config_server_client__";
|
||||
@@ -654,6 +668,12 @@ fn resolve_instance_id_inner(instance_name: &str) -> Option<String> {
|
||||
resolve_instance_id_from_state(&collect_runtime_state_inner(), instance_name)
|
||||
}
|
||||
|
||||
pub(crate) fn build_default_network_config_json() -> Result<String, String> {
|
||||
let config = NetworkConfig::new_from_config(TomlConfigLoader::default())
|
||||
.map_err(|e| format!("default_network_config failed {}", e))?;
|
||||
serde_json::to_string(&config).map_err(|e| format!("default_network_config failed {}", e))
|
||||
}
|
||||
|
||||
fn convert_toml_to_network_config_inner(toml_text: &str) -> Result<String, String> {
|
||||
let config = NetworkConfig::new_from_config(
|
||||
TomlConfigLoader::new_from_str(toml_text).map_err(|e| e.to_string())?,
|
||||
@@ -730,18 +750,6 @@ pub(crate) fn run_network_instance_from_json(cfg_json: &str) -> bool {
|
||||
}
|
||||
}
|
||||
|
||||
fn start_kernel_with_config_id(config_id: &str) -> bool {
|
||||
let Some(raw) = config::repository::load_config_json(config_id) else {
|
||||
return false;
|
||||
};
|
||||
let display_name = get_config_display_name(config_id).unwrap_or_else(|| config_id.to_string());
|
||||
let started = run_network_instance_from_json(&raw);
|
||||
if started && let Ok(config) = serde_json::from_str::<NetworkConfig>(&raw) {
|
||||
cache_runtime_config_snapshot(config_id.to_string(), display_name, config);
|
||||
}
|
||||
started
|
||||
}
|
||||
|
||||
fn parse_instance_uuid(config_id: &str) -> Option<Uuid> {
|
||||
match Uuid::parse_str(config_id) {
|
||||
Ok(uuid) => Some(uuid),
|
||||
@@ -839,7 +847,7 @@ pub fn import_config_store_snapshot(source_path: String) -> bool {
|
||||
|
||||
#[napi]
|
||||
pub fn import_config_store_snapshot_with_result(source_path: String) -> SnapshotImportResult {
|
||||
exports::config_api::import_config_store_snapshot_with_result(source_path).into()
|
||||
exports::config_api::import_config_store_snapshot_with_result(source_path)
|
||||
}
|
||||
|
||||
#[napi]
|
||||
@@ -1048,9 +1056,6 @@ pub async fn call_nearby_management_json_rpc(
|
||||
#[napi]
|
||||
pub fn collect_network_infos() -> Vec<KeyValuePair> {
|
||||
exports::runtime_api::collect_network_infos()
|
||||
.into_iter()
|
||||
.map(Into::into)
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[napi]
|
||||
@@ -1058,53 +1063,14 @@ pub fn set_tun_fd(config_id: String, fd: i32) -> bool {
|
||||
exports::runtime_api::set_tun_fd(config_id, fd, parse_instance_uuid)
|
||||
}
|
||||
|
||||
#[napi]
|
||||
pub fn enable_socket_protection() -> bool {
|
||||
easytier_ohos_core::socket_protection::enable_socket_protection()
|
||||
}
|
||||
|
||||
#[napi]
|
||||
pub async fn next_socket_protection_request() -> Option<SocketProtectionRequest> {
|
||||
easytier_ohos_core::socket_protection::SOCKET_PROTECTION_MANAGER
|
||||
.next_request()
|
||||
.await
|
||||
.map(Into::into)
|
||||
}
|
||||
|
||||
#[napi]
|
||||
pub fn complete_socket_protection(
|
||||
request_id: String,
|
||||
success: bool,
|
||||
error: Option<String>,
|
||||
) -> bool {
|
||||
let Ok(request_id) = request_id.parse::<u64>() else {
|
||||
return false;
|
||||
};
|
||||
easytier_ohos_core::socket_protection::SOCKET_PROTECTION_MANAGER
|
||||
.complete_request(request_id, success, error)
|
||||
}
|
||||
|
||||
#[napi]
|
||||
pub fn disable_socket_protection() -> bool {
|
||||
easytier_ohos_core::socket_protection::disable_socket_protection()
|
||||
}
|
||||
|
||||
#[napi]
|
||||
pub fn fail_socket_protection() -> bool {
|
||||
easytier_ohos_core::socket_protection::fail_socket_protection()
|
||||
}
|
||||
|
||||
#[napi]
|
||||
pub fn get_network_config_schema() -> NetworkConfigSchema {
|
||||
build_network_config_schema().into()
|
||||
build_network_config_schema()
|
||||
}
|
||||
|
||||
#[napi]
|
||||
pub fn get_network_config_field_mappings() -> Vec<ConfigFieldMapping> {
|
||||
build_network_config_field_mappings()
|
||||
.into_iter()
|
||||
.map(Into::into)
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -1152,7 +1118,6 @@ mod tests {
|
||||
events: vec![],
|
||||
routes: vec![],
|
||||
peers: vec![],
|
||||
manual_routes: vec![],
|
||||
},
|
||||
RuntimeInstanceState {
|
||||
config_id: "ec7b6a3c-aeae-4c0e-844e-f7ec2dbdc2ce".to_string(),
|
||||
@@ -1168,7 +1133,6 @@ mod tests {
|
||||
events: vec![],
|
||||
routes: vec![],
|
||||
peers: vec![],
|
||||
manual_routes: vec![],
|
||||
},
|
||||
],
|
||||
tun: runtime::state::runtime_state::TunAggregateState {
|
||||
@@ -1235,7 +1199,7 @@ pub fn build_config_share_link(config_id: String, only_start: Option<bool>) -> O
|
||||
|
||||
#[napi]
|
||||
pub fn parse_config_share_link(share_link: String) -> Option<SharedConfigLinkPayload> {
|
||||
parse_config_share_link_inner(&share_link).map(Into::into)
|
||||
parse_config_share_link_inner(&share_link)
|
||||
}
|
||||
|
||||
#[napi]
|
||||
|
||||
@@ -1,492 +0,0 @@
|
||||
#![allow(dead_code)]
|
||||
|
||||
use easytier_ohos_core::runtime::state::runtime_state as kernel_types;
|
||||
use easytier_ohos_features::config::services::schema_service as feature_schema;
|
||||
use easytier_ohos_features::config::types::stored_config as feature_types;
|
||||
use napi_derive_ohos::napi;
|
||||
use serde::Serialize;
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[napi(object)]
|
||||
pub struct SocketProtectionRequest {
|
||||
pub request_id: String,
|
||||
pub socket_fd: i32,
|
||||
pub purpose: String,
|
||||
}
|
||||
|
||||
impl From<easytier_ohos_core::socket_protection::SocketProtectionRequest>
|
||||
for SocketProtectionRequest
|
||||
{
|
||||
fn from(value: easytier_ohos_core::socket_protection::SocketProtectionRequest) -> Self {
|
||||
Self {
|
||||
request_id: value.request_id.to_string(),
|
||||
socket_fd: value.socket_fd,
|
||||
purpose: value.purpose,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[napi(object)]
|
||||
pub struct StoredConfigMeta {
|
||||
pub config_id: String,
|
||||
pub display_name: String,
|
||||
pub created_at: String,
|
||||
pub updated_at: String,
|
||||
pub favorite: bool,
|
||||
pub temporary: bool,
|
||||
}
|
||||
|
||||
impl From<feature_types::StoredConfigMeta> for StoredConfigMeta {
|
||||
fn from(value: feature_types::StoredConfigMeta) -> Self {
|
||||
Self {
|
||||
config_id: value.config_id,
|
||||
display_name: value.display_name,
|
||||
created_at: value.created_at,
|
||||
updated_at: value.updated_at,
|
||||
favorite: value.favorite,
|
||||
temporary: value.temporary,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[napi(object)]
|
||||
pub struct StoredConfigRecord {
|
||||
pub meta: StoredConfigMeta,
|
||||
pub config_json: String,
|
||||
}
|
||||
|
||||
impl From<feature_types::StoredConfigRecord> for StoredConfigRecord {
|
||||
fn from(value: feature_types::StoredConfigRecord) -> Self {
|
||||
Self {
|
||||
meta: value.meta.into(),
|
||||
config_json: value.config_json,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[napi(object)]
|
||||
pub struct StoredConfigList {
|
||||
pub configs: Vec<StoredConfigMeta>,
|
||||
}
|
||||
|
||||
impl From<feature_types::StoredConfigList> for StoredConfigList {
|
||||
fn from(value: feature_types::StoredConfigList) -> Self {
|
||||
Self {
|
||||
configs: value.configs.into_iter().map(Into::into).collect(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[napi(object)]
|
||||
pub struct ExportTomlResult {
|
||||
pub toml_text: String,
|
||||
}
|
||||
|
||||
impl From<feature_types::ExportTomlResult> for ExportTomlResult {
|
||||
fn from(value: feature_types::ExportTomlResult) -> Self {
|
||||
Self {
|
||||
toml_text: value.toml_text,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[napi(object)]
|
||||
pub struct SharedConfigLinkPayload {
|
||||
pub config_json: String,
|
||||
pub display_name: Option<String>,
|
||||
pub only_start: bool,
|
||||
}
|
||||
|
||||
impl From<feature_types::SharedConfigLinkPayload> for SharedConfigLinkPayload {
|
||||
fn from(value: feature_types::SharedConfigLinkPayload) -> Self {
|
||||
Self {
|
||||
config_json: value.config_json,
|
||||
display_name: value.display_name,
|
||||
only_start: value.only_start,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[napi(object)]
|
||||
pub struct LocalSocketSyncMessage {
|
||||
pub message_type: String,
|
||||
pub payload_json: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[napi(object)]
|
||||
pub struct KeyValuePair {
|
||||
pub key: String,
|
||||
pub value: String,
|
||||
}
|
||||
|
||||
impl From<feature_types::KeyValuePair> for KeyValuePair {
|
||||
fn from(value: feature_types::KeyValuePair) -> Self {
|
||||
Self {
|
||||
key: value.key,
|
||||
value: value.value,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[napi(object)]
|
||||
pub struct SnapshotImportResult {
|
||||
pub ok: bool,
|
||||
pub error_code: String,
|
||||
pub error_message: String,
|
||||
pub snapshot_invalid: bool,
|
||||
}
|
||||
|
||||
impl From<feature_types::SnapshotImportResult> for SnapshotImportResult {
|
||||
fn from(value: feature_types::SnapshotImportResult) -> Self {
|
||||
Self {
|
||||
ok: value.ok,
|
||||
error_code: value.error_code,
|
||||
error_message: value.error_message,
|
||||
snapshot_invalid: value.snapshot_invalid,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[napi(object)]
|
||||
pub struct FieldOption {
|
||||
pub label: String,
|
||||
pub value: String,
|
||||
}
|
||||
|
||||
impl From<feature_schema::FieldOption> for FieldOption {
|
||||
fn from(value: feature_schema::FieldOption) -> Self {
|
||||
Self {
|
||||
label: value.label,
|
||||
value: value.value,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[napi(object)]
|
||||
pub struct ValidationRule {
|
||||
pub rule_type: String,
|
||||
pub arg: String,
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
impl From<feature_schema::ValidationRule> for ValidationRule {
|
||||
fn from(value: feature_schema::ValidationRule) -> Self {
|
||||
Self {
|
||||
rule_type: value.rule_type,
|
||||
arg: value.arg,
|
||||
message: value.message,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[napi(object)]
|
||||
pub struct NetworkConfigSchema {
|
||||
pub node_kind: String,
|
||||
pub name: String,
|
||||
pub field_number: i32,
|
||||
pub type_name: Option<String>,
|
||||
pub semantic_type: Option<String>,
|
||||
pub value_kind: String,
|
||||
pub is_list: bool,
|
||||
pub required: bool,
|
||||
pub default_value_text: Option<String>,
|
||||
pub enum_options: Vec<FieldOption>,
|
||||
pub validations: Vec<ValidationRule>,
|
||||
pub children: Vec<NetworkConfigSchema>,
|
||||
pub definitions: Vec<NetworkConfigSchema>,
|
||||
}
|
||||
|
||||
impl From<feature_schema::NetworkConfigSchema> for NetworkConfigSchema {
|
||||
fn from(value: feature_schema::NetworkConfigSchema) -> Self {
|
||||
Self {
|
||||
node_kind: value.node_kind,
|
||||
name: value.name,
|
||||
field_number: value.field_number,
|
||||
type_name: value.type_name,
|
||||
semantic_type: value.semantic_type,
|
||||
value_kind: value.value_kind,
|
||||
is_list: value.is_list,
|
||||
required: value.required,
|
||||
default_value_text: value.default_value_text,
|
||||
enum_options: value.enum_options.into_iter().map(Into::into).collect(),
|
||||
validations: value.validations.into_iter().map(Into::into).collect(),
|
||||
children: value.children.into_iter().map(Into::into).collect(),
|
||||
definitions: value.definitions.into_iter().map(Into::into).collect(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[napi(object)]
|
||||
pub struct ConfigFieldMapping {
|
||||
pub field_name: String,
|
||||
pub field_number: i32,
|
||||
}
|
||||
|
||||
impl From<feature_schema::ConfigFieldMapping> for ConfigFieldMapping {
|
||||
fn from(value: feature_schema::ConfigFieldMapping) -> Self {
|
||||
Self {
|
||||
field_name: value.field_name,
|
||||
field_number: value.field_number,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[napi(object)]
|
||||
pub struct PeerConnStats {
|
||||
pub rx_bytes: i64,
|
||||
pub tx_bytes: i64,
|
||||
pub rx_packets: i64,
|
||||
pub tx_packets: i64,
|
||||
pub latency_us: i64,
|
||||
}
|
||||
|
||||
impl From<kernel_types::PeerConnStats> for PeerConnStats {
|
||||
fn from(value: kernel_types::PeerConnStats) -> Self {
|
||||
Self {
|
||||
rx_bytes: value.rx_bytes,
|
||||
tx_bytes: value.tx_bytes,
|
||||
rx_packets: value.rx_packets,
|
||||
tx_packets: value.tx_packets,
|
||||
latency_us: value.latency_us,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[napi(object)]
|
||||
pub struct PeerConnInfo {
|
||||
pub conn_id: String,
|
||||
pub my_peer_id: i64,
|
||||
pub peer_id: i64,
|
||||
pub features: Vec<String>,
|
||||
pub tunnel_type: Option<String>,
|
||||
pub local_addr: Option<String>,
|
||||
pub remote_addr: Option<String>,
|
||||
pub resolved_remote_addr: Option<String>,
|
||||
pub stats: Option<PeerConnStats>,
|
||||
pub loss_rate: Option<f64>,
|
||||
pub is_client: bool,
|
||||
pub network_name: Option<String>,
|
||||
pub is_closed: bool,
|
||||
pub secure_auth_level: Option<i32>,
|
||||
pub peer_identity_type: Option<i32>,
|
||||
}
|
||||
|
||||
impl From<kernel_types::PeerConnInfo> for PeerConnInfo {
|
||||
fn from(value: kernel_types::PeerConnInfo) -> Self {
|
||||
Self {
|
||||
conn_id: value.conn_id,
|
||||
my_peer_id: value.my_peer_id,
|
||||
peer_id: value.peer_id,
|
||||
features: value.features,
|
||||
tunnel_type: value.tunnel_type,
|
||||
local_addr: value.local_addr,
|
||||
remote_addr: value.remote_addr,
|
||||
resolved_remote_addr: value.resolved_remote_addr,
|
||||
stats: value.stats.map(Into::into),
|
||||
loss_rate: value.loss_rate,
|
||||
is_client: value.is_client,
|
||||
network_name: value.network_name,
|
||||
is_closed: value.is_closed,
|
||||
secure_auth_level: value.secure_auth_level,
|
||||
peer_identity_type: value.peer_identity_type,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[napi(object)]
|
||||
pub struct PeerInfo {
|
||||
pub peer_id: i64,
|
||||
pub default_conn_id: Option<String>,
|
||||
pub directly_connected_conns: Vec<String>,
|
||||
pub conns: Vec<PeerConnInfo>,
|
||||
}
|
||||
|
||||
impl From<kernel_types::PeerInfo> for PeerInfo {
|
||||
fn from(value: kernel_types::PeerInfo) -> Self {
|
||||
Self {
|
||||
peer_id: value.peer_id,
|
||||
default_conn_id: value.default_conn_id,
|
||||
directly_connected_conns: value.directly_connected_conns,
|
||||
conns: value.conns.into_iter().map(Into::into).collect(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[napi(object)]
|
||||
pub struct RouteView {
|
||||
pub peer_id: i64,
|
||||
pub hostname: Option<String>,
|
||||
pub ipv4: Option<String>,
|
||||
pub ipv4_cidr: Option<String>,
|
||||
pub ipv6_cidr: Option<String>,
|
||||
pub proxy_cidrs: Vec<String>,
|
||||
pub next_hop_peer_id: Option<i64>,
|
||||
pub cost: Option<i32>,
|
||||
pub path_latency: Option<i64>,
|
||||
pub udp_nat_type: Option<i32>,
|
||||
pub tcp_nat_type: Option<i32>,
|
||||
pub inst_id: Option<String>,
|
||||
pub version: Option<String>,
|
||||
pub is_public_server: Option<bool>,
|
||||
}
|
||||
|
||||
impl From<kernel_types::RouteView> for RouteView {
|
||||
fn from(value: kernel_types::RouteView) -> Self {
|
||||
Self {
|
||||
peer_id: value.peer_id,
|
||||
hostname: value.hostname,
|
||||
ipv4: value.ipv4,
|
||||
ipv4_cidr: value.ipv4_cidr,
|
||||
ipv6_cidr: value.ipv6_cidr,
|
||||
proxy_cidrs: value.proxy_cidrs,
|
||||
next_hop_peer_id: value.next_hop_peer_id,
|
||||
cost: value.cost,
|
||||
path_latency: value.path_latency,
|
||||
udp_nat_type: value.udp_nat_type,
|
||||
tcp_nat_type: value.tcp_nat_type,
|
||||
inst_id: value.inst_id,
|
||||
version: value.version,
|
||||
is_public_server: value.is_public_server,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[napi(object)]
|
||||
pub struct MyNodeInfo {
|
||||
pub virtual_ipv4: Option<String>,
|
||||
pub virtual_ipv4_cidr: Option<String>,
|
||||
pub hostname: Option<String>,
|
||||
pub version: Option<String>,
|
||||
pub peer_id: Option<i64>,
|
||||
pub listeners: Vec<String>,
|
||||
pub vpn_portal_cfg: Option<String>,
|
||||
pub udp_nat_type: Option<i32>,
|
||||
pub tcp_nat_type: Option<i32>,
|
||||
}
|
||||
|
||||
impl From<kernel_types::MyNodeInfo> for MyNodeInfo {
|
||||
fn from(value: kernel_types::MyNodeInfo) -> Self {
|
||||
Self {
|
||||
virtual_ipv4: value.virtual_ipv4,
|
||||
virtual_ipv4_cidr: value.virtual_ipv4_cidr,
|
||||
hostname: value.hostname,
|
||||
version: value.version,
|
||||
peer_id: value.peer_id,
|
||||
listeners: value.listeners,
|
||||
vpn_portal_cfg: value.vpn_portal_cfg,
|
||||
udp_nat_type: value.udp_nat_type,
|
||||
tcp_nat_type: value.tcp_nat_type,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[napi(object)]
|
||||
pub struct RuntimeInstanceState {
|
||||
pub config_id: String,
|
||||
pub instance_id: String,
|
||||
pub display_name: String,
|
||||
pub running: bool,
|
||||
pub tun_required: bool,
|
||||
pub tun_attached: bool,
|
||||
pub magic_dns_enabled: bool,
|
||||
pub need_exit_node: bool,
|
||||
pub error_message: Option<String>,
|
||||
pub my_node_info: Option<MyNodeInfo>,
|
||||
pub events: Vec<String>,
|
||||
pub routes: Vec<RouteView>,
|
||||
pub peers: Vec<PeerInfo>,
|
||||
}
|
||||
|
||||
impl From<kernel_types::RuntimeInstanceState> for RuntimeInstanceState {
|
||||
fn from(value: kernel_types::RuntimeInstanceState) -> Self {
|
||||
Self {
|
||||
config_id: value.config_id,
|
||||
instance_id: value.instance_id,
|
||||
display_name: value.display_name,
|
||||
running: value.running,
|
||||
tun_required: value.tun_required,
|
||||
tun_attached: value.tun_attached,
|
||||
magic_dns_enabled: value.magic_dns_enabled,
|
||||
need_exit_node: value.need_exit_node,
|
||||
error_message: value.error_message,
|
||||
my_node_info: value.my_node_info.map(Into::into),
|
||||
events: value.events,
|
||||
routes: value.routes.into_iter().map(Into::into).collect(),
|
||||
peers: value.peers.into_iter().map(Into::into).collect(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[napi(object)]
|
||||
pub struct TunAggregateState {
|
||||
pub active: bool,
|
||||
pub attached_instance_ids: Vec<String>,
|
||||
pub aggregated_routes: Vec<String>,
|
||||
pub dns_servers: Vec<String>,
|
||||
pub need_rebuild: bool,
|
||||
}
|
||||
|
||||
impl From<kernel_types::TunAggregateState> for TunAggregateState {
|
||||
fn from(value: kernel_types::TunAggregateState) -> Self {
|
||||
Self {
|
||||
active: value.active,
|
||||
attached_instance_ids: value.attached_instance_ids,
|
||||
aggregated_routes: value.aggregated_routes,
|
||||
dns_servers: value.dns_servers,
|
||||
need_rebuild: value.need_rebuild,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[napi(object)]
|
||||
pub struct RuntimeAggregateState {
|
||||
pub instances: Vec<RuntimeInstanceState>,
|
||||
pub tun: TunAggregateState,
|
||||
pub running_instance_count: i32,
|
||||
}
|
||||
|
||||
impl From<kernel_types::RuntimeAggregateState> for RuntimeAggregateState {
|
||||
fn from(value: kernel_types::RuntimeAggregateState) -> Self {
|
||||
Self {
|
||||
instances: value.instances.into_iter().map(Into::into).collect(),
|
||||
tun: value.tun.into(),
|
||||
running_instance_count: value.running_instance_count,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
pub(crate) mod state;
|
||||
@@ -0,0 +1 @@
|
||||
pub(crate) mod runtime_state;
|
||||
+12
-18
@@ -1,4 +1,5 @@
|
||||
use easytier::proto::{api, common};
|
||||
use napi_derive_ohos::napi;
|
||||
use serde::Serialize;
|
||||
use std::collections::HashSet;
|
||||
use std::sync::Mutex;
|
||||
@@ -28,6 +29,7 @@ pub fn is_tun_attached(instance_id: &str) -> bool {
|
||||
|
||||
#[derive(Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[napi(object)]
|
||||
pub struct PeerConnStats {
|
||||
pub rx_bytes: i64,
|
||||
pub tx_bytes: i64,
|
||||
@@ -38,6 +40,7 @@ pub struct PeerConnStats {
|
||||
|
||||
#[derive(Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[napi(object)]
|
||||
pub struct PeerConnInfo {
|
||||
pub conn_id: String,
|
||||
pub my_peer_id: i64,
|
||||
@@ -58,6 +61,7 @@ pub struct PeerConnInfo {
|
||||
|
||||
#[derive(Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[napi(object)]
|
||||
pub struct PeerInfo {
|
||||
pub peer_id: i64,
|
||||
pub default_conn_id: Option<String>,
|
||||
@@ -67,6 +71,7 @@ pub struct PeerInfo {
|
||||
|
||||
#[derive(Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[napi(object)]
|
||||
pub struct RouteView {
|
||||
pub peer_id: i64,
|
||||
pub hostname: Option<String>,
|
||||
@@ -86,6 +91,7 @@ pub struct RouteView {
|
||||
|
||||
#[derive(Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[napi(object)]
|
||||
pub struct MyNodeInfo {
|
||||
pub virtual_ipv4: Option<String>,
|
||||
pub virtual_ipv4_cidr: Option<String>,
|
||||
@@ -100,6 +106,7 @@ pub struct MyNodeInfo {
|
||||
|
||||
#[derive(Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[napi(object)]
|
||||
pub struct RuntimeInstanceState {
|
||||
pub config_id: String,
|
||||
pub instance_id: String,
|
||||
@@ -114,12 +121,11 @@ pub struct RuntimeInstanceState {
|
||||
pub events: Vec<String>,
|
||||
pub routes: Vec<RouteView>,
|
||||
pub peers: Vec<PeerInfo>,
|
||||
#[serde(skip)]
|
||||
pub manual_routes: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[napi(object)]
|
||||
pub struct TunAggregateState {
|
||||
pub active: bool,
|
||||
pub attached_instance_ids: Vec<String>,
|
||||
@@ -130,6 +136,7 @@ pub struct TunAggregateState {
|
||||
|
||||
#[derive(Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[napi(object)]
|
||||
pub struct RuntimeAggregateState {
|
||||
pub instances: Vec<RuntimeInstanceState>,
|
||||
pub tun: TunAggregateState,
|
||||
@@ -317,7 +324,7 @@ fn route_to_view(route: api::instance::Route) -> RouteView {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn peer_conn_to_view(conn: api::instance::PeerConnInfo) -> PeerConnInfo {
|
||||
pub(crate) fn peer_conn_to_view(conn: api::instance::PeerConnInfo) -> PeerConnInfo {
|
||||
let stats = conn.stats.map(|stats| PeerConnStats {
|
||||
rx_bytes: stats.rx_bytes as i64,
|
||||
tx_bytes: stats.tx_bytes as i64,
|
||||
@@ -392,22 +399,12 @@ fn my_node_info_to_view(info: api::manage::MyNodeInfo) -> MyNodeInfo {
|
||||
pub fn runtime_instance_from_running_info(
|
||||
config_id: String,
|
||||
display_name: String,
|
||||
config: Option<api::manage::NetworkConfig>,
|
||||
magic_dns_enabled: bool,
|
||||
need_exit_node: bool,
|
||||
info: api::manage::NetworkInstanceRunningInfo,
|
||||
) -> RuntimeInstanceState {
|
||||
let tun_attached = info.running && is_tun_attached(&config_id);
|
||||
let tun_required = info.running && (info.dev_name != "no_tun" || tun_attached);
|
||||
let magic_dns_enabled = config
|
||||
.as_ref()
|
||||
.and_then(|config| config.enable_magic_dns)
|
||||
.unwrap_or(false);
|
||||
let need_exit_node = config
|
||||
.as_ref()
|
||||
.is_some_and(|config| !config.exit_nodes.is_empty());
|
||||
let manual_routes = config
|
||||
.as_ref()
|
||||
.map(|config| config.routes.clone())
|
||||
.unwrap_or_default();
|
||||
|
||||
RuntimeInstanceState {
|
||||
config_id: config_id.clone(),
|
||||
@@ -423,7 +420,6 @@ pub fn runtime_instance_from_running_info(
|
||||
events: info.events,
|
||||
routes: info.routes.into_iter().map(route_to_view).collect(),
|
||||
peers: info.peers.into_iter().map(peer_to_view).collect(),
|
||||
manual_routes,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -438,7 +434,6 @@ pub fn runtime_instance_from_config_snapshot(
|
||||
running && (config.dev_name.as_deref().unwrap_or("") != "no_tun" || tun_attached);
|
||||
let endpoint_urls = config_endpoint_urls(&config);
|
||||
let public_server_url = non_empty_string(config.public_server_url.clone());
|
||||
let manual_routes = config.routes.clone();
|
||||
let my_node_info = MyNodeInfo {
|
||||
virtual_ipv4: non_empty_string(config.virtual_ipv4.clone()),
|
||||
virtual_ipv4_cidr: config_virtual_ipv4_cidr(&config),
|
||||
@@ -465,6 +460,5 @@ pub fn runtime_instance_from_config_snapshot(
|
||||
events: Vec::new(),
|
||||
routes: configured_route_views(&endpoint_urls, public_server_url.as_deref()),
|
||||
peers: configured_peer_views(&endpoint_urls),
|
||||
manual_routes,
|
||||
}
|
||||
}
|
||||
@@ -4,22 +4,24 @@ version = "0.1.0"
|
||||
edition.workspace = true
|
||||
|
||||
[dependencies]
|
||||
tokio = { workspace = true, default-features = true, features = ["full"] }
|
||||
tracing.workspace = true
|
||||
tracing-subscriber = { workspace = true, default-features = true, features = ["env-filter"] }
|
||||
anyhow.workspace = true
|
||||
serde = { workspace = true, features = ["derive"] }
|
||||
serde_json.workspace = true
|
||||
chrono = { workspace = true, features = ["serde"] }
|
||||
uuid = { workspace = true, features = ["v4", "serde"] }
|
||||
tokio = { version = "1.0", features = ["full"] }
|
||||
tracing = "0.1"
|
||||
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||
anyhow = "1.0"
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
serde_json = "1.0"
|
||||
chrono = { version = "0.4", features = ["serde"] }
|
||||
uuid = { version = "1.0", features = ["v4", "serde"] }
|
||||
guarden = "0.1"
|
||||
|
||||
# Axum web framework
|
||||
axum = { version = "0.8.9", features = ["macros"] }
|
||||
axum-extra = { version = "0.12", features = ["query"] }
|
||||
tower-http = { workspace = true, features = ["cors", "compression-full"] }
|
||||
axum = { version = "0.8.4", features = ["macros"] }
|
||||
axum-extra = { version = "0.10", features = ["query"] }
|
||||
tower-http = { version = "0.6", features = ["cors", "compression-full"] }
|
||||
tower = "0.5"
|
||||
|
||||
# SeaORM dependencies
|
||||
sea-orm = { workspace = true, features = [
|
||||
sea-orm = { version = "1.1", features = [
|
||||
"sqlx-sqlite",
|
||||
"runtime-tokio-rustls",
|
||||
"macros",
|
||||
@@ -27,23 +29,39 @@ sea-orm = { workspace = true, features = [
|
||||
"with-uuid",
|
||||
"with-json"
|
||||
] }
|
||||
sea-orm-migration.workspace = true
|
||||
sqlx = { workspace = true, features = ["sqlite", "runtime-tokio-rustls", "chrono", "uuid"] }
|
||||
sea-orm-migration = { version = "1.1" }
|
||||
sqlx = { version = "0.8", features = ["sqlite", "runtime-tokio-rustls", "chrono", "uuid"] }
|
||||
|
||||
# Validation
|
||||
validator = { version = "0.21", features = ["derive"] }
|
||||
thiserror.workspace = true
|
||||
jsonwebtoken = { version = "11.0", features = ["rust_crypto"] }
|
||||
validator = { version = "0.18", features = ["derive"] }
|
||||
thiserror = "1.0"
|
||||
jsonwebtoken = "9.0"
|
||||
|
||||
# Configuration and serialization
|
||||
serde_yaml = "0.9"
|
||||
toml = "0.8"
|
||||
|
||||
# Network and async
|
||||
async-trait.workspace = true
|
||||
tokio-util = { workspace = true, features = ["full"] }
|
||||
async-trait = "0.1"
|
||||
futures = "0.3"
|
||||
tokio-util = { version = "0.7", features = ["full"] }
|
||||
|
||||
# Filesystem operations
|
||||
tempfile = "3.8"
|
||||
|
||||
# Additional utilities
|
||||
dashmap.workspace = true
|
||||
clap = { workspace = true, features = ["derive"] }
|
||||
dashmap = "6.1.0"
|
||||
clap = { version = "4.0", features = ["derive"] }
|
||||
parking_lot = "0.12"
|
||||
once_cell = "1.19"
|
||||
|
||||
# EasyTier core
|
||||
easytier = { workspace = true, default-features = true }
|
||||
easytier = { path = "../../easytier" }
|
||||
|
||||
mimalloc.workspace = true
|
||||
mimalloc = { version = "*" }
|
||||
|
||||
# Testing
|
||||
[dev-dependencies]
|
||||
mockall = "0.12"
|
||||
tokio-test = "0.4"
|
||||
reqwest = "0.12"
|
||||
|
||||
@@ -383,14 +383,7 @@ mod tests {
|
||||
assert_eq!(node.name, "Test Node");
|
||||
assert_eq!(node.host, "test.example.com");
|
||||
assert_eq!(node.port, 11010);
|
||||
assert!(!node.is_active);
|
||||
assert!(!node.is_approved);
|
||||
|
||||
let node = NodeOperations::update_node_status(&db, node.id, true, Some(1))
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(node.is_active);
|
||||
assert_eq!(node.current_connections, 1);
|
||||
|
||||
// 测试获取节点
|
||||
let found_node = NodeOperations::get_node_by_id(&db, node.id).await.unwrap();
|
||||
|
||||
+61
-70
@@ -3,7 +3,7 @@ name = "easytier-core"
|
||||
description = "EasyTier OS-free control-plane core primitives."
|
||||
homepage = "https://github.com/EasyTier/EasyTier"
|
||||
repository = "https://github.com/EasyTier/EasyTier"
|
||||
version = "2.7.0"
|
||||
version = "2.6.4"
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
authors = ["kkrainbow"]
|
||||
@@ -18,76 +18,76 @@ crate-type = ["rlib", "cdylib"]
|
||||
wasm-opt = ["-Oz", "--enable-bulk-memory", "--enable-nontrapping-float-to-int"]
|
||||
|
||||
[dependencies]
|
||||
anyhow.workspace = true
|
||||
ariadne = { version = "0.6", optional = true }
|
||||
arc-swap.workspace = true
|
||||
async-ringbuf = "0.3.9"
|
||||
async-trait.workspace = true
|
||||
auto_impl.workspace = true
|
||||
base64.workspace = true
|
||||
bitflags = "2.13"
|
||||
bytecodec.workspace = true
|
||||
bytes.workspace = true
|
||||
chrono = { workspace = true, features = ["clock"] }
|
||||
cidr = { workspace = true, features = ["serde"] }
|
||||
crossbeam.workspace = true
|
||||
dashmap.workspace = true
|
||||
bon.workspace = true
|
||||
easytier-proto = { workspace = true, features = ["core"] }
|
||||
futures.workspace = true
|
||||
guarden.workspace = true
|
||||
hmac.workspace = true
|
||||
http-body-util = { workspace = true, optional = true }
|
||||
hyper = { workspace = true, features = ["client", "http1"], optional = true }
|
||||
hyper-util = { workspace = true, features = ["tokio"], optional = true }
|
||||
idna = "1.1"
|
||||
atomic-shim.workspace = true
|
||||
ordered_hash_map = "0.6.1"
|
||||
parking_lot.workspace = true
|
||||
percent-encoding.workspace = true
|
||||
petgraph = "0.8.3"
|
||||
pin-project-lite.workspace = true
|
||||
prefix-trie = { version = "0.10.1", features = ["cidr"] }
|
||||
prost.workspace = true
|
||||
prost-types.workspace = true
|
||||
rand.workspace = true
|
||||
quanta.workspace = true
|
||||
anyhow = "1.0"
|
||||
ariadne = { version = "0.5", optional = true }
|
||||
arc-swap = "1.7"
|
||||
async-ringbuf = "0.3.1"
|
||||
async-trait = "0.1.74"
|
||||
auto_impl = "1.1.0"
|
||||
base64 = "0.22"
|
||||
bitflags = "2.5"
|
||||
bytecodec = "0.4.15"
|
||||
bytes = "1.5.0"
|
||||
chrono = { version = "0.4.37", features = ["clock"] }
|
||||
cidr = { version = "0.3.1", features = ["serde"] }
|
||||
crossbeam = "0.8.4"
|
||||
dashmap = "6.0"
|
||||
derive_builder = "0.20.2"
|
||||
easytier-proto = { path = "../easytier-proto", default-features = false, features = ["core"] }
|
||||
futures = "0.3"
|
||||
guarden = "0.2"
|
||||
hmac = "0.12.1"
|
||||
http-body-util = { version = "0.1", optional = true }
|
||||
hyper = { version = "1", default-features = false, features = ["client", "http1"], optional = true }
|
||||
hyper-util = { version = "0.1", default-features = false, features = ["tokio"], optional = true }
|
||||
idna = "1.0"
|
||||
atomic-shim = "0.2.0"
|
||||
ordered_hash_map = "0.5.0"
|
||||
parking_lot = "0.12.1"
|
||||
percent-encoding = "2.3.1"
|
||||
petgraph = "0.8.1"
|
||||
pin-project-lite = "0.2.13"
|
||||
prefix-trie = { version = "0.7.0", features = ["cidr"] }
|
||||
prost = "0.14.3"
|
||||
prost-types = "0.14.3"
|
||||
rand = "0.8.5"
|
||||
quanta = "0.12"
|
||||
ring = { version = "0.17", optional = true }
|
||||
rustls = { workspace = true, features = ["ring", "std", "tls12"], optional = true }
|
||||
serde = { workspace = true, features = ["derive"] }
|
||||
serde_json.workspace = true
|
||||
sha2.workspace = true
|
||||
smoltcp = { workspace = true, optional = true }
|
||||
stun_codec.workspace = true
|
||||
thiserror.workspace = true
|
||||
tracing.workspace = true
|
||||
strum = { workspace = true, features = ["derive"] }
|
||||
toml.workspace = true
|
||||
tokio = { workspace = true, features = [
|
||||
rustls = { version = "0.23", default-features = false, features = ["ring", "std", "tls12"], optional = true }
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
sha2 = "0.10.8"
|
||||
smoltcp = { git = "https://github.com/smoltcp-rs/smoltcp.git", rev = "0a926767a68bc88d5512afefa7529c5ecdade4ea", optional = true, default-features = false }
|
||||
stun_codec = "0.3.4"
|
||||
thiserror = "1.0"
|
||||
tracing = "0.1"
|
||||
strum = { version = "0.27.2", features = ["derive"] }
|
||||
toml = "0.8.12"
|
||||
tokio = { version = "1", default-features = false, features = [
|
||||
"rt",
|
||||
"time",
|
||||
"sync",
|
||||
"macros",
|
||||
"io-util",
|
||||
] }
|
||||
tokio-util = { workspace = true, features = ["io", "rt"] }
|
||||
tokio-rustls = { workspace = true, optional = true }
|
||||
url = { workspace = true, features = ["serde"] }
|
||||
wildmatch = "2.6.1"
|
||||
uuid = { workspace = true, features = ["v4", "fast-rng", "serde"] }
|
||||
webpki-roots = { version = "1.0", optional = true }
|
||||
x25519-dalek = { workspace = true, features = ["static_secrets"] }
|
||||
zerocopy = { workspace = true, features = ["derive", "simd"] }
|
||||
zstd = { version = "0.14", optional = true }
|
||||
aes-gcm = { version = "0.11.1", optional = true }
|
||||
chacha20poly1305 = { version = "0.11.0", optional = true }
|
||||
tokio-util = { version = "0.7", features = ["io", "rt"] }
|
||||
tokio-rustls = { version = "0.26", default-features = false, optional = true }
|
||||
url = { version = "2.5", features = ["serde"] }
|
||||
wildmatch = "2.3.4"
|
||||
uuid = { version = "1.5.0", features = ["v4", "fast-rng", "serde"] }
|
||||
webpki-roots = { version = "0.26", optional = true }
|
||||
x25519-dalek = { version = "2.0", features = ["static_secrets"] }
|
||||
zerocopy = { version = "0.7.32", features = ["derive", "simd"] }
|
||||
zstd = { version = "0.13", optional = true }
|
||||
aes-gcm = { version = "0.10.3", optional = true }
|
||||
chacha20poly1305 = { version = "0.10.1", optional = true }
|
||||
openssl = { version = "0.10", optional = true, features = ["vendored"] }
|
||||
|
||||
[target.'cfg(all(target_arch = "wasm32", target_os = "unknown"))'.dependencies]
|
||||
getrandom-02 = { package = "getrandom", version = "0.2.17", features = ["js"] }
|
||||
getrandom-03 = { package = "getrandom", version = "0.3.4", features = ["wasm_js"] }
|
||||
getrandom-02 = { package = "getrandom", version = "0.2.15", features = ["js"] }
|
||||
getrandom-03 = { package = "getrandom", version = "0.3.2", features = ["wasm_js"] }
|
||||
snow = { version = "0.10.0", default-features = false, features = ["default-resolver", "default-resolver-crypto"] }
|
||||
uuid = { workspace = true, features = ["js"] }
|
||||
uuid = { version = "1.5.0", features = ["js"] }
|
||||
wasm-bindgen = "0.2"
|
||||
|
||||
[target.'cfg(not(all(target_arch = "wasm32", target_os = "unknown")))'.dependencies]
|
||||
@@ -146,18 +146,9 @@ proxy-smoltcp-stack = [
|
||||
"smoltcp/proto-ipv6",
|
||||
"smoltcp/async",
|
||||
]
|
||||
wasm-host-tunnel = []
|
||||
wasm-host-tunnel-outbound = ["wasm-host-tunnel"]
|
||||
test-utils = []
|
||||
tracing-log = ["tracing/log"]
|
||||
zstd = ["dep:zstd"]
|
||||
|
||||
[target.'cfg(not(target_os = "wasi"))'.dev-dependencies]
|
||||
tokio = { workspace = true, features = ["rt-multi-thread"] }
|
||||
|
||||
[package.metadata.cargo-machete]
|
||||
ignored = [
|
||||
# Enable browser entropy backends for transitive rand/snow dependencies.
|
||||
"getrandom-02",
|
||||
"getrandom-03",
|
||||
]
|
||||
tokio = { version = "1", default-features = false, features = ["rt-multi-thread"] }
|
||||
|
||||
@@ -4,10 +4,7 @@ use easytier_proto::api::manage::{
|
||||
self, NetworkConfig, NetworkingMethod, PortForwardConfig as ApiPortForwardConfig,
|
||||
};
|
||||
|
||||
use super::{
|
||||
api_input::managed_credential_to_proto,
|
||||
toml::{ConfigLoader as _, TomlConfig},
|
||||
};
|
||||
use super::toml::{ConfigLoader as _, TomlConfig};
|
||||
|
||||
pub fn network_config_from_toml(config: &TomlConfig) -> NetworkConfig {
|
||||
let default_config = TomlConfig::default();
|
||||
@@ -83,19 +80,11 @@ pub fn network_config_from_toml(config: &TomlConfig) -> NetworkConfig {
|
||||
}
|
||||
|
||||
if let Some(vpn_config) = config.get_vpn_portal_config() {
|
||||
result.vpn_portal_config = Some(manage::VpnPortalConfig {
|
||||
wireguard_listen: vpn_config.wireguard_listen.to_string(),
|
||||
wireguard_private_key: vpn_config.wireguard_private_key,
|
||||
clients: vpn_config
|
||||
.clients
|
||||
.into_iter()
|
||||
.map(|client| manage::VpnPortalClientConfig {
|
||||
name: client.name,
|
||||
virtual_ip: client.virtual_ip.to_string(),
|
||||
groups: client.groups,
|
||||
})
|
||||
.collect(),
|
||||
});
|
||||
result.enable_vpn_portal = Some(true);
|
||||
result.vpn_portal_client_network_addr =
|
||||
Some(vpn_config.client_cidr.first_address().to_string());
|
||||
result.vpn_portal_client_network_len = Some(vpn_config.client_cidr.network_length() as i32);
|
||||
result.vpn_portal_listen_port = Some(vpn_config.wireguard_listen.port() as i32);
|
||||
}
|
||||
|
||||
if let Some(routes) = config.get_routes()
|
||||
@@ -121,11 +110,6 @@ pub fn network_config_from_toml(config: &TomlConfig) -> NetworkConfig {
|
||||
result.credential_file = config
|
||||
.get_credential_file()
|
||||
.map(|path| path.to_string_lossy().into_owned());
|
||||
result.managed_credentials = config
|
||||
.get_managed_credentials()
|
||||
.into_iter()
|
||||
.map(managed_credential_to_proto)
|
||||
.collect();
|
||||
|
||||
let flags = config.get_flags();
|
||||
let default_flags = default_config.get_flags();
|
||||
@@ -153,7 +137,6 @@ pub fn network_config_from_toml(config: &TomlConfig) -> NetworkConfig {
|
||||
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);
|
||||
@@ -181,38 +164,3 @@ pub fn network_config_from_toml(config: &TomlConfig) -> NetworkConfig {
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::config::toml::ManagedCredentialConfig;
|
||||
|
||||
#[test]
|
||||
fn includes_managed_credentials() {
|
||||
let config = TomlConfig::default();
|
||||
config.set_managed_credentials(vec![ManagedCredentialConfig {
|
||||
credential_id: "managed-a".to_owned(),
|
||||
credential_secret: "credential-secret".to_owned(),
|
||||
groups: vec!["ops".to_owned()],
|
||||
allow_relay: true,
|
||||
allowed_proxy_cidrs: vec!["10.0.0.0/24".to_owned()],
|
||||
expiry_unix: 2_000_000_000,
|
||||
reusable: false,
|
||||
}]);
|
||||
|
||||
let projected = network_config_from_toml(&config);
|
||||
|
||||
assert_eq!(
|
||||
projected.managed_credentials,
|
||||
vec![manage::ManagedCredentialConfig {
|
||||
credential_id: "managed-a".to_owned(),
|
||||
credential_secret: "credential-secret".to_owned(),
|
||||
groups: vec!["ops".to_owned()],
|
||||
allow_relay: true,
|
||||
allowed_proxy_cidrs: vec!["10.0.0.0/24".to_owned()],
|
||||
expiry_unix: 2_000_000_000,
|
||||
reusable: Some(false),
|
||||
}]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,8 +8,8 @@ use easytier_proto::api::manage;
|
||||
use crate::config::{
|
||||
MappedListenerPolicy, normalize_secure_mode_config,
|
||||
toml::{
|
||||
ConfigLoader, ManagedCredentialConfig, NetworkIdentity, PeerConfig, PortForwardConfig,
|
||||
TomlConfigLoader, VpnPortalClientConfig, VpnPortalConfig, gen_default_flags,
|
||||
ConfigLoader, NetworkIdentity, PeerConfig, PortForwardConfig, TomlConfigLoader,
|
||||
VpnPortalConfig, gen_default_flags,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -51,153 +51,11 @@ pub fn add_proxy_network_to_config(
|
||||
pub type NetworkingMethod = easytier_proto::api::manage::NetworkingMethod;
|
||||
pub type NetworkConfig = easytier_proto::api::manage::NetworkConfig;
|
||||
|
||||
pub(crate) fn managed_credential_from_proto(
|
||||
credential: &manage::ManagedCredentialConfig,
|
||||
) -> ManagedCredentialConfig {
|
||||
ManagedCredentialConfig {
|
||||
credential_id: credential.credential_id.clone(),
|
||||
credential_secret: credential.credential_secret.clone(),
|
||||
groups: credential.groups.clone(),
|
||||
allow_relay: credential.allow_relay,
|
||||
allowed_proxy_cidrs: credential.allowed_proxy_cidrs.clone(),
|
||||
expiry_unix: credential.expiry_unix,
|
||||
reusable: credential.reusable.unwrap_or(true),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn 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 {
|
||||
fn gen_config(&self) -> Result<TomlConfigLoader, anyhow::Error>;
|
||||
fn new_from_config(config: impl ConfigLoader) -> Result<NetworkConfig, anyhow::Error>;
|
||||
}
|
||||
|
||||
#[cfg(all(
|
||||
feature = "browser-config",
|
||||
any(test, all(target_arch = "wasm32", target_os = "unknown"))
|
||||
))]
|
||||
const FORM_MANAGED_TOML_FIELDS: &[&str] = &[
|
||||
"hostname",
|
||||
"instance_id",
|
||||
"ipv4",
|
||||
"ipv6_public_addr_provider",
|
||||
"ipv6_public_addr_auto",
|
||||
"ipv6_public_addr_prefix",
|
||||
"dhcp",
|
||||
"network_identity",
|
||||
"listeners",
|
||||
"mapped_listeners",
|
||||
"exit_nodes",
|
||||
"peer",
|
||||
"proxy_network",
|
||||
"vpn_portal_config",
|
||||
"routes",
|
||||
"socks5_proxy",
|
||||
"port_forward",
|
||||
"secure_mode",
|
||||
"acl",
|
||||
"credential_file",
|
||||
"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"))
|
||||
))]
|
||||
pub(crate) fn merge_network_config_toml(
|
||||
original_toml: &str,
|
||||
config: &NetworkConfig,
|
||||
) -> Result<String, anyhow::Error> {
|
||||
let generated_toml = config.gen_config()?.dump();
|
||||
let mut original = toml::from_str::<toml::Table>(original_toml)
|
||||
.context("failed to parse the original TOML document")?;
|
||||
let mut generated = toml::from_str::<toml::Table>(&generated_toml)
|
||||
.context("failed to parse the generated TOML document")?;
|
||||
|
||||
for (key, value) in original.iter() {
|
||||
if key != "flags" && !FORM_MANAGED_TOML_FIELDS.contains(&key.as_str()) {
|
||||
generated.insert(key.clone(), value.clone());
|
||||
}
|
||||
}
|
||||
if !original.contains_key("instance_name") {
|
||||
generated.remove("instance_name");
|
||||
}
|
||||
|
||||
let mut merged_flags: toml::Table = original
|
||||
.remove("flags")
|
||||
.and_then(|value| value.try_into().ok())
|
||||
.unwrap_or_default();
|
||||
let generated_flags: toml::Table = generated
|
||||
.remove("flags")
|
||||
.and_then(|value| value.try_into().ok())
|
||||
.unwrap_or_default();
|
||||
for key in FORM_MANAGED_FLAG_FIELDS {
|
||||
if let Some(value) = generated_flags.get(*key) {
|
||||
merged_flags.insert((*key).to_owned(), value.clone());
|
||||
} else {
|
||||
merged_flags.remove(*key);
|
||||
}
|
||||
}
|
||||
if !merged_flags.is_empty() {
|
||||
generated.insert("flags".to_owned(), toml::Value::Table(merged_flags));
|
||||
}
|
||||
|
||||
toml::to_string_pretty(&generated).context("failed to serialize the merged TOML document")
|
||||
}
|
||||
|
||||
fn parse_peer(peer: &manage::NetworkPeerConfig) -> Result<Option<PeerConfig>, anyhow::Error> {
|
||||
let uri = peer.uri.trim();
|
||||
if uri.is_empty() {
|
||||
@@ -240,7 +98,6 @@ fn parse_peer_urls(peer_urls: &[String]) -> Result<Vec<PeerConfig>, anyhow::Erro
|
||||
}
|
||||
|
||||
impl NetworkConfigExt for NetworkConfig {
|
||||
#[allow(deprecated)]
|
||||
fn gen_config(&self) -> Result<TomlConfigLoader, anyhow::Error> {
|
||||
let cfg = TomlConfigLoader::default();
|
||||
cfg.set_id(
|
||||
@@ -362,37 +219,29 @@ impl NetworkConfigExt for NetworkConfig {
|
||||
);
|
||||
}
|
||||
|
||||
if self.enable_vpn_portal == Some(true) {
|
||||
anyhow::bail!(
|
||||
"legacy VPN portal configuration is no longer supported; configure vpn_portal_config with named clients"
|
||||
if self.enable_vpn_portal.unwrap_or_default() {
|
||||
let cidr = format!(
|
||||
"{}/{}",
|
||||
self.vpn_portal_client_network_addr
|
||||
.clone()
|
||||
.unwrap_or_default(),
|
||||
self.vpn_portal_client_network_len.unwrap_or(24)
|
||||
);
|
||||
}
|
||||
|
||||
if let Some(vpn_config) = &self.vpn_portal_config {
|
||||
cfg.set_vpn_portal_config(VpnPortalConfig {
|
||||
wireguard_listen: vpn_config.wireguard_listen.parse().with_context(|| {
|
||||
client_cidr: cidr
|
||||
.parse()
|
||||
.with_context(|| format!("failed to parse vpn portal client cidr: {}", cidr))?,
|
||||
wireguard_listen: format!(
|
||||
"0.0.0.0:{}",
|
||||
self.vpn_portal_listen_port.unwrap_or_default()
|
||||
)
|
||||
.parse()
|
||||
.with_context(|| {
|
||||
format!(
|
||||
"failed to parse vpn portal wireguard listen address: {}",
|
||||
vpn_config.wireguard_listen
|
||||
"failed to parse vpn portal wireguard listen port. {:?}",
|
||||
self.vpn_portal_listen_port
|
||||
)
|
||||
})?,
|
||||
wireguard_private_key: vpn_config.wireguard_private_key.clone(),
|
||||
clients: vpn_config
|
||||
.clients
|
||||
.iter()
|
||||
.map(|client| {
|
||||
Ok(VpnPortalClientConfig {
|
||||
name: client.name.clone(),
|
||||
virtual_ip: client.virtual_ip.parse().with_context(|| {
|
||||
format!(
|
||||
"failed to parse vpn portal virtual IP for client {}: {}",
|
||||
client.name, client.virtual_ip
|
||||
)
|
||||
})?,
|
||||
groups: client.groups.clone(),
|
||||
})
|
||||
})
|
||||
.collect::<Result<Vec<_>, anyhow::Error>>()?,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -440,13 +289,6 @@ impl NetworkConfigExt for NetworkConfig {
|
||||
cfg.set_credential_file(Some(credential_file.into()));
|
||||
}
|
||||
|
||||
cfg.set_managed_credentials(
|
||||
self.managed_credentials
|
||||
.iter()
|
||||
.map(managed_credential_from_proto)
|
||||
.collect(),
|
||||
);
|
||||
|
||||
if let Some(credential_secret) = credential_secret {
|
||||
cfg.set_secure_mode(Some(normalize_secure_mode_config(
|
||||
easytier_proto::common::SecureModeConfig {
|
||||
@@ -587,10 +429,6 @@ impl NetworkConfigExt for NetworkConfig {
|
||||
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;
|
||||
}
|
||||
@@ -718,19 +556,13 @@ impl NetworkConfigExt for NetworkConfig {
|
||||
}
|
||||
|
||||
if let Some(vpn_config) = config.get_vpn_portal_config() {
|
||||
result.vpn_portal_config = Some(manage::VpnPortalConfig {
|
||||
wireguard_listen: vpn_config.wireguard_listen.to_string(),
|
||||
wireguard_private_key: vpn_config.wireguard_private_key,
|
||||
clients: vpn_config
|
||||
.clients
|
||||
.into_iter()
|
||||
.map(|client| manage::VpnPortalClientConfig {
|
||||
name: client.name,
|
||||
virtual_ip: client.virtual_ip.to_string(),
|
||||
groups: client.groups,
|
||||
})
|
||||
.collect(),
|
||||
});
|
||||
result.enable_vpn_portal = Some(true);
|
||||
|
||||
let cidr = vpn_config.client_cidr;
|
||||
result.vpn_portal_client_network_addr = Some(cidr.first_address().to_string());
|
||||
result.vpn_portal_client_network_len = Some(cidr.network_length() as i32);
|
||||
|
||||
result.vpn_portal_listen_port = Some(vpn_config.wireguard_listen.port() as i32);
|
||||
}
|
||||
|
||||
if let Some(routes) = config.get_routes()
|
||||
@@ -759,11 +591,6 @@ impl NetworkConfigExt for NetworkConfig {
|
||||
result.credential_file = config
|
||||
.get_credential_file()
|
||||
.map(|path| path.to_string_lossy().into_owned());
|
||||
result.managed_credentials = config
|
||||
.get_managed_credentials()
|
||||
.into_iter()
|
||||
.map(managed_credential_to_proto)
|
||||
.collect();
|
||||
let flags = config.get_flags();
|
||||
let default_flags = default_config.get_flags();
|
||||
result.latency_first = Some(flags.latency_first);
|
||||
@@ -790,7 +617,6 @@ impl NetworkConfigExt for NetworkConfig {
|
||||
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);
|
||||
@@ -824,164 +650,3 @@ impl NetworkConfigExt for NetworkConfig {
|
||||
Ok(result)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#![allow(deprecated)]
|
||||
|
||||
use super::*;
|
||||
|
||||
fn api_portal_config() -> manage::VpnPortalConfig {
|
||||
manage::VpnPortalConfig {
|
||||
wireguard_listen: "0.0.0.0:51820".to_owned(),
|
||||
wireguard_private_key: Some("server-private-key".to_owned()),
|
||||
clients: vec![manage::VpnPortalClientConfig {
|
||||
name: "alice".to_owned(),
|
||||
virtual_ip: "10.144.144.10/16".to_owned(),
|
||||
groups: vec!["staff".to_owned()],
|
||||
}],
|
||||
}
|
||||
}
|
||||
|
||||
fn standalone_config() -> NetworkConfig {
|
||||
NetworkConfig {
|
||||
networking_method: Some(NetworkingMethod::Standalone as i32),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vpn_portal_api_config_round_trips_through_toml_model() {
|
||||
let input = NetworkConfig {
|
||||
vpn_portal_config: Some(api_portal_config()),
|
||||
..standalone_config()
|
||||
};
|
||||
|
||||
let config = input.gen_config().unwrap();
|
||||
let portal = config.get_vpn_portal_config().unwrap();
|
||||
assert_eq!(portal.wireguard_listen, "0.0.0.0:51820".parse().unwrap());
|
||||
assert_eq!(
|
||||
portal.wireguard_private_key.as_deref(),
|
||||
Some("server-private-key")
|
||||
);
|
||||
assert_eq!(portal.clients[0].name, "alice");
|
||||
assert_eq!(portal.clients[0].virtual_ip.to_string(), "10.144.144.10/16");
|
||||
assert_eq!(portal.clients[0].groups, vec!["staff".to_owned()]);
|
||||
|
||||
let output = NetworkConfig::new_from_config(&config).unwrap();
|
||||
assert_eq!(output.vpn_portal_config, input.vpn_portal_config);
|
||||
assert_eq!(output.enable_vpn_portal, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn managed_credentials_round_trip_through_toml_model() {
|
||||
let input = NetworkConfig {
|
||||
managed_credentials: vec![manage::ManagedCredentialConfig {
|
||||
credential_id: "managed-a".to_owned(),
|
||||
credential_secret: "secret".to_owned(),
|
||||
groups: vec!["ops".to_owned()],
|
||||
allow_relay: true,
|
||||
allowed_proxy_cidrs: vec!["10.0.0.0/24".to_owned()],
|
||||
expiry_unix: 2_000_000_000,
|
||||
reusable: None,
|
||||
}],
|
||||
..standalone_config()
|
||||
};
|
||||
|
||||
let config = input.gen_config().unwrap();
|
||||
let output = NetworkConfig::new_from_config(&config).unwrap();
|
||||
assert_eq!(output.managed_credentials[0].credential_id, "managed-a");
|
||||
assert_eq!(output.managed_credentials[0].reusable, Some(true));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn peer_relay_preference_round_trips_independently() {
|
||||
let input = NetworkConfig {
|
||||
disable_relay_data: Some(false),
|
||||
prefer_peer_relay: Some(true),
|
||||
..standalone_config()
|
||||
};
|
||||
|
||||
let config = input.gen_config().unwrap();
|
||||
let flags = config.get_flags();
|
||||
assert!(!flags.disable_relay_data);
|
||||
assert!(flags.prefer_peer_relay);
|
||||
|
||||
let output = NetworkConfig::new_from_config(&config).unwrap();
|
||||
assert_eq!(output.disable_relay_data, Some(false));
|
||||
assert_eq!(output.prefer_peer_relay, Some(true));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_enabled_vpn_portal_config_reports_migration_error() {
|
||||
let error = NetworkConfig {
|
||||
enable_vpn_portal: Some(true),
|
||||
..standalone_config()
|
||||
}
|
||||
.gen_config()
|
||||
.unwrap_err()
|
||||
.to_string();
|
||||
|
||||
assert!(error.contains("legacy VPN portal"), "{error}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_disabled_vpn_portal_defaults_are_ignored() {
|
||||
let config = NetworkConfig {
|
||||
enable_vpn_portal: Some(false),
|
||||
vpn_portal_listen_port: Some(0),
|
||||
vpn_portal_client_network_addr: Some(String::new()),
|
||||
vpn_portal_client_network_len: Some(0),
|
||||
..standalone_config()
|
||||
}
|
||||
.gen_config()
|
||||
.unwrap();
|
||||
|
||||
assert!(config.get_vpn_portal_config().is_none());
|
||||
}
|
||||
|
||||
#[cfg(feature = "browser-config")]
|
||||
#[test]
|
||||
fn browser_merge_preserves_fields_outside_the_shared_form() {
|
||||
let original = r#"
|
||||
instance_name = "module-instance"
|
||||
rpc_portal = "0.0.0.0:15888"
|
||||
tcp_whitelist = ["22"]
|
||||
stun_servers = ["custom.example.com:3478"]
|
||||
|
||||
[network_identity]
|
||||
network_name = "old-network"
|
||||
network_secret = "secret"
|
||||
|
||||
[flags]
|
||||
default_protocol = "udp"
|
||||
disable_p2p = true
|
||||
"#;
|
||||
let parsed = TomlConfigLoader::new_from_str(original).unwrap();
|
||||
let mut network_config = NetworkConfig::new_from_config(&parsed).unwrap();
|
||||
network_config.network_name = Some("edited-network".to_owned());
|
||||
network_config.disable_p2p = Some(false);
|
||||
|
||||
let merged = merge_network_config_toml(original, &network_config).unwrap();
|
||||
let merged: toml::Table = toml::from_str(&merged).unwrap();
|
||||
|
||||
assert_eq!(merged["instance_name"].as_str(), Some("module-instance"));
|
||||
assert_eq!(merged["rpc_portal"].as_str(), Some("0.0.0.0:15888"));
|
||||
assert_eq!(merged["tcp_whitelist"][0].as_str(), Some("22"));
|
||||
assert_eq!(
|
||||
merged["stun_servers"][0].as_str(),
|
||||
Some("custom.example.com:3478")
|
||||
);
|
||||
assert_eq!(
|
||||
merged["network_identity"]["network_name"].as_str(),
|
||||
Some("edited-network")
|
||||
);
|
||||
assert_eq!(merged["flags"]["default_protocol"].as_str(), Some("udp"));
|
||||
assert!(
|
||||
!merged["flags"]
|
||||
.as_table()
|
||||
.unwrap()
|
||||
.contains_key("disable_p2p")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
use wasm_bindgen::prelude::*;
|
||||
|
||||
use super::{
|
||||
api_input::{NetworkConfig, NetworkConfigExt, merge_network_config_toml},
|
||||
api_input::{NetworkConfig, NetworkConfigExt},
|
||||
toml::{ConfigLoader, TomlConfig},
|
||||
};
|
||||
|
||||
@@ -18,12 +18,6 @@ pub fn generate_config(config_json: &str) -> Result<String, JsValue> {
|
||||
.map_err(js_error)
|
||||
}
|
||||
|
||||
#[wasm_bindgen]
|
||||
pub fn merge_config(original_toml: &str, config_json: &str) -> Result<String, JsValue> {
|
||||
let config: NetworkConfig = serde_json::from_str(config_json).map_err(js_error)?;
|
||||
merge_network_config_toml(original_toml, &config).map_err(js_error)
|
||||
}
|
||||
|
||||
#[wasm_bindgen]
|
||||
pub fn parse_config(toml_config: &str) -> Result<String, JsValue> {
|
||||
let config = TomlConfig::new_from_str(toml_config)
|
||||
|
||||
@@ -119,12 +119,13 @@ pub fn normalize_secure_mode_config(
|
||||
match config.local_public_key.as_ref() {
|
||||
None => config.local_public_key = Some(generated_public_key),
|
||||
Some(configured_public_key) => {
|
||||
config.public_key()?;
|
||||
if configured_public_key != &generated_public_key {
|
||||
let public_key = config.public_key()?;
|
||||
let canonical_public_key = BASE64_STANDARD.encode(public_key.as_bytes());
|
||||
if configured_public_key != &canonical_public_key {
|
||||
anyhow::bail!(
|
||||
"local public key {} does not match generated public key {}",
|
||||
configured_public_key,
|
||||
generated_public_key
|
||||
canonical_public_key
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -747,24 +748,6 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn secure_mode_normalization_rejects_mismatched_public_key() {
|
||||
let private_key = StaticSecret::from([7; 32]);
|
||||
let other_public_key = PublicKey::from(&StaticSecret::from([9; 32]));
|
||||
let error = normalize_secure_mode_config(common_pb::SecureModeConfig {
|
||||
enabled: true,
|
||||
local_private_key: Some(BASE64_STANDARD.encode(private_key.as_bytes())),
|
||||
local_public_key: Some(BASE64_STANDARD.encode(other_public_key.as_bytes())),
|
||||
})
|
||||
.unwrap_err()
|
||||
.to_string();
|
||||
|
||||
assert!(
|
||||
error.contains("does not match generated public key"),
|
||||
"{error}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn disabled_secure_mode_does_not_validate_keys() {
|
||||
let config = common_pb::SecureModeConfig {
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
//! `crate::peers`.
|
||||
|
||||
use anyhow::Context as _;
|
||||
use cidr::Ipv6Cidr;
|
||||
use cidr::{Ipv4Cidr, Ipv6Cidr};
|
||||
use easytier_proto::common::{FlagsInConfig, PeerFeatureFlag, SecureModeConfig, StunInfo};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
@@ -185,14 +185,6 @@ impl AclRuleConfig {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn for_credential_peer(&self) -> Self {
|
||||
let mut config = self.clone();
|
||||
if let Some(acl) = config.acl.as_mut().and_then(|acl| acl.acl_v1.as_mut()) {
|
||||
acl.group = None;
|
||||
}
|
||||
config
|
||||
}
|
||||
|
||||
pub fn build(&self) -> anyhow::Result<Option<Acl>> {
|
||||
let mut config = self.clone();
|
||||
config.generate_acl_from_whitelists()?;
|
||||
@@ -237,6 +229,7 @@ pub struct PeerRuntimeSnapshot {
|
||||
pub easytier_version: String,
|
||||
pub avoid_relay_data_preference: bool,
|
||||
pub flags: FlagsInConfig,
|
||||
pub vpn_portal_cidr: Option<Ipv4Cidr>,
|
||||
pub pinned_peers: Vec<(url::Url, Option<String>)>,
|
||||
pub peer_group_memberships: Vec<PeerGroupIdentity>,
|
||||
pub acl_group_declarations: Vec<PeerGroupIdentity>,
|
||||
@@ -253,6 +246,7 @@ impl PeerRuntimeSnapshot {
|
||||
easytier_version: env!("CARGO_PKG_VERSION").to_owned(),
|
||||
avoid_relay_data_preference,
|
||||
flags,
|
||||
vpn_portal_cidr: None,
|
||||
pinned_peers: Vec::new(),
|
||||
peer_group_memberships: Vec::new(),
|
||||
acl_group_declarations: Vec::new(),
|
||||
@@ -318,42 +312,4 @@ mod tests {
|
||||
|
||||
assert!(error.to_string().contains("Start port must be <= end port"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn credential_peer_acl_preserves_chains_without_group_secrets() {
|
||||
let config = AclRuleConfig {
|
||||
acl: Some(Acl {
|
||||
acl_v1: Some(AclV1 {
|
||||
chains: vec![Chain {
|
||||
name: "forward".to_owned(),
|
||||
chain_type: ChainType::Forward as i32,
|
||||
rules: vec![Rule {
|
||||
action: Action::Drop as i32,
|
||||
..Default::default()
|
||||
}],
|
||||
..Default::default()
|
||||
}],
|
||||
group: Some(GroupInfo {
|
||||
declares: vec![crate::proto::acl::GroupIdentity {
|
||||
group_name: "ops".to_owned(),
|
||||
group_secret: "secret".to_owned(),
|
||||
}],
|
||||
members: vec!["ops".to_owned()],
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
tcp_whitelist: vec!["22".to_owned()],
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let sanitized = config.for_credential_peer();
|
||||
|
||||
let acl = sanitized.acl.unwrap().acl_v1.unwrap();
|
||||
assert_eq!(acl.chains.len(), 1);
|
||||
assert_eq!(acl.chains[0].name, "forward");
|
||||
assert_eq!(acl.chains[0].rules[0].action, Action::Drop as i32);
|
||||
assert!(acl.group.is_none());
|
||||
assert_eq!(sanitized.tcp_whitelist, ["22"]);
|
||||
assert!(config.acl.unwrap().acl_v1.unwrap().group.is_some());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -145,15 +145,6 @@ impl CoreRuntimeConfigStore {
|
||||
pub fn subscribe_service_runtime_changes(&self) -> tokio::sync::watch::Receiver<u64> {
|
||||
self.inner.service_changes.subscribe()
|
||||
}
|
||||
#[cfg(test)]
|
||||
pub(crate) fn peer_change_subscriber_count(&self) -> usize {
|
||||
self.inner.peer_changes.receiver_count()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn service_change_subscriber_count(&self) -> usize {
|
||||
self.inner.service_changes.receiver_count()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -6,13 +6,14 @@ use std::{
|
||||
sync::{Arc, Mutex},
|
||||
};
|
||||
|
||||
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 serde::{Deserialize, Serialize};
|
||||
|
||||
#[cfg(feature = "config-write")]
|
||||
use crate::config::{DEFAULT_UDP_STUN_SERVERS, DEFAULT_UDP_V6_STUN_SERVERS, default_stun_servers};
|
||||
use crate::proto::{
|
||||
acl::Acl,
|
||||
common::{CompressionAlgoPb, SecureModeConfig},
|
||||
@@ -26,6 +27,16 @@ pub(crate) fn default_instance_name() -> String {
|
||||
"default".to_owned()
|
||||
}
|
||||
|
||||
#[cfg(feature = "config-write")]
|
||||
fn default_udp_stun_servers() -> Vec<String> {
|
||||
default_stun_servers(DEFAULT_UDP_STUN_SERVERS)
|
||||
}
|
||||
|
||||
#[cfg(feature = "config-write")]
|
||||
fn default_udp_v6_stun_servers() -> Vec<String> {
|
||||
default_stun_servers(DEFAULT_UDP_V6_STUN_SERVERS)
|
||||
}
|
||||
|
||||
pub fn gen_default_flags() -> Flags {
|
||||
#[allow(deprecated)]
|
||||
Flags {
|
||||
@@ -70,7 +81,6 @@ pub fn gen_default_flags() -> Flags {
|
||||
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,
|
||||
}
|
||||
@@ -164,7 +174,6 @@ define_flags_diff! {
|
||||
need_p2p,
|
||||
disable_upnp,
|
||||
disable_relay_data,
|
||||
prefer_peer_relay,
|
||||
enable_udp_broadcast_relay,
|
||||
socket_mark,
|
||||
],
|
||||
@@ -257,11 +266,6 @@ pub trait ConfigLoader: Send + Sync {
|
||||
fn get_stun_servers(&self) -> Option<Vec<String>>;
|
||||
fn set_stun_servers(&self, servers: Option<Vec<String>>);
|
||||
|
||||
fn get_tcp_stun_servers(&self) -> Option<Vec<String>> {
|
||||
None
|
||||
}
|
||||
fn set_tcp_stun_servers(&self, _servers: Option<Vec<String>>) {}
|
||||
|
||||
fn get_stun_servers_v6(&self) -> Option<Vec<String>>;
|
||||
fn set_stun_servers_v6(&self, servers: Option<Vec<String>>);
|
||||
|
||||
@@ -273,20 +277,12 @@ pub trait ConfigLoader: Send + Sync {
|
||||
}
|
||||
fn set_credential_file(&self, _path: Option<std::path::PathBuf>) {}
|
||||
|
||||
fn get_managed_credentials(&self) -> Vec<ManagedCredentialConfig> {
|
||||
Vec::new()
|
||||
}
|
||||
fn set_managed_credentials(&self, _credentials: Vec<ManagedCredentialConfig>) {}
|
||||
|
||||
fn get_network_config_source(&self) -> ConfigSource {
|
||||
ConfigSource::User
|
||||
}
|
||||
fn set_network_config_source(&self, _source: Option<ConfigSource>) {}
|
||||
|
||||
fn dump(&self) -> String;
|
||||
fn dump_redacted(&self) -> String {
|
||||
self.dump()
|
||||
}
|
||||
}
|
||||
|
||||
pub trait LoggingConfigLoader {
|
||||
@@ -428,11 +424,11 @@ pub struct ConsoleLoggerConfig {
|
||||
pub level: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, bon::Builder)]
|
||||
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, derive_builder::Builder)]
|
||||
pub struct LoggingConfig {
|
||||
#[builder(into)]
|
||||
#[builder(setter(into, strip_option), default = None)]
|
||||
pub file_logger: Option<FileLoggerConfig>,
|
||||
#[builder(into)]
|
||||
#[builder(setter(into, strip_option), default = None)]
|
||||
pub console_logger: Option<ConsoleLoggerConfig>,
|
||||
}
|
||||
|
||||
@@ -446,72 +442,10 @@ impl LoggingConfigLoader for &LoggingConfig {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Deserialize, Serialize, PartialEq)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)]
|
||||
pub struct VpnPortalConfig {
|
||||
pub client_cidr: cidr::Ipv4Cidr,
|
||||
pub wireguard_listen: SocketAddr,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub wireguard_private_key: Option<String>,
|
||||
#[serde(default)]
|
||||
pub clients: Vec<VpnPortalClientConfig>,
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for VpnPortalConfig {
|
||||
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
formatter
|
||||
.debug_struct("VpnPortalConfig")
|
||||
.field("wireguard_listen", &self.wireguard_listen)
|
||||
.field(
|
||||
"wireguard_private_key",
|
||||
&self.wireguard_private_key.as_ref().map(|_| "<redacted>"),
|
||||
)
|
||||
.field("clients", &self.clients)
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct VpnPortalClientConfig {
|
||||
pub name: String,
|
||||
pub virtual_ip: cidr::Ipv4Inet,
|
||||
#[serde(default)]
|
||||
pub groups: Vec<String>,
|
||||
}
|
||||
|
||||
fn default_true() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
#[derive(Clone, Deserialize, Serialize, PartialEq, Eq)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct ManagedCredentialConfig {
|
||||
pub credential_id: String,
|
||||
pub credential_secret: String,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub groups: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub allow_relay: bool,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub allowed_proxy_cidrs: Vec<String>,
|
||||
pub expiry_unix: i64,
|
||||
#[serde(default = "default_true")]
|
||||
pub reusable: bool,
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for ManagedCredentialConfig {
|
||||
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
formatter
|
||||
.debug_struct("ManagedCredentialConfig")
|
||||
.field("credential_id", &self.credential_id)
|
||||
.field("credential_secret", &"<redacted>")
|
||||
.field("groups", &self.groups)
|
||||
.field("allow_relay", &self.allow_relay)
|
||||
.field("allowed_proxy_cidrs", &self.allowed_proxy_cidrs)
|
||||
.field("expiry_unix", &self.expiry_unix)
|
||||
.field("reusable", &self.reusable)
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Deserialize)]
|
||||
@@ -555,12 +489,9 @@ struct Config {
|
||||
tcp_whitelist: Option<Vec<String>>,
|
||||
udp_whitelist: Option<Vec<String>>,
|
||||
stun_servers: Option<Vec<String>>,
|
||||
tcp_stun_servers: Option<Vec<String>>,
|
||||
stun_servers_v6: Option<Vec<String>>,
|
||||
|
||||
credential_file: Option<PathBuf>,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
managed_credentials: Vec<ManagedCredentialConfig>,
|
||||
source: Option<ConfigSourceConfig>,
|
||||
}
|
||||
|
||||
@@ -624,62 +555,6 @@ impl TomlConfig {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "config-write")]
|
||||
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
|
||||
}
|
||||
|
||||
#[cfg(feature = "config-write")]
|
||||
fn redact_secrets(config: &mut Config) {
|
||||
const REDACTED: &str = "<redacted>";
|
||||
|
||||
if let Some(secret) = config
|
||||
.network_identity
|
||||
.as_mut()
|
||||
.and_then(|identity| identity.network_secret.as_mut())
|
||||
&& !secret.is_empty()
|
||||
{
|
||||
*secret = REDACTED.to_owned();
|
||||
}
|
||||
if let Some(private_key) = config
|
||||
.secure_mode
|
||||
.as_mut()
|
||||
.and_then(|secure_mode| secure_mode.local_private_key.as_mut())
|
||||
&& !private_key.is_empty()
|
||||
{
|
||||
*private_key = REDACTED.to_owned();
|
||||
}
|
||||
if let Some(private_key) = config
|
||||
.vpn_portal_config
|
||||
.as_mut()
|
||||
.and_then(|portal| portal.wireguard_private_key.as_mut())
|
||||
&& !private_key.is_empty()
|
||||
{
|
||||
*private_key = REDACTED.to_owned();
|
||||
}
|
||||
if let Some(declarations) = config
|
||||
.acl
|
||||
.as_mut()
|
||||
.and_then(|acl| acl.acl_v1.as_mut())
|
||||
.and_then(|acl| acl.group.as_mut())
|
||||
.map(|group| &mut group.declares)
|
||||
{
|
||||
for declaration in declarations {
|
||||
if !declaration.group_secret.is_empty() {
|
||||
declaration.group_secret = REDACTED.to_owned();
|
||||
}
|
||||
}
|
||||
}
|
||||
for credential in &mut config.managed_credentials {
|
||||
if !credential.credential_secret.is_empty() {
|
||||
credential.credential_secret = REDACTED.to_owned();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn new_from_str(config_str: &str) -> Result<Self, anyhow::Error> {
|
||||
Self::new_from_str_with_source("inline config", config_str)
|
||||
}
|
||||
@@ -706,12 +581,6 @@ impl TomlConfig {
|
||||
Self::gen_flags(config.flags.clone().unwrap_or_default())
|
||||
.context("failed to parse flags")?,
|
||||
);
|
||||
config.secure_mode = config
|
||||
.secure_mode
|
||||
.take()
|
||||
.map(normalize_secure_mode_config)
|
||||
.transpose()
|
||||
.context("failed to normalize [secure_mode] config")?;
|
||||
let has_network_identity = config.network_identity.is_some();
|
||||
|
||||
let config = TomlConfig {
|
||||
@@ -1112,14 +981,6 @@ impl ConfigLoader for TomlConfig {
|
||||
self.config.lock().unwrap().stun_servers = servers;
|
||||
}
|
||||
|
||||
fn get_tcp_stun_servers(&self) -> Option<Vec<String>> {
|
||||
self.config.lock().unwrap().tcp_stun_servers.clone()
|
||||
}
|
||||
|
||||
fn set_tcp_stun_servers(&self, servers: Option<Vec<String>>) {
|
||||
self.config.lock().unwrap().tcp_stun_servers = servers;
|
||||
}
|
||||
|
||||
fn get_stun_servers_v6(&self) -> Option<Vec<String>> {
|
||||
self.config.lock().unwrap().stun_servers_v6.clone()
|
||||
}
|
||||
@@ -1144,14 +1005,6 @@ impl ConfigLoader for TomlConfig {
|
||||
self.config.lock().unwrap().credential_file = path;
|
||||
}
|
||||
|
||||
fn get_managed_credentials(&self) -> Vec<ManagedCredentialConfig> {
|
||||
self.config.lock().unwrap().managed_credentials.clone()
|
||||
}
|
||||
|
||||
fn set_managed_credentials(&self, credentials: Vec<ManagedCredentialConfig>) {
|
||||
self.config.lock().unwrap().managed_credentials = credentials;
|
||||
}
|
||||
|
||||
fn get_network_config_source(&self) -> ConfigSource {
|
||||
self.config
|
||||
.lock()
|
||||
@@ -1172,19 +1025,15 @@ impl ConfigLoader for TomlConfig {
|
||||
fn dump(&self) -> String {
|
||||
#[cfg(feature = "config-write")]
|
||||
{
|
||||
toml::to_string_pretty(&self.config_for_dump()).unwrap()
|
||||
}
|
||||
#[cfg(not(feature = "config-write"))]
|
||||
{
|
||||
panic!("this build does not include TOML configuration serialization")
|
||||
}
|
||||
}
|
||||
|
||||
fn dump_redacted(&self) -> String {
|
||||
#[cfg(feature = "config-write")]
|
||||
{
|
||||
let mut config = self.config_for_dump();
|
||||
Self::redact_secrets(&mut 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()));
|
||||
if config.stun_servers == Some(default_udp_stun_servers()) {
|
||||
config.stun_servers = None;
|
||||
}
|
||||
if config.stun_servers_v6 == Some(default_udp_v6_stun_servers()) {
|
||||
config.stun_servers_v6 = None;
|
||||
}
|
||||
toml::to_string_pretty(&config).unwrap()
|
||||
}
|
||||
#[cfg(not(feature = "config-write"))]
|
||||
@@ -1252,102 +1101,6 @@ socket_mark = 0
|
||||
assert_eq!(restored.get_flags().socket_mark, Some(0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_vpn_portal_client_cidr_is_rejected_explicitly() {
|
||||
let error = TomlConfig::new_from_str(
|
||||
r#"
|
||||
[vpn_portal_config]
|
||||
client_cidr = "10.14.14.0/24"
|
||||
wireguard_listen = "0.0.0.0:51820"
|
||||
"#,
|
||||
)
|
||||
.unwrap_err()
|
||||
.to_string();
|
||||
|
||||
assert!(error.contains("client_cidr"), "{error}");
|
||||
}
|
||||
|
||||
#[cfg(feature = "config-write")]
|
||||
#[test]
|
||||
fn vpn_portal_round_trip_and_redacted_dump_preserve_dump_semantics() {
|
||||
let config = TomlConfig::new_from_str(
|
||||
r#"
|
||||
[network_identity]
|
||||
network_name = "network-a"
|
||||
network_secret = "network-secret"
|
||||
|
||||
[secure_mode]
|
||||
enabled = true
|
||||
local_private_key = "YWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWE="
|
||||
|
||||
[vpn_portal_config]
|
||||
wireguard_listen = "0.0.0.0:51820"
|
||||
wireguard_private_key = "wireguard-private-key"
|
||||
|
||||
[[vpn_portal_config.clients]]
|
||||
name = "alice"
|
||||
virtual_ip = "10.144.144.10/24"
|
||||
groups = ["staff"]
|
||||
|
||||
[acl.acl_v1.group]
|
||||
|
||||
[[acl.acl_v1.group.declares]]
|
||||
group_name = "staff"
|
||||
group_secret = "group-secret"
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let dumped = config.dump();
|
||||
assert!(dumped.contains("network-secret"));
|
||||
assert!(dumped.contains("YWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWE="));
|
||||
assert!(dumped.contains("wireguard-private-key"));
|
||||
assert!(dumped.contains("group-secret"));
|
||||
assert_eq!(
|
||||
TomlConfig::new_from_str(&dumped)
|
||||
.unwrap()
|
||||
.get_vpn_portal_config(),
|
||||
config.get_vpn_portal_config()
|
||||
);
|
||||
|
||||
let redacted = config.dump_redacted();
|
||||
assert!(!redacted.contains("network-secret"));
|
||||
assert!(!redacted.contains("YWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWE="));
|
||||
assert!(!redacted.contains("wireguard-private-key"));
|
||||
assert!(!redacted.contains("group-secret"));
|
||||
assert_eq!(redacted.matches("<redacted>").count(), 4);
|
||||
}
|
||||
|
||||
#[cfg(feature = "config-write")]
|
||||
#[test]
|
||||
fn managed_credentials_round_trip_and_redact_secret() {
|
||||
let config = TomlConfig::new_from_str(
|
||||
r#"
|
||||
[[managed_credentials]]
|
||||
credential_id = "managed-a"
|
||||
credential_secret = "private-key-material"
|
||||
groups = ["ops"]
|
||||
allow_relay = true
|
||||
allowed_proxy_cidrs = ["10.0.0.0/24"]
|
||||
expiry_unix = 2000000000
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let dumped = config.dump();
|
||||
let restored = TomlConfig::new_from_str(&dumped).unwrap();
|
||||
assert_eq!(
|
||||
restored.get_managed_credentials(),
|
||||
config.get_managed_credentials()
|
||||
);
|
||||
assert!(dumped.contains("private-key-material"));
|
||||
|
||||
let redacted = config.dump_redacted();
|
||||
assert!(!redacted.contains("private-key-material"));
|
||||
assert!(redacted.contains("<redacted>"));
|
||||
assert!(!TomlConfig::default().dump().contains("managed_credentials"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hostname_normalization_is_portable_and_has_no_host_fallback() {
|
||||
let absent = TomlConfig::default();
|
||||
@@ -1403,7 +1156,6 @@ source = "web"
|
||||
#[cfg(test)]
|
||||
mod compatibility_tests {
|
||||
use super::*;
|
||||
use base64::{Engine as _, prelude::BASE64_STANDARD};
|
||||
|
||||
#[cfg(feature = "config-write")]
|
||||
#[test]
|
||||
@@ -1498,7 +1250,6 @@ socket_mark = 66
|
||||
flags.bind_device = false;
|
||||
flags.enable_ipv6 = false;
|
||||
flags.relay_network_whitelist = "".to_string();
|
||||
flags.prefer_peer_relay = true;
|
||||
flags.mtu = 0;
|
||||
flags.foreign_relay_bps_limit = u64::MAX - 1;
|
||||
flags.instance_recv_bps_limit = u64::MAX - 2;
|
||||
@@ -1532,7 +1283,6 @@ socket_mark = 66
|
||||
assert!(!reloaded_flags.bind_device);
|
||||
assert!(!reloaded_flags.enable_ipv6);
|
||||
assert_eq!(reloaded_flags.relay_network_whitelist, "");
|
||||
assert!(reloaded_flags.prefer_peer_relay);
|
||||
assert_eq!(reloaded_flags.mtu, 0);
|
||||
assert_eq!(reloaded_flags.foreign_relay_bps_limit, u64::MAX - 1);
|
||||
assert_eq!(reloaded_flags.instance_recv_bps_limit, u64::MAX - 2);
|
||||
@@ -1548,7 +1298,6 @@ socket_mark = 66
|
||||
let config = TomlConfigLoader::default();
|
||||
let stun_servers = config.get_stun_servers();
|
||||
assert!(stun_servers.is_none());
|
||||
assert!(config.get_tcp_stun_servers().is_none());
|
||||
|
||||
// Test setting custom stun servers
|
||||
let custom_servers = vec!["txt:stun.easytier.cn".to_string()];
|
||||
@@ -1556,12 +1305,6 @@ socket_mark = 66
|
||||
|
||||
let retrieved_servers = config.get_stun_servers();
|
||||
assert_eq!(retrieved_servers.unwrap(), custom_servers);
|
||||
|
||||
let custom_tcp_servers = vec!["tcp-stun.example.com:3478".to_string()];
|
||||
config.set_tcp_stun_servers(Some(custom_tcp_servers.clone()));
|
||||
|
||||
let retrieved_tcp_servers = config.get_tcp_stun_servers();
|
||||
assert_eq!(retrieved_tcp_servers.unwrap(), custom_tcp_servers);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1572,33 +1315,15 @@ stun_servers = [
|
||||
"stun.l.google.com:19302",
|
||||
"stun1.l.google.com:19302",
|
||||
"txt:stun.easytier.cn"
|
||||
]
|
||||
tcp_stun_servers = [
|
||||
"tcp-stun.example.com:3478"
|
||||
]"#;
|
||||
|
||||
let config = TomlConfigLoader::new_from_str(config_str).unwrap();
|
||||
let stun_servers = config.get_stun_servers().unwrap();
|
||||
let tcp_stun_servers = config.get_tcp_stun_servers().unwrap();
|
||||
|
||||
assert_eq!(stun_servers.len(), 3);
|
||||
assert_eq!(stun_servers[0], "stun.l.google.com:19302");
|
||||
assert_eq!(stun_servers[1], "stun1.l.google.com:19302");
|
||||
assert_eq!(stun_servers[2], "txt:stun.easytier.cn");
|
||||
assert_eq!(tcp_stun_servers, ["tcp-stun.example.com:3478"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_empty_tcp_stun_servers_toml_parsing() {
|
||||
let config = TomlConfigLoader::new_from_str(
|
||||
r#"
|
||||
instance_name = "test"
|
||||
tcp_stun_servers = []
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(config.get_tcp_stun_servers(), Some(Vec::new()));
|
||||
}
|
||||
|
||||
#[cfg(feature = "config-write")]
|
||||
@@ -1657,112 +1382,6 @@ enabled = true
|
||||
assert!(identity.network_secret_digest.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_toml_secure_mode_generates_keypair_when_keys_missing() {
|
||||
let config = TomlConfigLoader::new_from_str(
|
||||
r#"
|
||||
[secure_mode]
|
||||
enabled = true
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let secure_mode = config.get_secure_mode().unwrap();
|
||||
let private_key = secure_mode.private_key().unwrap();
|
||||
let public_key = secure_mode.public_key().unwrap();
|
||||
assert_eq!(
|
||||
x25519_dalek::PublicKey::from(&private_key).as_bytes(),
|
||||
public_key.as_bytes()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_toml_secure_mode_derives_public_key_from_private_key() {
|
||||
let private = x25519_dalek::StaticSecret::random_from_rng(rand::rngs::OsRng);
|
||||
let config = TomlConfigLoader::new_from_str(&format!(
|
||||
r#"
|
||||
[secure_mode]
|
||||
enabled = true
|
||||
local_private_key = "{}"
|
||||
"#,
|
||||
BASE64_STANDARD.encode(private.as_bytes())
|
||||
))
|
||||
.unwrap();
|
||||
|
||||
let secure_mode = config.get_secure_mode().unwrap();
|
||||
let private_key = secure_mode.private_key().unwrap();
|
||||
assert_eq!(private_key.as_bytes(), private.as_bytes());
|
||||
assert_eq!(
|
||||
secure_mode.public_key().unwrap().as_bytes(),
|
||||
x25519_dalek::PublicKey::from(&private).as_bytes()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_toml_secure_mode_rejects_mismatched_keypair() {
|
||||
let private = x25519_dalek::StaticSecret::random_from_rng(rand::rngs::OsRng);
|
||||
let other_public = x25519_dalek::PublicKey::from(
|
||||
&x25519_dalek::StaticSecret::random_from_rng(rand::rngs::OsRng),
|
||||
);
|
||||
let error = TomlConfigLoader::new_from_str(&format!(
|
||||
r#"
|
||||
[secure_mode]
|
||||
enabled = true
|
||||
local_private_key = "{}"
|
||||
local_public_key = "{}"
|
||||
"#,
|
||||
BASE64_STANDARD.encode(private.as_bytes()),
|
||||
BASE64_STANDARD.encode(other_public.as_bytes())
|
||||
))
|
||||
.unwrap_err();
|
||||
let error = format!("{error:#}");
|
||||
|
||||
assert!(
|
||||
error.contains("failed to normalize [secure_mode] config"),
|
||||
"{error}"
|
||||
);
|
||||
assert!(
|
||||
error.contains("does not match generated public key"),
|
||||
"{error}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_toml_secure_mode_disabled_keeps_keys_unset() {
|
||||
let config = TomlConfigLoader::new_from_str(
|
||||
r#"
|
||||
[secure_mode]
|
||||
enabled = false
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let secure_mode = config.get_secure_mode().unwrap();
|
||||
assert!(!secure_mode.enabled);
|
||||
assert_eq!(secure_mode.local_private_key, None);
|
||||
assert_eq!(secure_mode.local_public_key, None);
|
||||
}
|
||||
|
||||
#[cfg(feature = "config-write")]
|
||||
#[test]
|
||||
fn test_toml_secure_mode_keypair_survives_roundtrip() {
|
||||
let config = TomlConfigLoader::new_from_str(
|
||||
r#"
|
||||
[secure_mode]
|
||||
enabled = true
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let dumped = config.dump();
|
||||
let restored = TomlConfigLoader::new_from_str(&dumped).unwrap();
|
||||
|
||||
assert_eq!(
|
||||
config.get_secure_mode().unwrap(),
|
||||
restored.get_secure_mode().unwrap()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_acl_toml_rule_uses_defaults_for_omitted_fields() {
|
||||
use crate::proto::acl::{Action, ChainType, Protocol};
|
||||
|
||||
@@ -103,17 +103,6 @@ impl InterfaceAddrCache {
|
||||
/// Mechanical connector operations supplied by one process-wide runtime.
|
||||
#[async_trait]
|
||||
pub trait ConnectorRuntime: VirtualTcpSocketFactory + Send + Sync + 'static {
|
||||
fn supports_external_tunnel(&self, _scheme: &str) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
async fn connect_external_tunnel(
|
||||
&self,
|
||||
_url: &Url,
|
||||
) -> anyhow::Result<Option<Box<dyn crate::tunnel::Tunnel>>> {
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
async fn connect_byte_stream(
|
||||
&self,
|
||||
url: &Url,
|
||||
@@ -218,17 +207,6 @@ where
|
||||
S: ConnectorRuntime + VirtualUdpSocketFactory,
|
||||
E: ConnectorEnvironment,
|
||||
{
|
||||
fn supports_external_tunnel(&self, scheme: &str) -> bool {
|
||||
self.sockets.supports_external_tunnel(scheme)
|
||||
}
|
||||
|
||||
async fn connect_external_tunnel(
|
||||
&self,
|
||||
url: &Url,
|
||||
) -> anyhow::Result<Option<Box<dyn crate::tunnel::Tunnel>>> {
|
||||
self.sockets.connect_external_tunnel(url).await
|
||||
}
|
||||
|
||||
async fn local_addr_for_remote(
|
||||
&self,
|
||||
remote_addr: SocketAddr,
|
||||
|
||||
@@ -24,7 +24,6 @@ use url::Url;
|
||||
use crate::{
|
||||
connectivity::{
|
||||
composite::{ConnectorEnvironment, ConnectorHostAdapter, ConnectorRuntime},
|
||||
manual::ExternalTunnelConnector,
|
||||
transport::ConnectedByteStream,
|
||||
},
|
||||
host::environment::{HostConnectorEnvironmentIo, local_addr_for_remote},
|
||||
@@ -114,7 +113,6 @@ where
|
||||
listeners: HostTcpListenerFactory<B>,
|
||||
environment: Arc<HostConnectorEnvironmentSnapshot>,
|
||||
environment_io: Arc<E>,
|
||||
external_tunnel_connector: Option<Arc<dyn ExternalTunnelConnector>>,
|
||||
}
|
||||
|
||||
impl<B, E> HostConnectorRuntime<B, E>
|
||||
@@ -134,17 +132,8 @@ where
|
||||
listeners: HostTcpListenerFactory::new(runtime, backend),
|
||||
environment: Arc::new(environment),
|
||||
environment_io,
|
||||
external_tunnel_connector: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn with_external_tunnel_connector(
|
||||
mut self,
|
||||
connector: Arc<dyn ExternalTunnelConnector>,
|
||||
) -> Self {
|
||||
self.external_tunnel_connector = Some(connector);
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
@@ -192,25 +181,6 @@ where
|
||||
B: ConnectorHostSocketBackend,
|
||||
E: HostConnectorEnvironmentIo,
|
||||
{
|
||||
fn supports_external_tunnel(&self, scheme: &str) -> bool {
|
||||
self.external_tunnel_connector
|
||||
.as_ref()
|
||||
.is_some_and(|connector| connector.supports_scheme(scheme))
|
||||
}
|
||||
|
||||
async fn connect_external_tunnel(
|
||||
&self,
|
||||
url: &Url,
|
||||
) -> anyhow::Result<Option<Box<dyn crate::tunnel::Tunnel>>> {
|
||||
let Some(connector) = &self.external_tunnel_connector else {
|
||||
return Ok(None);
|
||||
};
|
||||
if !connector.supports_scheme(url.scheme()) {
|
||||
return Ok(None);
|
||||
}
|
||||
Ok(Some(connector.connect(url).await?))
|
||||
}
|
||||
|
||||
async fn connect_byte_stream(
|
||||
&self,
|
||||
url: &Url,
|
||||
@@ -287,24 +257,6 @@ where
|
||||
ConnectorHostAdapter::new(runtime.clone(), runtime)
|
||||
}
|
||||
|
||||
pub fn new_connector_host_with_external_tunnel<B, E>(
|
||||
socket_runtime: HostSocketRuntime,
|
||||
backend: Arc<B>,
|
||||
environment: HostConnectorEnvironmentSnapshot,
|
||||
environment_io: Arc<E>,
|
||||
connector: Arc<dyn ExternalTunnelConnector>,
|
||||
) -> ConnectorHost<B, E>
|
||||
where
|
||||
B: ConnectorHostSocketBackend,
|
||||
E: HostConnectorEnvironmentIo,
|
||||
{
|
||||
let runtime = Arc::new(
|
||||
HostConnectorRuntime::new(socket_runtime, backend, environment, environment_io)
|
||||
.with_external_tunnel_connector(connector),
|
||||
);
|
||||
ConnectorHostAdapter::new(runtime.clone(), runtime)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::{
|
||||
@@ -347,19 +299,6 @@ mod tests {
|
||||
|
||||
struct FixedStunProvider;
|
||||
|
||||
struct ExternalFailingConnector;
|
||||
|
||||
#[async_trait]
|
||||
impl ExternalTunnelConnector for ExternalFailingConnector {
|
||||
fn supports_scheme(&self, scheme: &str) -> bool {
|
||||
scheme == "ws"
|
||||
}
|
||||
|
||||
async fn connect(&self, _url: &Url) -> anyhow::Result<Box<dyn crate::tunnel::Tunnel>> {
|
||||
anyhow::bail!("external connector called")
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl StunInfoProvider for FixedStunProvider {
|
||||
fn get_stun_info(&self) -> StunInfo {
|
||||
@@ -640,34 +579,6 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn delegates_external_tunnel_connections() {
|
||||
let host = new_connector_host_with_external_tunnel(
|
||||
HostSocketRuntime::new(),
|
||||
Arc::new(UnsupportedBackend::default()),
|
||||
test_environment_snapshot(),
|
||||
Arc::new(TestEnvironmentIo::default()),
|
||||
Arc::new(ExternalFailingConnector),
|
||||
);
|
||||
|
||||
let error = ManualConnectorHost::connect_external_tunnel(
|
||||
&host,
|
||||
&"ws://relay.example/".parse().unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert_eq!(error.to_string(), "external connector called");
|
||||
assert!(
|
||||
ManualConnectorHost::connect_external_tunnel(
|
||||
&host,
|
||||
&"tcp://relay.example:11010".parse().unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap()
|
||||
.is_none()
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn direct_rpc_projects_host_observations_without_instance_policy() {
|
||||
let host = Arc::new(new_connector_host(
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
use std::{
|
||||
collections::HashSet,
|
||||
hash::Hash,
|
||||
net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr},
|
||||
sync::{Arc, Weak},
|
||||
time::Duration,
|
||||
time::{Duration, Instant as StdInstant},
|
||||
};
|
||||
|
||||
use anyhow::Context;
|
||||
use async_trait::async_trait;
|
||||
use dashmap::DashMap;
|
||||
use quanta::Instant;
|
||||
use rand::Rng;
|
||||
use serde::{Deserialize, Serialize};
|
||||
@@ -24,7 +26,6 @@ use crate::{
|
||||
},
|
||||
transport::{self, ConnectedTransport, UdpSessionMode},
|
||||
},
|
||||
foundation::expiring_set::ExpiringSet,
|
||||
foundation::task::{PeerTaskLauncher, PeerTaskManager},
|
||||
host::dns::DnsResolver,
|
||||
peers::{
|
||||
@@ -156,6 +157,50 @@ impl DirectConnectorOptions {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct ExpiringSet<K>
|
||||
where
|
||||
K: Eq + Hash,
|
||||
{
|
||||
entries: DashMap<K, StdInstant>,
|
||||
}
|
||||
|
||||
impl<K> Default for ExpiringSet<K>
|
||||
where
|
||||
K: Eq + Hash,
|
||||
{
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
entries: DashMap::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<K> ExpiringSet<K>
|
||||
where
|
||||
K: Eq + Hash + Clone,
|
||||
{
|
||||
fn insert(&self, key: K, ttl: Duration) {
|
||||
self.entries.insert(key, StdInstant::now() + ttl);
|
||||
}
|
||||
|
||||
fn contains(&self, key: &K) -> bool {
|
||||
let active = self
|
||||
.entries
|
||||
.get(key)
|
||||
.is_some_and(|expires_at| *expires_at > StdInstant::now());
|
||||
if !active {
|
||||
self.entries.remove(key);
|
||||
}
|
||||
active
|
||||
}
|
||||
|
||||
fn cleanup(&self) {
|
||||
let now = StdInstant::now();
|
||||
self.entries.retain(|_, expires_at| *expires_at > now);
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Hash, Eq, PartialEq, Clone)]
|
||||
struct ListenerBlacklistKey(PeerId, String);
|
||||
|
||||
|
||||
@@ -474,7 +474,7 @@ where
|
||||
self.stopping.store(false, Ordering::Release);
|
||||
}
|
||||
reaper.replace(AbortOnDropHandle::new(tokio::spawn(
|
||||
reap_joinset_background(Arc::downgrade(&self.tasks), "tcp hole punch"),
|
||||
reap_joinset_background(self.tasks.clone(), "tcp hole punch"),
|
||||
)));
|
||||
}
|
||||
|
||||
|
||||
@@ -56,10 +56,7 @@ where
|
||||
socket_context: SocketContext,
|
||||
) -> Self {
|
||||
let tasks = Arc::new(Mutex::new(JoinSet::new()));
|
||||
tokio::spawn(reap_joinset_background(
|
||||
Arc::downgrade(&tasks),
|
||||
"UdpSocketArray",
|
||||
));
|
||||
tokio::spawn(reap_joinset_background(tasks.clone(), "UdpSocketArray"));
|
||||
|
||||
Self {
|
||||
sockets: Arc::new(DashMap::new()),
|
||||
|
||||
@@ -94,14 +94,6 @@ pub struct ManualInterfaceAddrs {
|
||||
|
||||
#[async_trait]
|
||||
pub trait ManualConnectorHost: VirtualTcpSocketFactory + VirtualUdpSocketFactory {
|
||||
fn supports_external_tunnel(&self, _scheme: &str) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
async fn connect_external_tunnel(&self, _url: &Url) -> anyhow::Result<Option<Box<dyn Tunnel>>> {
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
async fn local_addr_for_remote(
|
||||
&self,
|
||||
remote_addr: SocketAddr,
|
||||
@@ -118,13 +110,6 @@ pub trait ManualConnectorHost: VirtualTcpSocketFactory + VirtualUdpSocketFactory
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
pub trait ExternalTunnelConnector: Send + Sync + 'static {
|
||||
fn supports_scheme(&self, scheme: &str) -> bool;
|
||||
|
||||
async fn connect(&self, url: &Url) -> anyhow::Result<Box<dyn Tunnel>>;
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
pub(crate) trait ManualEndpointResolver: Send + Sync + 'static {
|
||||
async fn resolve_endpoint(&self, url: &Url) -> anyhow::Result<Url>;
|
||||
@@ -197,14 +182,6 @@ where
|
||||
));
|
||||
}
|
||||
|
||||
if let Some(tunnel) = self.host.connect_external_tunnel(&endpoint.url).await? {
|
||||
return Ok(apply_resolved_endpoint_info(
|
||||
tunnel,
|
||||
requested_url,
|
||||
endpoint.tunnel_prefixes,
|
||||
));
|
||||
}
|
||||
|
||||
if !self.protocol.supports_scheme(endpoint.url.scheme()) {
|
||||
anyhow::bail!(
|
||||
"unsupported client protocol upgrader: {}",
|
||||
@@ -733,20 +710,17 @@ where
|
||||
return Err(error);
|
||||
}
|
||||
};
|
||||
let ip_versions = match if data.host.supports_external_tunnel(normalized_url.scheme()) {
|
||||
Ok(vec![IpVersion::Both])
|
||||
} else {
|
||||
resolve_reconnect_ip_versions(
|
||||
&normalized_url,
|
||||
connect_timeout,
|
||||
ManualTransport::from_url(&normalized_url)
|
||||
.ok()
|
||||
.map(|transport| data.options.socket_context(transport, IpVersion::Both))
|
||||
.unwrap_or_default(),
|
||||
data.dns.as_ref(),
|
||||
)
|
||||
.await
|
||||
} {
|
||||
let ip_versions = match resolve_reconnect_ip_versions(
|
||||
&normalized_url,
|
||||
connect_timeout,
|
||||
ManualTransport::from_url(&normalized_url)
|
||||
.ok()
|
||||
.map(|transport| data.options.socket_context(transport, IpVersion::Both))
|
||||
.unwrap_or_default(),
|
||||
data.dns.as_ref(),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(ip_versions) => ip_versions,
|
||||
Err(error) => {
|
||||
emit_connect_error(&data, &url, IpVersion::Both, &error);
|
||||
@@ -796,17 +770,13 @@ where
|
||||
),
|
||||
)
|
||||
.await?;
|
||||
let uses_external_tunnel = data.host.supports_external_tunnel(endpoint.url.scheme());
|
||||
if endpoint.url.scheme() != "ring"
|
||||
&& !uses_external_tunnel
|
||||
&& !data.protocol.supports_scheme(endpoint.url.scheme())
|
||||
{
|
||||
if endpoint.url.scheme() != "ring" && !data.protocol.supports_scheme(endpoint.url.scheme()) {
|
||||
anyhow::bail!(
|
||||
"unsupported client protocol upgrader: {}",
|
||||
endpoint.url.scheme()
|
||||
);
|
||||
}
|
||||
let transport = (endpoint.url.scheme() != "ring" && !uses_external_tunnel)
|
||||
let transport = (endpoint.url.scheme() != "ring")
|
||||
.then(|| ManualTransport::from_url(&endpoint.url))
|
||||
.transpose()?;
|
||||
let resolved = match transport {
|
||||
@@ -852,15 +822,6 @@ where
|
||||
if endpoint.url.scheme() == "ring" {
|
||||
return connect_ring_tunnel(&data.ring_registry, &endpoint.url);
|
||||
}
|
||||
if uses_external_tunnel {
|
||||
return data
|
||||
.host
|
||||
.connect_external_tunnel(&endpoint.url)
|
||||
.await?
|
||||
.ok_or_else(|| {
|
||||
anyhow::anyhow!("host did not provide external tunnel for {}", endpoint.url)
|
||||
});
|
||||
}
|
||||
let transport = transport.expect("non-Ring endpoint should have a transport");
|
||||
let connected = match resolved {
|
||||
Some((remote_addr, bind_addrs)) => {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user