mirror of
https://github.com/EasyTier/EasyTier.git
synced 2026-09-20 11:22:39 +00:00
Compare commits
102
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0f2f019f16 | ||
|
|
86bf3df0fd | ||
|
|
daf30bff6e | ||
|
|
b5bc4a853d | ||
|
|
144e306562 | ||
|
|
52bb26680a | ||
|
|
0b27ac2885 | ||
|
|
23d55373a4 | ||
|
|
d375d7e455 | ||
|
|
1e40350c89 | ||
|
|
e31bde1836 | ||
|
|
86222771c5 | ||
|
|
d114cdd20f | ||
|
|
df874b85be | ||
|
|
8d475dc3fc | ||
|
|
40c857748f | ||
|
|
afbba5d928 | ||
|
|
fc3914baf5 | ||
|
|
4923a13c5d | ||
|
|
d55e63b88e | ||
|
|
7b506e25a7 | ||
|
|
7fb42c3b73 | ||
|
|
dc11298558 | ||
|
|
021f523431 | ||
|
|
346f32d3d0 | ||
|
|
f24735a86f | ||
|
|
7756a15cbe | ||
|
|
741460e1e4 | ||
|
|
13412895c5 | ||
|
|
425a24273b | ||
|
|
4e61612944 | ||
|
|
15e5d89f70 | ||
|
|
46f1b57367 | ||
|
|
9cb3833216 | ||
|
|
7205517160 | ||
|
|
be2034dd06 | ||
|
|
f0d00d6161 | ||
|
|
034f5066cd | ||
|
|
9869ddaa4b | ||
|
|
5ea6766238 | ||
|
|
5efbc8587f | ||
|
|
7632cd64da | ||
|
|
ba86e47cc6 | ||
|
|
d742fa34aa | ||
|
|
800c840bb5 | ||
|
|
16b666ad25 | ||
|
|
ca17e856a6 | ||
|
|
d87624b353 | ||
|
|
a7ab60e0e4 | ||
|
|
8909e88484 | ||
|
|
3d0d2bed9e | ||
|
|
7ef023fcdd | ||
|
|
69d476ad13 | ||
|
|
cf3fcb75b5 | ||
|
|
17195a5cc4 | ||
|
|
22bcfebe3e | ||
|
|
47a2fb19a6 | ||
|
|
62e43855cb | ||
|
|
5efbf4853a | ||
|
|
3e0bde68b8 | ||
|
|
bda06ebb61 | ||
|
|
be0859aca6 | ||
|
|
b2f1b37336 | ||
|
|
583fb45939 | ||
|
|
a343a907a9 | ||
|
|
b1ac64f40f | ||
|
|
122fb9b54e | ||
|
|
09780c1698 | ||
|
|
e533b48244 | ||
|
|
c0bd050742 | ||
|
|
5edc4cb1cd | ||
|
|
e7709f1cb5 | ||
|
|
c0f42ebe8c | ||
|
|
9d965cae64 | ||
|
|
da28c8badc | ||
|
|
e38b1354b3 | ||
|
|
793b57c2a1 | ||
|
|
4a25ca934b | ||
|
|
13f2ebfe12 | ||
|
|
9ba364ff60 | ||
|
|
ba653da9a0 | ||
|
|
64c4d73044 | ||
|
|
e0745f4bab | ||
|
|
e3ca7ffa54 | ||
|
|
df97f3a64d | ||
|
|
00957e5f9d | ||
|
|
bfa3383aaa | ||
|
|
73bea01f40 | ||
|
|
0378191783 | ||
|
|
d5fa6a608d | ||
|
|
811f151155 | ||
|
|
8428a89d2d | ||
|
|
513695297c | ||
|
|
bfbfa2ef8d | ||
|
|
8e1d079142 | ||
|
|
55f15bb6f0 | ||
|
|
96fd39649a | ||
|
|
74fc8b300d | ||
|
|
baeee40b79 | ||
|
|
4342c8d7a2 | ||
|
|
1178b312fa | ||
|
|
362aa7a9cd |
@@ -9,6 +9,14 @@ rustflags = ["-C", "link-arg=-fuse-ld=mold"]
|
||||
[target.'cfg(all(windows, target_env = "msvc"))']
|
||||
rustflags = ["-C", "target-feature=+crt-static"]
|
||||
|
||||
[target.wasm32-unknown-unknown]
|
||||
rustflags = [
|
||||
"-C",
|
||||
"opt-level=z",
|
||||
"--cfg",
|
||||
'getrandom_backend="wasm_js"',
|
||||
]
|
||||
|
||||
# region
|
||||
|
||||
# region CI
|
||||
|
||||
@@ -33,10 +33,24 @@ runs:
|
||||
sudo apt-get install -qqy build-essential mold musl-tools
|
||||
shell: bash
|
||||
|
||||
- name: Setup protoc
|
||||
uses: arduino/setup-protoc@v3
|
||||
with:
|
||||
version: '35.1'
|
||||
# GitHub repo token to use to avoid rate limiter
|
||||
repo-token: ${{ inputs.token }}
|
||||
|
||||
- name: Verify protoc version
|
||||
run: |
|
||||
version="$(protoc --version | tr -d '\r')"
|
||||
test "$version" = "libprotoc 35.1"
|
||||
shell: bash
|
||||
|
||||
- name: Setup Frontend Environment
|
||||
if: ${{ inputs.pnpm == 'true' }}
|
||||
uses: ./.github/actions/prepare-pnpm
|
||||
with:
|
||||
token: ${{ inputs.token }}
|
||||
build-filter: ${{ inputs.pnpm-build-filter }}
|
||||
|
||||
- name: Install GUI dependencies (Linux)
|
||||
@@ -82,9 +96,3 @@ runs:
|
||||
ar x libgcc.a _ctzsi2.o _clz.o _bswapsi2.o
|
||||
ar rcs libctz.a _ctzsi2.o _clz.o _bswapsi2.o
|
||||
shell: bash
|
||||
|
||||
- name: Setup protoc
|
||||
uses: arduino/setup-protoc@v3
|
||||
with:
|
||||
# GitHub repo token to use to avoid rate limiter
|
||||
repo-token: ${{ inputs.token }}
|
||||
@@ -3,6 +3,9 @@ author: Luna
|
||||
description: 'Setup Node.js, pnpm, and install dependencies'
|
||||
|
||||
inputs:
|
||||
token:
|
||||
description: 'GitHub token, used by setup-protoc action'
|
||||
required: false
|
||||
build-filter:
|
||||
description: 'The filter argument for pnpm build (e.g. ./easytier-web/*)'
|
||||
required: false
|
||||
@@ -11,6 +14,22 @@ inputs:
|
||||
runs:
|
||||
using: "composite"
|
||||
steps:
|
||||
- uses: actions-rust-lang/setup-rust-toolchain@v1
|
||||
with:
|
||||
toolchain: 1.95
|
||||
target: wasm32-unknown-unknown
|
||||
cache: false
|
||||
rustflags: ''
|
||||
|
||||
- uses: taiki-e/install-action@v2
|
||||
with:
|
||||
tool: wasm-pack
|
||||
|
||||
- uses: arduino/setup-protoc@v3
|
||||
with:
|
||||
version: '35.1'
|
||||
repo-token: ${{ inputs.token }}
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v5
|
||||
with:
|
||||
@@ -41,8 +60,16 @@ runs:
|
||||
pnpm -r install
|
||||
if [ -n "${{ inputs.build-filter }}" ]; then
|
||||
echo "Building with filter: ${{ inputs.build-filter }}"
|
||||
pnpm -r --filter "${{ inputs.build-filter }}" build
|
||||
pnpm -r --workspace-concurrency=1 --filter "${{ inputs.build-filter }}" build
|
||||
else
|
||||
echo "No build filter provided, building all packages"
|
||||
pnpm -r build
|
||||
pnpm -r --workspace-concurrency=1 build
|
||||
fi
|
||||
|
||||
- name: Bundle config generator with web frontend
|
||||
shell: bash
|
||||
run: |
|
||||
if [ -f easytier-web/frontend/dist/index.html ] && [ -f easytier-web/config-generator/dist/index.html ]; then
|
||||
mkdir -p easytier-web/frontend/dist/config-generator
|
||||
cp -R easytier-web/config-generator/dist/. easytier-web/frontend/dist/config-generator/
|
||||
fi
|
||||
|
||||
@@ -42,4 +42,7 @@ EXPOSE 11011/tcp
|
||||
# wss
|
||||
EXPOSE 11012/tcp
|
||||
|
||||
HEALTHCHECK --interval=30s --timeout=10s --start-period=60s --retries=5 \
|
||||
CMD ["/usr/local/bin/easytier-cli", "--rpc-portal", "127.0.0.1:15888", "--output", "json", "node", "info"]
|
||||
|
||||
ENTRYPOINT ["/sbin/tini", "--", "easytier-core"]
|
||||
|
||||
@@ -36,7 +36,7 @@ jobs:
|
||||
concurrent_skipping: 'same_content_newer'
|
||||
skip_after_successful_duplicate: 'true'
|
||||
cancel_others: 'true'
|
||||
paths: '["Cargo.toml", "Cargo.lock", "easytier/**", ".github/workflows/core.yml", ".github/actions/**", "easytier-web/**"]'
|
||||
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
|
||||
@@ -47,6 +47,7 @@ jobs:
|
||||
- name: Setup Frontend Environment
|
||||
uses: ./.github/actions/prepare-pnpm
|
||||
with:
|
||||
token: ${{ github.token }}
|
||||
build-filter: './easytier-web/*'
|
||||
|
||||
- name: Archive artifact
|
||||
@@ -57,7 +58,7 @@ jobs:
|
||||
easytier-web/frontend/dist/*
|
||||
build:
|
||||
strategy:
|
||||
fail-fast: true
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- TARGET: x86_64-unknown-linux-musl
|
||||
@@ -157,6 +158,9 @@ jobs:
|
||||
|
||||
- uses: mlugg/setup-zig@v2
|
||||
if: ${{ contains(matrix.OS, 'ubuntu') }}
|
||||
with:
|
||||
version: 0.16.0
|
||||
use-cache: true
|
||||
|
||||
- uses: taiki-e/install-action@v2
|
||||
if: ${{ contains(matrix.OS, 'ubuntu') }}
|
||||
@@ -227,7 +231,7 @@ jobs:
|
||||
*) UPX_ARCH="amd64" ;;
|
||||
esac
|
||||
|
||||
UPX_VERSION=5.1.1
|
||||
UPX_VERSION=4.2.4
|
||||
UPX_PKG="upx-${UPX_VERSION}-${UPX_ARCH}_linux"
|
||||
curl -L "https://github.com/upx/upx/releases/download/v${UPX_VERSION}/${UPX_PKG}.tar.xz" -s | tar xJvf -
|
||||
cp "${UPX_PKG}/upx" .
|
||||
|
||||
@@ -11,7 +11,7 @@ on:
|
||||
image_tag:
|
||||
description: 'Tag for this image build'
|
||||
type: string
|
||||
default: 'v2.6.3'
|
||||
default: 'v2.6.4'
|
||||
required: true
|
||||
mark_latest:
|
||||
description: 'Mark this image as latest'
|
||||
|
||||
@@ -35,7 +35,7 @@ jobs:
|
||||
concurrent_skipping: 'same_content_newer'
|
||||
skip_after_successful_duplicate: 'true'
|
||||
cancel_others: 'true'
|
||||
paths: '["Cargo.toml", "Cargo.lock", "easytier/**", "easytier-gui/**", ".github/workflows/gui.yml", ".github/actions/**", "easytier-web/frontend-lib/**"]'
|
||||
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
|
||||
@@ -117,13 +117,92 @@ jobs:
|
||||
find "./easytier/third_party/${ARCH_DIR}" -maxdepth 1 -type f \( -name "*.dll" -o -name "*.sys" \) -exec cp {} ./easytier-gui/src-tauri/ \;
|
||||
fi
|
||||
|
||||
- name: Validate macOS signing secrets
|
||||
if: ${{ contains(matrix.GUI_TARGET, 'darwin') && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository) }}
|
||||
env:
|
||||
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
|
||||
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
|
||||
APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }}
|
||||
APPLE_ID: ${{ secrets.APPLE_ID }}
|
||||
APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }}
|
||||
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
|
||||
run: |
|
||||
missing=()
|
||||
for name in APPLE_CERTIFICATE APPLE_CERTIFICATE_PASSWORD APPLE_SIGNING_IDENTITY APPLE_ID APPLE_PASSWORD APPLE_TEAM_ID; do
|
||||
if [[ -z "${!name}" ]]; then
|
||||
missing+=("$name")
|
||||
fi
|
||||
done
|
||||
if (( ${#missing[@]} )); then
|
||||
printf 'Missing macOS signing secret(s): %s\n' "${missing[*]}" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Build GUI
|
||||
if: ${{ matrix.GUI_TARGET != '' }}
|
||||
if: ${{ matrix.GUI_TARGET != '' && (!contains(matrix.GUI_TARGET, 'darwin') || (github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name != github.repository)) }}
|
||||
uses: tauri-apps/tauri-action@v0
|
||||
with:
|
||||
projectPath: ./easytier-gui
|
||||
args: --verbose --target ${{ matrix.GUI_TARGET }}
|
||||
|
||||
- name: Build GUI (signed and notarized)
|
||||
if: ${{ contains(matrix.GUI_TARGET, 'darwin') && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository) }}
|
||||
timeout-minutes: 60
|
||||
uses: tauri-apps/tauri-action@v0
|
||||
env:
|
||||
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
|
||||
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
|
||||
APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }}
|
||||
APPLE_ID: ${{ secrets.APPLE_ID }}
|
||||
APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }}
|
||||
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
|
||||
with:
|
||||
projectPath: ./easytier-gui
|
||||
args: --verbose --target ${{ matrix.GUI_TARGET }}
|
||||
|
||||
- name: Notarize and staple macOS DMG
|
||||
if: ${{ contains(matrix.GUI_TARGET, 'darwin') && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository) }}
|
||||
timeout-minutes: 45
|
||||
env:
|
||||
APPLE_ID: ${{ secrets.APPLE_ID }}
|
||||
APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }}
|
||||
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
dmg_dir="./target/$GUI_TARGET/release/bundle/dmg"
|
||||
if [[ ! -d "$dmg_dir" ]]; then
|
||||
printf 'macOS DMG directory not found: %s\n' "$dmg_dir" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
dmgs=()
|
||||
while IFS= read -r dmg; do
|
||||
dmgs+=("$dmg")
|
||||
done < <(find "$dmg_dir" -maxdepth 1 -type f -name "*.dmg" | sort)
|
||||
if (( ${#dmgs[@]} == 0 )); then
|
||||
printf 'No macOS DMG found in %s\n' "$dmg_dir" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
for dmg in "${dmgs[@]}"; do
|
||||
printf 'Verifying signed DMG: %s\n' "$dmg"
|
||||
codesign --verify --verbose=4 "$dmg"
|
||||
codesign -dv --verbose=4 "$dmg"
|
||||
|
||||
printf 'Notarizing DMG: %s\n' "$dmg"
|
||||
xcrun notarytool submit "$dmg" \
|
||||
--apple-id "$APPLE_ID" \
|
||||
--password "$APPLE_PASSWORD" \
|
||||
--team-id "$APPLE_TEAM_ID" \
|
||||
--wait \
|
||||
--timeout 40m
|
||||
|
||||
printf 'Stapling DMG: %s\n' "$dmg"
|
||||
xcrun stapler staple "$dmg"
|
||||
xcrun stapler validate "$dmg"
|
||||
done
|
||||
|
||||
- name: Collect artifact
|
||||
run: |
|
||||
mkdir -p ./artifacts/objects/
|
||||
|
||||
@@ -35,7 +35,7 @@ jobs:
|
||||
concurrent_skipping: 'same_content_newer'
|
||||
skip_after_successful_duplicate: 'true'
|
||||
cancel_others: 'true'
|
||||
paths: '["Cargo.toml", "Cargo.lock", "easytier/**", "easytier-gui/**", "tauri-plugin-vpnservice/**", ".github/workflows/mobile.yml", ".github/actions/**"]'
|
||||
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
|
||||
|
||||
+157
-198
@@ -1,246 +1,205 @@
|
||||
name: EasyTier OHOS
|
||||
name: ohos
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: ["develop", "main", "releases/**"]
|
||||
branches: [develop, main, "releases/**", "ohos/**"]
|
||||
tags:
|
||||
- 'v*'
|
||||
- '!*-pre'
|
||||
- "v*"
|
||||
- "!*-pre"
|
||||
pull_request:
|
||||
branches: ["develop", "main"]
|
||||
branches: [develop, main, "ohos/**"]
|
||||
types: [opened, synchronize, reopened, ready_for_review]
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
publish:
|
||||
description: Publish this non-main branch and dispatch downstream builds
|
||||
required: false
|
||||
default: false
|
||||
type: boolean
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
|
||||
cancel-in-progress: true
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: read
|
||||
|
||||
env:
|
||||
CARGO_TERM_COLOR: always
|
||||
|
||||
defaults:
|
||||
run:
|
||||
# necessary for windows
|
||||
shell: bash
|
||||
|
||||
jobs:
|
||||
cargo_fmt_check:
|
||||
ohos:
|
||||
name: ohos
|
||||
if: github.event_name != 'pull_request' || !github.event.pull_request.draft
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
|
||||
- name: Prepare build environment
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v5
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Set up Rust
|
||||
uses: ./.github/actions/prepare-build
|
||||
with:
|
||||
target: aarch64-unknown-linux-ohos
|
||||
gui: false
|
||||
pnpm: false
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- uses: actions-rust-lang/setup-rust-toolchain@v1
|
||||
with:
|
||||
components: rustfmt
|
||||
|
||||
- name: Check formatting
|
||||
working-directory: ./easytier-contrib/easytier-ohrs
|
||||
run: cargo fmt --all -- --check
|
||||
|
||||
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
|
||||
# Map a step output to a job output
|
||||
outputs:
|
||||
# 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: '["Cargo.toml", "Cargo.lock", "easytier/**", "easytier-contrib/easytier-ohrs/**", ".github/workflows/ohos.yml", ".github/actions/**"]'
|
||||
|
||||
build-ohos:
|
||||
runs-on: ubuntu-latest
|
||||
needs: pre_job
|
||||
env:
|
||||
OHPM_PUBLISH_CODE: ${{ secrets.OHPM_PUBLISH_CODE }}
|
||||
if: needs.pre_job.outputs.should_skip != 'true'
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -qq \
|
||||
build-essential \
|
||||
wget \
|
||||
unzip \
|
||||
git \
|
||||
pkg-config curl libgl1-mesa-dev expect
|
||||
|
||||
- name: Resolve easytier version
|
||||
run: |
|
||||
set -e
|
||||
|
||||
UPSTREAM_REPO="https://github.com/EasyTier/EasyTier.git"
|
||||
|
||||
git remote add upstream "$UPSTREAM_REPO" 2>/dev/null || true
|
||||
git fetch --unshallow upstream main || git fetch upstream main
|
||||
git fetch --tags upstream --force
|
||||
|
||||
# 读取 cargo 版本
|
||||
CARGO_VERSION=$(cargo metadata --format-version 1 --no-deps --manifest-path easytier/Cargo.toml \
|
||||
| jq -r '.packages[0].version')
|
||||
|
||||
# 获取 upstream/main 最新 tag
|
||||
LAST_TAG=$(git describe --tags --abbrev=0 upstream/main 2>/dev/null || echo "")
|
||||
LAST_TAG_VERSION="${LAST_TAG#v}"
|
||||
|
||||
# 语义版本比较
|
||||
version_gt() {
|
||||
[ "$(printf '%s\n' "$1" "$2" | sort -V | tail -n1)" = "$1" ] && [ "$1" != "$2" ]
|
||||
}
|
||||
|
||||
if [ -z "$LAST_TAG_VERSION" ]; then
|
||||
BASE_VERSION="$CARGO_VERSION"
|
||||
DIFF_COUNT=$(git rev-list --count upstream/main)
|
||||
elif version_gt "$CARGO_VERSION" "$LAST_TAG_VERSION"; then
|
||||
BASE_VERSION="$CARGO_VERSION"
|
||||
DIFF_COUNT=0
|
||||
else
|
||||
BASE_VERSION="$LAST_TAG_VERSION"
|
||||
DIFF_COUNT=$(git rev-list --count "${LAST_TAG}..upstream/main")
|
||||
fi
|
||||
|
||||
COMMIT_HASH=$(git rev-parse --short upstream/main)
|
||||
EASYTIER_VERSION="${BASE_VERSION}-${DIFF_COUNT}-${COMMIT_HASH}"
|
||||
|
||||
echo "EASYTIER_VERSION=$EASYTIER_VERSION"
|
||||
echo "EASYTIER_VERSION=$EASYTIER_VERSION" >> $GITHUB_ENV
|
||||
|
||||
cd ./easytier-contrib/easytier-ohrs/package
|
||||
jq --arg v "$EASYTIER_VERSION" '.version = $v' oh-package.json5 > oh-package.tmp.json5
|
||||
mv oh-package.tmp.json5 oh-package.json5
|
||||
|
||||
|
||||
- name: Generate CHANGELOG.md for current commit
|
||||
working-directory: ./easytier-contrib/easytier-ohrs/package
|
||||
run: |
|
||||
{
|
||||
echo "## easytier-ohrs ${EASYTIER_VERSION}"
|
||||
echo
|
||||
git log -1 --pretty=format:"- %s"
|
||||
echo
|
||||
} > CHANGELOG.md
|
||||
|
||||
- name: Setup HarmonyOS CLI tools
|
||||
- name: Set up HarmonyOS
|
||||
uses: ErBWs/setup-ohos@v1
|
||||
|
||||
- name: Download and Extract Custom SDK
|
||||
run: |
|
||||
wget https://github.com/FrankHan052176/Easytier-OHOS-sdk/releases/download/v1/ohos-sdk.zip -O /tmp/ohos-sdk.zip
|
||||
sudo unzip -o /tmp/ohos-sdk.zip -d /tmp/custom-sdk
|
||||
sudo cp -rf /tmp/custom-sdk/linux/native/* $OHOS_NDK_HOME/native
|
||||
echo "Custom SDK files deployed to $OHOS_NDK_HOME/native"
|
||||
ls -a $OHOS_NDK_HOME/native
|
||||
|
||||
- name: Setup build environment
|
||||
run: |
|
||||
echo "TARGET_ARCH=aarch64-linux-ohos" >> $GITHUB_ENV
|
||||
|
||||
rustup install stable
|
||||
rustup default stable
|
||||
|
||||
rustup target add aarch64-unknown-linux-ohos
|
||||
|
||||
- uses: taiki-e/install-action@v2
|
||||
- name: Install ohrs
|
||||
uses: taiki-e/install-action@v2
|
||||
with:
|
||||
tool: ohrs
|
||||
|
||||
- name: Create clang wrapper script
|
||||
- name: Build HAR
|
||||
id: package
|
||||
env:
|
||||
DEFAULT_BRANCH: ${{ github.event.repository.default_branch }}
|
||||
run: |
|
||||
sudo mkdir -p $OHOS_NDK_HOME/native/llvm
|
||||
sudo tee $OHOS_NDK_HOME/native/llvm/aarch64-unknown-linux-ohos-clang.sh > /dev/null <<'EOF'
|
||||
set -euo pipefail
|
||||
sudo apt-get install -qqy \
|
||||
pkg-config curl libgl1-mesa-dev expect llvm clang lldb lld
|
||||
rustup component add rustfmt
|
||||
cargo fmt --all --manifest-path \
|
||||
easytier-contrib/easytier-ohrs/Cargo.toml -- --check
|
||||
|
||||
cargo_version=$(cargo metadata --format-version 1 --no-deps \
|
||||
--manifest-path easytier/Cargo.toml | jq -r '.packages[0].version')
|
||||
last_tag=$(git describe --tags --abbrev=0 HEAD 2>/dev/null || true)
|
||||
if [ -n "$last_tag" ]; then
|
||||
base_version=$(printf '%s\n' "$cargo_version" "${last_tag#v}" \
|
||||
| sort -V | tail -n 1)
|
||||
commit_count=$(git rev-list --count "$last_tag..HEAD")
|
||||
else
|
||||
base_version=$cargo_version
|
||||
commit_count=0
|
||||
fi
|
||||
|
||||
source_branch=${GITHUB_HEAD_REF:-}
|
||||
if [ -z "$source_branch" ]; then
|
||||
if [ "$GITHUB_REF_TYPE" = branch ]; then
|
||||
source_branch=$GITHUB_REF_NAME
|
||||
else
|
||||
source_branch=${DEFAULT_BRANCH:-main}
|
||||
fi
|
||||
fi
|
||||
branch_id=$(printf '%s' "$source_branch" \
|
||||
| tr '[:upper:]' '[:lower:]' \
|
||||
| sed -E 's/[^a-z0-9-]+/-/g; s/^-+//; s/-+$//' \
|
||||
| cut -c1-64)
|
||||
branch_id=${branch_id:-main}
|
||||
|
||||
package_name=easytier-ohrs
|
||||
package_version="${base_version}-${branch_id}-${commit_count}-${GITHUB_RUN_NUMBER}-${GITHUB_RUN_ATTEMPT}-g$(git rev-parse --short=8 HEAD)"
|
||||
echo "name=$package_name" >> "$GITHUB_OUTPUT"
|
||||
echo "EASYTIER_PACKAGE_NAME=$package_name" >> "$GITHUB_ENV"
|
||||
echo "EASYTIER_VERSION=$package_version" >> "$GITHUB_ENV"
|
||||
|
||||
package_dir=easytier-contrib/easytier-ohrs/package
|
||||
jq --arg name "$package_name" --arg version "$package_version" \
|
||||
'.name = $name | .version = $version' \
|
||||
"$package_dir/oh-package.json5" > "$package_dir/oh-package.tmp.json5"
|
||||
mv "$package_dir/oh-package.tmp.json5" "$package_dir/oh-package.json5"
|
||||
{
|
||||
echo "## $package_name $package_version"
|
||||
echo
|
||||
echo "- Core version: $base_version"
|
||||
echo "- Core commit: $GITHUB_SHA"
|
||||
git log -1 --pretty=format:'- %s'
|
||||
echo
|
||||
} > "$package_dir/CHANGELOG.md"
|
||||
|
||||
sudo mkdir -p "$OHOS_NDK_HOME/native/llvm"
|
||||
sudo tee "$OHOS_NDK_HOME/native/llvm/aarch64-unknown-linux-ohos-clang.sh" >/dev/null <<'EOF'
|
||||
#!/bin/sh
|
||||
exec $OHOS_NDK_HOME/native/llvm/bin/clang \
|
||||
exec "$OHOS_NDK_HOME/native/llvm/bin/clang" \
|
||||
-target aarch64-linux-ohos \
|
||||
--sysroot=$OHOS_NDK_HOME/native/sysroot \
|
||||
-D__MUSL__ \
|
||||
"$@"
|
||||
--sysroot="$OHOS_NDK_HOME/native/sysroot" \
|
||||
-D__MUSL__ "$@"
|
||||
EOF
|
||||
sudo chmod +x $OHOS_NDK_HOME/native/llvm/aarch64-unknown-linux-ohos-clang.sh
|
||||
sudo chmod +x \
|
||||
"$OHOS_NDK_HOME/native/llvm/aarch64-unknown-linux-ohos-clang.sh"
|
||||
|
||||
- name: Build latest Har
|
||||
working-directory: ./easytier-contrib/easytier-ohrs
|
||||
run: |
|
||||
sudo apt-get install -y llvm clang lldb lld
|
||||
sudo apt-get install -y protobuf-compiler
|
||||
cd easytier-contrib/easytier-ohrs
|
||||
source env.sh
|
||||
ohrs doctor
|
||||
ohrs build --release --arch aarch
|
||||
ohrs artifact
|
||||
mv package.har easytier-ohrs.har
|
||||
mv package.har "$package_name.har"
|
||||
|
||||
- name: Build Release Package
|
||||
if: startsWith(github.ref, 'refs/tags/')
|
||||
working-directory: ./easytier-contrib/easytier-ohrs
|
||||
run: |
|
||||
echo "🎉 Official Release detected. Building easytier-release..."
|
||||
TAG_NAME="${{ github.ref_name }}"
|
||||
TAG_VERSION="${TAG_NAME#v}"
|
||||
echo "Release Version: $TAG_VERSION"
|
||||
cd package
|
||||
jq --arg v "$TAG_VERSION" '.name = "easytier-release" | .version = $v' oh-package.json5 > oh-package.tmp.json5 && mv oh-package.tmp.json5 oh-package.json5
|
||||
cd ..
|
||||
ohrs build --release --arch aarch
|
||||
cd dist/arm64-v8a
|
||||
mv libeasytier_ohrs.so libeasytier_release.so
|
||||
cd ../..
|
||||
ohrs artifact
|
||||
mv package.har easytier-release.har
|
||||
|
||||
- name: Upload artifact
|
||||
- name: Upload HAR
|
||||
uses: actions/upload-artifact@v5
|
||||
with:
|
||||
name: easytier-ohos
|
||||
path: |
|
||||
./easytier-contrib/easytier-ohrs/easytier-ohrs.har
|
||||
name: ${{ steps.package.outputs.name }}
|
||||
path: easytier-contrib/easytier-ohrs/${{ steps.package.outputs.name }}.har
|
||||
retention-days: 5
|
||||
if-no-files-found: error
|
||||
|
||||
- name: Publish To Center Ohpm
|
||||
working-directory: ./easytier-contrib/easytier-ohrs
|
||||
- name: Publish and dispatch
|
||||
if: >-
|
||||
(github.event_name == 'push' &&
|
||||
github.ref_type == 'branch' &&
|
||||
github.ref_name == 'main' &&
|
||||
github.event.forced != true) ||
|
||||
(github.event_name == 'workflow_dispatch' &&
|
||||
github.ref_type == 'branch' &&
|
||||
(github.ref_name == 'main' || inputs.publish))
|
||||
working-directory: easytier-contrib/easytier-ohrs
|
||||
env:
|
||||
OHPM_PRIVATE_KEY: ${{ secrets.OHPM_PRIVATE_KEY }}
|
||||
OHPM_KEY_PASSPHRASE: ${{ secrets.OHPM_KEY_PASSPHRASE }}
|
||||
if: ${{ env.OHPM_PUBLISH_CODE != '' && github.event_name == 'push' }}
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
CODEARTS_PRIVATE_OHPM: ${{ secrets.CODEARTS_PRIVATE_OHPM }}
|
||||
DOWNSTREAM_DISPATCH_TOKEN: ${{ secrets.DOWNSTREAM_DISPATCH_TOKEN }}
|
||||
run: |
|
||||
ohpm config set publish_id "$OHPM_PUBLISH_CODE"
|
||||
ohpm config set publish_registry https://ohpm.openharmony.cn/ohpm
|
||||
TMP_DIR=$(mktemp -d)
|
||||
PRIVATE_KEY_FILE="$TMP_DIR/private_key"
|
||||
printf '%s' "$OHPM_PRIVATE_KEY" > "$PRIVATE_KEY_FILE"
|
||||
chmod 600 "$PRIVATE_KEY_FILE"
|
||||
ohpm config set key_path $PRIVATE_KEY_FILE
|
||||
unzip ohpm_crypto.zip -d /home/runner/work/
|
||||
ohpm config set crypto_path /home/runner/work/ohpm_crypto
|
||||
chmod 755 /home/runner/work/ohpm_crypto/*
|
||||
PASSPHRASE="$(printf '%s' "$OHPM_KEY_PASSPHRASE" | tr -d '\r\n')"
|
||||
ohpm config set key_passphrase "$PASSPHRASE"
|
||||
ohpm publish easytier-ohrs.har
|
||||
|
||||
- name: Publish To Private Ohpm
|
||||
working-directory: ./easytier-contrib/easytier-ohrs
|
||||
if: ${{ env.OHPM_PUBLISH_CODE != '' && github.event_name == 'push' }}
|
||||
run: |
|
||||
printf '%s' "${{ secrets.CODEARTS_PRIVATE_OHPM }}" > ~/.ohpm/.ohpmrc
|
||||
ohpm config set strict_ssl false
|
||||
ohpm publish easytier-ohrs.har
|
||||
if [ -f "easytier-release.har" ]; then
|
||||
echo "🚀 Publishing Release package..."
|
||||
ohpm publish easytier-release.har
|
||||
set -euo pipefail
|
||||
if [ "$GITHUB_EVENT_NAME" = push ]; then
|
||||
pull_requests=$(gh api \
|
||||
-H "Accept: application/vnd.github+json" \
|
||||
"/repos/$GITHUB_REPOSITORY/commits/$GITHUB_SHA/pulls")
|
||||
if ! jq -e \
|
||||
--arg repository "$GITHUB_REPOSITORY" \
|
||||
--arg branch "$GITHUB_REF_NAME" \
|
||||
--arg sha "$GITHUB_SHA" \
|
||||
'any(.[];
|
||||
.merged_at != null and
|
||||
.base.repo.full_name == $repository and
|
||||
.base.ref == $branch and
|
||||
.merge_commit_sha == $sha)' \
|
||||
<<< "$pull_requests" >/dev/null; then
|
||||
echo "Direct push: HAR built without publishing."
|
||||
exit 0
|
||||
fi
|
||||
fi
|
||||
curl --header "Content-Type: application/json" --request POST --data "{}" ${{ secrets.CODEARTS_WEBHOOKS }}
|
||||
|
||||
mkdir -p "$HOME/.ohpm"
|
||||
umask 077
|
||||
printf '%s' "$CODEARTS_PRIVATE_OHPM" > "$HOME/.ohpm/.ohpmrc"
|
||||
trap 'rm -f "$HOME/.ohpm/.ohpmrc"' EXIT
|
||||
ohpm publish "$EASYTIER_PACKAGE_NAME.har"
|
||||
|
||||
payload=$(jq -nc \
|
||||
--arg repository "$GITHUB_REPOSITORY" \
|
||||
--arg ref "refs/heads/$GITHUB_REF_NAME" \
|
||||
--arg package "$EASYTIER_PACKAGE_NAME" \
|
||||
'{
|
||||
event_type: "core-har-published",
|
||||
client_payload: {
|
||||
core_repository: $repository,
|
||||
core_ref: $ref,
|
||||
package_name: $package
|
||||
}
|
||||
}')
|
||||
for repository in \
|
||||
FrankHan052176/EasyTier-ArkTS \
|
||||
FrankHan052176/easytier-pro-app; do
|
||||
curl --fail-with-body --silent --show-error \
|
||||
-X POST \
|
||||
-H "Accept: application/vnd.github+json" \
|
||||
-H "Authorization: Bearer $DOWNSTREAM_DISPATCH_TOKEN" \
|
||||
-H "X-GitHub-Api-Version: 2022-11-28" \
|
||||
"$GITHUB_API_URL/repos/$repository/dispatches" \
|
||||
--data "$payload"
|
||||
done
|
||||
|
||||
@@ -18,7 +18,7 @@ on:
|
||||
version:
|
||||
description: 'Version for this release'
|
||||
type: string
|
||||
default: 'v2.6.3'
|
||||
default: 'v2.6.4'
|
||||
required: true
|
||||
make_latest:
|
||||
description: 'Mark this release as latest'
|
||||
|
||||
@@ -34,7 +34,7 @@ jobs:
|
||||
# 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: '["Cargo.toml", "Cargo.lock", "easytier/**", ".github/workflows/test.yml", ".github/actions/**"]'
|
||||
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
|
||||
@@ -98,7 +98,9 @@ jobs:
|
||||
- uses: taiki-e/install-action@nextest
|
||||
|
||||
- name: Archive test
|
||||
run: cargo nextest archive --archive-file tests.tar.zst --package easytier --features full
|
||||
run: >-
|
||||
cargo nextest archive --archive-file tests.tar.zst
|
||||
--package easytier --package easytier-core --features full
|
||||
|
||||
- uses: actions/upload-artifact@v5
|
||||
with:
|
||||
@@ -136,6 +138,11 @@ jobs:
|
||||
- name: Setup system for test
|
||||
run: |
|
||||
sudo modprobe br_netfilter
|
||||
sudo modprobe tun
|
||||
if [ ! -e /dev/net/tun ]; then
|
||||
sudo mkdir -p /dev/net
|
||||
sudo mknod /dev/net/tun c 10 200
|
||||
fi
|
||||
sudo sysctl net.bridge.bridge-nf-call-iptables=0
|
||||
sudo sysctl net.bridge.bridge-nf-call-ip6tables=0
|
||||
sudo sysctl net.ipv6.conf.lo.disable_ipv6=0
|
||||
@@ -151,7 +158,8 @@ jobs:
|
||||
- name: Run tests
|
||||
run: |
|
||||
sudo prlimit --pid $$ --nofile=1048576:1048576
|
||||
sudo -E env "PATH=$PATH" cargo nextest run --archive-file tests.tar.zst ${{ matrix.opts }}
|
||||
sudo -E env "PATH=$PATH" EASYTIER_LINUX_BPF_INTEGRATION=required \
|
||||
cargo nextest run --archive-file tests.tar.zst ${{ matrix.opts }}
|
||||
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
@@ -34,6 +34,9 @@ easytier-panic.log
|
||||
|
||||
# web
|
||||
node_modules
|
||||
easytier-web/frontend-lib/src/generated/
|
||||
easytier-web/config-generator/dist/
|
||||
easytier-web/config-generator/src/generated/
|
||||
|
||||
.vite
|
||||
|
||||
@@ -43,3 +46,6 @@ easytier-gui/src-tauri/*.sys
|
||||
|
||||
.direnv
|
||||
.flake-profile
|
||||
|
||||
# contrib
|
||||
go.sum
|
||||
|
||||
+30
@@ -0,0 +1,30 @@
|
||||
# EasyTier Domain Context
|
||||
|
||||
## Module layers
|
||||
|
||||
`easytier-core` layers dependencies from `foundation` upward through the
|
||||
portable networking domains. `foundation` contains infrastructure Modules
|
||||
that have no dependency on a networking domain and may be used by any higher
|
||||
layer.
|
||||
|
||||
## Operation broker
|
||||
|
||||
An operation broker owns the lifecycle of asynchronous work submitted by an
|
||||
external caller to core. It allocates opaque operation IDs, arbitrates
|
||||
completion, cancellation, and disposal, retains terminal outcomes, and
|
||||
publishes a batch-drainable completion queue.
|
||||
|
||||
The broker does not interpret operation kinds, outcomes, resources, wire
|
||||
formats, or domain errors. Each domain Module owns those semantics and composes
|
||||
the broker under the same lock as any state that must change atomically with an
|
||||
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.
|
||||
|
||||
## 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.
|
||||
+12
-1
@@ -113,6 +113,17 @@ cargo build --release --target x86_64-pc-windows-msvc # Windows x86_64
|
||||
|
||||
Build artifacts: `target/[target-triple]/release/`
|
||||
|
||||
### Building the WASI core
|
||||
|
||||
```bash
|
||||
script/build-wasi-core.sh
|
||||
```
|
||||
|
||||
This builds the `easytier-core` Go-host profile for `wasm32-wasip1`, then
|
||||
optimizes it with the pinned official Binaryen release. Binaryen is downloaded
|
||||
once into `target/binaryen/` and verified by SHA-256; set `WASM_OPT` to use an
|
||||
existing matching binary.
|
||||
|
||||
### Building GUI
|
||||
|
||||
```bash
|
||||
@@ -222,4 +233,4 @@ Feel free to:
|
||||
- Join our community discussions
|
||||
- Reach out to maintainers
|
||||
|
||||
Thank you for contributing to EasyTier!
|
||||
Thank you for contributing to EasyTier!
|
||||
|
||||
Generated
+443
-321
File diff suppressed because it is too large
Load Diff
+8
-1
@@ -1,10 +1,12 @@
|
||||
[workspace]
|
||||
resolver = "2"
|
||||
members = [
|
||||
"easytier-core",
|
||||
"easytier-proto",
|
||||
"easytier",
|
||||
"easytier-gui/src-tauri",
|
||||
"easytier-rpc-build",
|
||||
"easytier-web",
|
||||
"easytier-contrib/easytier-mini",
|
||||
"easytier-contrib/easytier-ffi",
|
||||
"easytier-contrib/easytier-uptime",
|
||||
"easytier-contrib/easytier-android-jni",
|
||||
@@ -28,3 +30,8 @@ lto = true
|
||||
codegen-units = 1
|
||||
opt-level = 3
|
||||
strip = true
|
||||
|
||||
[profile.mini]
|
||||
inherits = "release"
|
||||
opt-level = "z"
|
||||
strip = "symbols"
|
||||
|
||||
@@ -0,0 +1,529 @@
|
||||
# EasyTier Core Architecture
|
||||
|
||||
## Status and scope
|
||||
|
||||
This document describes the current architecture after the portable-core
|
||||
refactor. It is the source of truth for ownership, dependency direction,
|
||||
feature boundaries, and validation. It intentionally records the resulting
|
||||
design rather than the migration history.
|
||||
|
||||
The refactor has three principal crate roles:
|
||||
|
||||
- `easytier-core` owns portable EasyTier configuration, protocol state,
|
||||
routing, peer state, connectivity orchestration, packet processing, and
|
||||
instance lifecycle.
|
||||
- `easytier` is the native composition root. It owns operating-system
|
||||
resources, native protocol engines, process integration, CLI and native
|
||||
presentation.
|
||||
- `easytier-proto` owns generated protobuf and RPC types, descriptor data, and
|
||||
the feature slices needed by core and presentation users.
|
||||
|
||||
`easytier-core` is designed to compile without direct operating-system network
|
||||
access. It supports native hosts through Rust traits and has a target-only WASI
|
||||
adapter and ABI implementation under `easytier-core/src/wasi`.
|
||||
|
||||
This architecture does not require compatibility with old internal module
|
||||
paths. Wire compatibility, configuration compatibility, management semantics,
|
||||
and externally used application behaviour remain compatibility requirements.
|
||||
|
||||
## Architectural vocabulary
|
||||
|
||||
The following terms have specific meanings in this document:
|
||||
|
||||
- **Module**: an interface and the implementation hidden behind it.
|
||||
- **Host**: the process or runtime embedding core and owning platform
|
||||
resources.
|
||||
- **Host capability**: an operation core may request but must not implement
|
||||
with direct OS calls.
|
||||
- **Adapter**: a concrete implementation of a Host capability or protocol
|
||||
extension.
|
||||
- **Composition root**: code that creates core configuration, Host Adapters,
|
||||
instances, and process-level services.
|
||||
- **Runtime configuration**: the authoritative normalized state used after an
|
||||
instance starts.
|
||||
- **Packet plane**: portable packet classification, routing, transformation,
|
||||
proxy/NAT state, and forwarding decisions.
|
||||
|
||||
New abstractions should pass a deletion test: deleting a useful deep Module
|
||||
should force non-trivial policy or lifecycle logic to reappear in multiple
|
||||
callers. A pass-through wrapper with no independent invariant is not an
|
||||
architectural boundary.
|
||||
|
||||
## Crate dependency direction
|
||||
|
||||
The principal dependency direction is:
|
||||
|
||||
```text
|
||||
easytier-proto <- easytier-core <- easytier
|
||||
```
|
||||
|
||||
Presentation crates and platform integrations consume these crates. Portable
|
||||
policy must not move outward merely because one current consumer is native.
|
||||
Conversely, core must not absorb an OS mechanism or a protocol engine whose
|
||||
dependencies cannot satisfy the core target contract.
|
||||
|
||||
### `easytier-proto`
|
||||
|
||||
The protobuf crate is split by public Cargo features:
|
||||
|
||||
- `core` provides the common wire messages, peer RPC messages, generated RPC
|
||||
runtime, and descriptor bytes needed by core.
|
||||
- `api` adds management API messages.
|
||||
- protocol-specific features add only their generated message modules.
|
||||
- `json-rpc` enables the well-known protobuf JSON types used by the management
|
||||
plane.
|
||||
- `full` is the compatibility aggregate used by complete products.
|
||||
|
||||
The core crate depends on `easytier-proto` with default features disabled and
|
||||
enables only `core`, adding API or JSON-RPC types through its own management
|
||||
features.
|
||||
|
||||
The main core/native path has no `prost-reflect` dependency. OSPF route
|
||||
reflection uses the focused wire editor in
|
||||
`peers/route/route_peer_wire.rs`. It retains the original encoded
|
||||
`RoutePeerInfo`, replaces only the fields credential filtering is allowed to
|
||||
change, and leaves all other top-level and nested fields intact. This is
|
||||
required so unknown fields survive mixed-version, multi-hop propagation.
|
||||
Generated Rust types remain responsible for normal message construction and
|
||||
validation.
|
||||
|
||||
Descriptor sets are still generated and embedded by `easytier-proto`; removing
|
||||
runtime reflection did not remove descriptor data used by configuration and
|
||||
RPC tooling. The OHOS integration has its own schema service and dependency
|
||||
policy and is outside this replacement.
|
||||
|
||||
### `easytier-core`
|
||||
|
||||
Core owns portable behaviour and exposes capability seams. Its normal
|
||||
dependencies use Tokio runtime, time, synchronization, and I/O traits without
|
||||
requiring the full Tokio feature set.
|
||||
|
||||
Core may depend on optional portable engines when their owning feature is
|
||||
enabled. It does not create real native TCP/UDP sockets, alter routes, open a
|
||||
TUN device, enter a network namespace, configure system DNS, manage a service,
|
||||
or invoke UPnP/NAT-PMP directly.
|
||||
|
||||
### `easytier`
|
||||
|
||||
The native crate owns:
|
||||
|
||||
- process startup, shutdown, signals, service management, and allocators;
|
||||
- filesystem configuration input and persistence;
|
||||
- real TCP/UDP, DNS, TUN, raw-socket, route, interface, namespace, and socket
|
||||
option operations;
|
||||
- UPnP and NAT-PMP operations;
|
||||
- Unix and FakeTCP resources;
|
||||
- WebSocket/WSS, QUIC, WireGuard, and KCP concrete engines;
|
||||
- native Magic DNS serving and system DNS integration;
|
||||
- CLI, web, GUI, FFI, and native management presentation.
|
||||
|
||||
Native code may translate values and assemble Adapters. It must not maintain a
|
||||
second peer graph, reproduce core routing or hole-punch policy, or invent an
|
||||
alternative instance lifecycle.
|
||||
|
||||
## Internal core layers
|
||||
|
||||
The physical module layout follows this downward order:
|
||||
|
||||
```text
|
||||
foundation
|
||||
<- config / packet
|
||||
<- socket
|
||||
<- host
|
||||
<- tunnel
|
||||
<- listener / connectivity
|
||||
<- peers / rpc
|
||||
<- gateway
|
||||
<- instance
|
||||
<- management
|
||||
```
|
||||
|
||||
`process_runtime` is a process- or module-scoped owner shared by instances.
|
||||
`wasi` is target integration and is compiled only for tests or the WASI target;
|
||||
it is not an additional portable domain layer.
|
||||
|
||||
### Foundation
|
||||
|
||||
`foundation/` contains task supervision, the time facade, rate limiting,
|
||||
statistics primitives, and the domain-neutral external operation broker. The
|
||||
broker owns asynchronous operation lifecycle and completion storage while the
|
||||
calling domain owns operation kinds, outcomes, resources, and errors.
|
||||
Foundation must not depend on a domain layer.
|
||||
|
||||
### Configuration and packets
|
||||
|
||||
`config/` owns:
|
||||
|
||||
- the complete `TomlConfig` model;
|
||||
- parsing, serialization, and validation;
|
||||
- OS-independent defaults;
|
||||
- peer, encryption, gateway, and API input models;
|
||||
- normalized runtime snapshots and the live runtime configuration store.
|
||||
|
||||
The Host supplies platform facts through `CoreInstanceHostConfig`. Core applies
|
||||
the policy that combines those facts with TOML input. This is especially
|
||||
important for a WASI build: the compile-time guest target cannot be used as a
|
||||
proxy for the Host operating system.
|
||||
|
||||
`packet/` owns EasyTier packet structures, compression, STUN and hole-punch
|
||||
wire codecs. It does not own socket I/O or connection policy.
|
||||
|
||||
### Socket and Host seams
|
||||
|
||||
`socket/` contains transport-neutral primitives:
|
||||
|
||||
- `SocketContext`, including IP-family policy, optional socket mark, and an
|
||||
opaque network-namespace token;
|
||||
- virtual TCP socket, listener, and factory traits;
|
||||
- virtual UDP socket and factory traits;
|
||||
- UDP session multiplexing, classification, and lifecycle;
|
||||
- in-process Ring sockets.
|
||||
|
||||
`host/` is the single home of Host capability seams:
|
||||
|
||||
- DNS and DNS record resolution;
|
||||
- connector environment observations;
|
||||
- packet ingress and egress;
|
||||
- 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.
|
||||
|
||||
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
|
||||
instance-host projection may expose listener and interface facts, but it does
|
||||
not become another socket factory.
|
||||
|
||||
### Tunnel and listener
|
||||
|
||||
A socket is a raw communication endpoint. A Tunnel is an EasyTier connection
|
||||
created by adding framing, metadata, handshakes, and protocol lifecycle.
|
||||
|
||||
Core owns:
|
||||
|
||||
- raw TCP framing and upgrade;
|
||||
- UDP tunnel/session framing and classification;
|
||||
- Ring Tunnel identity and registry state;
|
||||
- encryption and secure-datagram policy that is portable;
|
||||
- client/server protocol selection interfaces;
|
||||
- listener planning, optional/required listener policy, retry, accept
|
||||
scheduling, running-listener registry, and orderly shutdown.
|
||||
|
||||
Native protocol Adapters own WebSocket/WSS, QUIC, WireGuard, and KCP engines.
|
||||
Unix and FakeTCP are socket resources that feed a core protocol upgrader; they
|
||||
are not independent owners of EasyTier peer state.
|
||||
|
||||
Each protocol registration must provide a coherent client/server Adapter.
|
||||
Unavailable configured transports must be rejected during validation or
|
||||
protocol selection in the standard runtime, rather than silently falling back
|
||||
to another transport. A compact compatibility Host may instead retain the
|
||||
desired value for management readback and omit it from normalized runtime
|
||||
state; it must not advertise or partially activate the unavailable transport.
|
||||
|
||||
### Connectivity
|
||||
|
||||
`connectivity/` owns:
|
||||
|
||||
- manual connection and endpoint discovery policy;
|
||||
- direct candidate selection;
|
||||
- retry, backoff, blacklists, and listener reuse;
|
||||
- STUN requests, responses, probing, NAT inference, and published endpoint
|
||||
state;
|
||||
- TCP and UDP hole-punch state machines;
|
||||
- UDP port-mapping policy and lease lifecycle;
|
||||
- conversion of successful sockets into protocol-upgrade requests.
|
||||
|
||||
The Host owns DNS execution, socket syscalls, interface enumeration, bind
|
||||
device/mark/namespace operations, and concrete UPnP/NAT-PMP calls. STUN-only
|
||||
hole punching remains available when the Host does not supply a port-mapping
|
||||
Adapter.
|
||||
|
||||
Some connectivity files intentionally implement peer-facing adapter traits for
|
||||
`PeerManagerCore`. These are localized integration edges between adjacent
|
||||
domains, not permission for lower socket or Host layers to depend on peers.
|
||||
|
||||
### Peers and RPC
|
||||
|
||||
`peers/` is the authoritative owner of:
|
||||
|
||||
- admission and connection sessions;
|
||||
- peer maps and connection lifecycle;
|
||||
- ACL and whitelist decisions;
|
||||
- OSPF route calculation and graph algorithms;
|
||||
- peer and credential RPC registration;
|
||||
- foreign-network admission, identity, relay, and lifecycle;
|
||||
- peer-center state and public IPv6 policy;
|
||||
- traffic metrics and peer snapshots.
|
||||
|
||||
Submodules progress from kernel types and utilities, through ACL/context,
|
||||
connection state, route state, manager services, and finally foreign-network
|
||||
and peer-center composition. Callers consume the public surface declared by
|
||||
the domain rather than reaching into a parallel native peer owner.
|
||||
|
||||
`rpc/` owns the peer-flavoured RPC transport, packet fragmentation, client and
|
||||
server lifecycle, handler registry, and standalone listener/client lifecycle.
|
||||
Generated service descriptors and message types remain in `easytier-proto`.
|
||||
|
||||
### Gateway
|
||||
|
||||
`gateway/` owns portable packet-plane features:
|
||||
|
||||
- proxy CIDR state and monitoring policy;
|
||||
- packet parsing, reassembly, NAT/proxy state, and TCP/UDP/ICMP decisions;
|
||||
- the smoltcp-backed portable dataplane selected by its feature;
|
||||
- SOCKS5 framing, authentication, association, routing, and session state;
|
||||
- wrapped-transport planning and session state used by KCP and QUIC Adapters;
|
||||
- DHCP allocation policy;
|
||||
- Magic DNS route and response policy;
|
||||
- VPN portal client/session policy;
|
||||
- UDP broadcast classification and rewrite policy.
|
||||
|
||||
TUN, raw sockets, transparent-destination lookup, concrete protocol engines,
|
||||
native DNS servers, namespace operations, and route application stay in native
|
||||
Adapters.
|
||||
|
||||
Optional gateway capabilities are selected by cohesive Modules. Disabled
|
||||
implementations retain stable lifecycle calls and report unsupported
|
||||
configuration in the standard runtime. A compact compatibility Host may
|
||||
silently normalize those settings to no-ops while preserving the desired TOML
|
||||
model; disabled implementations do not duplicate portable policy.
|
||||
|
||||
The instance-scoped `DataPlaneSession` composes the foundation operation broker
|
||||
under the same session lock as its resource and quota state. The broker owns
|
||||
generic completion, cancellation, free, drain, and take transitions. The data
|
||||
plane retains TCP/UDP resource ownership, operation metadata, route deadlines,
|
||||
and error semantics.
|
||||
|
||||
The proposed restructuring of the smoltcp data plane, SOCKS5 and port-forward
|
||||
Adapters, portable KCP engine, event-driven FFI/WASI completion model, and Go
|
||||
Host integration is tracked in
|
||||
[`data-plane-runtime-plan.md`](data-plane-runtime-plan.md). That document is a
|
||||
future implementation plan; this document remains the source of truth for the
|
||||
currently implemented architecture until the plan is completed.
|
||||
|
||||
### Instance and management
|
||||
|
||||
`CoreInstance::new(CoreInstanceConfig, CoreHostAdapters)` is the sole direct
|
||||
construction path for a normalized instance. `CoreInstance::from_toml` uses
|
||||
the same normalization and construction path. Core constructs the peer graph,
|
||||
runtime store, STUN collector, connectivity managers, listener runtime, packet
|
||||
plane, gateway runtimes, and lifecycle owners.
|
||||
|
||||
A core instance:
|
||||
|
||||
- owns all mutable portable state for one network;
|
||||
- is one-shot after `stop`;
|
||||
- exposes one complete `start` and one `stop` lifecycle interface;
|
||||
- starts Modules in a fixed serial composition order without cross-Module
|
||||
started flags or staged activation;
|
||||
- installs initial ACL, proxy CIDR, and manual-peer inputs before startup;
|
||||
- serializes lifecycle operations with one instance-level operation lock;
|
||||
- owns cooperative cancellation and component shutdown order;
|
||||
- exposes `CorePacketPlane` as the narrow packet/route projection used by Host
|
||||
dataplane Adapters;
|
||||
- treats its normalized runtime store as authoritative after construction.
|
||||
|
||||
`CoreHostAdapters` contains the required Host, DNS, packet sink, and
|
||||
`CoreProcessRuntime`, plus optional protocol and platform capabilities. The
|
||||
bundle carries capabilities, not preconstructed portable managers.
|
||||
|
||||
Each Module owns partial-start cleanup for its internal resources.
|
||||
`CoreInstance` has one outer cancellation and recovery path for the complete
|
||||
serial startup. `Running` therefore means the Host runtime and every enabled
|
||||
portable Module have started successfully; there is no separate post-Host
|
||||
activation state. Host packet tasks stop before PeerManager resources are
|
||||
cleared.
|
||||
|
||||
`InstanceManager<F>` is the canonical UUID-indexed instance collection for one
|
||||
Host composition. Its `InstanceFactory` constructs one complete record before
|
||||
the manager performs an atomic uniqueness check. The manager owns collection
|
||||
membership; it does not own startup order, persistence, daemon policy, cached
|
||||
errors, ABI handles, or RPC projections.
|
||||
|
||||
`management/` consumes the canonical manager and instances. It owns:
|
||||
|
||||
- stable UUID/name selection;
|
||||
- read-only instance and peer management RPC;
|
||||
- full process mutation and configuration transactions when enabled;
|
||||
- persistence and logger-control capability interfaces;
|
||||
- management listener/client lifecycle and JSON-RPC presentation.
|
||||
|
||||
There is one process-level management entry. Instances and the manager do not
|
||||
depend on management response projections.
|
||||
|
||||
## Process-scoped state
|
||||
|
||||
`CoreProcessRuntime` owns portable resources shared across instances in one
|
||||
process or instantiated module:
|
||||
|
||||
- the Ring Tunnel registry and namespace;
|
||||
- a reference-counted protected TCP-port registry.
|
||||
|
||||
The composition root creates and shares one runtime. Management listener ports
|
||||
are protected before bind and held by leases after the concrete port is known.
|
||||
Native and target adapters supply bound resources but do not implement a
|
||||
second protected-port registry.
|
||||
|
||||
Process-global capability objects may contain stateless or shared platform
|
||||
mechanisms. They must not contain instance-specific peer, route,
|
||||
configuration, or connectivity state.
|
||||
|
||||
## Runtime configuration authority
|
||||
|
||||
`TomlConfig` is the authoritative desired configuration used for management
|
||||
readback and patch transactions. Compact Hosts keep unsupported accepted values
|
||||
there so controllers observe the configuration they submitted.
|
||||
|
||||
The separately typed, normalized core runtime store is authoritative for live
|
||||
behavior:
|
||||
|
||||
- peer feature flags and routing policy;
|
||||
- listeners and initial peers;
|
||||
- ACL and whitelist inputs;
|
||||
- manual and VPN portal CIDRs;
|
||||
- gateway and connectivity settings;
|
||||
- runtime configuration patches.
|
||||
|
||||
Host persistence is an effect following a successful core transaction. A Host
|
||||
Adapter must not call back into an instance to obtain a hidden configuration
|
||||
snapshot while core is applying an operation.
|
||||
|
||||
Non-serializable resources such as TUN descriptors, packet sinks, execution
|
||||
domains, and native protocol engines are construction context, not TOML
|
||||
fields.
|
||||
|
||||
## Logging
|
||||
|
||||
The main native runtime uses a small logger implemented in
|
||||
`easytier/src/common/log`:
|
||||
|
||||
- `log` records and `tracing` events share console and file sinks;
|
||||
- timestamps, compact formatting, optional terminal colours, `NO_COLOR`, and
|
||||
basic `RUST_LOG` target/level filters are implemented directly;
|
||||
- file rotation uses the existing EasyTier rolling appender;
|
||||
- management RPC can reload the file level;
|
||||
- an atomic maximum-level gate rejects disabled events before target matching
|
||||
or file-filter locking;
|
||||
- concurrent file-level reload serializes the filter and atomic-level update.
|
||||
|
||||
File logging and no-file logging are separate selected backends. The default
|
||||
tracing backend records events and deliberately ignores span trees. The
|
||||
optional `tracing` feature selects the tokio-console subscriber integration;
|
||||
only that diagnostic profile pulls the main crate's `tracing-subscriber` and
|
||||
`console-subscriber` dependencies.
|
||||
|
||||
Contrib applications and platform integrations may have independent logging
|
||||
requirements and are not implicitly wired to the native process logger.
|
||||
|
||||
## Feature model
|
||||
|
||||
Features represent coherent capabilities, not arbitrary source fragments.
|
||||
Important core feature relationships are:
|
||||
|
||||
- `management-rpc` enables generated management API types and read-only
|
||||
management services.
|
||||
- `management` adds configuration writes, full management composition, rich
|
||||
errors, and JSON-RPC.
|
||||
- `proxy-packet` enables portable packet parsing/proxy machinery and the
|
||||
required smoltcp packet features.
|
||||
- `proxy-smoltcp-stack` adds the async TCP/UDP smoltcp stack.
|
||||
- `dns-resolver` is the shared Hickory resolver leaf used by endpoint
|
||||
discovery and Magic DNS without coupling either capability to the other.
|
||||
- `endpoint-discovery` adds HTTPS endpoint discovery dependencies.
|
||||
- `magic-dns` enables its DNS server, management wire messages, and portable
|
||||
packet-query integration.
|
||||
- `tcp-hole-punch` enables the TCP hole-punch runtime.
|
||||
- `dhcp-ipv4`, `public-ipv6-provider`, `vpn-portal`,
|
||||
`wrapped-transport`, and `proxy-cidr-monitor` are independent gateway or
|
||||
platform-policy leaves.
|
||||
- `extended-services` is the compatibility aggregate for those leaves.
|
||||
- encryption and compression engines remain independently selectable.
|
||||
|
||||
The native crate maps product features to the core and protocol features it
|
||||
actually consumes. A protocol feature must not accidentally enable unrelated
|
||||
gateway or management capabilities.
|
||||
|
||||
Production feature and platform selection belongs at Module or Adapter
|
||||
boundaries rather than inside shared implementations. The logger demonstrates
|
||||
the intended pattern: file and tracing variants are complete backend modules
|
||||
with one stable interface, so shared event processing contains no feature
|
||||
branches.
|
||||
|
||||
## Module boundaries
|
||||
|
||||
The dependency directions in this document define the intended module
|
||||
boundaries. Changes that require a new upward edge must first define a stable
|
||||
lower-layer interface or explicitly revise this architecture.
|
||||
|
||||
Modules are `pub(crate)` by default. Each domain's `mod.rs` declares its
|
||||
outward surface. Public visibility is used for real cross-crate Host,
|
||||
configuration, management, packet-plane, or test-support interfaces.
|
||||
|
||||
## Architectural invariants
|
||||
|
||||
1. Portable EasyTier policy has one owner in `easytier-core`.
|
||||
2. Core does not perform real OS socket, DNS, TUN, route, filesystem
|
||||
configuration, process, or service-manager operations.
|
||||
3. Host-OS policy is runtime input; a WASI compile target is not Host policy.
|
||||
4. Every real socket and DNS operation crosses a Host capability seam.
|
||||
5. Core owns socket scheduling, backpressure, protocol state, and cancellation.
|
||||
6. Dial, accept, and hole-punch paths produce sockets before protocol upgrade.
|
||||
7. Peer admission consumes upgraded transports and does not create OS
|
||||
resources.
|
||||
8. Each instance owns its mutable peer, route, connectivity, gateway, and
|
||||
runtime configuration state.
|
||||
9. One Host composition has one canonical UUID-to-instance manager.
|
||||
10. Process-level runtimes do not capture instance state.
|
||||
11. `CoreInstance::new` is the sole normalized direct construction entry.
|
||||
12. The manager owns membership, not lifecycle or presentation.
|
||||
13. Management consumes the manager; the manager does not return management
|
||||
projections.
|
||||
14. Unknown protobuf fields in reflected route information survive forwarding
|
||||
and credential filtering.
|
||||
15. Feature selection is localized at cohesive Module/Adapter boundaries.
|
||||
16. The standard runtime rejects unsupported configured capabilities. Compact
|
||||
compatibility Hosts may preserve them as runtime no-ops, but never change
|
||||
wire protocol, advertise them, or silently fall back to an unsafe mode.
|
||||
|
||||
## Validation
|
||||
|
||||
Changes to these boundaries should run, at minimum:
|
||||
|
||||
```text
|
||||
cargo fmt --all -- --check
|
||||
cargo check -p easytier-core -p easytier-proto -p easytier --features full
|
||||
cargo test -p easytier-core --lib
|
||||
```
|
||||
|
||||
Feature work should add focused checks for the changed no-default, isolated,
|
||||
default, full, and cross-target profiles. Socket, TUN, namespace, protocol
|
||||
engine, and multi-node changes require the relevant Docker integration tests.
|
||||
WASI ABI or Adapter changes require a `wasm32-wasip1` build and target-side
|
||||
tests. These compiler-resolved profiles are the authority for feature and
|
||||
target boundaries.
|
||||
|
||||
CI path filters include `easytier-core`, `easytier-proto`, native, web, GUI
|
||||
Tauri, and contrib. The archived Rust test suite contains both `easytier` and
|
||||
`easytier-core`.
|
||||
|
||||
## Known limitations and debt
|
||||
|
||||
- Some production feature and platform gates still select fields or statements
|
||||
inside shared implementations. New code should prefer complete Module or
|
||||
Adapter variants, and existing cases should move only when their owning
|
||||
Module is changed.
|
||||
- Connectivity retains localized Adapter implementations that name
|
||||
`PeerManagerCore`; further decoupling requires an interface extraction, not
|
||||
a visibility-only move.
|
||||
- Native Linux namespace guards exist in paths that can cross async suspension.
|
||||
Because `setns` is thread-local, those operations should eventually be kept
|
||||
on one non-migrating execution context.
|
||||
- QUIC session retirement after failed or exhausted accepted sessions remains
|
||||
separate native-engine correctness work; it must preserve multiple
|
||||
connections sharing one QUIC endpoint/session.
|
||||
|
||||
These limitations are not reasons to add fallback owners or parallel state.
|
||||
Fixes should preserve the ownership rules above and address the responsible
|
||||
Module directly.
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,65 @@
|
||||
# HarmonyOS HAR delivery
|
||||
|
||||
The `ohos` workflow builds the Core HAR on pushes, pull requests, tags, and
|
||||
manual runs. Every successful run retains a short-lived HAR artifact, while
|
||||
publication to the private OHPM registry is deliberately restricted:
|
||||
|
||||
- A push to `main` publishes only when the pushed SHA is the merge commit of a
|
||||
pull request targeting `main` and the push is not forced.
|
||||
- A manual run on `main` publishes by default.
|
||||
- A manual run on another branch publishes only when its `publish` input is
|
||||
enabled.
|
||||
- Direct pushes, pull requests, tags, and ordinary non-main branch builds do
|
||||
not publish.
|
||||
|
||||
## Package identity
|
||||
|
||||
All branches publish the same private package name, `easytier-ohrs`. The
|
||||
source branch is encoded in the package version instead of the package name:
|
||||
|
||||
```text
|
||||
<core-version>-<branch-id>-<commits-since-tag>-<run-number>-<run-attempt>-g<short-sha>
|
||||
```
|
||||
|
||||
`branch-id` is a lowercase, OHPM-safe form of the source branch. Publishing a
|
||||
new version advances the registry's `latest` version. After publication, Core
|
||||
sends the `core-har-published` repository dispatch to the ArkTS and Pro
|
||||
repositories. The payload contains only `core_repository`, `core_ref`, and
|
||||
`package_name`.
|
||||
|
||||
## App install sequence
|
||||
|
||||
ArkTS and Pro use the same three OHPM commands:
|
||||
|
||||
```bash
|
||||
ohpm uninstall "$CORE_HAR_PACKAGE"
|
||||
ohpm install "$CORE_HAR_PACKAGE@latest" \
|
||||
--registry "$CORE_HAR_REGISTRY"
|
||||
ohpm install
|
||||
```
|
||||
|
||||
The App workflow then reads the installed version from:
|
||||
|
||||
```text
|
||||
oh_modules/<package_name>/oh-package.json5
|
||||
```
|
||||
|
||||
The existing `oh-package-lock.json5` and `oh_modules` directory are not
|
||||
manually deleted. Because the package name remains `easytier-ohrs`, downstream
|
||||
source imports do not need to be rewritten.
|
||||
|
||||
## Secrets
|
||||
|
||||
Core requires:
|
||||
|
||||
- `CODEARTS_PRIVATE_OHPM`: publish-capable OHPM configuration.
|
||||
- `DOWNSTREAM_DISPATCH_TOKEN`: permission to dispatch both App repositories.
|
||||
|
||||
ArkTS and Pro require:
|
||||
|
||||
- `CODEARTS_PRIVATE_OHPM_READ`: read-only private OHPM authentication.
|
||||
- `SIGNING_REPOSITORY_TOKEN`: read access to the corresponding private signing
|
||||
repository.
|
||||
|
||||
Signing and AppGallery Connect credentials remain downstream application
|
||||
concerns and are not passed through the Core dispatch payload.
|
||||
@@ -0,0 +1,176 @@
|
||||
# QUIC TCP Proxy 内存对比(2026-07-27)
|
||||
|
||||
## 结论
|
||||
|
||||
在相同的双节点 network namespace 环境中,当前分支相对 2.6.4:
|
||||
|
||||
- 空闲且未建立 TCP proxy 连接时,两端合计 USS 从 15.95 MiB
|
||||
降至 11.66 MiB,下降 26.9%;
|
||||
- 66 条空闲 TCP proxy 连接时,两端合计 USS 从 19.45 MiB
|
||||
降至 13.85 MiB,下降 28.8%;
|
||||
- 固定 1 Gbit/s 的单流 TCP proxy 传输中,两端平均 USS 从
|
||||
20.69 MiB 降至 14.75 MiB,下降 28.7%,同步峰值从
|
||||
21.50 MiB 降至 15.02 MiB;
|
||||
- 从 0 增长到 66 条空闲连接推算,每条连接在两个 core 上合计
|
||||
增加约 33.9 KiB USS;2.6.4 为 54.2 KiB,下降 37.4%;
|
||||
- 当前分支的匿名内存下降约 40% 至 44%,说明堆和连接缓冲区开销
|
||||
确实降低。
|
||||
|
||||
当前分支的 RSS 比 2.6.4 高约 5% 至 11%,但这部分差异没有出现在
|
||||
Anonymous 中,主要体现为非匿名或共享驻留页。PSS 在高连接数及
|
||||
固定吞吐场景基本持平,USS 和 Anonymous 则显著更低。因此不能
|
||||
只根据 RSS 判断发生了内存回退。
|
||||
|
||||
## 测试对象
|
||||
|
||||
| 版本 | 标识 | 二进制 |
|
||||
|---|---|---|
|
||||
| 当前分支 | commit `9e2ed33aeb37`,版本 `2.6.4-9e2ed33a` | `target/x86_64-unknown-linux-musl/release/easytier-core` |
|
||||
| 2.6.4 | 版本 `2.6.4-8428a89d` | `/data/tickets/easytier/easytier-linux-x86_64/easytier-core` |
|
||||
|
||||
当前分支使用以下命令重新构建,确保被测二进制准确对应 HEAD:
|
||||
|
||||
```console
|
||||
cargo build --release \
|
||||
--target x86_64-unknown-linux-musl \
|
||||
-p easytier \
|
||||
--features jemalloc \
|
||||
--bin easytier-core \
|
||||
--bin easytier-cli
|
||||
```
|
||||
|
||||
两个二进制均为 stripped static PIE。当前分支明确使用 musl 和
|
||||
jemalloc。
|
||||
|
||||
## 测试拓扑
|
||||
|
||||
- 两个 `easytier-core` 分别运行在独立的 network namespace;
|
||||
- namespace 通过 Linux bridge 和 veth 连接;
|
||||
- underlay 地址为 `10.251.89.10/24` 和 `10.251.89.11/24`;
|
||||
- EasyTier 虚拟地址为 `10.144.144.1/24` 和
|
||||
`10.144.144.2/24`;
|
||||
- 两个节点之间使用 UDP listener 建立 EasyTier peer 连接;
|
||||
- 源节点启用 `--enable-quic-proxy true`;
|
||||
- 两个节点均保留默认 QUIC input;
|
||||
- TCP client 从 `10.144.144.1` 访问绑定在
|
||||
`10.144.144.2` 上的 server;
|
||||
- `tcp_proxy_connect` 指标的 `protocol` 标签确认为 `QUIC`;
|
||||
- 66 条连接场景通过两端各 132 个 established TCP socket 条目
|
||||
确认当前连接数。
|
||||
|
||||
## 采样口径
|
||||
|
||||
数据读取自 `/proc/<pid>/smaps_rollup`:
|
||||
|
||||
- RSS:进程映射的全部驻留页,包含共享代码页;
|
||||
- PSS:共享页按共享进程数量分摊后的驻留内存;
|
||||
- USS:`Private_Clean + Private_Dirty`,表示进程独占内存;
|
||||
- Anonymous:匿名页,主要反映堆、栈和运行时缓冲区。
|
||||
|
||||
空闲场景每隔 2 秒采样一次,共 5 次,表格记录均值。固定吞吐场景
|
||||
持续 20 秒,每隔 2 秒采样一次,共 8 次,同时记录均值和峰值。
|
||||
所有容量单位均为 MiB。
|
||||
|
||||
## 空闲连接结果
|
||||
|
||||
以下数据均为两个 EasyTier core 的合计值:
|
||||
|
||||
| 当前连接数 | 当前 RSS | 2.6.4 RSS | 当前 PSS | 2.6.4 PSS | 当前 USS | 2.6.4 USS | USS 变化 | 当前 Anonymous | 2.6.4 Anonymous |
|
||||
|---:|---:|---:|---:|---:|---:|---:|---:|---:|---:|
|
||||
| 0 | 40.42 | 36.33 | 26.03 | 24.50 | 11.66 | 15.95 | -26.9% | 8.89 | 15.62 |
|
||||
| 1 | 41.52 | 38.46 | 26.49 | 25.98 | 11.46 | 16.82 | -31.9% | 9.08 | 16.23 |
|
||||
| 10 | 41.63 | 38.79 | 26.59 | 26.32 | 11.56 | 17.15 | -32.6% | 9.19 | 16.55 |
|
||||
| 66 | 43.68 | 41.11 | 28.76 | 28.63 | 13.85 | 19.45 | -28.8% | 11.22 | 18.79 |
|
||||
|
||||
0 条和 1 条连接之间的小幅反向波动属于分配器回收和采样时序噪声,
|
||||
不能解释为连接产生负开销。使用 0 到 66 条连接的跨度估算单位
|
||||
连接成本更稳定。
|
||||
|
||||
### 分节点 USS
|
||||
|
||||
| 当前连接数 | 当前源端 | 当前目的端 | 2.6.4 源端 | 2.6.4 目的端 |
|
||||
|---:|---:|---:|---:|---:|
|
||||
| 0 | 5.45 | 6.21 | 8.07 | 7.88 |
|
||||
| 1 | 5.43 | 6.03 | 8.68 | 8.14 |
|
||||
| 10 | 5.49 | 6.07 | 8.90 | 8.25 |
|
||||
| 66 | 6.60 | 7.25 | 10.19 | 9.25 |
|
||||
|
||||
### 单位连接增量
|
||||
|
||||
以 0 到 66 条连接的 USS 增量计算:
|
||||
|
||||
| 版本 | 两端 USS 增量 | 每连接两端合计 | 每连接单端平均 |
|
||||
|---|---:|---:|---:|
|
||||
| 当前分支 | 2.19 MiB | 33.9 KiB | 17.0 KiB |
|
||||
| 2.6.4 | 3.50 MiB | 54.2 KiB | 27.1 KiB |
|
||||
|
||||
当前分支的每连接独占内存增量下降约 37.4%。
|
||||
|
||||
## 固定 1 Gbit/s 活跃流量
|
||||
|
||||
为排除两个版本最大吞吐不同造成的缓冲区差异,使用
|
||||
`iperf3 -b 1G -P 1 -t 20` 将两个版本都限制为 1 Gbit/s。
|
||||
两次测试均实际完成 2.33 GiB 传输,接收端报告 1000 Mbit/s。
|
||||
|
||||
### 平均值
|
||||
|
||||
| 版本 | 节点 | RSS | PSS | USS | Anonymous |
|
||||
|---|---|---:|---:|---:|---:|
|
||||
| 当前分支 | 源端 | 22.89 | 15.41 | 7.94 | 6.88 |
|
||||
| 当前分支 | 目的端 | 21.77 | 14.29 | 6.82 | 5.27 |
|
||||
| 当前分支 | 两端合计 | 44.67 | 29.70 | 14.75 | 12.14 |
|
||||
| 2.6.4 | 源端 | 22.59 | 16.34 | 11.70 | 11.35 |
|
||||
| 2.6.4 | 目的端 | 19.95 | 13.67 | 8.99 | 8.77 |
|
||||
| 2.6.4 | 两端合计 | 42.53 | 30.01 | 20.69 | 20.13 |
|
||||
|
||||
### 对比
|
||||
|
||||
| 指标 | 当前分支 | 2.6.4 | 变化 |
|
||||
|---|---:|---:|---:|
|
||||
| 两端平均 RSS | 44.67 | 42.53 | +5.0% |
|
||||
| 两端平均 PSS | 29.70 | 30.01 | -1.0% |
|
||||
| 两端平均 USS | 14.75 | 20.69 | -28.7% |
|
||||
| 两端平均 Anonymous | 12.14 | 20.13 | -39.7% |
|
||||
| 两端同步峰值 USS | 15.02 | 21.50 | -30.2% |
|
||||
|
||||
## 分节点原始统计
|
||||
|
||||
下表保留各场景所有样本计算出的均值;`max_uss` 是该节点采样期间
|
||||
的最大 USS。
|
||||
|
||||
| 版本 | 场景 | 节点 | 样本数 | mean_rss | mean_pss | mean_uss | mean_anon | max_uss |
|
||||
|---|---|---|---:|---:|---:|---:|---:|---:|
|
||||
| 当前 | 0 连接 | 源端 | 5 | 19.830 | 12.636 | 5.451 | 4.314 | 5.582 |
|
||||
| 当前 | 0 连接 | 目的端 | 5 | 20.587 | 13.393 | 6.208 | 4.579 | 6.320 |
|
||||
| 当前 | 1 连接 | 源端 | 5 | 20.463 | 12.944 | 5.432 | 4.401 | 5.465 |
|
||||
| 当前 | 1 连接 | 目的端 | 5 | 21.061 | 13.541 | 6.030 | 4.682 | 6.051 |
|
||||
| 当前 | 10 连接 | 源端 | 5 | 20.522 | 13.002 | 5.491 | 4.459 | 5.496 |
|
||||
| 当前 | 10 连接 | 目的端 | 5 | 21.105 | 13.585 | 6.073 | 4.726 | 6.086 |
|
||||
| 当前 | 66 连接 | 源端 | 5 | 21.513 | 14.050 | 6.595 | 5.498 | 6.672 |
|
||||
| 当前 | 66 连接 | 目的端 | 5 | 22.169 | 14.706 | 7.251 | 5.723 | 7.375 |
|
||||
| 当前 | 1 Gbit/s | 源端 | 8 | 22.893 | 15.410 | 7.936 | 6.877 | 8.188 |
|
||||
| 当前 | 1 Gbit/s | 目的端 | 8 | 21.773 | 14.291 | 6.816 | 5.266 | 6.832 |
|
||||
| 2.6.4 | 0 连接 | 源端 | 5 | 18.278 | 12.355 | 8.071 | 7.876 | 8.328 |
|
||||
| 2.6.4 | 0 连接 | 目的端 | 5 | 18.048 | 12.144 | 7.880 | 7.747 | 8.203 |
|
||||
| 2.6.4 | 1 连接 | 源端 | 5 | 19.535 | 13.279 | 8.676 | 8.262 | 8.727 |
|
||||
| 2.6.4 | 1 连接 | 目的端 | 5 | 18.920 | 12.705 | 8.143 | 7.971 | 8.191 |
|
||||
| 2.6.4 | 10 连接 | 源端 | 5 | 19.762 | 13.506 | 8.902 | 8.473 | 8.910 |
|
||||
| 2.6.4 | 10 连接 | 目的端 | 5 | 19.027 | 12.812 | 8.250 | 8.078 | 8.297 |
|
||||
| 2.6.4 | 66 连接 | 源端 | 5 | 21.069 | 14.805 | 10.194 | 9.702 | 10.320 |
|
||||
| 2.6.4 | 66 连接 | 目的端 | 5 | 20.045 | 13.822 | 9.252 | 9.088 | 9.293 |
|
||||
| 2.6.4 | 1 Gbit/s | 源端 | 8 | 22.588 | 16.343 | 11.697 | 11.354 | 12.258 |
|
||||
| 2.6.4 | 1 Gbit/s | 目的端 | 8 | 19.946 | 13.670 | 8.993 | 8.774 | 9.277 |
|
||||
|
||||
## 解释和限制
|
||||
|
||||
1. 以固定 1 Gbit/s 场景为例,当前分支 RSS 增加 5.0%,但
|
||||
Anonymous 下降 39.7%,PSS 下降 1.0%。这说明差异主要体现
|
||||
在非匿名或共享驻留页;本次没有保存逐 VMA 数据,因此不进一步
|
||||
将它归因到某一个具体映射。
|
||||
2. 两个相同版本进程运行在同一宿主机时会共享可执行文件代码页,
|
||||
所以 PSS 比 RSS 更适合估算该测试拓扑的宿主机总成本,USS 和
|
||||
Anonymous 更适合判断 EasyTier 私有堆及缓冲区的变化。
|
||||
3. 这是一轮受控 A/B 测试,而不是长期统计分布。数值可用于确认
|
||||
差异方向和量级;若作为发布门禁,应固定机器负载并增加多轮重复。
|
||||
4. 本文只比较 QUIC TCP proxy 内存,不使用未限速吞吐结果推断性能,
|
||||
避免吞吐差异污染内存结论。
|
||||
@@ -13,4 +13,5 @@ log = "0.4"
|
||||
android_logger = "0.13"
|
||||
serde = { version = "1.0.220", features = ["derive"] }
|
||||
serde_json = "1.0"
|
||||
easytier = { path = "../../easytier" }
|
||||
easytier = { path = "../../easytier" }
|
||||
easytier-ffi = { path = "../easytier-ffi", default-features = false }
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
- 📱 原生 Android JNI 支持
|
||||
- 🔧 支持多种 Android 架构 (arm64-v8a, armeabi-v7a, x86, x86_64)
|
||||
- 🛡️ 类型安全的 Java 接口
|
||||
- 🔌 支持通过 JSON 调用已暴露的 EasyTier RPC 查询/管理接口
|
||||
- 📝 详细的错误处理和日志记录
|
||||
|
||||
## 支持的架构
|
||||
@@ -176,6 +177,20 @@ public class EasyTierManager {
|
||||
}
|
||||
```
|
||||
|
||||
### 通用 JSON RPC
|
||||
|
||||
`EasyTierJNI.callJsonRpc(serviceName, methodName, domainName, payloadJson)` 可以调用已暴露的
|
||||
EasyTier RPC 服务,payload 和返回值均为 protobuf JSON。该接口不支持
|
||||
`api.manage.WebClientService`;实例启动、保留、删除、信息收集仍使用专用 JNI API。
|
||||
|
||||
```java
|
||||
String response = EasyTierJNI.callJsonRpc(
|
||||
"api.logger.LoggerRpcService",
|
||||
"get_logger_config",
|
||||
"{}"
|
||||
);
|
||||
```
|
||||
|
||||
### VPN 服务集成
|
||||
|
||||
如果您要在 Android VPN 服务中使用:
|
||||
@@ -264,4 +279,4 @@ public class EasyTierVpnService extends VpnService {
|
||||
|
||||
- [EasyTier 主项目](https://github.com/EasyTier/EasyTier)
|
||||
- [Android NDK 文档](https://developer.android.com/ndk)
|
||||
- [Rust JNI 文档](https://docs.rs/jni/)
|
||||
- [Rust JNI 文档](https://docs.rs/jni/)
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
use std::{env, path::PathBuf};
|
||||
|
||||
fn main() {
|
||||
let target_os = env::var("CARGO_CFG_TARGET_OS").unwrap_or_default();
|
||||
if !matches!(target_os.as_str(), "android" | "linux") {
|
||||
return;
|
||||
}
|
||||
|
||||
let manifest_dir = PathBuf::from(env::var("CARGO_MANIFEST_DIR").unwrap());
|
||||
let exports = manifest_dir.join("exports.map");
|
||||
println!("cargo:rerun-if-changed={}", exports.display());
|
||||
println!(
|
||||
"cargo:rustc-cdylib-link-arg=-Wl,--version-script={}",
|
||||
exports.display()
|
||||
);
|
||||
println!("cargo:rustc-cdylib-link-arg=-Wl,--exclude-libs,ALL");
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
global:
|
||||
Java_com_easytier_jni_EasyTierJNI_*;
|
||||
local:
|
||||
*;
|
||||
};
|
||||
@@ -1,8 +1,11 @@
|
||||
package com.easytier.jni
|
||||
|
||||
/** EasyTier JNI 接口类 提供 Android 应用调用 EasyTier 网络功能的接口 */
|
||||
object EasyTierJNI {
|
||||
fun interface ConfigServerEventCallback {
|
||||
fun onEvent(eventJson: String)
|
||||
}
|
||||
|
||||
/** EasyTier JNI 接口类 提供 Android 应用调用 EasyTier 核心网络功能的接口 */
|
||||
object EasyTierJNI {
|
||||
init {
|
||||
// 加载本地库
|
||||
System.loadLibrary("easytier_android_jni")
|
||||
@@ -33,6 +36,35 @@ object EasyTierJNI {
|
||||
*/
|
||||
@JvmStatic external fun runNetworkInstance(config: String): Int
|
||||
|
||||
/**
|
||||
* 启动配置服务器客户端
|
||||
* @param url 配置服务器 URL
|
||||
* @param hostname 主机名,传入 null 使用系统主机名
|
||||
* @param machineId 稳定机器 ID,由调用方负责持久化
|
||||
* @param secureMode 是否启用 secure mode
|
||||
* @param callback 远程配置应用/删除事件回调
|
||||
* @return 0 表示成功,-1 表示失败
|
||||
* @throws RuntimeException 当客户端启动失败时抛出异常
|
||||
*/
|
||||
@JvmStatic
|
||||
external fun startConfigServerClient(
|
||||
url: String,
|
||||
hostname: String?,
|
||||
machineId: String,
|
||||
secureMode: Boolean,
|
||||
callback: ConfigServerEventCallback?
|
||||
): Int
|
||||
|
||||
/**
|
||||
* 停止配置服务器客户端
|
||||
* @return 0 表示成功,-1 表示失败
|
||||
* @throws RuntimeException 当客户端停止失败时抛出异常
|
||||
*/
|
||||
@JvmStatic external fun stopConfigServerClient(): Int
|
||||
|
||||
/** 查询配置服务器客户端是否已连接 */
|
||||
@JvmStatic external fun isConfigServerClientConnected(): Boolean
|
||||
|
||||
/**
|
||||
* 保留指定的网络实例,停止其他实例
|
||||
* @param instanceNames 要保留的实例名称数组,传入 null 或空数组将停止所有实例
|
||||
@@ -44,11 +76,48 @@ object EasyTierJNI {
|
||||
/**
|
||||
* 收集网络信息
|
||||
* @param maxLength 最大返回条目数
|
||||
* @return 包含网络信息的字符串数组,每个元素格式为 "key=value"
|
||||
* @return 包含网络信息的 JSON 字符串
|
||||
* @throws RuntimeException 当操作失败时抛出异常
|
||||
*/
|
||||
@JvmStatic external fun collectNetworkInfos(maxLength: Int): String?
|
||||
|
||||
/**
|
||||
* 列出当前运行的实例名称和实例 ID。
|
||||
* @param maxLength 最大返回条目数
|
||||
* @return JSON 对象,key 为 instance name,value 为 instance id
|
||||
* @throws RuntimeException 当操作失败时抛出异常
|
||||
*/
|
||||
@JvmStatic external fun listInstances(maxLength: Int): String?
|
||||
|
||||
/**
|
||||
* 调用暴露的 EasyTier RPC 方法,输入和输出均为 protobuf JSON 字符串。
|
||||
*
|
||||
* 不支持 api.manage.WebClientService;实例启动、保留、删除、信息收集请继续使用专用 JNI API。
|
||||
* payloadJson 需要包含目标 RPC 所需的 instance selector。
|
||||
*
|
||||
* @param serviceName RPC 服务名,例如 api.instance.PeerManageRpcService
|
||||
* @param methodName RPC 方法名,支持 snake_case 或 proto 方法名
|
||||
* @param domainName 仅 TcpProxyRpcService 使用;传 null 或空字符串默认 tcp
|
||||
* @param payloadJson protobuf JSON 请求体
|
||||
* @return protobuf JSON 响应体
|
||||
* @throws RuntimeException 当 RPC 调用失败时抛出异常
|
||||
*/
|
||||
@JvmStatic
|
||||
external fun callJsonRpc(
|
||||
serviceName: String,
|
||||
methodName: String,
|
||||
domainName: String?,
|
||||
payloadJson: String
|
||||
): String?
|
||||
|
||||
/**
|
||||
* 调用不需要 domainName 的 EasyTier RPC 方法。
|
||||
*/
|
||||
@JvmStatic
|
||||
fun callJsonRpc(serviceName: String, methodName: String, payloadJson: String): String? {
|
||||
return callJsonRpc(serviceName, methodName, null, payloadJson)
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取最后的错误消息
|
||||
* @return 错误消息字符串,如果没有错误则返回 null
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
use std::{
|
||||
ffi::{CStr, c_char, c_void},
|
||||
sync::{Arc, Mutex, MutexGuard},
|
||||
};
|
||||
|
||||
use easytier_ffi::ConfigServerEventCallback;
|
||||
use jni::JNIEnv;
|
||||
use jni::objects::{GlobalRef, JObject, JValue};
|
||||
use once_cell::sync::Lazy;
|
||||
|
||||
use crate::error;
|
||||
|
||||
pub(crate) struct JniConfigServerCallback {
|
||||
java_vm: jni::JavaVM,
|
||||
callback: GlobalRef,
|
||||
}
|
||||
|
||||
static CONFIG_SERVER_CALLBACK: Lazy<Mutex<Option<Arc<JniConfigServerCallback>>>> =
|
||||
Lazy::new(|| Mutex::new(None));
|
||||
|
||||
pub(crate) fn lock_callback_storage()
|
||||
-> Result<MutexGuard<'static, Option<Arc<JniConfigServerCallback>>>, String> {
|
||||
CONFIG_SERVER_CALLBACK
|
||||
.lock()
|
||||
.map_err(|e| format!("Failed to lock config server callback: {}", e))
|
||||
}
|
||||
|
||||
pub(crate) fn new_callback(
|
||||
env: &mut JNIEnv,
|
||||
callback: &JObject,
|
||||
) -> Result<Arc<JniConfigServerCallback>, String> {
|
||||
let java_vm = env
|
||||
.get_java_vm()
|
||||
.map_err(|e| format!("Failed to get JavaVM: {:?}", e))?;
|
||||
let callback = env
|
||||
.new_global_ref(callback)
|
||||
.map_err(|e| format!("Failed to create callback global ref: {:?}", e))?;
|
||||
Ok(Arc::new(JniConfigServerCallback { java_vm, callback }))
|
||||
}
|
||||
|
||||
pub(crate) fn callback_fn(
|
||||
callback: &Option<Arc<JniConfigServerCallback>>,
|
||||
) -> ConfigServerEventCallback {
|
||||
callback
|
||||
.as_ref()
|
||||
.map(|_| config_server_event_callback as unsafe extern "C" fn(*const c_char, *mut c_void))
|
||||
}
|
||||
|
||||
pub(crate) fn user_data(callback: &Option<Arc<JniConfigServerCallback>>) -> *mut c_void {
|
||||
callback
|
||||
.as_ref()
|
||||
.map(|callback| Arc::as_ptr(callback) as *mut c_void)
|
||||
.unwrap_or(std::ptr::null_mut())
|
||||
}
|
||||
|
||||
impl JniConfigServerCallback {
|
||||
fn clear_pending_exception(
|
||||
env: &mut JNIEnv,
|
||||
context: &str,
|
||||
error: &dyn std::fmt::Debug,
|
||||
) -> String {
|
||||
match env.exception_check() {
|
||||
Ok(true) => {
|
||||
if let Err(clear_err) = env.exception_clear() {
|
||||
return format!(
|
||||
"{}: {:?}; failed to clear pending Java exception: {:?}",
|
||||
context, error, clear_err
|
||||
);
|
||||
}
|
||||
}
|
||||
Ok(false) => {}
|
||||
Err(check_err) => {
|
||||
return format!(
|
||||
"{}: {:?}; failed to check pending Java exception: {:?}",
|
||||
context, error, check_err
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
format!("{}: {:?}", context, error)
|
||||
}
|
||||
|
||||
fn on_event(&self, event_json: *const c_char) -> Result<(), String> {
|
||||
let event_json = unsafe { CStr::from_ptr(event_json) }
|
||||
.to_str()
|
||||
.map_err(|e| format!("Invalid config server event JSON: {:?}", e))?;
|
||||
let mut env = self
|
||||
.java_vm
|
||||
.attach_current_thread()
|
||||
.map_err(|e| format!("Failed to attach callback thread: {:?}", e))?;
|
||||
let event_json = env.new_string(event_json).map_err(|e| {
|
||||
Self::clear_pending_exception(&mut env, "Failed to create event string", &e)
|
||||
})?;
|
||||
|
||||
if let Err(e) = env.call_method(
|
||||
self.callback.as_obj(),
|
||||
"onEvent",
|
||||
"(Ljava/lang/String;)V",
|
||||
&[JValue::from(&event_json)],
|
||||
) {
|
||||
return Err(Self::clear_pending_exception(
|
||||
&mut env,
|
||||
"Failed to call config server callback",
|
||||
&e,
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
unsafe extern "C" fn config_server_event_callback(
|
||||
event_json: *const c_char,
|
||||
user_data: *mut c_void,
|
||||
) {
|
||||
if event_json.is_null() || user_data.is_null() {
|
||||
return;
|
||||
}
|
||||
|
||||
let callback = unsafe { &*(user_data as *const JniConfigServerCallback) };
|
||||
|
||||
if let Err(error) = callback.on_event(event_json) {
|
||||
error::set_callback_error(error);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
use std::ptr;
|
||||
|
||||
use easytier_ffi::{
|
||||
in_config_server_callback, is_config_server_client_connected, start_config_server_client,
|
||||
stop_config_server_client,
|
||||
};
|
||||
use jni::JNIEnv;
|
||||
use jni::objects::{JClass, JObject, JString};
|
||||
use jni::sys::{JNI_FALSE, JNI_TRUE, jboolean, jint};
|
||||
|
||||
use crate::{
|
||||
callback, error,
|
||||
strings::{jstring_to_cstring, optional_jstring_to_cstring},
|
||||
};
|
||||
|
||||
pub(crate) fn start_config_server_client_jni(
|
||||
env: &mut JNIEnv,
|
||||
config_server_url: JString,
|
||||
hostname: JString,
|
||||
machine_id: JString,
|
||||
secure_mode: jboolean,
|
||||
callback_obj: JObject,
|
||||
) -> jint {
|
||||
if in_config_server_callback() {
|
||||
error::throw_exception(
|
||||
env,
|
||||
"Cannot start config server client from config server callback",
|
||||
);
|
||||
return -1;
|
||||
}
|
||||
|
||||
let config_server_url = match jstring_to_cstring(env, &config_server_url) {
|
||||
Ok(cstr) => cstr,
|
||||
Err(e) => {
|
||||
error::throw_exception(env, &format!("Invalid config server URL: {}", e));
|
||||
return -1;
|
||||
}
|
||||
};
|
||||
let hostname = match optional_jstring_to_cstring(env, &hostname) {
|
||||
Ok(cstr) => cstr,
|
||||
Err(e) => {
|
||||
error::throw_exception(env, &format!("Invalid hostname: {}", e));
|
||||
return -1;
|
||||
}
|
||||
};
|
||||
let machine_id = match jstring_to_cstring(env, &machine_id) {
|
||||
Ok(cstr) => cstr,
|
||||
Err(e) => {
|
||||
error::throw_exception(env, &format!("Invalid machine ID: {}", e));
|
||||
return -1;
|
||||
}
|
||||
};
|
||||
|
||||
let callback_ref = if callback_obj.is_null() {
|
||||
None
|
||||
} else {
|
||||
match callback::new_callback(env, &callback_obj) {
|
||||
Ok(state) => Some(state),
|
||||
Err(e) => {
|
||||
error::throw_exception(env, &e);
|
||||
return -1;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let mut callback_guard = match callback::lock_callback_storage() {
|
||||
Ok(guard) => guard,
|
||||
Err(e) => {
|
||||
error::throw_exception(env, &e);
|
||||
return -1;
|
||||
}
|
||||
};
|
||||
if callback_guard.is_none() {
|
||||
error::clear_callback_error();
|
||||
}
|
||||
|
||||
let callback_fn = callback::callback_fn(&callback_ref);
|
||||
let user_data = callback::user_data(&callback_ref);
|
||||
let result = unsafe {
|
||||
start_config_server_client(
|
||||
config_server_url.as_ptr(),
|
||||
hostname
|
||||
.as_ref()
|
||||
.map(|value| value.as_ptr())
|
||||
.unwrap_or(ptr::null()),
|
||||
machine_id.as_ptr(),
|
||||
secure_mode == JNI_TRUE,
|
||||
callback_fn,
|
||||
user_data,
|
||||
)
|
||||
};
|
||||
if result != 0 {
|
||||
if let Some(error_msg) = error::get_last_error() {
|
||||
error::throw_exception(env, &error_msg);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
*callback_guard = callback_ref;
|
||||
result
|
||||
}
|
||||
|
||||
pub(crate) fn stop_config_server_client_jni(mut env: JNIEnv, _class: JClass) -> jint {
|
||||
if in_config_server_callback() {
|
||||
let result = stop_config_server_client();
|
||||
if result != 0
|
||||
&& let Some(error_msg) = error::get_last_error()
|
||||
{
|
||||
error::throw_exception(&mut env, &error_msg);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
let mut callback_guard = match callback::lock_callback_storage() {
|
||||
Ok(guard) => guard,
|
||||
Err(e) => {
|
||||
error::throw_exception(&mut env, &e);
|
||||
return -1;
|
||||
}
|
||||
};
|
||||
|
||||
let result = stop_config_server_client();
|
||||
if result != 0 {
|
||||
if let Some(error_msg) = error::get_last_error() {
|
||||
error::throw_exception(&mut env, &error_msg);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
*callback_guard = None;
|
||||
result
|
||||
}
|
||||
|
||||
pub(crate) fn is_config_server_client_connected_jni(_env: JNIEnv, _class: JClass) -> jboolean {
|
||||
if is_config_server_client_connected() != 0 {
|
||||
JNI_TRUE
|
||||
} else {
|
||||
JNI_FALSE
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
use std::{
|
||||
ffi::{CStr, c_char},
|
||||
ptr,
|
||||
sync::Mutex,
|
||||
};
|
||||
|
||||
use easytier_ffi::{free_string, get_error_msg};
|
||||
use jni::JNIEnv;
|
||||
use jni::objects::JClass;
|
||||
use jni::sys::jstring;
|
||||
use once_cell::sync::Lazy;
|
||||
|
||||
static JNI_CALLBACK_ERROR: Lazy<Mutex<Option<String>>> = Lazy::new(|| Mutex::new(None));
|
||||
|
||||
pub(crate) fn set_callback_error(error: String) {
|
||||
log::error!("{}", error);
|
||||
if let Ok(mut guard) = JNI_CALLBACK_ERROR.lock() {
|
||||
*guard = Some(error);
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn clear_callback_error() {
|
||||
if let Ok(mut guard) = JNI_CALLBACK_ERROR.lock() {
|
||||
*guard = None;
|
||||
}
|
||||
}
|
||||
|
||||
fn take_callback_error() -> Option<String> {
|
||||
JNI_CALLBACK_ERROR
|
||||
.lock()
|
||||
.ok()
|
||||
.and_then(|mut guard| guard.take())
|
||||
}
|
||||
|
||||
fn get_ffi_last_error() -> Option<String> {
|
||||
unsafe {
|
||||
let mut error_ptr: *const c_char = ptr::null();
|
||||
get_error_msg(&mut error_ptr);
|
||||
if error_ptr.is_null() {
|
||||
None
|
||||
} else {
|
||||
let error_cstr = CStr::from_ptr(error_ptr);
|
||||
let error_str = error_cstr.to_string_lossy().into_owned();
|
||||
free_string(error_ptr);
|
||||
Some(error_str)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn get_last_error() -> Option<String> {
|
||||
match (get_ffi_last_error(), take_callback_error()) {
|
||||
(Some(ffi_error), Some(callback_error)) => Some(format!(
|
||||
"{}; config server callback error: {}",
|
||||
ffi_error, callback_error
|
||||
)),
|
||||
(Some(ffi_error), None) => Some(ffi_error),
|
||||
(None, Some(callback_error)) => Some(callback_error),
|
||||
(None, None) => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn throw_exception(env: &mut JNIEnv, message: &str) {
|
||||
let _ = env.throw_new("java/lang/RuntimeException", message);
|
||||
}
|
||||
|
||||
pub(crate) fn get_last_error_jni(env: JNIEnv, _class: JClass) -> jstring {
|
||||
match get_last_error() {
|
||||
Some(error) => match env.new_string(&error) {
|
||||
Ok(jstr) => jstr.into_raw(),
|
||||
Err(_) => ptr::null_mut(),
|
||||
},
|
||||
None => ptr::null_mut(),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
use std::{
|
||||
ffi::{CStr, c_char},
|
||||
ptr,
|
||||
};
|
||||
|
||||
use easytier_ffi::{call_json_rpc, free_string};
|
||||
use jni::JNIEnv;
|
||||
use jni::objects::{JClass, JString};
|
||||
use jni::sys::jstring;
|
||||
|
||||
use crate::{
|
||||
error::{get_last_error, throw_exception},
|
||||
strings::{jstring_to_cstring, optional_jstring_to_cstring},
|
||||
};
|
||||
|
||||
pub(crate) fn call_json_rpc_jni(
|
||||
mut env: JNIEnv,
|
||||
_class: JClass,
|
||||
service_name: JString,
|
||||
method_name: JString,
|
||||
domain_name: JString,
|
||||
payload_json: JString,
|
||||
) -> jstring {
|
||||
let service_name_cstr = match jstring_to_cstring(&mut env, &service_name) {
|
||||
Ok(cstr) => cstr,
|
||||
Err(e) => {
|
||||
throw_exception(&mut env, &format!("Invalid service name: {}", e));
|
||||
return ptr::null_mut();
|
||||
}
|
||||
};
|
||||
let method_name_cstr = match jstring_to_cstring(&mut env, &method_name) {
|
||||
Ok(cstr) => cstr,
|
||||
Err(e) => {
|
||||
throw_exception(&mut env, &format!("Invalid method name: {}", e));
|
||||
return ptr::null_mut();
|
||||
}
|
||||
};
|
||||
let domain_name_cstr = match optional_jstring_to_cstring(&mut env, &domain_name) {
|
||||
Ok(cstr) => cstr,
|
||||
Err(e) => {
|
||||
throw_exception(&mut env, &format!("Invalid domain name: {}", e));
|
||||
return ptr::null_mut();
|
||||
}
|
||||
};
|
||||
let payload_json_cstr = match jstring_to_cstring(&mut env, &payload_json) {
|
||||
Ok(cstr) => cstr,
|
||||
Err(e) => {
|
||||
throw_exception(&mut env, &format!("Invalid payload JSON: {}", e));
|
||||
return ptr::null_mut();
|
||||
}
|
||||
};
|
||||
|
||||
let domain_name_ptr = domain_name_cstr
|
||||
.as_ref()
|
||||
.map_or(ptr::null(), |cstr| cstr.as_ptr());
|
||||
let mut response_ptr: *const c_char = ptr::null();
|
||||
let result = unsafe {
|
||||
call_json_rpc(
|
||||
service_name_cstr.as_ptr(),
|
||||
method_name_cstr.as_ptr(),
|
||||
domain_name_ptr,
|
||||
payload_json_cstr.as_ptr(),
|
||||
&mut response_ptr,
|
||||
)
|
||||
};
|
||||
|
||||
if result != 0 {
|
||||
if let Some(error) = get_last_error() {
|
||||
throw_exception(&mut env, &error);
|
||||
}
|
||||
return ptr::null_mut();
|
||||
}
|
||||
|
||||
if response_ptr.is_null() {
|
||||
throw_exception(&mut env, "JSON RPC returned a null response");
|
||||
return ptr::null_mut();
|
||||
}
|
||||
|
||||
let response = unsafe { CStr::from_ptr(response_ptr) }
|
||||
.to_string_lossy()
|
||||
.into_owned();
|
||||
free_string(response_ptr);
|
||||
|
||||
match env.new_string(&response) {
|
||||
Ok(jstr) => jstr.into_raw(),
|
||||
Err(_) => {
|
||||
throw_exception(&mut env, "Failed to create JSON RPC response string");
|
||||
ptr::null_mut()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,319 +1,253 @@
|
||||
use easytier::proto::api::manage::{NetworkInstanceRunningInfo, NetworkInstanceRunningInfoMap};
|
||||
//! JNI facade for Android callers of EasyTier.
|
||||
//!
|
||||
//! This file intentionally lists every Java-visible native method exported by
|
||||
//! `libeasytier_android_jni.so`. The implementation details live in sibling
|
||||
//! modules so this facade stays readable as an API map.
|
||||
//!
|
||||
//! Network management APIs:
|
||||
//! - `setTunFd(instanceName, fd)`: attach an Android TUN fd to an instance.
|
||||
//! - `parseConfig(config)`: validate TOML config text.
|
||||
//! - `runNetworkInstance(config)`: start a local network instance.
|
||||
//! - `retainNetworkInstance(instanceNames)`: retain named instances and stop the rest.
|
||||
//! - `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.
|
||||
//!
|
||||
//! Config server client APIs:
|
||||
//! - `startConfigServerClient(url, hostname, machineId, secureMode, callback)`:
|
||||
//! start the managed remote config client.
|
||||
//! - `stopConfigServerClient()`: stop the managed client and release its Java callback.
|
||||
//! - `isConfigServerClientConnected()`: return whether the managed client is connected.
|
||||
//!
|
||||
//! Error API:
|
||||
//! - `getLastError()`: return the latest FFI/JNI error string for the calling thread.
|
||||
//!
|
||||
mod callback;
|
||||
mod config_server_api;
|
||||
mod error;
|
||||
mod json_rpc_api;
|
||||
mod logger;
|
||||
mod network_api;
|
||||
mod strings;
|
||||
|
||||
use jni::JNIEnv;
|
||||
use jni::objects::{JClass, JObjectArray, JString};
|
||||
use jni::sys::{jint, jstring};
|
||||
use once_cell::sync::Lazy;
|
||||
use std::ffi::{CStr, CString};
|
||||
use std::ptr;
|
||||
use jni::objects::{JClass, JObject, JObjectArray, JString};
|
||||
use jni::sys::{jboolean, jint, jstring};
|
||||
|
||||
// 定义 KeyValuePair 结构体
|
||||
#[repr(C)]
|
||||
#[derive(Clone, Copy)]
|
||||
pub struct KeyValuePair {
|
||||
pub key: *const std::ffi::c_char,
|
||||
pub value: *const std::ffi::c_char,
|
||||
}
|
||||
|
||||
// 声明外部 C 函数
|
||||
unsafe extern "C" {
|
||||
fn set_tun_fd(inst_name: *const std::ffi::c_char, fd: std::ffi::c_int) -> std::ffi::c_int;
|
||||
fn get_error_msg(out: *mut *const std::ffi::c_char);
|
||||
fn free_string(s: *const std::ffi::c_char);
|
||||
fn parse_config(cfg_str: *const std::ffi::c_char) -> std::ffi::c_int;
|
||||
fn run_network_instance(cfg_str: *const std::ffi::c_char) -> std::ffi::c_int;
|
||||
fn retain_network_instance(
|
||||
inst_names: *const *const std::ffi::c_char,
|
||||
length: usize,
|
||||
) -> std::ffi::c_int;
|
||||
fn collect_network_infos(infos: *mut KeyValuePair, max_length: usize) -> std::ffi::c_int;
|
||||
}
|
||||
|
||||
// 初始化 Android 日志
|
||||
static LOGGER_INIT: Lazy<()> = Lazy::new(|| {
|
||||
android_logger::init_once(
|
||||
android_logger::Config::default()
|
||||
.with_max_level(log::LevelFilter::Debug)
|
||||
.with_tag("EasyTier-JNI"),
|
||||
);
|
||||
});
|
||||
|
||||
// 辅助函数:从 Java String 转换为 CString
|
||||
fn jstring_to_cstring(env: &mut JNIEnv, jstr: &JString) -> Result<CString, String> {
|
||||
let java_str = env
|
||||
.get_string(jstr)
|
||||
.map_err(|e| format!("Failed to get string: {:?}", e))?;
|
||||
let rust_str = java_str.to_str().map_err(|_| "Invalid UTF-8".to_string())?;
|
||||
CString::new(rust_str).map_err(|_| "String contains null byte".to_string())
|
||||
}
|
||||
|
||||
// 辅助函数:获取错误消息
|
||||
fn get_last_error() -> Option<String> {
|
||||
unsafe {
|
||||
let mut error_ptr: *const std::ffi::c_char = ptr::null();
|
||||
get_error_msg(&mut error_ptr);
|
||||
if error_ptr.is_null() {
|
||||
None
|
||||
} else {
|
||||
let error_cstr = CStr::from_ptr(error_ptr);
|
||||
let error_str = error_cstr.to_string_lossy().into_owned();
|
||||
free_string(error_ptr);
|
||||
Some(error_str)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 辅助函数:抛出 Java 异常
|
||||
fn throw_exception(env: &mut JNIEnv, message: &str) {
|
||||
let _ = env.throw_new("java/lang/RuntimeException", message);
|
||||
}
|
||||
|
||||
/// 设置 TUN 文件描述符
|
||||
/// Attach a TUN file descriptor to an EasyTier network instance.
|
||||
///
|
||||
/// Java signature:
|
||||
/// `EasyTierJNI.setTunFd(instanceName: String, fd: Int): Int`
|
||||
///
|
||||
/// `instanceName` must name an instance known to the shared FFI instance cache.
|
||||
/// The `fd` must be a valid Android TUN file descriptor. On failure this
|
||||
/// returns `-1` and throws `RuntimeException` with the FFI error message when
|
||||
/// one is available.
|
||||
#[unsafe(no_mangle)]
|
||||
pub extern "system" fn Java_com_easytier_jni_EasyTierJNI_setTunFd(
|
||||
mut env: JNIEnv,
|
||||
_class: JClass,
|
||||
env: JNIEnv,
|
||||
class: JClass,
|
||||
inst_name: JString,
|
||||
fd: jint,
|
||||
) -> jint {
|
||||
Lazy::force(&LOGGER_INIT);
|
||||
|
||||
let inst_name_cstr = match jstring_to_cstring(&mut env, &inst_name) {
|
||||
Ok(cstr) => cstr,
|
||||
Err(e) => {
|
||||
throw_exception(&mut env, &format!("Invalid instance name: {}", e));
|
||||
return -1;
|
||||
}
|
||||
};
|
||||
|
||||
unsafe {
|
||||
let result = set_tun_fd(inst_name_cstr.as_ptr(), fd);
|
||||
if result != 0
|
||||
&& let Some(error) = get_last_error()
|
||||
{
|
||||
throw_exception(&mut env, &error);
|
||||
}
|
||||
result
|
||||
}
|
||||
logger::init();
|
||||
network_api::set_tun_fd_jni(env, class, inst_name, fd)
|
||||
}
|
||||
|
||||
/// 解析配置
|
||||
/// Validate a TOML network config string.
|
||||
///
|
||||
/// Java signature:
|
||||
/// `EasyTierJNI.parseConfig(config: String): Int`
|
||||
///
|
||||
/// This only validates the config text; it does not start or mutate any
|
||||
/// instance. On failure this returns `-1` and throws `RuntimeException`.
|
||||
#[unsafe(no_mangle)]
|
||||
pub extern "system" fn Java_com_easytier_jni_EasyTierJNI_parseConfig(
|
||||
mut env: JNIEnv,
|
||||
_class: JClass,
|
||||
env: JNIEnv,
|
||||
class: JClass,
|
||||
config: JString,
|
||||
) -> jint {
|
||||
Lazy::force(&LOGGER_INIT);
|
||||
|
||||
let config_cstr = match jstring_to_cstring(&mut env, &config) {
|
||||
Ok(cstr) => cstr,
|
||||
Err(e) => {
|
||||
throw_exception(&mut env, &format!("Invalid config string: {}", e));
|
||||
return -1;
|
||||
}
|
||||
};
|
||||
|
||||
unsafe {
|
||||
let result = parse_config(config_cstr.as_ptr());
|
||||
if result != 0
|
||||
&& let Some(error) = get_last_error()
|
||||
{
|
||||
throw_exception(&mut env, &error);
|
||||
}
|
||||
result
|
||||
}
|
||||
logger::init();
|
||||
network_api::parse_config_jni(env, class, config)
|
||||
}
|
||||
|
||||
/// 运行网络实例
|
||||
/// Start one local EasyTier network instance from TOML config text.
|
||||
///
|
||||
/// Java signature:
|
||||
/// `EasyTierJNI.runNetworkInstance(config: String): Int`
|
||||
///
|
||||
/// The instance name in the config must be unique in the FFI instance cache.
|
||||
/// On failure this returns `-1` and throws `RuntimeException`.
|
||||
#[unsafe(no_mangle)]
|
||||
pub extern "system" fn Java_com_easytier_jni_EasyTierJNI_runNetworkInstance(
|
||||
mut env: JNIEnv,
|
||||
_class: JClass,
|
||||
env: JNIEnv,
|
||||
class: JClass,
|
||||
config: JString,
|
||||
) -> jint {
|
||||
Lazy::force(&LOGGER_INIT);
|
||||
|
||||
let config_cstr = match jstring_to_cstring(&mut env, &config) {
|
||||
Ok(cstr) => cstr,
|
||||
Err(e) => {
|
||||
throw_exception(&mut env, &format!("Invalid config string: {}", e));
|
||||
return -1;
|
||||
}
|
||||
};
|
||||
|
||||
unsafe {
|
||||
let result = run_network_instance(config_cstr.as_ptr());
|
||||
if result != 0
|
||||
&& let Some(error) = get_last_error()
|
||||
{
|
||||
throw_exception(&mut env, &error);
|
||||
}
|
||||
result
|
||||
}
|
||||
logger::init();
|
||||
network_api::run_network_instance_jni(env, class, config)
|
||||
}
|
||||
|
||||
/// 保持网络实例
|
||||
/// Retain the named network instances and stop all other instances.
|
||||
///
|
||||
/// Java signature:
|
||||
/// `EasyTierJNI.retainNetworkInstance(instanceNames: Array<String>?): Int`
|
||||
///
|
||||
/// Passing `null` or an empty array stops all instances. Null elements inside a
|
||||
/// non-empty array are invalid. On failure this returns `-1` and throws
|
||||
/// `RuntimeException`.
|
||||
#[unsafe(no_mangle)]
|
||||
pub extern "system" fn Java_com_easytier_jni_EasyTierJNI_retainNetworkInstance(
|
||||
mut env: JNIEnv,
|
||||
_class: JClass,
|
||||
env: JNIEnv,
|
||||
class: JClass,
|
||||
instance_names: JObjectArray,
|
||||
) -> jint {
|
||||
Lazy::force(&LOGGER_INIT);
|
||||
|
||||
// 处理 null 数组的情况
|
||||
if instance_names.is_null() {
|
||||
unsafe {
|
||||
let result = retain_network_instance(ptr::null(), 0);
|
||||
if result != 0
|
||||
&& let Some(error) = get_last_error()
|
||||
{
|
||||
throw_exception(&mut env, &error);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
// 获取数组长度
|
||||
let array_length = match env.get_array_length(&instance_names) {
|
||||
Ok(len) => len as usize,
|
||||
Err(e) => {
|
||||
throw_exception(&mut env, &format!("Failed to get array length: {:?}", e));
|
||||
return -1;
|
||||
}
|
||||
};
|
||||
|
||||
// 如果数组为空,停止所有实例
|
||||
if array_length == 0 {
|
||||
unsafe {
|
||||
let result = retain_network_instance(ptr::null(), 0);
|
||||
if result != 0
|
||||
&& let Some(error) = get_last_error()
|
||||
{
|
||||
throw_exception(&mut env, &error);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
// 转换 Java 字符串数组为 C 字符串数组
|
||||
let mut c_strings = Vec::with_capacity(array_length);
|
||||
let mut c_string_ptrs = Vec::with_capacity(array_length);
|
||||
|
||||
for i in 0..array_length {
|
||||
let java_string = match env.get_object_array_element(&instance_names, i as i32) {
|
||||
Ok(obj) => obj,
|
||||
Err(e) => {
|
||||
throw_exception(
|
||||
&mut env,
|
||||
&format!("Failed to get array element {}: {:?}", i, e),
|
||||
);
|
||||
return -1;
|
||||
}
|
||||
};
|
||||
|
||||
if java_string.is_null() {
|
||||
continue; // 跳过 null 元素
|
||||
}
|
||||
|
||||
let jstring = JString::from(java_string);
|
||||
let c_string = match jstring_to_cstring(&mut env, &jstring) {
|
||||
Ok(cstr) => cstr,
|
||||
Err(e) => {
|
||||
throw_exception(
|
||||
&mut env,
|
||||
&format!("Invalid instance name at index {}: {}", i, e),
|
||||
);
|
||||
return -1;
|
||||
}
|
||||
};
|
||||
|
||||
c_string_ptrs.push(c_string.as_ptr());
|
||||
c_strings.push(c_string); // 保持 CString 的所有权
|
||||
}
|
||||
|
||||
unsafe {
|
||||
let result = retain_network_instance(c_string_ptrs.as_ptr(), c_string_ptrs.len());
|
||||
if result != 0
|
||||
&& let Some(error) = get_last_error()
|
||||
{
|
||||
throw_exception(&mut env, &error);
|
||||
}
|
||||
result
|
||||
}
|
||||
logger::init();
|
||||
network_api::retain_network_instance_jni(env, class, instance_names)
|
||||
}
|
||||
|
||||
/// 收集网络信息
|
||||
/// Collect running network instance information.
|
||||
///
|
||||
/// Java signature:
|
||||
/// `EasyTierJNI.collectNetworkInfos(maxLength: Int): String?`
|
||||
///
|
||||
/// Returns a JSON string containing `NetworkInstanceRunningInfoMap`, or null if
|
||||
/// collection fails. `maxLength` limits how many FFI entries are collected. On
|
||||
/// failure this throws `RuntimeException` when an error message is available.
|
||||
#[unsafe(no_mangle)]
|
||||
pub extern "system" fn Java_com_easytier_jni_EasyTierJNI_collectNetworkInfos(
|
||||
mut env: JNIEnv,
|
||||
_class: JClass,
|
||||
env: JNIEnv,
|
||||
class: JClass,
|
||||
max_length: jint,
|
||||
) -> jstring {
|
||||
Lazy::force(&LOGGER_INIT);
|
||||
|
||||
const MAX_INFOS: usize = 100;
|
||||
let mut infos = vec![
|
||||
KeyValuePair {
|
||||
key: ptr::null(),
|
||||
value: ptr::null(),
|
||||
};
|
||||
MAX_INFOS
|
||||
];
|
||||
|
||||
unsafe {
|
||||
let count = collect_network_infos(infos.as_mut_ptr(), MAX_INFOS);
|
||||
if count < 0 {
|
||||
if let Some(error) = get_last_error() {
|
||||
throw_exception(&mut env, &error);
|
||||
}
|
||||
return ptr::null_mut();
|
||||
}
|
||||
|
||||
let mut ret = NetworkInstanceRunningInfoMap::default();
|
||||
|
||||
// 使用 serde_json 构建 JSON
|
||||
for info in infos.iter().take(count as usize) {
|
||||
let key_ptr = info.key;
|
||||
let val_ptr = info.value;
|
||||
if key_ptr.is_null() || val_ptr.is_null() {
|
||||
break;
|
||||
}
|
||||
|
||||
let key = CStr::from_ptr(key_ptr).to_string_lossy();
|
||||
let val = CStr::from_ptr(val_ptr).to_string_lossy();
|
||||
let value = match serde_json::from_str::<NetworkInstanceRunningInfo>(val.as_ref()) {
|
||||
Ok(v) => v,
|
||||
Err(_) => {
|
||||
throw_exception(&mut env, "Failed to parse JSON");
|
||||
continue;
|
||||
}
|
||||
};
|
||||
ret.map.insert(key.to_string(), value);
|
||||
}
|
||||
|
||||
let json_str = serde_json::to_string(&ret).unwrap_or_else(|_| "{}".to_string());
|
||||
|
||||
match env.new_string(&json_str) {
|
||||
Ok(jstr) => jstr.into_raw(),
|
||||
Err(_) => {
|
||||
throw_exception(&mut env, "Failed to create JSON string");
|
||||
ptr::null_mut()
|
||||
}
|
||||
}
|
||||
}
|
||||
logger::init();
|
||||
network_api::collect_network_infos_jni(env, class, max_length)
|
||||
}
|
||||
|
||||
/// 获取最后的错误信息
|
||||
/// List running network instance names and IDs.
|
||||
///
|
||||
/// Java signature:
|
||||
/// `EasyTierJNI.listInstances(maxLength: Int): String?`
|
||||
///
|
||||
/// Returns a JSON object whose keys are instance names and whose values are
|
||||
/// instance ID strings. On failure this returns null and throws
|
||||
/// `RuntimeException`.
|
||||
#[unsafe(no_mangle)]
|
||||
pub extern "system" fn Java_com_easytier_jni_EasyTierJNI_listInstances(
|
||||
env: JNIEnv,
|
||||
class: JClass,
|
||||
max_length: jint,
|
||||
) -> jstring {
|
||||
logger::init();
|
||||
network_api::list_instances_jni(env, class, max_length)
|
||||
}
|
||||
|
||||
/// Call an exposed EasyTier RPC method using protobuf JSON.
|
||||
///
|
||||
/// Java signature:
|
||||
/// `EasyTierJNI.callJsonRpc(serviceName, methodName, domainName, payloadJson): String?`
|
||||
///
|
||||
/// Instance lifecycle management RPCs are intentionally not exposed here. Use
|
||||
/// the dedicated EasyTierJNI instance APIs for start/retain/delete/collect.
|
||||
/// `payloadJson` must include any `instance` selector required by the target
|
||||
/// RPC. On failure this returns null and throws `RuntimeException`.
|
||||
#[unsafe(no_mangle)]
|
||||
pub extern "system" fn Java_com_easytier_jni_EasyTierJNI_callJsonRpc(
|
||||
env: JNIEnv,
|
||||
class: JClass,
|
||||
service_name: JString,
|
||||
method_name: JString,
|
||||
domain_name: JString,
|
||||
payload_json: JString,
|
||||
) -> jstring {
|
||||
logger::init();
|
||||
json_rpc_api::call_json_rpc_jni(
|
||||
env,
|
||||
class,
|
||||
service_name,
|
||||
method_name,
|
||||
domain_name,
|
||||
payload_json,
|
||||
)
|
||||
}
|
||||
|
||||
/// Return the latest FFI/JNI error string for the calling thread.
|
||||
///
|
||||
/// Java signature:
|
||||
/// `EasyTierJNI.getLastError(): String?`
|
||||
///
|
||||
/// This combines the FFI thread-local error with any pending config-server Java
|
||||
/// callback error. It returns null when no error is available.
|
||||
#[unsafe(no_mangle)]
|
||||
pub extern "system" fn Java_com_easytier_jni_EasyTierJNI_getLastError(
|
||||
env: JNIEnv,
|
||||
_class: JClass,
|
||||
class: JClass,
|
||||
) -> jstring {
|
||||
match get_last_error() {
|
||||
Some(error) => match env.new_string(&error) {
|
||||
Ok(jstr) => jstr.into_raw(),
|
||||
Err(_) => ptr::null_mut(),
|
||||
},
|
||||
None => ptr::null_mut(),
|
||||
}
|
||||
error::get_last_error_jni(env, class)
|
||||
}
|
||||
|
||||
/// Start the managed config-server client.
|
||||
///
|
||||
/// Java signature:
|
||||
/// `EasyTierJNI.startConfigServerClient(url, hostname, machineId, secureMode, callback): Int`
|
||||
///
|
||||
/// JNI only converts Java values and keeps the Java callback alive. The FFI
|
||||
/// layer owns singleton lifecycle, config-server/data-plane mutual exclusion,
|
||||
/// remote instance tracking, and callback event timing. If `callback` is
|
||||
/// non-null, each remote apply/delete event is delivered to
|
||||
/// `ConfigServerEventCallback.onEvent(eventJson)`.
|
||||
///
|
||||
/// On failure this returns `-1` and throws `RuntimeException`.
|
||||
#[unsafe(no_mangle)]
|
||||
pub extern "system" fn Java_com_easytier_jni_EasyTierJNI_startConfigServerClient(
|
||||
mut env: JNIEnv,
|
||||
_class: JClass,
|
||||
config_server_url: JString,
|
||||
hostname: JString,
|
||||
machine_id: JString,
|
||||
secure_mode: jboolean,
|
||||
callback: JObject,
|
||||
) -> jint {
|
||||
logger::init();
|
||||
config_server_api::start_config_server_client_jni(
|
||||
&mut env,
|
||||
config_server_url,
|
||||
hostname,
|
||||
machine_id,
|
||||
secure_mode,
|
||||
callback,
|
||||
)
|
||||
}
|
||||
|
||||
/// Stop the managed config-server client.
|
||||
///
|
||||
/// Java signature:
|
||||
/// `EasyTierJNI.stopConfigServerClient(): Int`
|
||||
///
|
||||
/// The FFI layer performs the actual stop and managed instance cleanup. JNI
|
||||
/// releases the Java callback reference after FFI stop succeeds. On failure
|
||||
/// this returns `-1` and throws `RuntimeException`.
|
||||
#[unsafe(no_mangle)]
|
||||
pub extern "system" fn Java_com_easytier_jni_EasyTierJNI_stopConfigServerClient(
|
||||
env: JNIEnv,
|
||||
class: JClass,
|
||||
) -> jint {
|
||||
logger::init();
|
||||
config_server_api::stop_config_server_client_jni(env, class)
|
||||
}
|
||||
|
||||
/// Report whether the managed config-server client is connected.
|
||||
///
|
||||
/// Java signature:
|
||||
/// `EasyTierJNI.isConfigServerClientConnected(): Boolean`
|
||||
///
|
||||
/// Returns `JNI_TRUE` only when the FFI config-server client exists and reports
|
||||
/// connected.
|
||||
#[unsafe(no_mangle)]
|
||||
pub extern "system" fn Java_com_easytier_jni_EasyTierJNI_isConfigServerClientConnected(
|
||||
env: JNIEnv,
|
||||
class: JClass,
|
||||
) -> jboolean {
|
||||
logger::init();
|
||||
config_server_api::is_config_server_client_connected_jni(env, class)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
use once_cell::sync::Lazy;
|
||||
|
||||
static LOGGER_INIT: Lazy<()> = Lazy::new(|| {
|
||||
android_logger::init_once(
|
||||
android_logger::Config::default()
|
||||
.with_max_level(log::LevelFilter::Debug)
|
||||
.with_tag("EasyTier-JNI"),
|
||||
);
|
||||
});
|
||||
|
||||
pub(crate) fn init() {
|
||||
Lazy::force(&LOGGER_INIT);
|
||||
}
|
||||
@@ -0,0 +1,261 @@
|
||||
use std::{ffi::CStr, ptr};
|
||||
|
||||
use easytier::proto::api::manage::{NetworkInstanceRunningInfo, NetworkInstanceRunningInfoMap};
|
||||
use easytier_ffi::{
|
||||
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};
|
||||
use jni::sys::{jint, jstring};
|
||||
|
||||
use crate::{
|
||||
error::{get_last_error, throw_exception},
|
||||
strings::jstring_to_cstring,
|
||||
};
|
||||
|
||||
pub(crate) fn set_tun_fd_jni(
|
||||
mut env: JNIEnv,
|
||||
_class: JClass,
|
||||
inst_name: JString,
|
||||
fd: jint,
|
||||
) -> jint {
|
||||
let inst_name_cstr = match jstring_to_cstring(&mut env, &inst_name) {
|
||||
Ok(cstr) => cstr,
|
||||
Err(e) => {
|
||||
throw_exception(&mut env, &format!("Invalid instance name: {}", e));
|
||||
return -1;
|
||||
}
|
||||
};
|
||||
unsafe {
|
||||
let result = set_tun_fd(inst_name_cstr.as_ptr(), fd);
|
||||
if result != 0
|
||||
&& let Some(error) = get_last_error()
|
||||
{
|
||||
throw_exception(&mut env, &error);
|
||||
}
|
||||
result
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn parse_config_jni(mut env: JNIEnv, _class: JClass, config: JString) -> jint {
|
||||
let config_cstr = match jstring_to_cstring(&mut env, &config) {
|
||||
Ok(cstr) => cstr,
|
||||
Err(e) => {
|
||||
throw_exception(&mut env, &format!("Invalid config string: {}", e));
|
||||
return -1;
|
||||
}
|
||||
};
|
||||
unsafe {
|
||||
let result = parse_config(config_cstr.as_ptr());
|
||||
if result != 0
|
||||
&& let Some(error) = get_last_error()
|
||||
{
|
||||
throw_exception(&mut env, &error);
|
||||
}
|
||||
result
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn run_network_instance_jni(mut env: JNIEnv, _class: JClass, config: JString) -> jint {
|
||||
let config_cstr = match jstring_to_cstring(&mut env, &config) {
|
||||
Ok(cstr) => cstr,
|
||||
Err(e) => {
|
||||
throw_exception(&mut env, &format!("Invalid config string: {}", e));
|
||||
return -1;
|
||||
}
|
||||
};
|
||||
unsafe {
|
||||
let result = run_network_instance(config_cstr.as_ptr());
|
||||
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,
|
||||
instance_names: JObjectArray,
|
||||
) -> jint {
|
||||
if instance_names.is_null() {
|
||||
return retain_all(&mut env);
|
||||
}
|
||||
|
||||
let array_length = match env.get_array_length(&instance_names) {
|
||||
Ok(len) => len as usize,
|
||||
Err(e) => {
|
||||
throw_exception(&mut env, &format!("Failed to get array length: {:?}", e));
|
||||
return -1;
|
||||
}
|
||||
};
|
||||
|
||||
if array_length == 0 {
|
||||
return retain_all(&mut env);
|
||||
}
|
||||
|
||||
let mut c_strings = Vec::with_capacity(array_length);
|
||||
let mut c_string_ptrs = Vec::with_capacity(array_length);
|
||||
|
||||
for i in 0..array_length {
|
||||
let java_string = match env.get_object_array_element(&instance_names, i as i32) {
|
||||
Ok(obj) => obj,
|
||||
Err(e) => {
|
||||
throw_exception(
|
||||
&mut env,
|
||||
&format!("Failed to get array element {}: {:?}", i, e),
|
||||
);
|
||||
return -1;
|
||||
}
|
||||
};
|
||||
|
||||
if java_string.is_null() {
|
||||
throw_exception(
|
||||
&mut env,
|
||||
&format!("Invalid instance name at index {}: null", i),
|
||||
);
|
||||
return -1;
|
||||
}
|
||||
|
||||
let jstring = JString::from(java_string);
|
||||
let c_string = match jstring_to_cstring(&mut env, &jstring) {
|
||||
Ok(cstr) => cstr,
|
||||
Err(e) => {
|
||||
throw_exception(
|
||||
&mut env,
|
||||
&format!("Invalid instance name at index {}: {}", i, e),
|
||||
);
|
||||
return -1;
|
||||
}
|
||||
};
|
||||
|
||||
c_string_ptrs.push(c_string.as_ptr());
|
||||
c_strings.push(c_string);
|
||||
}
|
||||
|
||||
unsafe {
|
||||
let result = retain_network_instance(c_string_ptrs.as_ptr(), c_string_ptrs.len());
|
||||
if result != 0
|
||||
&& let Some(error) = get_last_error()
|
||||
{
|
||||
throw_exception(&mut env, &error);
|
||||
}
|
||||
result
|
||||
}
|
||||
}
|
||||
|
||||
fn retain_all(env: &mut JNIEnv) -> jint {
|
||||
unsafe {
|
||||
let result = retain_network_instance(ptr::null(), 0);
|
||||
if result != 0
|
||||
&& let Some(error) = get_last_error()
|
||||
{
|
||||
throw_exception(env, &error);
|
||||
}
|
||||
result
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn collect_network_infos_jni(
|
||||
mut env: JNIEnv,
|
||||
_class: JClass,
|
||||
max_length: jint,
|
||||
) -> jstring {
|
||||
let max_length = max_length.max(0) as usize;
|
||||
let mut infos = vec![
|
||||
KeyValuePair {
|
||||
key: ptr::null(),
|
||||
value: ptr::null(),
|
||||
};
|
||||
max_length
|
||||
];
|
||||
|
||||
unsafe {
|
||||
let count = collect_network_infos(infos.as_mut_ptr(), max_length);
|
||||
if count < 0 {
|
||||
if let Some(error) = get_last_error() {
|
||||
throw_exception(&mut env, &error);
|
||||
}
|
||||
return ptr::null_mut();
|
||||
}
|
||||
|
||||
let mut ret = NetworkInstanceRunningInfoMap::default();
|
||||
for info in infos.iter().take(count as usize) {
|
||||
let key_ptr = info.key;
|
||||
let val_ptr = info.value;
|
||||
if key_ptr.is_null() || val_ptr.is_null() {
|
||||
break;
|
||||
}
|
||||
|
||||
let key = CStr::from_ptr(key_ptr).to_string_lossy().into_owned();
|
||||
let val = CStr::from_ptr(val_ptr).to_string_lossy().into_owned();
|
||||
free_string(key_ptr);
|
||||
free_string(val_ptr);
|
||||
let value = match serde_json::from_str::<NetworkInstanceRunningInfo>(&val) {
|
||||
Ok(v) => v,
|
||||
Err(_) => {
|
||||
throw_exception(&mut env, "Failed to parse JSON");
|
||||
continue;
|
||||
}
|
||||
};
|
||||
ret.map.insert(key, value);
|
||||
}
|
||||
|
||||
let json_str = serde_json::to_string(&ret).unwrap_or_else(|_| "{}".to_string());
|
||||
match env.new_string(&json_str) {
|
||||
Ok(jstr) => jstr.into_raw(),
|
||||
Err(_) => {
|
||||
throw_exception(&mut env, "Failed to create JSON string");
|
||||
ptr::null_mut()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn list_instances_jni(mut env: JNIEnv, _class: JClass, max_length: jint) -> jstring {
|
||||
let max_length = max_length.max(0) as usize;
|
||||
let mut infos = vec![
|
||||
KeyValuePair {
|
||||
key: ptr::null(),
|
||||
value: ptr::null(),
|
||||
};
|
||||
max_length
|
||||
];
|
||||
|
||||
unsafe {
|
||||
let count = list_instance(infos.as_mut_ptr(), max_length);
|
||||
if count < 0 {
|
||||
if let Some(error) = get_last_error() {
|
||||
throw_exception(&mut env, &error);
|
||||
}
|
||||
return ptr::null_mut();
|
||||
}
|
||||
|
||||
let mut ret = serde_json::Map::new();
|
||||
for info in infos.iter().take(count as usize) {
|
||||
let key_ptr = info.key;
|
||||
let val_ptr = info.value;
|
||||
if key_ptr.is_null() || val_ptr.is_null() {
|
||||
break;
|
||||
}
|
||||
|
||||
let key = CStr::from_ptr(key_ptr).to_string_lossy().into_owned();
|
||||
let val = CStr::from_ptr(val_ptr).to_string_lossy().into_owned();
|
||||
free_string(key_ptr);
|
||||
free_string(val_ptr);
|
||||
ret.insert(key, serde_json::Value::String(val));
|
||||
}
|
||||
|
||||
let json_str = serde_json::Value::Object(ret).to_string();
|
||||
match env.new_string(&json_str) {
|
||||
Ok(jstr) => jstr.into_raw(),
|
||||
Err(_) => {
|
||||
throw_exception(&mut env, "Failed to create instance list JSON string");
|
||||
ptr::null_mut()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
use std::ffi::CString;
|
||||
|
||||
use jni::JNIEnv;
|
||||
use jni::objects::JString;
|
||||
|
||||
pub(crate) fn jstring_to_cstring(env: &mut JNIEnv, jstr: &JString) -> Result<CString, String> {
|
||||
let java_str = env
|
||||
.get_string(jstr)
|
||||
.map_err(|e| format!("Failed to get string: {:?}", e))?;
|
||||
let rust_str = java_str.to_str().map_err(|_| "Invalid UTF-8".to_string())?;
|
||||
CString::new(rust_str).map_err(|_| "String contains null byte".to_string())
|
||||
}
|
||||
|
||||
pub(crate) fn optional_jstring_to_cstring(
|
||||
env: &mut JNIEnv,
|
||||
jstr: &JString,
|
||||
) -> Result<Option<CString>, String> {
|
||||
if jstr.is_null() {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
jstring_to_cstring(env, jstr).map(Some)
|
||||
}
|
||||
@@ -4,14 +4,31 @@ version = "0.1.0"
|
||||
edition.workspace = true
|
||||
|
||||
[lib]
|
||||
crate-type = ["cdylib"]
|
||||
crate-type = ["cdylib", "rlib"]
|
||||
|
||||
[features]
|
||||
default = ["c-abi", "ffi-dataplane"]
|
||||
c-abi = []
|
||||
ffi-dataplane = [
|
||||
"easytier/ffi-dataplane",
|
||||
"easytier-core/proxy-smoltcp-stack",
|
||||
]
|
||||
macos-ne = ["easytier/macos-ne"]
|
||||
|
||||
[dependencies]
|
||||
easytier = { path = "../../easytier" }
|
||||
easytier = { path = "../../easytier", features = ["tracing-log"] }
|
||||
easytier-core = { path = "../../easytier-core" }
|
||||
|
||||
once_cell = "1.18.0"
|
||||
dashmap = "6.0"
|
||||
tokio = { version = "1", features = ["rt-multi-thread", "io-util", "time", "sync", "macros"] }
|
||||
async-trait = "0.1"
|
||||
log = "0.4"
|
||||
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
uuid = "1.17.0"
|
||||
|
||||
[build-dependencies]
|
||||
thunk-rs = { git = "https://github.com/easytier/thunk.git", default-features = false, features = [
|
||||
"win7",
|
||||
] }
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
# Native data-plane ABI v3
|
||||
|
||||
The native data-plane ABI is a thin adapter over the instance-owned
|
||||
`DataPlaneSession`. It does not own sockets, operation state, completion
|
||||
queues, routing policy, or timeouts.
|
||||
|
||||
## Conventions
|
||||
|
||||
- Every immediate call returns `0` on success or a negative
|
||||
`DataPlaneErrorKind` value on failure.
|
||||
- `data_plane_completion_wait` returns `1` when a completion is ready, `0` on
|
||||
timeout or session close, and a negative error value on failure.
|
||||
- `data_plane_completion_drain` returns a non-negative descriptor count or a
|
||||
negative error value.
|
||||
- Handle zero is invalid.
|
||||
- `timeout_ms == UINT64_MAX` means no deadline.
|
||||
- TCP connect/bind/accept and UDP bind timeouts start when submission is
|
||||
accepted.
|
||||
- TCP streams and UDP sockets have persistent read and write deadlines.
|
||||
`data_plane_resource_deadline_set` replaces the selected directions'
|
||||
deadlines immediately, including for active operations. An expired deadline
|
||||
remains expired until it is replaced or cleared with `UINT64_MAX`.
|
||||
- Deadline direction `1` selects reads, `2` selects writes, and `3` selects
|
||||
both.
|
||||
- Request and write bytes are copied before a submit call returns.
|
||||
- Socket-address fields use native-endian integers. Address bytes are in
|
||||
network order. ABI v3 accepts IPv4 only.
|
||||
|
||||
`DataPlaneSocketAddr` is:
|
||||
|
||||
```c
|
||||
typedef struct {
|
||||
uint16_t family; /* 4 */
|
||||
uint16_t port;
|
||||
uint8_t address[16]; /* IPv4 uses the first four bytes */
|
||||
} DataPlaneSocketAddr;
|
||||
```
|
||||
|
||||
`DataPlaneCompletion` is:
|
||||
|
||||
```c
|
||||
typedef struct {
|
||||
uint64_t operation_id;
|
||||
uint16_t operation_kind;
|
||||
uint16_t status; /* 0 or DataPlaneErrorKind */
|
||||
} DataPlaneCompletion;
|
||||
```
|
||||
|
||||
## Lifecycle
|
||||
|
||||
One native session may be open for an EasyTier instance at a time:
|
||||
|
||||
```text
|
||||
data_plane_session_open
|
||||
-> set resource deadlines
|
||||
-> submit operations
|
||||
-> completion_wait
|
||||
-> completion_drain
|
||||
-> typed result_take
|
||||
-> resource_close / operation_free
|
||||
data_plane_session_close
|
||||
```
|
||||
|
||||
Closing a native session cancels and discards its outstanding operations and
|
||||
resources and wakes a thread blocked in `data_plane_completion_wait`.
|
||||
|
||||
The resource and operation IDs returned by the ABI belong to that session.
|
||||
They must always be passed together with the same session handle.
|
||||
|
||||
## Completion and result ownership
|
||||
|
||||
Submission returns an operation ID immediately. Completion descriptors carry
|
||||
only the operation ID, operation kind, and terminal status. Draining a
|
||||
descriptor makes its typed result available but does not consume it.
|
||||
|
||||
`data_plane_result_size` reports the TCP-read or UDP-receive payload size.
|
||||
Typed result-take functions consume the result exactly once. If a supplied
|
||||
buffer is too small, they return `-BufferTooSmall` and leave the result
|
||||
available for a later call.
|
||||
|
||||
Call `data_plane_operation_free` when a drained result is intentionally
|
||||
abandoned. Call `data_plane_resource_close` for TCP streams, listeners, and
|
||||
UDP sockets.
|
||||
|
||||
## Operation kinds
|
||||
|
||||
| Value | Operation |
|
||||
| ---: | --- |
|
||||
| 1 | TCP connect |
|
||||
| 2 | TCP bind |
|
||||
| 3 | TCP accept |
|
||||
| 4 | TCP read |
|
||||
| 5 | TCP write |
|
||||
| 6 | UDP bind |
|
||||
| 7 | UDP receive |
|
||||
| 8 | UDP send |
|
||||
|
||||
The exported function families are:
|
||||
|
||||
- `data_plane_tcp_*_submit`
|
||||
- `data_plane_udp_*_submit`
|
||||
- `data_plane_resource_deadline_set`
|
||||
- `data_plane_completion_wait`
|
||||
- `data_plane_completion_drain`
|
||||
- `data_plane_*_result_take`
|
||||
- `data_plane_operation_cancel`
|
||||
- `data_plane_operation_free`
|
||||
- `data_plane_resource_close`
|
||||
@@ -0,0 +1,8 @@
|
||||
fn main() {
|
||||
let target_os = std::env::var("CARGO_CFG_TARGET_OS").unwrap_or_default();
|
||||
let target_arch = std::env::var("CARGO_CFG_TARGET_ARCH").unwrap_or_default();
|
||||
|
||||
if target_os == "windows" && (target_arch == "x86" || target_arch == "x86_64") {
|
||||
thunk::thunk();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <stdbool.h>
|
||||
#include <unistd.h> // for sleep
|
||||
|
||||
// FFI struct and function declarations
|
||||
typedef struct {
|
||||
const char* key;
|
||||
const char* value;
|
||||
} KeyValuePair;
|
||||
|
||||
typedef void (*config_server_event_callback)(
|
||||
const char* event_json,
|
||||
void* user_data
|
||||
);
|
||||
|
||||
extern int parse_config(const char* cfg_str);
|
||||
extern int run_network_instance(const char* cfg_str);
|
||||
extern void get_error_msg(const char** out);
|
||||
extern void free_string(const char* s);
|
||||
extern int collect_network_infos(KeyValuePair* infos, size_t max_length);
|
||||
extern int start_config_server_client(
|
||||
const char* config_server_url,
|
||||
const char* hostname,
|
||||
const char* machine_id,
|
||||
bool secure_mode,
|
||||
config_server_event_callback callback,
|
||||
void* user_data
|
||||
);
|
||||
extern int stop_config_server_client(void);
|
||||
extern int is_config_server_client_connected(void);
|
||||
|
||||
static void on_config_server_event(const char* event_json, void* user_data) {
|
||||
(void)user_data;
|
||||
printf("config server event: %s\n", event_json);
|
||||
}
|
||||
|
||||
int main() {
|
||||
const char* config = "inst_name = \"test\"\nnetwork = \"test_network\"\n";
|
||||
int ret;
|
||||
|
||||
// 调用 parse_config
|
||||
ret = parse_config(config);
|
||||
if (ret != 0) {
|
||||
const char* err = NULL;
|
||||
get_error_msg(&err);
|
||||
if (err) {
|
||||
printf("parse_config error: %s\n", err);
|
||||
free_string(err);
|
||||
}
|
||||
return 1;
|
||||
}
|
||||
printf("parse_config success\n");
|
||||
|
||||
// 调用 run_network_instance
|
||||
ret = run_network_instance(config);
|
||||
if (ret != 0) {
|
||||
const char* err = NULL;
|
||||
get_error_msg(&err);
|
||||
if (err) {
|
||||
printf("run_network_instance error: %s\n", err);
|
||||
free_string(err);
|
||||
}
|
||||
return 1;
|
||||
}
|
||||
printf("run_network_instance success\n");
|
||||
|
||||
// 周期性调用 collect_network_infos 并打印
|
||||
const size_t max_infos = 8;
|
||||
KeyValuePair* infos = (KeyValuePair*)malloc(sizeof(KeyValuePair) * max_infos);
|
||||
if (!infos) {
|
||||
fprintf(stderr, "malloc failed\n");
|
||||
return 1;
|
||||
}
|
||||
|
||||
for (int i = 0; i < 5; ++i) { // 循环5次作为示例
|
||||
memset(infos, 0, sizeof(KeyValuePair) * max_infos);
|
||||
int count = collect_network_infos(infos, max_infos);
|
||||
if (count < 0) {
|
||||
const char* err = NULL;
|
||||
get_error_msg(&err);
|
||||
if (err) {
|
||||
printf("collect_network_infos error: %s\n", err);
|
||||
free_string(err);
|
||||
}
|
||||
break;
|
||||
}
|
||||
printf("collect_network_infos: %d instance(s)\n", count);
|
||||
for (int j = 0; j < count; ++j) {
|
||||
printf(" [%d] key: %s\n value: %s\n", j, infos[j].key, infos[j].value);
|
||||
free_string(infos[j].key);
|
||||
free_string(infos[j].value);
|
||||
}
|
||||
sleep(1);
|
||||
}
|
||||
free(infos);
|
||||
|
||||
return 0;
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
github.com/go-webgpu/goffi v0.4.1 h1:2hQH5XXloxTyTtIleYv+Rajlwzp6UOETURhSZ5+zJxU=
|
||||
github.com/go-webgpu/goffi v0.4.1/go.mod h1:wfoxNsJkU+5RFbV1kNN1kunhc1lFHuJKK3zpgx08/uM=
|
||||
@@ -0,0 +1,482 @@
|
||||
use std::{
|
||||
cell::Cell,
|
||||
collections::HashSet,
|
||||
ffi::{CString, c_char, c_int, c_void},
|
||||
sync::{
|
||||
Arc, Mutex,
|
||||
atomic::{AtomicBool, Ordering},
|
||||
},
|
||||
};
|
||||
|
||||
use easytier::{
|
||||
common::{
|
||||
MachineIdOptions,
|
||||
config::{ConfigLoader as _, TomlConfigLoader},
|
||||
},
|
||||
web_client::{WebClient, WebClientHooks, parse_config_server_endpoint, run_web_client},
|
||||
};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::{
|
||||
data_plane::remove_data_plane_sessions_by_instance_ids,
|
||||
error::set_error_msg,
|
||||
state::{ffi_context, resolve_instance_id_by_name},
|
||||
strings::{c_str_to_string, optional_c_str_to_string},
|
||||
types::ConfigServerEventCallback,
|
||||
};
|
||||
|
||||
thread_local! {
|
||||
static IN_CONFIG_SERVER_CALLBACK: Cell<bool> = const { Cell::new(false) };
|
||||
}
|
||||
|
||||
static CONFIG_SERVER_CLIENT: once_cell::sync::Lazy<Mutex<Option<ManagedConfigServerClient>>> =
|
||||
once_cell::sync::Lazy::new(|| Mutex::new(None));
|
||||
static CONFIG_SERVER_CLIENT_ACTIVE: once_cell::sync::Lazy<AtomicBool> =
|
||||
once_cell::sync::Lazy::new(|| AtomicBool::new(false));
|
||||
static CONFIG_SERVER_CLIENT_STOPPING: once_cell::sync::Lazy<AtomicBool> =
|
||||
once_cell::sync::Lazy::new(|| AtomicBool::new(false));
|
||||
static LAST_CONFIG_SERVER_CALLBACK_ERROR: once_cell::sync::Lazy<Mutex<Option<String>>> =
|
||||
once_cell::sync::Lazy::new(|| Mutex::new(None));
|
||||
|
||||
pub(crate) struct ConfigServerCallbackScope;
|
||||
|
||||
impl ConfigServerCallbackScope {
|
||||
pub(crate) fn enter() -> Self {
|
||||
IN_CONFIG_SERVER_CALLBACK.with(|in_callback| in_callback.set(true));
|
||||
Self
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for ConfigServerCallbackScope {
|
||||
fn drop(&mut self) {
|
||||
IN_CONFIG_SERVER_CALLBACK.with(|in_callback| in_callback.set(false));
|
||||
}
|
||||
}
|
||||
|
||||
pub fn in_config_server_callback() -> bool {
|
||||
IN_CONFIG_SERVER_CALLBACK.with(Cell::get)
|
||||
}
|
||||
|
||||
fn config_server_machine_id_options(machine_id: String) -> MachineIdOptions {
|
||||
MachineIdOptions {
|
||||
explicit_machine_id: Some(machine_id),
|
||||
state_dir: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn validate_config_server_client_options(
|
||||
config_server_url_s: &str,
|
||||
machine_id: &str,
|
||||
) -> Result<(), String> {
|
||||
if machine_id.trim().is_empty() {
|
||||
return Err("machine_id is empty".to_string());
|
||||
}
|
||||
|
||||
parse_config_server_endpoint(config_server_url_s)
|
||||
.map(|_| ())
|
||||
.map_err(|error| error.to_string())
|
||||
}
|
||||
|
||||
struct ManagedConfigServerClient {
|
||||
client: WebClient,
|
||||
hooks: Arc<ManagedConfigServerClientHooks>,
|
||||
}
|
||||
|
||||
pub(crate) struct ManagedConfigServerClientHooks {
|
||||
pub(crate) instance_ids: Mutex<HashSet<Uuid>>,
|
||||
callback_delivery: Mutex<()>,
|
||||
stopping: AtomicBool,
|
||||
callback: ConfigServerEventCallback,
|
||||
user_data: usize,
|
||||
}
|
||||
|
||||
impl ManagedConfigServerClientHooks {
|
||||
pub(crate) fn new(callback: ConfigServerEventCallback, user_data: *mut c_void) -> Self {
|
||||
Self {
|
||||
instance_ids: Mutex::new(HashSet::new()),
|
||||
callback_delivery: Mutex::new(()),
|
||||
stopping: AtomicBool::new(false),
|
||||
callback,
|
||||
user_data: user_data as usize,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn tracked_instance_ids(&self) -> Vec<Uuid> {
|
||||
self.instance_ids
|
||||
.lock()
|
||||
.map(|guard| guard.iter().copied().collect())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
fn remove_tracked_instance_ids(&self, ids: &[Uuid]) -> Result<Vec<Uuid>, String> {
|
||||
let mut guard = self.instance_ids.lock().map_err(|err| err.to_string())?;
|
||||
Ok(ids
|
||||
.iter()
|
||||
.filter_map(|id| guard.remove(id).then_some(*id))
|
||||
.collect())
|
||||
}
|
||||
|
||||
fn validate_instance_name(&self, inst_name: &str, inst_id: Uuid) -> Result<(), String> {
|
||||
if let Some(existing_id) =
|
||||
resolve_instance_id_by_name(inst_name).map_err(|error| error.to_string())?
|
||||
&& existing_id != inst_id
|
||||
{
|
||||
return Err(format!("instance name {} already exists", inst_name));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn start_stopping(&self) -> Vec<Uuid> {
|
||||
let _delivery_guard = if in_config_server_callback() {
|
||||
None
|
||||
} else {
|
||||
self.callback_delivery.lock().ok()
|
||||
};
|
||||
let mut guard = match self.instance_ids.lock() {
|
||||
Ok(guard) => guard,
|
||||
Err(_) => return Vec::new(),
|
||||
};
|
||||
self.stopping.store(true, Ordering::Release);
|
||||
guard.drain().collect()
|
||||
}
|
||||
|
||||
pub(crate) fn note_callback_error(&self, error: String) {
|
||||
log::warn!("config server event callback failed: {}", error);
|
||||
if let Ok(mut guard) = LAST_CONFIG_SERVER_CALLBACK_ERROR.lock() {
|
||||
*guard = Some(error);
|
||||
}
|
||||
}
|
||||
|
||||
fn emit_event_with_delivery_locked(
|
||||
&self,
|
||||
event: &str,
|
||||
instance_id: Uuid,
|
||||
) -> Result<(), String> {
|
||||
if self.stopping.load(Ordering::Acquire) {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let Some(callback) = self.callback else {
|
||||
return Ok(());
|
||||
};
|
||||
let instance_name = ffi_context()
|
||||
.manager
|
||||
.instance(instance_id)
|
||||
.map(|instance| instance.instance_name().to_owned())
|
||||
.unwrap_or_default();
|
||||
let network_name = ffi_context()
|
||||
.manager
|
||||
.config(instance_id)
|
||||
.map(|config| config.get_network_identity().network_name)
|
||||
.unwrap_or_default();
|
||||
let event_json = serde_json::json!({
|
||||
"event": event,
|
||||
"success": true,
|
||||
"instance_id": instance_id.to_string(),
|
||||
"instance_name": instance_name,
|
||||
"network_name": network_name,
|
||||
"error": null,
|
||||
})
|
||||
.to_string();
|
||||
let event_json = CString::new(event_json).map_err(|err| err.to_string())?;
|
||||
let _callback_scope = ConfigServerCallbackScope::enter();
|
||||
unsafe {
|
||||
callback(event_json.as_ptr(), self.user_data as *mut c_void);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn emit_event(&self, event: &str, instance_id: Uuid) -> Result<(), String> {
|
||||
let _delivery_guard = self
|
||||
.callback_delivery
|
||||
.lock()
|
||||
.map_err(|err| err.to_string())?;
|
||||
self.emit_event_with_delivery_locked(event, instance_id)
|
||||
}
|
||||
|
||||
fn wait_for_callback_delivery(&self) {
|
||||
if in_config_server_callback() {
|
||||
return;
|
||||
}
|
||||
|
||||
if let Ok(guard) = self.callback_delivery.lock() {
|
||||
drop(guard);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl WebClientHooks for ManagedConfigServerClientHooks {
|
||||
fn manages_remote_config_instances(&self) -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
async fn pre_run_network_instance(&self, cfg: &TomlConfigLoader) -> Result<(), String> {
|
||||
if self.stopping.load(Ordering::Acquire) {
|
||||
return Err("config server client is stopping".to_string());
|
||||
}
|
||||
|
||||
let inst_name = cfg.get_inst_name();
|
||||
let inst_id = cfg.get_id();
|
||||
|
||||
self.validate_instance_name(&inst_name, inst_id)
|
||||
}
|
||||
|
||||
async fn post_run_network_instance(&self, id: &Uuid) -> Result<(), String> {
|
||||
let _delivery_guard = self
|
||||
.callback_delivery
|
||||
.lock()
|
||||
.map_err(|err| err.to_string())?;
|
||||
if self.stopping.load(Ordering::Acquire) {
|
||||
return Err("config server client is stopping".to_string());
|
||||
}
|
||||
let Some(inst_name) = ffi_context()
|
||||
.manager
|
||||
.instance(*id)
|
||||
.map(|instance| instance.instance_name().to_owned())
|
||||
else {
|
||||
return Err(format!("instance {} not found after start", id));
|
||||
};
|
||||
|
||||
self.instance_ids
|
||||
.lock()
|
||||
.map_err(|err| err.to_string())?
|
||||
.insert(*id);
|
||||
if let Err(error) = self.validate_instance_name(&inst_name, *id) {
|
||||
self.remove_tracked_instance_ids(&[*id])?;
|
||||
return Err(error);
|
||||
}
|
||||
|
||||
remove_data_plane_sessions_by_instance_ids(&[*id]);
|
||||
|
||||
if let Err(err) = self.emit_event_with_delivery_locked("run_network_instance", *id) {
|
||||
self.note_callback_error(err);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn post_remove_network_instances(&self, ids: &[Uuid]) -> Result<(), String> {
|
||||
let removed_ids = self.remove_tracked_instance_ids(ids)?;
|
||||
remove_data_plane_sessions_by_instance_ids(&removed_ids);
|
||||
|
||||
for id in removed_ids {
|
||||
if let Err(err) = self.emit_event("delete_network_instance", id) {
|
||||
self.note_callback_error(err);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn remove_config_server_tracked_instance_ids(ids: &[Uuid]) {
|
||||
if ids.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
if let Ok(guard) = CONFIG_SERVER_CLIENT.lock()
|
||||
&& let Some(managed) = guard.as_ref()
|
||||
&& let Err(err) = managed.hooks.remove_tracked_instance_ids(ids)
|
||||
{
|
||||
log::warn!("failed to remove config server tracked ids: {}", err);
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn wait_for_config_server_delivery() {
|
||||
let hooks = CONFIG_SERVER_CLIENT
|
||||
.lock()
|
||||
.ok()
|
||||
.and_then(|guard| guard.as_ref().map(|managed| managed.hooks.clone()));
|
||||
if let Some(hooks) = hooks {
|
||||
hooks.wait_for_callback_delivery();
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn last_callback_error() -> Option<String> {
|
||||
LAST_CONFIG_SERVER_CALLBACK_ERROR
|
||||
.lock()
|
||||
.ok()
|
||||
.and_then(|guard| guard.clone())
|
||||
}
|
||||
|
||||
pub(crate) fn clear_last_callback_error() {
|
||||
if let Ok(mut guard) = LAST_CONFIG_SERVER_CALLBACK_ERROR.lock() {
|
||||
*guard = None;
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "ffi-dataplane")]
|
||||
pub(crate) fn is_config_server_active_or_stopping() -> bool {
|
||||
CONFIG_SERVER_CLIENT_ACTIVE.load(Ordering::Acquire)
|
||||
|| CONFIG_SERVER_CLIENT_STOPPING.load(Ordering::Acquire)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn set_active_for_test(active: bool) {
|
||||
CONFIG_SERVER_CLIENT_ACTIVE.store(active, Ordering::Release);
|
||||
}
|
||||
|
||||
/// # Safety
|
||||
/// Start the config server client.
|
||||
///
|
||||
/// `config_server_url` must be a valid null-terminated UTF-8 string.
|
||||
/// `hostname` may be null; if non-null it must be a valid null-terminated UTF-8 string.
|
||||
/// `machine_id` must be a valid null-terminated UTF-8 string.
|
||||
/// `event_json` passed to `callback` is valid only during that callback invocation.
|
||||
pub(crate) unsafe fn start_config_server_client(
|
||||
config_server_url: *const c_char,
|
||||
hostname: *const c_char,
|
||||
machine_id: *const c_char,
|
||||
secure_mode: bool,
|
||||
callback: ConfigServerEventCallback,
|
||||
user_data: *mut c_void,
|
||||
) -> c_int {
|
||||
if in_config_server_callback() {
|
||||
set_error_msg("cannot start config server client from config server callback");
|
||||
return -1;
|
||||
}
|
||||
|
||||
let config_server_url = match unsafe { c_str_to_string(config_server_url, "config_server_url") }
|
||||
{
|
||||
Ok(value) => value,
|
||||
Err(err) => {
|
||||
set_error_msg(&err);
|
||||
return -1;
|
||||
}
|
||||
};
|
||||
let hostname = match unsafe { optional_c_str_to_string(hostname, "hostname") } {
|
||||
Ok(value) => value,
|
||||
Err(err) => {
|
||||
set_error_msg(&err);
|
||||
return -1;
|
||||
}
|
||||
};
|
||||
let machine_id = match unsafe { c_str_to_string(machine_id, "machine_id") } {
|
||||
Err(err) => {
|
||||
set_error_msg(&err);
|
||||
return -1;
|
||||
}
|
||||
Ok(value) => value,
|
||||
};
|
||||
if let Err(err) = validate_config_server_client_options(&config_server_url, &machine_id) {
|
||||
set_error_msg(&err);
|
||||
return -1;
|
||||
}
|
||||
|
||||
let mut guard = match CONFIG_SERVER_CLIENT.lock() {
|
||||
Ok(guard) => guard,
|
||||
Err(err) => {
|
||||
set_error_msg(&format!("failed to lock config server client: {}", err));
|
||||
return -1;
|
||||
}
|
||||
};
|
||||
|
||||
if guard.is_some() {
|
||||
set_error_msg("config server client already exists");
|
||||
return -1;
|
||||
}
|
||||
if CONFIG_SERVER_CLIENT_STOPPING.load(Ordering::Acquire) {
|
||||
set_error_msg("config server client is stopping");
|
||||
return -1;
|
||||
}
|
||||
clear_last_callback_error();
|
||||
|
||||
#[cfg(feature = "ffi-dataplane")]
|
||||
let data_plane_usage_guard = match crate::data_plane::lock_for_config_server_start() {
|
||||
Ok(guard) => guard,
|
||||
Err(err) => {
|
||||
set_error_msg(&err);
|
||||
return -1;
|
||||
}
|
||||
};
|
||||
|
||||
CONFIG_SERVER_CLIENT_ACTIVE.store(true, Ordering::Release);
|
||||
#[cfg(feature = "ffi-dataplane")]
|
||||
drop(data_plane_usage_guard);
|
||||
|
||||
let hooks = Arc::new(ManagedConfigServerClientHooks::new(callback, user_data));
|
||||
let client = match ffi_context().runtime.block_on(run_web_client(
|
||||
&config_server_url,
|
||||
config_server_machine_id_options(machine_id),
|
||||
hostname,
|
||||
secure_mode,
|
||||
ffi_context().manager.clone(),
|
||||
Some(hooks.clone()),
|
||||
)) {
|
||||
Ok(client) => client,
|
||||
Err(err) => {
|
||||
CONFIG_SERVER_CLIENT_ACTIVE.store(false, Ordering::Release);
|
||||
set_error_msg(&format!("failed to start config server client: {}", err));
|
||||
return -1;
|
||||
}
|
||||
};
|
||||
|
||||
*guard = Some(ManagedConfigServerClient { client, hooks });
|
||||
0
|
||||
}
|
||||
|
||||
pub(crate) fn stop_config_server_client() -> c_int {
|
||||
if in_config_server_callback() {
|
||||
set_error_msg("cannot stop config server client from config server callback");
|
||||
return -1;
|
||||
}
|
||||
|
||||
let guard = match CONFIG_SERVER_CLIENT.lock() {
|
||||
Ok(guard) => guard,
|
||||
Err(err) => {
|
||||
set_error_msg(&format!("failed to lock config server client: {}", err));
|
||||
return -1;
|
||||
}
|
||||
};
|
||||
|
||||
let Some(managed) = guard.as_ref() else {
|
||||
CONFIG_SERVER_CLIENT_ACTIVE.store(false, Ordering::Release);
|
||||
return 0;
|
||||
};
|
||||
if CONFIG_SERVER_CLIENT_STOPPING.swap(true, Ordering::AcqRel) {
|
||||
set_error_msg("config server client is stopping");
|
||||
return -1;
|
||||
}
|
||||
let hooks = managed.hooks.clone();
|
||||
// Keep the client discoverable until the canonical transaction drains its
|
||||
// tracking. Earlier removals must still retire IDs from these same hooks.
|
||||
drop(guard);
|
||||
|
||||
let delete_result = ffi_context().runtime.block_on(
|
||||
ffi_context()
|
||||
.process_management
|
||||
.delete_owned_network_instances_selected_by(|| hooks.start_stopping()),
|
||||
);
|
||||
let managed = match CONFIG_SERVER_CLIENT.lock() {
|
||||
Ok(mut guard) => guard.take(),
|
||||
Err(err) => {
|
||||
hooks.wait_for_callback_delivery();
|
||||
CONFIG_SERVER_CLIENT_ACTIVE.store(false, Ordering::Release);
|
||||
set_error_msg(&format!("failed to lock config server client: {err}"));
|
||||
return -1;
|
||||
}
|
||||
};
|
||||
drop(managed);
|
||||
hooks.wait_for_callback_delivery();
|
||||
CONFIG_SERVER_CLIENT_ACTIVE.store(false, Ordering::Release);
|
||||
CONFIG_SERVER_CLIENT_STOPPING.store(false, Ordering::Release);
|
||||
|
||||
if let Err(err) = delete_result {
|
||||
set_error_msg(&format!(
|
||||
"failed to delete config server instances: {}",
|
||||
err
|
||||
));
|
||||
return -1;
|
||||
}
|
||||
0
|
||||
}
|
||||
|
||||
pub(crate) fn is_config_server_client_connected() -> c_int {
|
||||
CONFIG_SERVER_CLIENT
|
||||
.lock()
|
||||
.ok()
|
||||
.and_then(|guard| guard.as_ref().map(|managed| managed.client.is_connected()))
|
||||
.map(i32::from)
|
||||
.unwrap_or(0)
|
||||
}
|
||||
@@ -0,0 +1,685 @@
|
||||
use std::{
|
||||
ffi::{c_char, c_int, c_uchar},
|
||||
net::{IpAddr, Ipv4Addr, SocketAddr},
|
||||
ptr,
|
||||
};
|
||||
|
||||
use easytier_core::gateway::DataPlaneErrorKind;
|
||||
|
||||
use super::session::{self, NativeDataPlaneError, NativeDataPlaneResult};
|
||||
use crate::{
|
||||
error::set_error_msg,
|
||||
strings::c_str_to_string,
|
||||
types::{DataPlaneCompletion, DataPlaneSocketAddr},
|
||||
};
|
||||
|
||||
pub const DATA_PLANE_DEADLINE_READ: u32 = 1 << 0;
|
||||
pub const DATA_PLANE_DEADLINE_WRITE: u32 = 1 << 1;
|
||||
|
||||
fn failure(error: NativeDataPlaneError) -> c_int {
|
||||
set_error_msg(&error.message);
|
||||
-(error.kind as c_int)
|
||||
}
|
||||
|
||||
fn status(result: NativeDataPlaneResult<()>) -> c_int {
|
||||
match result {
|
||||
Ok(()) => 0,
|
||||
Err(error) => failure(error),
|
||||
}
|
||||
}
|
||||
|
||||
fn invalid(message: impl Into<String>) -> NativeDataPlaneError {
|
||||
NativeDataPlaneError {
|
||||
kind: DataPlaneErrorKind::Io,
|
||||
message: message.into(),
|
||||
}
|
||||
}
|
||||
|
||||
fn socket_addr(address: DataPlaneSocketAddr) -> NativeDataPlaneResult<SocketAddr> {
|
||||
let ip = match address.family {
|
||||
4 => IpAddr::V4(Ipv4Addr::new(
|
||||
address.address[0],
|
||||
address.address[1],
|
||||
address.address[2],
|
||||
address.address[3],
|
||||
)),
|
||||
6 => {
|
||||
return Err(NativeDataPlaneError {
|
||||
kind: DataPlaneErrorKind::AddressFamilyUnsupported,
|
||||
message: "IPv6 is not supported by data-plane ABI v3".to_string(),
|
||||
});
|
||||
}
|
||||
family => {
|
||||
return Err(NativeDataPlaneError {
|
||||
kind: DataPlaneErrorKind::AddressFamilyUnsupported,
|
||||
message: format!("unsupported address family {family}"),
|
||||
});
|
||||
}
|
||||
};
|
||||
Ok(SocketAddr::new(ip, address.port))
|
||||
}
|
||||
|
||||
fn ffi_socket_addr(address: SocketAddr) -> DataPlaneSocketAddr {
|
||||
match address.ip() {
|
||||
IpAddr::V4(ip) => {
|
||||
let mut bytes = [0; 16];
|
||||
bytes[..4].copy_from_slice(&ip.octets());
|
||||
DataPlaneSocketAddr {
|
||||
family: 4,
|
||||
port: address.port(),
|
||||
address: bytes,
|
||||
}
|
||||
}
|
||||
IpAddr::V6(ip) => DataPlaneSocketAddr {
|
||||
family: 6,
|
||||
port: address.port(),
|
||||
address: ip.octets(),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
unsafe fn copy_input(ptr: *const c_uchar, len: u32) -> NativeDataPlaneResult<Vec<u8>> {
|
||||
if len == 0 {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
if ptr.is_null() {
|
||||
return Err(invalid("input buffer is null"));
|
||||
}
|
||||
Ok(unsafe { std::slice::from_raw_parts(ptr, len as usize) }.to_vec())
|
||||
}
|
||||
|
||||
unsafe fn output_slice<'a>(ptr: *mut c_uchar, len: u32) -> NativeDataPlaneResult<&'a mut [u8]> {
|
||||
if len == 0 {
|
||||
return Ok(&mut []);
|
||||
}
|
||||
if ptr.is_null() {
|
||||
return Err(invalid("output buffer is null"));
|
||||
}
|
||||
Ok(unsafe { std::slice::from_raw_parts_mut(ptr, len as usize) })
|
||||
}
|
||||
|
||||
fn write_operation(
|
||||
out_operation: *mut u64,
|
||||
submit: impl FnOnce() -> NativeDataPlaneResult<u64>,
|
||||
) -> c_int {
|
||||
if out_operation.is_null() {
|
||||
return failure(invalid("out_operation is null"));
|
||||
}
|
||||
match submit() {
|
||||
Ok(operation) => {
|
||||
unsafe {
|
||||
*out_operation = operation;
|
||||
}
|
||||
0
|
||||
}
|
||||
Err(error) => failure(error),
|
||||
}
|
||||
}
|
||||
|
||||
/// # Safety
|
||||
///
|
||||
/// If non-null, `inst_name` must point to a valid NUL-terminated string.
|
||||
/// `out_session` must be null or point to writable, properly aligned storage
|
||||
/// for one `u64`.
|
||||
#[cfg_attr(feature = "c-abi", unsafe(no_mangle))]
|
||||
pub unsafe extern "C" fn data_plane_session_open(
|
||||
inst_name: *const c_char,
|
||||
out_session: *mut u64,
|
||||
) -> c_int {
|
||||
if out_session.is_null() {
|
||||
return failure(invalid("out_session is null"));
|
||||
}
|
||||
unsafe {
|
||||
*out_session = 0;
|
||||
}
|
||||
let inst_name = match unsafe { c_str_to_string(inst_name, "inst_name") } {
|
||||
Ok(inst_name) => inst_name,
|
||||
Err(error) => return failure(invalid(error)),
|
||||
};
|
||||
match session::open(&inst_name) {
|
||||
Ok(handle) => {
|
||||
unsafe {
|
||||
*out_session = handle;
|
||||
}
|
||||
0
|
||||
}
|
||||
Err(error) => failure(error),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "c-abi", unsafe(no_mangle))]
|
||||
pub extern "C" fn data_plane_session_close(session: u64) -> c_int {
|
||||
status(super::session::close(session))
|
||||
}
|
||||
|
||||
/// # Safety
|
||||
///
|
||||
/// `out_operation` must be null or point to writable, properly aligned
|
||||
/// storage for one `u64`.
|
||||
#[cfg_attr(feature = "c-abi", unsafe(no_mangle))]
|
||||
pub unsafe extern "C" fn data_plane_tcp_connect_submit(
|
||||
session: u64,
|
||||
peer_addr: DataPlaneSocketAddr,
|
||||
timeout_ms: u64,
|
||||
out_operation: *mut u64,
|
||||
) -> c_int {
|
||||
let peer_addr = match socket_addr(peer_addr) {
|
||||
Ok(address) => address,
|
||||
Err(error) => return failure(error),
|
||||
};
|
||||
write_operation(out_operation, || {
|
||||
super::session::submit_tcp_connect(session, peer_addr, timeout_ms)
|
||||
})
|
||||
}
|
||||
|
||||
/// # Safety
|
||||
///
|
||||
/// `out_operation` must be null or point to writable, properly aligned
|
||||
/// storage for one `u64`.
|
||||
#[cfg_attr(feature = "c-abi", unsafe(no_mangle))]
|
||||
pub unsafe extern "C" fn data_plane_tcp_bind_submit(
|
||||
session: u64,
|
||||
local_port: u16,
|
||||
timeout_ms: u64,
|
||||
out_operation: *mut u64,
|
||||
) -> c_int {
|
||||
write_operation(out_operation, || {
|
||||
super::session::submit_tcp_bind(session, local_port, timeout_ms)
|
||||
})
|
||||
}
|
||||
|
||||
/// # Safety
|
||||
///
|
||||
/// `out_operation` must be null or point to writable, properly aligned
|
||||
/// storage for one `u64`.
|
||||
#[cfg_attr(feature = "c-abi", unsafe(no_mangle))]
|
||||
pub unsafe extern "C" fn data_plane_tcp_accept_submit(
|
||||
session: u64,
|
||||
listener: u64,
|
||||
timeout_ms: u64,
|
||||
out_operation: *mut u64,
|
||||
) -> c_int {
|
||||
write_operation(out_operation, || {
|
||||
super::session::submit_tcp_accept(session, listener, timeout_ms)
|
||||
})
|
||||
}
|
||||
|
||||
/// # Safety
|
||||
///
|
||||
/// `out_operation` must be null or point to writable, properly aligned
|
||||
/// storage for one `u64`.
|
||||
#[cfg_attr(feature = "c-abi", unsafe(no_mangle))]
|
||||
pub unsafe extern "C" fn data_plane_tcp_read_submit(
|
||||
session: u64,
|
||||
stream: u64,
|
||||
max_len: u32,
|
||||
out_operation: *mut u64,
|
||||
) -> c_int {
|
||||
write_operation(out_operation, || {
|
||||
super::session::submit_tcp_read(session, stream, max_len)
|
||||
})
|
||||
}
|
||||
|
||||
/// # Safety
|
||||
///
|
||||
/// When `len` is nonzero, `data` must point to `len` readable bytes.
|
||||
/// `out_operation` must be null or point to writable, properly aligned
|
||||
/// storage for one `u64`.
|
||||
#[cfg_attr(feature = "c-abi", unsafe(no_mangle))]
|
||||
pub unsafe extern "C" fn data_plane_tcp_write_submit(
|
||||
session: u64,
|
||||
stream: u64,
|
||||
data: *const c_uchar,
|
||||
len: u32,
|
||||
out_operation: *mut u64,
|
||||
) -> c_int {
|
||||
let data = match unsafe { copy_input(data, len) } {
|
||||
Ok(data) => data,
|
||||
Err(error) => return failure(error),
|
||||
};
|
||||
write_operation(out_operation, || {
|
||||
super::session::submit_tcp_write(session, stream, data)
|
||||
})
|
||||
}
|
||||
|
||||
/// # Safety
|
||||
///
|
||||
/// `out_operation` must be null or point to writable, properly aligned
|
||||
/// storage for one `u64`.
|
||||
#[cfg_attr(feature = "c-abi", unsafe(no_mangle))]
|
||||
pub unsafe extern "C" fn data_plane_udp_bind_submit(
|
||||
session: u64,
|
||||
local_port: u16,
|
||||
timeout_ms: u64,
|
||||
out_operation: *mut u64,
|
||||
) -> c_int {
|
||||
write_operation(out_operation, || {
|
||||
super::session::submit_udp_bind(session, local_port, timeout_ms)
|
||||
})
|
||||
}
|
||||
|
||||
/// # Safety
|
||||
///
|
||||
/// `out_operation` must be null or point to writable, properly aligned
|
||||
/// storage for one `u64`.
|
||||
#[cfg_attr(feature = "c-abi", unsafe(no_mangle))]
|
||||
pub unsafe extern "C" fn data_plane_udp_receive_submit(
|
||||
session: u64,
|
||||
socket: u64,
|
||||
max_len: u32,
|
||||
out_operation: *mut u64,
|
||||
) -> c_int {
|
||||
write_operation(out_operation, || {
|
||||
super::session::submit_udp_receive(session, socket, max_len)
|
||||
})
|
||||
}
|
||||
|
||||
/// # Safety
|
||||
///
|
||||
/// When `len` is nonzero, `data` must point to `len` readable bytes.
|
||||
/// `out_operation` must be null or point to writable, properly aligned
|
||||
/// storage for one `u64`.
|
||||
#[cfg_attr(feature = "c-abi", unsafe(no_mangle))]
|
||||
pub unsafe extern "C" fn data_plane_udp_send_submit(
|
||||
session: u64,
|
||||
socket: u64,
|
||||
peer_addr: DataPlaneSocketAddr,
|
||||
data: *const c_uchar,
|
||||
len: u32,
|
||||
out_operation: *mut u64,
|
||||
) -> c_int {
|
||||
let peer_addr = match socket_addr(peer_addr) {
|
||||
Ok(address) => address,
|
||||
Err(error) => return failure(error),
|
||||
};
|
||||
let data = match unsafe { copy_input(data, len) } {
|
||||
Ok(data) => data,
|
||||
Err(error) => return failure(error),
|
||||
};
|
||||
write_operation(out_operation, || {
|
||||
super::session::submit_udp_send(session, socket, peer_addr, data)
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "c-abi", unsafe(no_mangle))]
|
||||
pub extern "C" fn data_plane_resource_deadline_set(
|
||||
session: u64,
|
||||
resource: u64,
|
||||
direction: u32,
|
||||
timeout_ms: u64,
|
||||
) -> c_int {
|
||||
let read = direction & DATA_PLANE_DEADLINE_READ != 0;
|
||||
let write = direction & DATA_PLANE_DEADLINE_WRITE != 0;
|
||||
if direction == 0 || direction & !(DATA_PLANE_DEADLINE_READ | DATA_PLANE_DEADLINE_WRITE) != 0 {
|
||||
return failure(invalid(format!("invalid deadline direction {direction}")));
|
||||
}
|
||||
status(super::session::set_resource_deadline(
|
||||
session, resource, read, write, timeout_ms,
|
||||
))
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "c-abi", unsafe(no_mangle))]
|
||||
pub extern "C" fn data_plane_operation_cancel(session: u64, operation: u64) -> c_int {
|
||||
status(super::session::cancel_operation(session, operation))
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "c-abi", unsafe(no_mangle))]
|
||||
pub extern "C" fn data_plane_operation_free(session: u64, operation: u64) -> c_int {
|
||||
status(super::session::free_operation(session, operation))
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "c-abi", unsafe(no_mangle))]
|
||||
pub extern "C" fn data_plane_resource_close(session: u64, resource: u64) -> c_int {
|
||||
status(super::session::close_resource(session, resource))
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "c-abi", unsafe(no_mangle))]
|
||||
pub extern "C" fn data_plane_completion_wait(session: u64, timeout_ms: u64) -> c_int {
|
||||
match super::session::completion_wait(session, timeout_ms) {
|
||||
Ok(true) => 1,
|
||||
Ok(false) => 0,
|
||||
Err(error) => failure(error),
|
||||
}
|
||||
}
|
||||
|
||||
/// # Safety
|
||||
///
|
||||
/// When `capacity` is nonzero, `completions` must point to writable, properly
|
||||
/// aligned storage for `capacity` consecutive [`DataPlaneCompletion`] values.
|
||||
#[cfg_attr(feature = "c-abi", unsafe(no_mangle))]
|
||||
pub unsafe extern "C" fn data_plane_completion_drain(
|
||||
session: u64,
|
||||
completions: *mut DataPlaneCompletion,
|
||||
capacity: u32,
|
||||
) -> c_int {
|
||||
if capacity != 0 && completions.is_null() {
|
||||
return failure(invalid("completions is null"));
|
||||
}
|
||||
let drained = match super::session::drain_completions(session, capacity as usize) {
|
||||
Ok(drained) => drained,
|
||||
Err(error) => return failure(error),
|
||||
};
|
||||
for (index, completion) in drained.iter().enumerate() {
|
||||
unsafe {
|
||||
ptr::write(
|
||||
completions.add(index),
|
||||
DataPlaneCompletion {
|
||||
operation_id: completion.operation_id.get(),
|
||||
operation_kind: completion.kind as u16,
|
||||
status: completion.status.code(),
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
drained.len() as c_int
|
||||
}
|
||||
|
||||
/// # Safety
|
||||
///
|
||||
/// `out_size` must be null or point to writable, properly aligned storage for
|
||||
/// one `u32`.
|
||||
#[cfg_attr(feature = "c-abi", unsafe(no_mangle))]
|
||||
pub unsafe extern "C" fn data_plane_result_size(
|
||||
session: u64,
|
||||
operation: u64,
|
||||
out_size: *mut u32,
|
||||
) -> c_int {
|
||||
if out_size.is_null() {
|
||||
return failure(invalid("out_size is null"));
|
||||
}
|
||||
match super::session::result_size(session, operation) {
|
||||
Ok(size) => match u32::try_from(size) {
|
||||
Ok(size) => {
|
||||
unsafe {
|
||||
*out_size = size;
|
||||
}
|
||||
0
|
||||
}
|
||||
Err(_) => failure(invalid("data-plane result size exceeds u32")),
|
||||
},
|
||||
Err(error) => failure(error),
|
||||
}
|
||||
}
|
||||
|
||||
/// # Safety
|
||||
///
|
||||
/// Each output pointer must be null or point to writable, properly aligned
|
||||
/// storage for its pointee type. Non-null output locations must not overlap.
|
||||
#[cfg_attr(feature = "c-abi", unsafe(no_mangle))]
|
||||
pub unsafe extern "C" fn data_plane_tcp_connect_result_take(
|
||||
session: u64,
|
||||
operation: u64,
|
||||
out_stream: *mut u64,
|
||||
out_local_addr: *mut DataPlaneSocketAddr,
|
||||
out_peer_addr: *mut DataPlaneSocketAddr,
|
||||
) -> c_int {
|
||||
if out_stream.is_null() || out_local_addr.is_null() || out_peer_addr.is_null() {
|
||||
return failure(invalid("TCP connect result output pointer is null"));
|
||||
}
|
||||
match super::session::take_tcp_connect(session, operation) {
|
||||
Ok(result) => {
|
||||
unsafe {
|
||||
*out_stream = result.stream;
|
||||
*out_local_addr = ffi_socket_addr(result.local_addr);
|
||||
*out_peer_addr = ffi_socket_addr(result.peer_addr);
|
||||
}
|
||||
0
|
||||
}
|
||||
Err(error) => failure(error),
|
||||
}
|
||||
}
|
||||
|
||||
/// # Safety
|
||||
///
|
||||
/// Each output pointer must be null or point to writable, properly aligned
|
||||
/// storage for its pointee type. Non-null output locations must not overlap.
|
||||
#[cfg_attr(feature = "c-abi", unsafe(no_mangle))]
|
||||
pub unsafe extern "C" fn data_plane_tcp_bind_result_take(
|
||||
session: u64,
|
||||
operation: u64,
|
||||
out_listener: *mut u64,
|
||||
out_local_addr: *mut DataPlaneSocketAddr,
|
||||
) -> c_int {
|
||||
if out_listener.is_null() || out_local_addr.is_null() {
|
||||
return failure(invalid("TCP bind result output pointer is null"));
|
||||
}
|
||||
match super::session::take_tcp_bind(session, operation) {
|
||||
Ok(result) => {
|
||||
unsafe {
|
||||
*out_listener = result.listener;
|
||||
*out_local_addr = ffi_socket_addr(result.local_addr);
|
||||
}
|
||||
0
|
||||
}
|
||||
Err(error) => failure(error),
|
||||
}
|
||||
}
|
||||
|
||||
/// # Safety
|
||||
///
|
||||
/// Each output pointer must be null or point to writable, properly aligned
|
||||
/// storage for its pointee type. Non-null output locations must not overlap.
|
||||
#[cfg_attr(feature = "c-abi", unsafe(no_mangle))]
|
||||
pub unsafe extern "C" fn data_plane_tcp_accept_result_take(
|
||||
session: u64,
|
||||
operation: u64,
|
||||
out_stream: *mut u64,
|
||||
out_local_addr: *mut DataPlaneSocketAddr,
|
||||
out_peer_addr: *mut DataPlaneSocketAddr,
|
||||
) -> c_int {
|
||||
if out_stream.is_null() || out_local_addr.is_null() || out_peer_addr.is_null() {
|
||||
return failure(invalid("TCP accept result output pointer is null"));
|
||||
}
|
||||
match super::session::take_tcp_accept(session, operation) {
|
||||
Ok(result) => {
|
||||
unsafe {
|
||||
*out_stream = result.stream;
|
||||
*out_local_addr = ffi_socket_addr(result.local_addr);
|
||||
*out_peer_addr = ffi_socket_addr(result.peer_addr);
|
||||
}
|
||||
0
|
||||
}
|
||||
Err(error) => failure(error),
|
||||
}
|
||||
}
|
||||
|
||||
/// # Safety
|
||||
///
|
||||
/// When `capacity` is nonzero, `data` must point to `capacity` writable bytes.
|
||||
/// Each scalar output pointer must be null or point to writable, properly
|
||||
/// aligned storage for its pointee type. Non-null output ranges must not
|
||||
/// overlap.
|
||||
#[cfg_attr(feature = "c-abi", unsafe(no_mangle))]
|
||||
pub unsafe extern "C" fn data_plane_tcp_read_result_take(
|
||||
session: u64,
|
||||
operation: u64,
|
||||
data: *mut c_uchar,
|
||||
capacity: u32,
|
||||
out_len: *mut u32,
|
||||
out_eof: *mut bool,
|
||||
) -> c_int {
|
||||
if out_len.is_null() || out_eof.is_null() {
|
||||
return failure(invalid("TCP read result output pointer is null"));
|
||||
}
|
||||
let data = match unsafe { output_slice(data, capacity) } {
|
||||
Ok(data) => data,
|
||||
Err(error) => return failure(error),
|
||||
};
|
||||
match super::session::take_tcp_read(session, operation, data) {
|
||||
Ok(result) => {
|
||||
unsafe {
|
||||
*out_len = result.len as u32;
|
||||
*out_eof = result.eof;
|
||||
}
|
||||
0
|
||||
}
|
||||
Err(error) => failure(error),
|
||||
}
|
||||
}
|
||||
|
||||
/// # Safety
|
||||
///
|
||||
/// `out_len` must be null or point to writable, properly aligned storage for
|
||||
/// one `u32`.
|
||||
#[cfg_attr(feature = "c-abi", unsafe(no_mangle))]
|
||||
pub unsafe extern "C" fn data_plane_tcp_write_result_take(
|
||||
session: u64,
|
||||
operation: u64,
|
||||
out_len: *mut u32,
|
||||
) -> c_int {
|
||||
if out_len.is_null() {
|
||||
return failure(invalid("out_len is null"));
|
||||
}
|
||||
match super::session::take_tcp_write(session, operation) {
|
||||
Ok(len) => match u32::try_from(len) {
|
||||
Ok(len) => {
|
||||
unsafe {
|
||||
*out_len = len;
|
||||
}
|
||||
0
|
||||
}
|
||||
Err(_) => failure(invalid("TCP write result exceeds u32")),
|
||||
},
|
||||
Err(error) => failure(error),
|
||||
}
|
||||
}
|
||||
|
||||
/// # Safety
|
||||
///
|
||||
/// Each output pointer must be null or point to writable, properly aligned
|
||||
/// storage for its pointee type. Non-null output locations must not overlap.
|
||||
#[cfg_attr(feature = "c-abi", unsafe(no_mangle))]
|
||||
pub unsafe extern "C" fn data_plane_udp_bind_result_take(
|
||||
session: u64,
|
||||
operation: u64,
|
||||
out_socket: *mut u64,
|
||||
out_local_addr: *mut DataPlaneSocketAddr,
|
||||
) -> c_int {
|
||||
if out_socket.is_null() || out_local_addr.is_null() {
|
||||
return failure(invalid("UDP bind result output pointer is null"));
|
||||
}
|
||||
match super::session::take_udp_bind(session, operation) {
|
||||
Ok(result) => {
|
||||
unsafe {
|
||||
*out_socket = result.socket;
|
||||
*out_local_addr = ffi_socket_addr(result.local_addr);
|
||||
}
|
||||
0
|
||||
}
|
||||
Err(error) => failure(error),
|
||||
}
|
||||
}
|
||||
|
||||
/// # Safety
|
||||
///
|
||||
/// When `capacity` is nonzero, `data` must point to `capacity` writable bytes.
|
||||
/// Each scalar output pointer must be null or point to writable, properly
|
||||
/// aligned storage for its pointee type. Non-null output ranges must not
|
||||
/// overlap.
|
||||
#[cfg_attr(feature = "c-abi", unsafe(no_mangle))]
|
||||
pub unsafe extern "C" fn data_plane_udp_receive_result_take(
|
||||
session: u64,
|
||||
operation: u64,
|
||||
data: *mut c_uchar,
|
||||
capacity: u32,
|
||||
out_len: *mut u32,
|
||||
out_peer_addr: *mut DataPlaneSocketAddr,
|
||||
out_truncated: *mut bool,
|
||||
) -> c_int {
|
||||
if out_len.is_null() || out_peer_addr.is_null() || out_truncated.is_null() {
|
||||
return failure(invalid("UDP receive result output pointer is null"));
|
||||
}
|
||||
let data = match unsafe { output_slice(data, capacity) } {
|
||||
Ok(data) => data,
|
||||
Err(error) => return failure(error),
|
||||
};
|
||||
match super::session::take_udp_receive(session, operation, data) {
|
||||
Ok(result) => {
|
||||
unsafe {
|
||||
*out_len = result.len as u32;
|
||||
*out_peer_addr = ffi_socket_addr(result.peer_addr);
|
||||
*out_truncated = result.truncated;
|
||||
}
|
||||
0
|
||||
}
|
||||
Err(error) => failure(error),
|
||||
}
|
||||
}
|
||||
|
||||
/// # Safety
|
||||
///
|
||||
/// `out_len` must be null or point to writable, properly aligned storage for
|
||||
/// one `u32`.
|
||||
#[cfg_attr(feature = "c-abi", unsafe(no_mangle))]
|
||||
pub unsafe extern "C" fn data_plane_udp_send_result_take(
|
||||
session: u64,
|
||||
operation: u64,
|
||||
out_len: *mut u32,
|
||||
) -> c_int {
|
||||
if out_len.is_null() {
|
||||
return failure(invalid("out_len is null"));
|
||||
}
|
||||
match super::session::take_udp_send(session, operation) {
|
||||
Ok(len) => match u32::try_from(len) {
|
||||
Ok(len) => {
|
||||
unsafe {
|
||||
*out_len = len;
|
||||
}
|
||||
0
|
||||
}
|
||||
Err(_) => failure(invalid("UDP send result exceeds u32")),
|
||||
},
|
||||
Err(error) => failure(error),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn socket_address_round_trip() {
|
||||
let address = "127.0.0.1:1234".parse::<SocketAddr>().unwrap();
|
||||
assert_eq!(socket_addr(ffi_socket_addr(address)).unwrap(), address);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ipv6_is_rejected_by_v3() {
|
||||
let error = socket_addr(ffi_socket_addr(
|
||||
"[2001:db8::1]:4321".parse::<SocketAddr>().unwrap(),
|
||||
))
|
||||
.unwrap_err();
|
||||
assert_eq!(error.kind, DataPlaneErrorKind::AddressFamilyUnsupported);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_address_family_is_stable() {
|
||||
let error = socket_addr(DataPlaneSocketAddr {
|
||||
family: 9,
|
||||
..Default::default()
|
||||
})
|
||||
.unwrap_err();
|
||||
assert_eq!(error.kind, DataPlaneErrorKind::AddressFamilyUnsupported);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_deadline_direction_is_rejected_before_session_lookup() {
|
||||
let invalid = -(DataPlaneErrorKind::Io as c_int);
|
||||
assert_eq!(data_plane_resource_deadline_set(u64::MAX, 1, 0, 0), invalid);
|
||||
assert_eq!(data_plane_resource_deadline_set(u64::MAX, 1, 4, 0), invalid);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn null_operation_output_does_not_submit() {
|
||||
let submitted = std::cell::Cell::new(false);
|
||||
|
||||
assert_eq!(
|
||||
write_operation(std::ptr::null_mut(), || {
|
||||
submitted.set(true);
|
||||
Ok(1)
|
||||
}),
|
||||
-(DataPlaneErrorKind::Io as c_int)
|
||||
);
|
||||
assert!(!submitted.get());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
//! Native C ABI adapter for the instance-scoped data-plane operation broker.
|
||||
|
||||
#[cfg(feature = "ffi-dataplane")]
|
||||
mod abi;
|
||||
#[cfg(feature = "ffi-dataplane")]
|
||||
mod session;
|
||||
|
||||
#[cfg(feature = "ffi-dataplane")]
|
||||
pub use abi::*;
|
||||
#[cfg(feature = "ffi-dataplane")]
|
||||
pub(crate) use session::{
|
||||
lock_for_config_server_start, remove_data_plane_sessions_by_instance_ids,
|
||||
};
|
||||
|
||||
#[cfg(not(feature = "ffi-dataplane"))]
|
||||
pub(crate) fn remove_data_plane_sessions_by_instance_ids(_ids: &[uuid::Uuid]) {}
|
||||
@@ -0,0 +1,646 @@
|
||||
use std::{
|
||||
collections::HashMap,
|
||||
net::SocketAddr,
|
||||
sync::{
|
||||
Arc, Mutex, RwLock,
|
||||
atomic::{AtomicBool, AtomicU64, Ordering},
|
||||
},
|
||||
time::Duration,
|
||||
};
|
||||
|
||||
use easytier::instance::host::NativeInstanceHost;
|
||||
use easytier_core::gateway::{
|
||||
DataPlaneCompletionDescriptor, DataPlaneError, DataPlaneErrorKind, DataPlaneOperationId,
|
||||
DataPlaneOperationKind, DataPlaneOperationResult, DataPlaneResourceId, DataPlaneSession,
|
||||
};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::{
|
||||
config_server::{in_config_server_callback, is_config_server_active_or_stopping},
|
||||
state::{ffi_context, resolve_instance_id_by_name},
|
||||
};
|
||||
|
||||
type CoreDataPlaneSession = DataPlaneSession<NativeInstanceHost>;
|
||||
|
||||
static NEXT_SESSION_HANDLE: AtomicU64 = AtomicU64::new(1);
|
||||
static SESSIONS: once_cell::sync::Lazy<Mutex<HashMap<u64, Arc<NativeDataPlaneSession>>>> =
|
||||
once_cell::sync::Lazy::new(|| Mutex::new(HashMap::new()));
|
||||
static DATA_PLANE_USAGE_LOCK: once_cell::sync::Lazy<RwLock<()>> =
|
||||
once_cell::sync::Lazy::new(|| RwLock::new(()));
|
||||
|
||||
#[derive(Debug)]
|
||||
pub(super) struct NativeDataPlaneError {
|
||||
pub(super) kind: DataPlaneErrorKind,
|
||||
pub(super) message: String,
|
||||
}
|
||||
|
||||
impl NativeDataPlaneError {
|
||||
fn new(kind: DataPlaneErrorKind, message: impl Into<String>) -> Self {
|
||||
Self {
|
||||
kind,
|
||||
message: message.into(),
|
||||
}
|
||||
}
|
||||
|
||||
fn invalid(message: impl Into<String>) -> Self {
|
||||
Self::new(DataPlaneErrorKind::Io, message)
|
||||
}
|
||||
|
||||
fn closed(message: impl Into<String>) -> Self {
|
||||
Self::new(DataPlaneErrorKind::HandleClosed, message)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<DataPlaneError> for NativeDataPlaneError {
|
||||
fn from(error: DataPlaneError) -> Self {
|
||||
Self::new(error.kind(), error.message())
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) type NativeDataPlaneResult<T> = Result<T, NativeDataPlaneError>;
|
||||
|
||||
pub(super) struct TcpConnectResult {
|
||||
pub(super) stream: u64,
|
||||
pub(super) local_addr: SocketAddr,
|
||||
pub(super) peer_addr: SocketAddr,
|
||||
}
|
||||
|
||||
pub(super) struct TcpBindResult {
|
||||
pub(super) listener: u64,
|
||||
pub(super) local_addr: SocketAddr,
|
||||
}
|
||||
|
||||
pub(super) struct TcpAcceptResult {
|
||||
pub(super) stream: u64,
|
||||
pub(super) local_addr: SocketAddr,
|
||||
pub(super) peer_addr: SocketAddr,
|
||||
}
|
||||
|
||||
pub(super) struct TcpReadResult {
|
||||
pub(super) len: usize,
|
||||
pub(super) eof: bool,
|
||||
}
|
||||
|
||||
pub(super) struct UdpBindResult {
|
||||
pub(super) socket: u64,
|
||||
pub(super) local_addr: SocketAddr,
|
||||
}
|
||||
|
||||
pub(super) struct UdpReceiveResult {
|
||||
pub(super) len: usize,
|
||||
pub(super) peer_addr: SocketAddr,
|
||||
pub(super) truncated: bool,
|
||||
}
|
||||
|
||||
struct NativeDataPlaneSession {
|
||||
instance_id: Uuid,
|
||||
runtime: tokio::runtime::Handle,
|
||||
core: Arc<CoreDataPlaneSession>,
|
||||
submit_gate: Mutex<()>,
|
||||
closed: AtomicBool,
|
||||
}
|
||||
|
||||
impl NativeDataPlaneSession {
|
||||
fn close(&self) {
|
||||
let _gate = self
|
||||
.submit_gate
|
||||
.lock()
|
||||
.unwrap_or_else(|error| error.into_inner());
|
||||
if self.closed.swap(true, Ordering::AcqRel) {
|
||||
return;
|
||||
}
|
||||
self.core.discard_all();
|
||||
}
|
||||
|
||||
fn call<T>(
|
||||
&self,
|
||||
call: impl FnOnce(&Arc<CoreDataPlaneSession>) -> Result<T, DataPlaneError>,
|
||||
) -> NativeDataPlaneResult<T> {
|
||||
let _gate = self
|
||||
.submit_gate
|
||||
.lock()
|
||||
.map_err(|error| NativeDataPlaneError::invalid(error.to_string()))?;
|
||||
if self.closed.load(Ordering::Acquire) {
|
||||
return Err(NativeDataPlaneError::closed(
|
||||
"native data-plane session is closed",
|
||||
));
|
||||
}
|
||||
let _runtime = self.runtime.enter();
|
||||
call(&self.core).map_err(Into::into)
|
||||
}
|
||||
|
||||
fn submit(
|
||||
&self,
|
||||
submit: impl FnOnce(&Arc<CoreDataPlaneSession>) -> Result<DataPlaneOperationId, DataPlaneError>,
|
||||
) -> NativeDataPlaneResult<u64> {
|
||||
self.call(submit).map(DataPlaneOperationId::get)
|
||||
}
|
||||
}
|
||||
|
||||
fn sessions()
|
||||
-> NativeDataPlaneResult<std::sync::MutexGuard<'static, HashMap<u64, Arc<NativeDataPlaneSession>>>>
|
||||
{
|
||||
SESSIONS
|
||||
.lock()
|
||||
.map_err(|error| NativeDataPlaneError::invalid(error.to_string()))
|
||||
}
|
||||
|
||||
fn get_session(handle: u64) -> NativeDataPlaneResult<Arc<NativeDataPlaneSession>> {
|
||||
if handle == 0 {
|
||||
return Err(NativeDataPlaneError::closed(
|
||||
"native data-plane session handle is invalid",
|
||||
));
|
||||
}
|
||||
let session = sessions()?
|
||||
.get(&handle)
|
||||
.cloned()
|
||||
.ok_or_else(|| NativeDataPlaneError::closed("native data-plane session is closed"))?;
|
||||
if session.closed.load(Ordering::Acquire) {
|
||||
return Err(NativeDataPlaneError::closed(
|
||||
"native data-plane session is closed",
|
||||
));
|
||||
}
|
||||
Ok(session)
|
||||
}
|
||||
|
||||
fn next_session_handle(
|
||||
sessions: &HashMap<u64, Arc<NativeDataPlaneSession>>,
|
||||
) -> NativeDataPlaneResult<u64> {
|
||||
for _ in 0..sessions.len().saturating_add(2) {
|
||||
let handle = NEXT_SESSION_HANDLE.fetch_add(1, Ordering::Relaxed);
|
||||
if handle != 0 && !sessions.contains_key(&handle) {
|
||||
return Ok(handle);
|
||||
}
|
||||
}
|
||||
Err(NativeDataPlaneError::new(
|
||||
DataPlaneErrorKind::ResourceLimit,
|
||||
"native data-plane session handle space is exhausted",
|
||||
))
|
||||
}
|
||||
|
||||
fn reject_data_plane_use() -> NativeDataPlaneResult<()> {
|
||||
if in_config_server_callback() {
|
||||
Err(NativeDataPlaneError::invalid(
|
||||
"cannot use data plane from config server callback",
|
||||
))
|
||||
} else if is_config_server_active_or_stopping() {
|
||||
Err(NativeDataPlaneError::invalid(
|
||||
"cannot use data plane while config server client is active",
|
||||
))
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn open(inst_name: &str) -> NativeDataPlaneResult<u64> {
|
||||
reject_data_plane_use()?;
|
||||
let _usage = DATA_PLANE_USAGE_LOCK
|
||||
.read()
|
||||
.map_err(|error| NativeDataPlaneError::invalid(error.to_string()))?;
|
||||
reject_data_plane_use()?;
|
||||
|
||||
let instance_id = resolve_instance_id_by_name(inst_name)
|
||||
.map_err(NativeDataPlaneError::invalid)?
|
||||
.ok_or_else(|| NativeDataPlaneError::closed("instance not found"))?;
|
||||
let manager = &ffi_context().manager;
|
||||
let core = manager.data_plane_session(&instance_id).ok_or_else(|| {
|
||||
NativeDataPlaneError::closed("instance data-plane session is unavailable")
|
||||
})?;
|
||||
let runtime = manager
|
||||
.data_plane_runtime_handle(&instance_id)
|
||||
.ok_or_else(|| NativeDataPlaneError::closed("instance runtime is unavailable"))?;
|
||||
|
||||
let mut sessions = sessions()?;
|
||||
if sessions
|
||||
.values()
|
||||
.any(|session| session.instance_id == instance_id)
|
||||
{
|
||||
return Err(NativeDataPlaneError::new(
|
||||
DataPlaneErrorKind::ResourceLimit,
|
||||
"instance already has an open native data-plane session",
|
||||
));
|
||||
}
|
||||
let handle = next_session_handle(&sessions)?;
|
||||
sessions.insert(
|
||||
handle,
|
||||
Arc::new(NativeDataPlaneSession {
|
||||
instance_id,
|
||||
runtime,
|
||||
core,
|
||||
submit_gate: Mutex::new(()),
|
||||
closed: AtomicBool::new(false),
|
||||
}),
|
||||
);
|
||||
Ok(handle)
|
||||
}
|
||||
|
||||
pub(super) fn close(handle: u64) -> NativeDataPlaneResult<()> {
|
||||
let _usage = DATA_PLANE_USAGE_LOCK
|
||||
.read()
|
||||
.map_err(|error| NativeDataPlaneError::invalid(error.to_string()))?;
|
||||
let mut sessions = sessions()?;
|
||||
let session = sessions
|
||||
.remove(&handle)
|
||||
.ok_or_else(|| NativeDataPlaneError::closed("native data-plane session is closed"))?;
|
||||
// Keep the registry locked until the shared core namespace is empty. An
|
||||
// open for the same instance must not publish a replacement session before
|
||||
// this old wrapper finishes discarding its operations and resources.
|
||||
session.close();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn timeout(timeout_ms: u64) -> Option<Duration> {
|
||||
(timeout_ms != u64::MAX).then(|| Duration::from_millis(timeout_ms))
|
||||
}
|
||||
|
||||
fn operation_id(raw: u64) -> NativeDataPlaneResult<DataPlaneOperationId> {
|
||||
DataPlaneOperationId::from_raw(raw)
|
||||
.ok_or_else(|| NativeDataPlaneError::closed("data-plane operation handle is invalid"))
|
||||
}
|
||||
|
||||
fn resource_id(raw: u64) -> NativeDataPlaneResult<DataPlaneResourceId> {
|
||||
DataPlaneResourceId::from_raw(raw)
|
||||
.ok_or_else(|| NativeDataPlaneError::closed("data-plane resource handle is invalid"))
|
||||
}
|
||||
|
||||
pub(super) fn submit_tcp_connect(
|
||||
session: u64,
|
||||
peer_addr: SocketAddr,
|
||||
timeout_ms: u64,
|
||||
) -> NativeDataPlaneResult<u64> {
|
||||
get_session(session)?.submit(|core| core.submit_tcp_connect(peer_addr, timeout(timeout_ms)))
|
||||
}
|
||||
|
||||
pub(super) fn submit_tcp_bind(
|
||||
session: u64,
|
||||
local_port: u16,
|
||||
timeout_ms: u64,
|
||||
) -> NativeDataPlaneResult<u64> {
|
||||
get_session(session)?.submit(|core| core.submit_tcp_bind(local_port, timeout(timeout_ms)))
|
||||
}
|
||||
|
||||
pub(super) fn submit_tcp_accept(
|
||||
session: u64,
|
||||
listener: u64,
|
||||
timeout_ms: u64,
|
||||
) -> NativeDataPlaneResult<u64> {
|
||||
let listener = resource_id(listener)?;
|
||||
get_session(session)?.submit(|core| core.submit_tcp_accept(listener, timeout(timeout_ms)))
|
||||
}
|
||||
|
||||
pub(super) fn submit_tcp_read(
|
||||
session: u64,
|
||||
stream: u64,
|
||||
max_len: u32,
|
||||
) -> NativeDataPlaneResult<u64> {
|
||||
let stream = resource_id(stream)?;
|
||||
get_session(session)?.submit(|core| core.submit_tcp_read(stream, max_len as usize))
|
||||
}
|
||||
|
||||
pub(super) fn submit_tcp_write(
|
||||
session: u64,
|
||||
stream: u64,
|
||||
data: Vec<u8>,
|
||||
) -> NativeDataPlaneResult<u64> {
|
||||
let stream = resource_id(stream)?;
|
||||
get_session(session)?.submit(|core| core.submit_tcp_write(stream, data))
|
||||
}
|
||||
|
||||
pub(super) fn submit_udp_bind(
|
||||
session: u64,
|
||||
local_port: u16,
|
||||
timeout_ms: u64,
|
||||
) -> NativeDataPlaneResult<u64> {
|
||||
get_session(session)?.submit(|core| core.submit_udp_bind(local_port, timeout(timeout_ms)))
|
||||
}
|
||||
|
||||
pub(super) fn submit_udp_receive(
|
||||
session: u64,
|
||||
socket: u64,
|
||||
max_len: u32,
|
||||
) -> NativeDataPlaneResult<u64> {
|
||||
let socket = resource_id(socket)?;
|
||||
get_session(session)?.submit(|core| core.submit_udp_receive(socket, max_len as usize))
|
||||
}
|
||||
|
||||
pub(super) fn submit_udp_send(
|
||||
session: u64,
|
||||
socket: u64,
|
||||
peer_addr: SocketAddr,
|
||||
data: Vec<u8>,
|
||||
) -> NativeDataPlaneResult<u64> {
|
||||
let socket = resource_id(socket)?;
|
||||
get_session(session)?.submit(|core| core.submit_udp_send(socket, peer_addr, data))
|
||||
}
|
||||
|
||||
pub(super) fn set_resource_deadline(
|
||||
session: u64,
|
||||
resource: u64,
|
||||
read: bool,
|
||||
write: bool,
|
||||
timeout_ms: u64,
|
||||
) -> NativeDataPlaneResult<()> {
|
||||
let resource = resource_id(resource)?;
|
||||
get_session(session)?
|
||||
.call(|core| core.set_resource_deadline(resource, read, write, timeout(timeout_ms)))
|
||||
}
|
||||
|
||||
pub(super) fn cancel_operation(session: u64, operation: u64) -> NativeDataPlaneResult<()> {
|
||||
let operation = operation_id(operation)?;
|
||||
get_session(session)?.core.cancel_operation(operation);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(super) fn free_operation(session: u64, operation: u64) -> NativeDataPlaneResult<()> {
|
||||
let operation = operation_id(operation)?;
|
||||
get_session(session)?.core.free_operation(operation);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(super) fn close_resource(session: u64, resource: u64) -> NativeDataPlaneResult<()> {
|
||||
let resource = resource_id(resource)?;
|
||||
get_session(session)?.core.close_resource(resource);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(super) fn completion_wait(session: u64, timeout_ms: u64) -> NativeDataPlaneResult<bool> {
|
||||
let session = get_session(session)?;
|
||||
let ready = session.core.completion_wait(timeout(timeout_ms));
|
||||
Ok(ready && !session.closed.load(Ordering::Acquire))
|
||||
}
|
||||
|
||||
pub(super) fn drain_completions(
|
||||
session: u64,
|
||||
max_count: usize,
|
||||
) -> NativeDataPlaneResult<Vec<DataPlaneCompletionDescriptor>> {
|
||||
Ok(get_session(session)?.core.drain_completions(max_count))
|
||||
}
|
||||
|
||||
pub(super) fn result_size(session: u64, operation: u64) -> NativeDataPlaneResult<usize> {
|
||||
let operation = operation_id(operation)?;
|
||||
get_session(session)?
|
||||
.core
|
||||
.result_payload_bytes(operation)
|
||||
.map_err(Into::into)
|
||||
}
|
||||
|
||||
fn take_result<T>(
|
||||
session: u64,
|
||||
operation: u64,
|
||||
expected: DataPlaneOperationKind,
|
||||
take: impl FnOnce(&DataPlaneOperationResult) -> Option<T>,
|
||||
) -> NativeDataPlaneResult<T> {
|
||||
let operation = operation_id(operation)?;
|
||||
let session = get_session(session)?;
|
||||
let actual = session.core.operation_kind(operation)?;
|
||||
if actual != expected {
|
||||
return Err(NativeDataPlaneError::invalid(format!(
|
||||
"operation kind mismatch: expected {expected:?}, got {actual:?}"
|
||||
)));
|
||||
}
|
||||
let result = session.core.take_result_with(operation, |outcome| {
|
||||
Some(match outcome {
|
||||
Ok(result) => take(result).ok_or_else(|| {
|
||||
NativeDataPlaneError::invalid("data-plane result variant does not match operation")
|
||||
}),
|
||||
Err(kind) => Err(NativeDataPlaneError::new(
|
||||
*kind,
|
||||
format!("data-plane operation failed with {kind:?}"),
|
||||
)),
|
||||
})
|
||||
})?;
|
||||
result
|
||||
.ok_or_else(|| NativeDataPlaneError::invalid("data-plane result could not be consumed"))?
|
||||
}
|
||||
|
||||
pub(super) fn take_tcp_connect(
|
||||
session: u64,
|
||||
operation: u64,
|
||||
) -> NativeDataPlaneResult<TcpConnectResult> {
|
||||
take_result(
|
||||
session,
|
||||
operation,
|
||||
DataPlaneOperationKind::TcpConnect,
|
||||
|result| match result {
|
||||
DataPlaneOperationResult::TcpConnected {
|
||||
stream,
|
||||
local_addr,
|
||||
peer_addr,
|
||||
} => Some(TcpConnectResult {
|
||||
stream: stream.get(),
|
||||
local_addr: *local_addr,
|
||||
peer_addr: *peer_addr,
|
||||
}),
|
||||
_ => None,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
pub(super) fn take_tcp_bind(session: u64, operation: u64) -> NativeDataPlaneResult<TcpBindResult> {
|
||||
take_result(
|
||||
session,
|
||||
operation,
|
||||
DataPlaneOperationKind::TcpBind,
|
||||
|result| match result {
|
||||
DataPlaneOperationResult::TcpBound {
|
||||
listener,
|
||||
local_addr,
|
||||
} => Some(TcpBindResult {
|
||||
listener: listener.get(),
|
||||
local_addr: *local_addr,
|
||||
}),
|
||||
_ => None,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
pub(super) fn take_tcp_accept(
|
||||
session: u64,
|
||||
operation: u64,
|
||||
) -> NativeDataPlaneResult<TcpAcceptResult> {
|
||||
take_result(
|
||||
session,
|
||||
operation,
|
||||
DataPlaneOperationKind::TcpAccept,
|
||||
|result| match result {
|
||||
DataPlaneOperationResult::TcpAccepted {
|
||||
stream,
|
||||
local_addr,
|
||||
peer_addr,
|
||||
} => Some(TcpAcceptResult {
|
||||
stream: stream.get(),
|
||||
local_addr: *local_addr,
|
||||
peer_addr: *peer_addr,
|
||||
}),
|
||||
_ => None,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
pub(super) fn take_tcp_read(
|
||||
session: u64,
|
||||
operation: u64,
|
||||
output: &mut [u8],
|
||||
) -> NativeDataPlaneResult<TcpReadResult> {
|
||||
let required = result_size(session, operation)?;
|
||||
if output.len() < required {
|
||||
return Err(NativeDataPlaneError::new(
|
||||
DataPlaneErrorKind::BufferTooSmall,
|
||||
format!(
|
||||
"TCP read result requires {required} bytes, buffer has {}",
|
||||
output.len()
|
||||
),
|
||||
));
|
||||
}
|
||||
take_result(
|
||||
session,
|
||||
operation,
|
||||
DataPlaneOperationKind::TcpRead,
|
||||
|result| match result {
|
||||
DataPlaneOperationResult::TcpRead { data, eof } => {
|
||||
output[..data.len()].copy_from_slice(data);
|
||||
Some(TcpReadResult {
|
||||
len: data.len(),
|
||||
eof: *eof,
|
||||
})
|
||||
}
|
||||
_ => None,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
pub(super) fn take_tcp_write(session: u64, operation: u64) -> NativeDataPlaneResult<usize> {
|
||||
take_result(
|
||||
session,
|
||||
operation,
|
||||
DataPlaneOperationKind::TcpWrite,
|
||||
|result| match result {
|
||||
DataPlaneOperationResult::TcpWritten { len } => Some(*len),
|
||||
_ => None,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
pub(super) fn take_udp_bind(session: u64, operation: u64) -> NativeDataPlaneResult<UdpBindResult> {
|
||||
take_result(
|
||||
session,
|
||||
operation,
|
||||
DataPlaneOperationKind::UdpBind,
|
||||
|result| match result {
|
||||
DataPlaneOperationResult::UdpBound { socket, local_addr } => Some(UdpBindResult {
|
||||
socket: socket.get(),
|
||||
local_addr: *local_addr,
|
||||
}),
|
||||
_ => None,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
pub(super) fn take_udp_receive(
|
||||
session: u64,
|
||||
operation: u64,
|
||||
output: &mut [u8],
|
||||
) -> NativeDataPlaneResult<UdpReceiveResult> {
|
||||
let required = result_size(session, operation)?;
|
||||
if output.len() < required {
|
||||
return Err(NativeDataPlaneError::new(
|
||||
DataPlaneErrorKind::BufferTooSmall,
|
||||
format!(
|
||||
"UDP receive result requires {required} bytes, buffer has {}",
|
||||
output.len()
|
||||
),
|
||||
));
|
||||
}
|
||||
take_result(
|
||||
session,
|
||||
operation,
|
||||
DataPlaneOperationKind::UdpReceive,
|
||||
|result| match result {
|
||||
DataPlaneOperationResult::UdpReceived {
|
||||
data,
|
||||
peer_addr,
|
||||
truncated,
|
||||
} => {
|
||||
output[..data.len()].copy_from_slice(data);
|
||||
Some(UdpReceiveResult {
|
||||
len: data.len(),
|
||||
peer_addr: *peer_addr,
|
||||
truncated: *truncated,
|
||||
})
|
||||
}
|
||||
_ => None,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
pub(super) fn take_udp_send(session: u64, operation: u64) -> NativeDataPlaneResult<usize> {
|
||||
take_result(
|
||||
session,
|
||||
operation,
|
||||
DataPlaneOperationKind::UdpSend,
|
||||
|result| match result {
|
||||
DataPlaneOperationResult::UdpSent { len } => Some(*len),
|
||||
_ => None,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) fn remove_data_plane_sessions_by_instance_ids(ids: &[Uuid]) {
|
||||
if ids.is_empty() {
|
||||
return;
|
||||
}
|
||||
let _usage = DATA_PLANE_USAGE_LOCK
|
||||
.write()
|
||||
.unwrap_or_else(|error| error.into_inner());
|
||||
let removed = {
|
||||
let mut sessions = SESSIONS.lock().unwrap_or_else(|error| error.into_inner());
|
||||
let handles = sessions
|
||||
.iter()
|
||||
.filter_map(|(handle, session)| ids.contains(&session.instance_id).then_some(*handle))
|
||||
.collect::<Vec<_>>();
|
||||
handles
|
||||
.into_iter()
|
||||
.filter_map(|handle| sessions.remove(&handle))
|
||||
.collect::<Vec<_>>()
|
||||
};
|
||||
for session in removed {
|
||||
session.close();
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn lock_for_config_server_start()
|
||||
-> Result<std::sync::RwLockWriteGuard<'static, ()>, String> {
|
||||
let guard = DATA_PLANE_USAGE_LOCK
|
||||
.write()
|
||||
.map_err(|error| format!("failed to lock data plane usage: {error}"))?;
|
||||
if !SESSIONS
|
||||
.lock()
|
||||
.map_err(|error| format!("failed to lock data-plane sessions: {error}"))?
|
||||
.is_empty()
|
||||
{
|
||||
return Err("cannot start config server client while data plane is in use".to_string());
|
||||
}
|
||||
Ok(guard)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::{sync::mpsc, time::Duration};
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn config_server_start_waits_for_session_open_or_close() {
|
||||
let read_guard = DATA_PLANE_USAGE_LOCK.read().unwrap();
|
||||
let (done_tx, done_rx) = mpsc::channel();
|
||||
let waiter = std::thread::spawn(move || {
|
||||
let _write_guard = lock_for_config_server_start().unwrap();
|
||||
done_tx.send(()).unwrap();
|
||||
});
|
||||
|
||||
assert!(done_rx.recv_timeout(Duration::from_millis(100)).is_err());
|
||||
drop(read_guard);
|
||||
done_rx.recv_timeout(Duration::from_secs(5)).unwrap();
|
||||
waiter.join().unwrap();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
use std::{
|
||||
cell::RefCell,
|
||||
ffi::{CString, c_char},
|
||||
};
|
||||
|
||||
thread_local! {
|
||||
// # Thread Safety
|
||||
// set_error_msg and get_error_msg must be called on the same thread to
|
||||
// get correct error. And since `Handle::block_on` polls the top-level
|
||||
// future on the calling thread, set_error_msg always runs on the same
|
||||
// thread as the corresponding get_error_msg.
|
||||
static ERROR_MSG: RefCell<Vec<u8>> = const { RefCell::new(Vec::new()) };
|
||||
}
|
||||
|
||||
pub(crate) fn set_error_msg(msg: &str) {
|
||||
ERROR_MSG.with(|cell| {
|
||||
let mut buf = cell.borrow_mut();
|
||||
buf.clear();
|
||||
buf.extend_from_slice(msg.as_bytes());
|
||||
});
|
||||
}
|
||||
|
||||
fn thread_local_error_msg() -> Option<String> {
|
||||
ERROR_MSG.with(|cell| {
|
||||
let buf = cell.borrow();
|
||||
if buf.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(String::from_utf8_lossy(&buf).into_owned())
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) unsafe fn get_error_msg(out: *mut *const c_char) {
|
||||
let msg = match (
|
||||
thread_local_error_msg(),
|
||||
crate::config_server::last_callback_error(),
|
||||
) {
|
||||
(Some(error), Some(callback_error)) => Some(format!(
|
||||
"{}; config server callback error: {}",
|
||||
error, callback_error
|
||||
)),
|
||||
(Some(error), None) => Some(error),
|
||||
(None, Some(callback_error)) => {
|
||||
Some(format!("config server callback error: {}", callback_error))
|
||||
}
|
||||
(None, None) => None,
|
||||
};
|
||||
let cstr = msg.and_then(|msg| CString::new(msg).ok());
|
||||
unsafe {
|
||||
*out = match cstr {
|
||||
Some(s) => s.into_raw() as *const c_char,
|
||||
None => std::ptr::null(),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn free_string(s: *const c_char) {
|
||||
if s.is_null() {
|
||||
return;
|
||||
}
|
||||
unsafe {
|
||||
let _ = CString::from_raw(s as *mut c_char);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,350 @@
|
||||
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::{
|
||||
config_server::{in_config_server_callback, wait_for_config_server_delivery},
|
||||
error::set_error_msg,
|
||||
state::{ffi_context, resolve_instance_id_by_name},
|
||||
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 {
|
||||
let inst_name = unsafe {
|
||||
assert!(!inst_name.is_null());
|
||||
std::ffi::CStr::from_ptr(inst_name)
|
||||
.to_string_lossy()
|
||||
.into_owned()
|
||||
};
|
||||
let inst_id = match resolve_instance_id_by_name(&inst_name) {
|
||||
Ok(Some(instance_id)) => instance_id,
|
||||
Ok(None) => {
|
||||
set_error_msg(&format!("instance not found: {inst_name}"));
|
||||
return -1;
|
||||
}
|
||||
Err(error) => {
|
||||
set_error_msg(&error.to_string());
|
||||
return -1;
|
||||
}
|
||||
};
|
||||
|
||||
#[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(e) => {
|
||||
set_error_msg(&format!("failed to set tun fd: {}", e));
|
||||
-1
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// # Safety
|
||||
/// Parse the config
|
||||
pub(crate) unsafe fn parse_config(cfg_str: *const std::ffi::c_char) -> std::ffi::c_int {
|
||||
let cfg_str = unsafe {
|
||||
assert!(!cfg_str.is_null());
|
||||
std::ffi::CStr::from_ptr(cfg_str)
|
||||
.to_string_lossy()
|
||||
.into_owned()
|
||||
};
|
||||
|
||||
if let Err(e) = TomlConfigLoader::new_from_str(&cfg_str) {
|
||||
set_error_msg(&format!("failed to parse config: {:?}", e));
|
||||
return -1;
|
||||
}
|
||||
|
||||
0
|
||||
}
|
||||
|
||||
/// # Safety
|
||||
/// Run the network instance
|
||||
pub(crate) unsafe fn run_network_instance(cfg_str: *const std::ffi::c_char) -> std::ffi::c_int {
|
||||
if in_config_server_callback() {
|
||||
set_error_msg("cannot run network instance from config server callback");
|
||||
return -1;
|
||||
}
|
||||
|
||||
let cfg_str = unsafe {
|
||||
assert!(!cfg_str.is_null());
|
||||
std::ffi::CStr::from_ptr(cfg_str)
|
||||
.to_string_lossy()
|
||||
.into_owned()
|
||||
};
|
||||
let cfg = match TomlConfigLoader::new_from_str(&cfg_str) {
|
||||
Ok(cfg) => cfg,
|
||||
Err(e) => {
|
||||
set_error_msg(&format!("failed to parse config: {}", e));
|
||||
return -1;
|
||||
}
|
||||
};
|
||||
|
||||
wait_for_config_server_delivery();
|
||||
if let Err(e) = ffi_context().runtime.block_on(
|
||||
ffi_context()
|
||||
.process_management
|
||||
.run_owned_network_instance(cfg, ConfigFileControl::STATIC_CONFIG),
|
||||
) {
|
||||
set_error_msg(&format!("failed to start instance: {}", e));
|
||||
return -1;
|
||||
}
|
||||
|
||||
0
|
||||
}
|
||||
|
||||
unsafe fn parse_instance_names(
|
||||
inst_names: *const *const c_char,
|
||||
length: usize,
|
||||
) -> Option<Vec<String>> {
|
||||
if length == 0 {
|
||||
return Some(Vec::new());
|
||||
}
|
||||
if inst_names.is_null() {
|
||||
set_error_msg("inst_names is null");
|
||||
return None;
|
||||
}
|
||||
|
||||
let names = unsafe { std::slice::from_raw_parts(inst_names, length) };
|
||||
let mut parsed = Vec::with_capacity(length);
|
||||
for (index, &name) in names.iter().enumerate() {
|
||||
if name.is_null() {
|
||||
set_error_msg(&format!("inst_names[{}] is null", index));
|
||||
return None;
|
||||
}
|
||||
parsed.push(
|
||||
unsafe { std::ffi::CStr::from_ptr(name) }
|
||||
.to_string_lossy()
|
||||
.into_owned(),
|
||||
);
|
||||
}
|
||||
Some(parsed)
|
||||
}
|
||||
|
||||
/// # Safety
|
||||
/// Retain the network instance
|
||||
pub(crate) unsafe fn retain_network_instance(
|
||||
inst_names: *const *const std::ffi::c_char,
|
||||
length: usize,
|
||||
) -> std::ffi::c_int {
|
||||
if in_config_server_callback() {
|
||||
set_error_msg("cannot retain network instances from config server callback");
|
||||
return -1;
|
||||
}
|
||||
|
||||
wait_for_config_server_delivery();
|
||||
let retained_names = if length == 0 {
|
||||
Vec::new()
|
||||
} else {
|
||||
let Some(inst_names) = (unsafe { parse_instance_names(inst_names, length) }) else {
|
||||
return -1;
|
||||
};
|
||||
inst_names
|
||||
};
|
||||
|
||||
if let Err(error) = ffi_context().runtime.block_on(
|
||||
ffi_context()
|
||||
.process_management
|
||||
.retain_owned_network_instances_by_name(retained_names),
|
||||
) {
|
||||
set_error_msg(&format!("failed to retain instances: {error}"));
|
||||
return -1;
|
||||
}
|
||||
|
||||
0
|
||||
}
|
||||
|
||||
/// # Safety
|
||||
/// Delete named network instances.
|
||||
pub(crate) unsafe fn delete_network_instance(
|
||||
inst_names: *const *const std::ffi::c_char,
|
||||
length: usize,
|
||||
) -> std::ffi::c_int {
|
||||
if in_config_server_callback() {
|
||||
set_error_msg("cannot delete network instances from config server callback");
|
||||
return -1;
|
||||
}
|
||||
|
||||
wait_for_config_server_delivery();
|
||||
if length == 0 {
|
||||
return 0;
|
||||
}
|
||||
|
||||
let Some(inst_names) = (unsafe { parse_instance_names(inst_names, length) }) else {
|
||||
return -1;
|
||||
};
|
||||
|
||||
if let Err(error) = ffi_context().runtime.block_on(
|
||||
ffi_context()
|
||||
.process_management
|
||||
.delete_owned_network_instances_by_name(inst_names),
|
||||
) {
|
||||
set_error_msg(&format!("failed to delete instances: {error}"));
|
||||
return -1;
|
||||
}
|
||||
|
||||
0
|
||||
}
|
||||
|
||||
/// # Safety
|
||||
/// Collect the network infos
|
||||
pub(crate) unsafe fn collect_network_infos(
|
||||
infos: *mut KeyValuePair,
|
||||
max_length: usize,
|
||||
) -> std::ffi::c_int {
|
||||
if in_config_server_callback() {
|
||||
set_error_msg("cannot collect network infos from config server callback");
|
||||
return -1;
|
||||
}
|
||||
|
||||
if max_length == 0 {
|
||||
return 0;
|
||||
}
|
||||
|
||||
let infos = unsafe {
|
||||
assert!(!infos.is_null());
|
||||
std::slice::from_raw_parts_mut(infos, max_length)
|
||||
};
|
||||
|
||||
let collected_infos = match ffi_context().manager.collect_network_infos_sync() {
|
||||
Ok(infos) => infos,
|
||||
Err(e) => {
|
||||
set_error_msg(&format!("failed to collect network infos: {}", e));
|
||||
return -1;
|
||||
}
|
||||
};
|
||||
|
||||
let mut index = 0;
|
||||
for (instance_id, value) in collected_infos.iter() {
|
||||
if index >= max_length {
|
||||
break;
|
||||
}
|
||||
let Some(key) = ffi_context()
|
||||
.manager
|
||||
.instance(*instance_id)
|
||||
.map(|instance| instance.instance_name().to_owned())
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
// convert value to json string
|
||||
let value = match serde_json::to_string(&value) {
|
||||
Ok(value) => value,
|
||||
Err(e) => {
|
||||
set_error_msg(&format!("failed to serialize instance info: {}", e));
|
||||
return -1;
|
||||
}
|
||||
};
|
||||
|
||||
infos[index] = KeyValuePair {
|
||||
key: std::ffi::CString::new(key).unwrap().into_raw(),
|
||||
value: std::ffi::CString::new(value).unwrap().into_raw(),
|
||||
};
|
||||
index += 1;
|
||||
}
|
||||
|
||||
index as std::ffi::c_int
|
||||
}
|
||||
|
||||
/// # Safety
|
||||
/// List the instance names and IDs known by the FFI instance manager.
|
||||
pub(crate) unsafe fn list_instance(infos: *mut KeyValuePair, max_length: usize) -> std::ffi::c_int {
|
||||
if in_config_server_callback() {
|
||||
set_error_msg("cannot list instances from config server callback");
|
||||
return -1;
|
||||
}
|
||||
|
||||
if max_length == 0 {
|
||||
return 0;
|
||||
}
|
||||
|
||||
if infos.is_null() {
|
||||
set_error_msg("infos is null");
|
||||
return -1;
|
||||
}
|
||||
|
||||
let infos = unsafe { std::slice::from_raw_parts_mut(infos, max_length) };
|
||||
let mut instances = ffi_context()
|
||||
.manager
|
||||
.instance_ids()
|
||||
.into_iter()
|
||||
.filter_map(|id| {
|
||||
ffi_context()
|
||||
.manager
|
||||
.instance(id)
|
||||
.map(|instance| (instance.instance_name().to_owned(), id))
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
instances.sort_by(|(left_name, left_id), (right_name, right_id)| {
|
||||
left_name
|
||||
.cmp(right_name)
|
||||
.then_with(|| left_id.to_string().cmp(&right_id.to_string()))
|
||||
});
|
||||
|
||||
let encoded_instances = match instances
|
||||
.into_iter()
|
||||
.take(max_length)
|
||||
.map(|(name, id)| {
|
||||
let key = CString::new(name)
|
||||
.map_err(|err| format!("failed to encode instance name: {}", err))?;
|
||||
let value = CString::new(id.to_string())
|
||||
.map_err(|err| format!("failed to encode instance id: {}", err))?;
|
||||
Ok((key, value))
|
||||
})
|
||||
.collect::<Result<Vec<_>, String>>()
|
||||
{
|
||||
Ok(value) => value,
|
||||
Err(err) => {
|
||||
set_error_msg(&err);
|
||||
return -1;
|
||||
}
|
||||
};
|
||||
|
||||
let count = encoded_instances.len();
|
||||
for (index, (key, value)) in encoded_instances.into_iter().enumerate() {
|
||||
infos[index] = KeyValuePair {
|
||||
key: key.into_raw(),
|
||||
value: value.into_raw(),
|
||||
};
|
||||
}
|
||||
|
||||
count as std::ffi::c_int
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
use std::{
|
||||
ffi::{CString, c_char, c_int},
|
||||
sync::Arc,
|
||||
};
|
||||
|
||||
use crate::{
|
||||
config_server::in_config_server_callback,
|
||||
error::set_error_msg,
|
||||
state::ffi_context,
|
||||
strings::{c_str_to_string, optional_c_str_to_string},
|
||||
};
|
||||
|
||||
/// # Safety
|
||||
/// See `crate::call_json_rpc`.
|
||||
pub(crate) unsafe fn call_json_rpc(
|
||||
service_name: *const c_char,
|
||||
method_name: *const c_char,
|
||||
domain_name: *const c_char,
|
||||
payload_json: *const c_char,
|
||||
out_response_json: *mut *const c_char,
|
||||
) -> c_int {
|
||||
if out_response_json.is_null() {
|
||||
set_error_msg("out_response_json is null");
|
||||
return -1;
|
||||
}
|
||||
unsafe {
|
||||
*out_response_json = std::ptr::null();
|
||||
}
|
||||
|
||||
if in_config_server_callback() {
|
||||
set_error_msg("cannot call JSON RPC from config server callback");
|
||||
return -1;
|
||||
}
|
||||
|
||||
let service_name = match unsafe { c_str_to_string(service_name, "service_name") } {
|
||||
Ok(value) => value,
|
||||
Err(err) => {
|
||||
set_error_msg(&err);
|
||||
return -1;
|
||||
}
|
||||
};
|
||||
let method_name = match unsafe { c_str_to_string(method_name, "method_name") } {
|
||||
Ok(value) => value,
|
||||
Err(err) => {
|
||||
set_error_msg(&err);
|
||||
return -1;
|
||||
}
|
||||
};
|
||||
let domain_name = match unsafe { optional_c_str_to_string(domain_name, "domain_name") } {
|
||||
Ok(value) => value,
|
||||
Err(err) => {
|
||||
set_error_msg(&err);
|
||||
return -1;
|
||||
}
|
||||
};
|
||||
let payload_json = match unsafe { c_str_to_string(payload_json, "payload_json") } {
|
||||
Ok(value) => value,
|
||||
Err(err) => {
|
||||
set_error_msg(&err);
|
||||
return -1;
|
||||
}
|
||||
};
|
||||
let payload = match serde_json::from_str::<serde_json::Value>(&payload_json) {
|
||||
Ok(value) => value,
|
||||
Err(err) => {
|
||||
set_error_msg(&format!("failed to parse payload_json: {}", err));
|
||||
return -1;
|
||||
}
|
||||
};
|
||||
|
||||
let response =
|
||||
match ffi_context()
|
||||
.runtime
|
||||
.block_on(easytier_core::management::call_management_json_rpc(
|
||||
&ffi_context().manager,
|
||||
Arc::new(easytier::rpc_service::logger::NativeLoggerControl),
|
||||
&service_name,
|
||||
&method_name,
|
||||
domain_name.as_deref(),
|
||||
payload,
|
||||
)) {
|
||||
Ok(value) => value,
|
||||
Err(err) => {
|
||||
set_error_msg(&format!("RPC Error: {}", err));
|
||||
return -1;
|
||||
}
|
||||
};
|
||||
let response_json = match serde_json::to_string(&response) {
|
||||
Ok(value) => value,
|
||||
Err(err) => {
|
||||
set_error_msg(&format!("failed to serialize RPC response: {}", err));
|
||||
return -1;
|
||||
}
|
||||
};
|
||||
let response_json = match CString::new(response_json) {
|
||||
Ok(value) => value,
|
||||
Err(err) => {
|
||||
set_error_msg(&format!("failed to allocate RPC response: {}", err));
|
||||
return -1;
|
||||
}
|
||||
};
|
||||
|
||||
unsafe {
|
||||
*out_response_json = response_json.into_raw();
|
||||
}
|
||||
0
|
||||
}
|
||||
@@ -1,267 +1,358 @@
|
||||
use std::sync::Mutex;
|
||||
//! C ABI facade for EasyTier.
|
||||
//!
|
||||
//! The exported API is intentionally kept in this file so C users and JNI
|
||||
//! bindings can see the full callable surface without reading the internal
|
||||
//! implementation modules.
|
||||
//!
|
||||
//! Network management APIs:
|
||||
//! - `parse_config`: validate a TOML network config string.
|
||||
//! - `run_network_instance`: start one local network instance from TOML.
|
||||
//! - `retain_network_instance`: keep named instances and stop all others.
|
||||
//! - `delete_network_instance`: stop named local network instances.
|
||||
//! - `list_instance`: list running instance names and IDs.
|
||||
//! - `collect_network_infos`: collect running instance info as key/value pairs.
|
||||
//! - `set_tun_fd`: attach a TUN file descriptor to a named instance.
|
||||
//! - `call_json_rpc`: call an exposed EasyTier RPC service with JSON payload.
|
||||
//!
|
||||
//! Config server client APIs:
|
||||
//! - `start_config_server_client`: start the managed remote config client.
|
||||
//! - `stop_config_server_client`: stop the remote config client and its managed instances.
|
||||
//! - `is_config_server_client_connected`: report whether the client is connected.
|
||||
//!
|
||||
//! Data plane APIs, enabled by the `ffi-dataplane` feature:
|
||||
//! - `data_plane_session_open` / `data_plane_session_close`: own one instance session.
|
||||
//! - `data_plane_*_submit`: submit non-blocking TCP and UDP operations.
|
||||
//! - `data_plane_completion_wait` / `data_plane_completion_drain`: await completions.
|
||||
//! - `data_plane_*_result_take`: consume typed operation results.
|
||||
//! - `data_plane_operation_cancel` / `data_plane_operation_free`: control operations.
|
||||
//! - `data_plane_resource_close`: close streams, listeners, and UDP sockets.
|
||||
//!
|
||||
//! Shared FFI helper APIs:
|
||||
//! - `get_error_msg`: copy the last FFI or config-server callback error message.
|
||||
//! - `free_string`: release strings allocated by this library.
|
||||
|
||||
use dashmap::DashMap;
|
||||
use easytier::{
|
||||
common::config::{ConfigFileControl, ConfigLoader as _, TomlConfigLoader},
|
||||
instance_manager::NetworkInstanceManager,
|
||||
mod config_server;
|
||||
mod data_plane;
|
||||
mod error;
|
||||
mod instance_api;
|
||||
mod json_rpc;
|
||||
mod state;
|
||||
mod strings;
|
||||
mod types;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
||||
pub use config_server::{in_config_server_callback, validate_config_server_client_options};
|
||||
pub use types::{
|
||||
ConfigServerEventCallback, DataPlaneCompletion, DataPlaneSocketAddr, KeyValuePair,
|
||||
};
|
||||
|
||||
static INSTANCE_NAME_ID_MAP: once_cell::sync::Lazy<DashMap<String, uuid::Uuid>> =
|
||||
once_cell::sync::Lazy::new(DashMap::new);
|
||||
static INSTANCE_MANAGER: once_cell::sync::Lazy<NetworkInstanceManager> =
|
||||
once_cell::sync::Lazy::new(NetworkInstanceManager::new);
|
||||
use std::ffi::{c_char, c_int, c_void};
|
||||
|
||||
static ERROR_MSG: once_cell::sync::Lazy<Mutex<Vec<u8>>> =
|
||||
once_cell::sync::Lazy::new(|| Mutex::new(Vec::new()));
|
||||
|
||||
#[repr(C)]
|
||||
pub struct KeyValuePair {
|
||||
pub key: *const std::ffi::c_char,
|
||||
pub value: *const std::ffi::c_char,
|
||||
}
|
||||
|
||||
fn set_error_msg(msg: &str) {
|
||||
let bytes = msg.as_bytes();
|
||||
let mut msg_buf = ERROR_MSG.lock().unwrap();
|
||||
let len = bytes.len();
|
||||
msg_buf.resize(len, 0);
|
||||
msg_buf[..len].copy_from_slice(bytes);
|
||||
}
|
||||
// ===== Network Management API =====
|
||||
|
||||
/// Validate a TOML network config string.
|
||||
///
|
||||
/// This only parses and validates the config. It does not start an instance and
|
||||
/// does not change global FFI state.
|
||||
///
|
||||
/// # Safety
|
||||
/// Set the tun fd
|
||||
#[unsafe(no_mangle)]
|
||||
pub unsafe extern "C" fn set_tun_fd(
|
||||
inst_name: *const std::ffi::c_char,
|
||||
fd: std::ffi::c_int,
|
||||
) -> std::ffi::c_int {
|
||||
let inst_name = unsafe {
|
||||
assert!(!inst_name.is_null());
|
||||
std::ffi::CStr::from_ptr(inst_name)
|
||||
.to_string_lossy()
|
||||
.into_owned()
|
||||
};
|
||||
if !INSTANCE_NAME_ID_MAP.contains_key(&inst_name) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
let inst_id = *INSTANCE_NAME_ID_MAP
|
||||
.get(&inst_name)
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.value();
|
||||
|
||||
match INSTANCE_MANAGER.set_tun_fd(&inst_id, fd) {
|
||||
Ok(_) => 0,
|
||||
Err(_) => -1,
|
||||
}
|
||||
/// `cfg_str` must be a non-null pointer to a null-terminated UTF-8 string.
|
||||
///
|
||||
/// # Return
|
||||
/// Returns `0` if the config parses successfully, or `-1` on failure. On
|
||||
/// failure, call `get_error_msg` on the same thread to retrieve details.
|
||||
#[cfg_attr(feature = "c-abi", unsafe(no_mangle))]
|
||||
pub unsafe extern "C" fn parse_config(cfg_str: *const c_char) -> c_int {
|
||||
unsafe { instance_api::parse_config(cfg_str) }
|
||||
}
|
||||
|
||||
/// Start one local EasyTier network instance from a TOML config string.
|
||||
///
|
||||
/// The config's `inst_name` must be unique among instances started through this
|
||||
/// FFI layer. This API is mutually exclusive with config-server callback
|
||||
/// execution and will fail if called from a config-server event callback.
|
||||
///
|
||||
/// # Safety
|
||||
/// Get the last error message
|
||||
#[unsafe(no_mangle)]
|
||||
pub unsafe extern "C" fn get_error_msg(out: *mut *const std::ffi::c_char) {
|
||||
let msg_buf = ERROR_MSG.lock().unwrap();
|
||||
if msg_buf.is_empty() {
|
||||
unsafe {
|
||||
*out = std::ptr::null();
|
||||
}
|
||||
return;
|
||||
}
|
||||
let cstr = std::ffi::CString::new(&msg_buf[..]).unwrap();
|
||||
unsafe {
|
||||
*out = cstr.into_raw();
|
||||
}
|
||||
}
|
||||
|
||||
#[unsafe(no_mangle)]
|
||||
pub extern "C" fn free_string(s: *const std::ffi::c_char) {
|
||||
if s.is_null() {
|
||||
return;
|
||||
}
|
||||
unsafe {
|
||||
let _ = std::ffi::CString::from_raw(s as *mut std::ffi::c_char);
|
||||
}
|
||||
/// `cfg_str` must be a non-null pointer to a null-terminated UTF-8 string.
|
||||
///
|
||||
/// # Return
|
||||
/// Returns `0` after the instance is started and registered in the FFI name
|
||||
/// cache, or `-1` on failure. On failure, call `get_error_msg` on the same
|
||||
/// thread to retrieve details.
|
||||
#[cfg_attr(feature = "c-abi", unsafe(no_mangle))]
|
||||
pub unsafe extern "C" fn run_network_instance(cfg_str: *const c_char) -> c_int {
|
||||
unsafe { instance_api::run_network_instance(cfg_str) }
|
||||
}
|
||||
|
||||
/// Keep the named network instances and stop all other instances.
|
||||
///
|
||||
/// Passing `length == 0` stops all instances. When `length > 0`, `inst_names`
|
||||
/// must point to an array of `length` non-null C strings. Instances that are not
|
||||
/// retained are removed from the FFI name cache and any related data-plane
|
||||
/// handles are closed.
|
||||
///
|
||||
/// This API fails if called from a config-server event callback.
|
||||
///
|
||||
/// # Safety
|
||||
/// Parse the config
|
||||
#[unsafe(no_mangle)]
|
||||
pub unsafe extern "C" fn parse_config(cfg_str: *const std::ffi::c_char) -> std::ffi::c_int {
|
||||
let cfg_str = unsafe {
|
||||
assert!(!cfg_str.is_null());
|
||||
std::ffi::CStr::from_ptr(cfg_str)
|
||||
.to_string_lossy()
|
||||
.into_owned()
|
||||
};
|
||||
|
||||
if let Err(e) = TomlConfigLoader::new_from_str(&cfg_str) {
|
||||
set_error_msg(&format!("failed to parse config: {:?}", e));
|
||||
return -1;
|
||||
}
|
||||
|
||||
0
|
||||
}
|
||||
|
||||
/// # Safety
|
||||
/// Run the network instance
|
||||
#[unsafe(no_mangle)]
|
||||
pub unsafe extern "C" fn run_network_instance(cfg_str: *const std::ffi::c_char) -> std::ffi::c_int {
|
||||
let cfg_str = unsafe {
|
||||
assert!(!cfg_str.is_null());
|
||||
std::ffi::CStr::from_ptr(cfg_str)
|
||||
.to_string_lossy()
|
||||
.into_owned()
|
||||
};
|
||||
let cfg = match TomlConfigLoader::new_from_str(&cfg_str) {
|
||||
Ok(cfg) => cfg,
|
||||
Err(e) => {
|
||||
set_error_msg(&format!("failed to parse config: {}", e));
|
||||
return -1;
|
||||
}
|
||||
};
|
||||
|
||||
let inst_name = cfg.get_inst_name();
|
||||
|
||||
if INSTANCE_NAME_ID_MAP.contains_key(&inst_name) {
|
||||
set_error_msg("instance already exists");
|
||||
return -1;
|
||||
}
|
||||
|
||||
let instance_id =
|
||||
match INSTANCE_MANAGER.run_network_instance(cfg, false, ConfigFileControl::STATIC_CONFIG) {
|
||||
Ok(id) => id,
|
||||
Err(e) => {
|
||||
set_error_msg(&format!("failed to start instance: {}", e));
|
||||
return -1;
|
||||
}
|
||||
};
|
||||
|
||||
INSTANCE_NAME_ID_MAP.insert(inst_name, instance_id);
|
||||
|
||||
0
|
||||
}
|
||||
|
||||
/// # Safety
|
||||
/// Retain the network instance
|
||||
#[unsafe(no_mangle)]
|
||||
/// If `length > 0`, `inst_names` must be a non-null pointer to an array of
|
||||
/// `length` non-null pointers to null-terminated UTF-8 strings.
|
||||
///
|
||||
/// # Return
|
||||
/// Returns `0` on success, or `-1` on failure. On failure, call
|
||||
/// `get_error_msg` on the same thread to retrieve details.
|
||||
#[cfg_attr(feature = "c-abi", unsafe(no_mangle))]
|
||||
pub unsafe extern "C" fn retain_network_instance(
|
||||
inst_names: *const *const std::ffi::c_char,
|
||||
inst_names: *const *const c_char,
|
||||
length: usize,
|
||||
) -> std::ffi::c_int {
|
||||
if length == 0 {
|
||||
if let Err(e) = INSTANCE_MANAGER.retain_network_instance(Vec::new()) {
|
||||
set_error_msg(&format!("failed to retain instances: {}", e));
|
||||
return -1;
|
||||
}
|
||||
INSTANCE_NAME_ID_MAP.clear();
|
||||
return 0;
|
||||
}
|
||||
|
||||
let inst_names = unsafe {
|
||||
assert!(!inst_names.is_null());
|
||||
std::slice::from_raw_parts(inst_names, length)
|
||||
.iter()
|
||||
.map(|&name| {
|
||||
assert!(!name.is_null());
|
||||
std::ffi::CStr::from_ptr(name)
|
||||
.to_string_lossy()
|
||||
.into_owned()
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
};
|
||||
|
||||
let inst_ids: Vec<uuid::Uuid> = inst_names
|
||||
.iter()
|
||||
.filter_map(|name| INSTANCE_NAME_ID_MAP.get(name).map(|id| *id))
|
||||
.collect();
|
||||
|
||||
if let Err(e) = INSTANCE_MANAGER.retain_network_instance(inst_ids) {
|
||||
set_error_msg(&format!("failed to retain instances: {}", e));
|
||||
return -1;
|
||||
}
|
||||
|
||||
INSTANCE_NAME_ID_MAP.retain(|k, _| inst_names.contains(k));
|
||||
|
||||
0
|
||||
) -> c_int {
|
||||
unsafe { instance_api::retain_network_instance(inst_names, length) }
|
||||
}
|
||||
|
||||
/// Stop the named network instances.
|
||||
///
|
||||
/// Passing `length == 0` is a no-op. When `length > 0`, `inst_names` must point
|
||||
/// to an array of `length` non-null C strings. Unknown names are ignored.
|
||||
/// Removed instances are also removed from the FFI name cache and any related
|
||||
/// data-plane handles are closed.
|
||||
///
|
||||
/// This API fails if called from a config-server event callback.
|
||||
///
|
||||
/// # Safety
|
||||
/// Collect the network infos
|
||||
#[unsafe(no_mangle)]
|
||||
/// If `length > 0`, `inst_names` must be a non-null pointer to an array of
|
||||
/// `length` non-null pointers to null-terminated UTF-8 strings.
|
||||
///
|
||||
/// # Return
|
||||
/// Returns `0` on success, or `-1` on failure. On failure, call
|
||||
/// `get_error_msg` on the same thread to retrieve details.
|
||||
#[cfg_attr(feature = "c-abi", unsafe(no_mangle))]
|
||||
pub unsafe extern "C" fn delete_network_instance(
|
||||
inst_names: *const *const c_char,
|
||||
length: usize,
|
||||
) -> c_int {
|
||||
unsafe { instance_api::delete_network_instance(inst_names, length) }
|
||||
}
|
||||
|
||||
/// List running network instance names and IDs.
|
||||
///
|
||||
/// Writes up to `max_length` entries into `infos`. Each returned key is the
|
||||
/// instance name and each returned value is the instance ID string. Returned
|
||||
/// key/value strings are allocated by this library and must be released with
|
||||
/// `free_string`.
|
||||
///
|
||||
/// This API fails if called from a config-server event callback.
|
||||
///
|
||||
/// # Safety
|
||||
/// If `max_length > 0`, `infos` must be a non-null pointer to writable storage
|
||||
/// for at least `max_length` `KeyValuePair` values.
|
||||
///
|
||||
/// # Return
|
||||
/// Returns the number of entries written, or `-1` on failure. On failure, call
|
||||
/// `get_error_msg` on the same thread to retrieve details.
|
||||
#[cfg_attr(feature = "c-abi", unsafe(no_mangle))]
|
||||
pub unsafe extern "C" fn list_instance(infos: *mut KeyValuePair, max_length: usize) -> c_int {
|
||||
unsafe { instance_api::list_instance(infos, max_length) }
|
||||
}
|
||||
|
||||
/// Collect running network instance information.
|
||||
///
|
||||
/// Writes up to `max_length` entries into `infos`. Each returned key is the
|
||||
/// instance name and each returned value is a JSON string containing that
|
||||
/// instance's running information. Returned key/value strings are allocated by
|
||||
/// this library and must be released with `free_string`.
|
||||
///
|
||||
/// This API fails if called from a config-server event callback.
|
||||
///
|
||||
/// # Safety
|
||||
/// If `max_length > 0`, `infos` must be a non-null pointer to writable storage
|
||||
/// for at least `max_length` `KeyValuePair` values.
|
||||
///
|
||||
/// # Return
|
||||
/// Returns the number of entries written, or `-1` on failure. On failure, call
|
||||
/// `get_error_msg` on the same thread to retrieve details.
|
||||
#[cfg_attr(feature = "c-abi", unsafe(no_mangle))]
|
||||
pub unsafe extern "C" fn collect_network_infos(
|
||||
infos: *mut KeyValuePair,
|
||||
max_length: usize,
|
||||
) -> std::ffi::c_int {
|
||||
if max_length == 0 {
|
||||
return 0;
|
||||
}
|
||||
|
||||
let infos = unsafe {
|
||||
assert!(!infos.is_null());
|
||||
std::slice::from_raw_parts_mut(infos, max_length)
|
||||
};
|
||||
|
||||
let collected_infos = match INSTANCE_MANAGER.collect_network_infos_sync() {
|
||||
Ok(infos) => infos,
|
||||
Err(e) => {
|
||||
set_error_msg(&format!("failed to collect network infos: {}", e));
|
||||
return -1;
|
||||
}
|
||||
};
|
||||
|
||||
let mut index = 0;
|
||||
for (instance_id, value) in collected_infos.iter() {
|
||||
if index >= max_length {
|
||||
break;
|
||||
}
|
||||
let Some(key) = INSTANCE_MANAGER.get_instance_name(instance_id) else {
|
||||
continue;
|
||||
};
|
||||
// convert value to json string
|
||||
let value = match serde_json::to_string(&value) {
|
||||
Ok(value) => value,
|
||||
Err(e) => {
|
||||
set_error_msg(&format!("failed to serialize instance info: {}", e));
|
||||
return -1;
|
||||
}
|
||||
};
|
||||
|
||||
infos[index] = KeyValuePair {
|
||||
key: std::ffi::CString::new(key).unwrap().into_raw(),
|
||||
value: std::ffi::CString::new(value).unwrap().into_raw(),
|
||||
};
|
||||
index += 1;
|
||||
}
|
||||
|
||||
index as std::ffi::c_int
|
||||
) -> c_int {
|
||||
unsafe { instance_api::collect_network_infos(infos, max_length) }
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
/// Attach a TUN file descriptor to a named network instance.
|
||||
///
|
||||
/// The instance must already have been registered in the FFI name cache by
|
||||
/// `run_network_instance` or by a managed config-server remote start event.
|
||||
///
|
||||
/// # Safety
|
||||
/// `inst_name` must be a non-null pointer to a null-terminated UTF-8 string.
|
||||
/// `fd` must be a valid TUN file descriptor owned by the caller.
|
||||
///
|
||||
/// # Return
|
||||
/// Returns `0` if the descriptor is accepted by the instance, or `-1` on
|
||||
/// failure.
|
||||
#[cfg_attr(feature = "c-abi", unsafe(no_mangle))]
|
||||
pub unsafe extern "C" fn set_tun_fd(inst_name: *const c_char, fd: c_int) -> c_int {
|
||||
unsafe { instance_api::set_tun_fd(inst_name, fd) }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_config() {
|
||||
let cfg_str = r#"
|
||||
inst_name = "test"
|
||||
network = "test_network"
|
||||
"#;
|
||||
let cstr = std::ffi::CString::new(cfg_str).unwrap();
|
||||
unsafe {
|
||||
assert_eq!(parse_config(cstr.as_ptr()), 0);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_run_network_instance() {
|
||||
let cfg_str = r#"
|
||||
inst_name = "test"
|
||||
network = "test_network"
|
||||
"#;
|
||||
let cstr = std::ffi::CString::new(cfg_str).unwrap();
|
||||
unsafe {
|
||||
assert_eq!(run_network_instance(cstr.as_ptr()), 0);
|
||||
}
|
||||
/// Call an exposed EasyTier RPC method using protobuf JSON.
|
||||
///
|
||||
/// This generic bridge intentionally excludes instance lifecycle management
|
||||
/// RPCs. Use the dedicated FFI APIs for starting, retaining, deleting, and
|
||||
/// collecting instances. `payload_json` must contain the protobuf JSON request,
|
||||
/// including any `instance` selector required by the target RPC.
|
||||
///
|
||||
/// `domain_name` may be null or empty. It is only used by
|
||||
/// `api.instance.TcpProxyRpcService`; null or empty defaults to `tcp`, and the
|
||||
/// only accepted explicit values are `tcp`, `kcp_src`, `kcp_dst`, `quic_src`,
|
||||
/// and `quic_dst`.
|
||||
///
|
||||
/// On success, writes a newly allocated JSON response string to
|
||||
/// `out_response_json`. The caller must release it with `free_string`.
|
||||
///
|
||||
/// This API fails if called from a config-server event callback.
|
||||
///
|
||||
/// # Safety
|
||||
/// `service_name`, `method_name`, `payload_json`, and `out_response_json` must
|
||||
/// be non-null. String pointers must point to null-terminated UTF-8 strings.
|
||||
/// `domain_name` may be null.
|
||||
///
|
||||
/// # Return
|
||||
/// Returns `0` on success, or `-1` on failure. On failure, call
|
||||
/// `get_error_msg` on the same thread to retrieve details.
|
||||
#[cfg_attr(feature = "c-abi", unsafe(no_mangle))]
|
||||
pub unsafe extern "C" fn call_json_rpc(
|
||||
service_name: *const c_char,
|
||||
method_name: *const c_char,
|
||||
domain_name: *const c_char,
|
||||
payload_json: *const c_char,
|
||||
out_response_json: *mut *const c_char,
|
||||
) -> c_int {
|
||||
unsafe {
|
||||
json_rpc::call_json_rpc(
|
||||
service_name,
|
||||
method_name,
|
||||
domain_name,
|
||||
payload_json,
|
||||
out_response_json,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// ===== Config Server Client API =====
|
||||
|
||||
/// Start the managed config-server client.
|
||||
///
|
||||
/// The client reuses EasyTier's web-client path and applies remote config
|
||||
/// changes through the shared `NativeInstanceManager`. Successful remote run
|
||||
/// and delete operations are delivered to `callback` as JSON event strings, one
|
||||
/// callback per affected instance. The event string is valid only for the
|
||||
/// duration of the callback; callers must copy it if they need to keep it.
|
||||
///
|
||||
/// The config-server client is mutually exclusive with the FFI data plane. If a
|
||||
/// data-plane handle exists or is being created, this function returns `-1`.
|
||||
///
|
||||
/// # Safety
|
||||
/// `config_server_url` and `machine_id` must be non-null pointers to
|
||||
/// null-terminated UTF-8 strings. `hostname` may be null; when non-null it must
|
||||
/// also point to a null-terminated UTF-8 string. `user_data` is passed back to
|
||||
/// `callback` unchanged and must remain valid for the callback's expectations.
|
||||
///
|
||||
/// # Return
|
||||
/// Returns `0` after the client starts successfully, or `-1` on failure. On
|
||||
/// failure, call `get_error_msg` on the same thread to retrieve details.
|
||||
#[cfg_attr(feature = "c-abi", unsafe(no_mangle))]
|
||||
pub unsafe extern "C" fn start_config_server_client(
|
||||
config_server_url: *const c_char,
|
||||
hostname: *const c_char,
|
||||
machine_id: *const c_char,
|
||||
secure_mode: bool,
|
||||
callback: ConfigServerEventCallback,
|
||||
user_data: *mut c_void,
|
||||
) -> c_int {
|
||||
unsafe {
|
||||
config_server::start_config_server_client(
|
||||
config_server_url,
|
||||
hostname,
|
||||
machine_id,
|
||||
secure_mode,
|
||||
callback,
|
||||
user_data,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// Stop the managed config-server client.
|
||||
///
|
||||
/// This stops the client, removes instances tracked as remote config-server
|
||||
/// instances, waits for in-flight callback delivery when safe to do so, and
|
||||
/// releases the config-server/data-plane mutual exclusion state.
|
||||
///
|
||||
/// # Return
|
||||
/// Returns `0` if no client exists or if the active client is stopped
|
||||
/// successfully. Returns `-1` on failure. On failure, call `get_error_msg` on
|
||||
/// the same thread to retrieve details.
|
||||
#[cfg_attr(feature = "c-abi", unsafe(no_mangle))]
|
||||
pub extern "C" fn stop_config_server_client() -> c_int {
|
||||
config_server::stop_config_server_client()
|
||||
}
|
||||
|
||||
/// Report whether the managed config-server client is currently connected.
|
||||
///
|
||||
/// # Return
|
||||
/// Returns `1` when a client exists and reports connected, otherwise returns
|
||||
/// `0`.
|
||||
#[cfg_attr(feature = "c-abi", unsafe(no_mangle))]
|
||||
pub extern "C" fn is_config_server_client_connected() -> c_int {
|
||||
config_server::is_config_server_client_connected()
|
||||
}
|
||||
|
||||
// ===== Data Plane API =====
|
||||
|
||||
#[cfg(feature = "ffi-dataplane")]
|
||||
pub use data_plane::{
|
||||
DATA_PLANE_DEADLINE_READ, DATA_PLANE_DEADLINE_WRITE, data_plane_completion_drain,
|
||||
data_plane_completion_wait, data_plane_operation_cancel, data_plane_operation_free,
|
||||
data_plane_resource_close, data_plane_resource_deadline_set, data_plane_result_size,
|
||||
data_plane_session_close, data_plane_session_open, data_plane_tcp_accept_result_take,
|
||||
data_plane_tcp_accept_submit, data_plane_tcp_bind_result_take, data_plane_tcp_bind_submit,
|
||||
data_plane_tcp_connect_result_take, data_plane_tcp_connect_submit,
|
||||
data_plane_tcp_read_result_take, data_plane_tcp_read_submit, data_plane_tcp_write_result_take,
|
||||
data_plane_tcp_write_submit, data_plane_udp_bind_result_take, data_plane_udp_bind_submit,
|
||||
data_plane_udp_receive_result_take, data_plane_udp_receive_submit,
|
||||
data_plane_udp_send_result_take, data_plane_udp_send_submit,
|
||||
};
|
||||
|
||||
// ===== Shared FFI Helper API =====
|
||||
|
||||
/// Return the last FFI error message.
|
||||
///
|
||||
/// API failures are stored in a thread-local buffer, so call this on the same
|
||||
/// thread that received a negative status or another documented failure
|
||||
/// sentinel. Config-server
|
||||
/// callback delivery failures may happen on a runtime thread; those are stored
|
||||
/// globally and are included here so direct FFI callers can still retrieve the
|
||||
/// last callback error. If there is no error message, this writes a null pointer
|
||||
/// to `out`.
|
||||
///
|
||||
/// The returned string is allocated by this library and must be released with
|
||||
/// `free_string`.
|
||||
///
|
||||
/// # Safety
|
||||
/// `out` must be a non-null pointer to writable storage for one C string
|
||||
/// pointer.
|
||||
#[cfg_attr(feature = "c-abi", unsafe(no_mangle))]
|
||||
pub unsafe extern "C" fn get_error_msg(out: *mut *const c_char) {
|
||||
unsafe { error::get_error_msg(out) }
|
||||
}
|
||||
|
||||
/// Release a C string allocated by this library.
|
||||
///
|
||||
/// Use this for strings returned through `get_error_msg`,
|
||||
/// `collect_network_infos`, and data-plane address output parameters. Passing a
|
||||
/// null pointer is allowed and has no effect.
|
||||
#[cfg_attr(feature = "c-abi", unsafe(no_mangle))]
|
||||
pub extern "C" fn free_string(s: *const c_char) {
|
||||
error::free_string(s)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use easytier::instance::factory::{
|
||||
NativeInstanceManager, NativeProcessManagement, native_instance_manager_with_runtime,
|
||||
native_process_management,
|
||||
};
|
||||
use tokio::runtime::{Builder, Runtime};
|
||||
|
||||
struct FfiOwnedInstanceHooks;
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl easytier_core::management::InstanceMutationHooks for FfiOwnedInstanceHooks {
|
||||
async fn post_remove_network_instances(
|
||||
&self,
|
||||
instance_ids: &[uuid::Uuid],
|
||||
) -> Result<(), String> {
|
||||
crate::config_server::remove_config_server_tracked_instance_ids(instance_ids);
|
||||
crate::data_plane::remove_data_plane_sessions_by_instance_ids(instance_ids);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) struct FfiContext {
|
||||
pub(crate) runtime: Runtime,
|
||||
pub(crate) manager: Arc<NativeInstanceManager>,
|
||||
pub(crate) process_management: NativeProcessManagement,
|
||||
}
|
||||
|
||||
impl FfiContext {
|
||||
fn new() -> Self {
|
||||
let runtime = Builder::new_multi_thread()
|
||||
.enable_all()
|
||||
.build()
|
||||
.expect("tokio runtime for easytier-ffi");
|
||||
let manager = Arc::new(native_instance_manager_with_runtime(
|
||||
runtime.handle().clone(),
|
||||
));
|
||||
let process_management =
|
||||
native_process_management(manager.clone(), Arc::new(FfiOwnedInstanceHooks));
|
||||
Self {
|
||||
runtime,
|
||||
manager,
|
||||
process_management,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static FFI_CONTEXT: once_cell::sync::Lazy<FfiContext> = once_cell::sync::Lazy::new(FfiContext::new);
|
||||
|
||||
pub(crate) fn ffi_context() -> &'static FfiContext {
|
||||
&FFI_CONTEXT
|
||||
}
|
||||
|
||||
pub(crate) fn resolve_instance_id_by_name(inst_name: &str) -> Result<Option<uuid::Uuid>, String> {
|
||||
easytier_core::management::resolve_optional_instance_by_name(
|
||||
ffi_context().manager.as_ref(),
|
||||
inst_name,
|
||||
)
|
||||
.map(|instance| instance.map(|instance| instance.instance_id()))
|
||||
.map_err(|error| error.to_string())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn find_instance_id_by_name(inst_name: &str) -> Option<uuid::Uuid> {
|
||||
resolve_instance_id_by_name(inst_name).ok().flatten()
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
use std::ffi::{CStr, c_char};
|
||||
|
||||
pub(crate) unsafe fn c_str_to_string(ptr: *const c_char, name: &str) -> Result<String, String> {
|
||||
if ptr.is_null() {
|
||||
return Err(format!("{} is null", name));
|
||||
}
|
||||
|
||||
unsafe { CStr::from_ptr(ptr) }
|
||||
.to_str()
|
||||
.map(|value| value.to_string())
|
||||
.map_err(|err| format!("{} is not valid UTF-8: {}", name, err))
|
||||
}
|
||||
|
||||
pub(crate) unsafe fn optional_c_str_to_string(
|
||||
ptr: *const c_char,
|
||||
name: &str,
|
||||
) -> Result<Option<String>, String> {
|
||||
if ptr.is_null() {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
unsafe { c_str_to_string(ptr, name) }.map(Some)
|
||||
}
|
||||
@@ -0,0 +1,746 @@
|
||||
use crate::{
|
||||
config_server::{
|
||||
ConfigServerCallbackScope, ManagedConfigServerClientHooks, set_active_for_test,
|
||||
},
|
||||
state::{ffi_context, find_instance_id_by_name},
|
||||
*,
|
||||
};
|
||||
use easytier::{
|
||||
common::config::{ConfigFileControl, ConfigLoader as _, TomlConfigLoader},
|
||||
web_client::WebClientHooks,
|
||||
};
|
||||
use serde_json::Value;
|
||||
use std::{
|
||||
collections::HashSet,
|
||||
ffi::{CStr, CString, c_char, c_int, c_void},
|
||||
sync::{Mutex, mpsc},
|
||||
time::Duration,
|
||||
};
|
||||
use uuid::Uuid;
|
||||
|
||||
#[test]
|
||||
fn test_parse_config() {
|
||||
let cfg_str = r#"
|
||||
inst_name = "test"
|
||||
network = "test_network"
|
||||
"#;
|
||||
let cstr = std::ffi::CString::new(cfg_str).unwrap();
|
||||
unsafe {
|
||||
assert_eq!(parse_config(cstr.as_ptr()), 0);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_run_network_instance() {
|
||||
let cfg_str = r#"
|
||||
inst_name = "test"
|
||||
network = "test_network"
|
||||
"#;
|
||||
let cstr = std::ffi::CString::new(cfg_str).unwrap();
|
||||
unsafe {
|
||||
assert_eq!(run_network_instance(cstr.as_ptr()), 0);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn get_error_msg_returns_config_server_callback_error() {
|
||||
let hooks = ManagedConfigServerClientHooks::new(None, std::ptr::null_mut());
|
||||
let callback_error = format!("callback delivery failed {}", Uuid::new_v4());
|
||||
crate::config_server::clear_last_callback_error();
|
||||
hooks.note_callback_error(callback_error.clone());
|
||||
|
||||
unsafe {
|
||||
let mut error_ptr: *const c_char = std::ptr::null();
|
||||
get_error_msg(&mut error_ptr);
|
||||
assert!(!error_ptr.is_null());
|
||||
let error_msg = CStr::from_ptr(error_ptr).to_string_lossy().into_owned();
|
||||
free_string(error_ptr);
|
||||
assert!(error_msg.contains(&callback_error));
|
||||
}
|
||||
|
||||
crate::config_server::clear_last_callback_error();
|
||||
}
|
||||
|
||||
unsafe extern "C" fn record_config_server_event(event_json: *const c_char, user_data: *mut c_void) {
|
||||
let events = unsafe { &*(user_data as *const Mutex<Vec<String>>) };
|
||||
events.lock().unwrap().push(
|
||||
unsafe { CStr::from_ptr(event_json) }
|
||||
.to_string_lossy()
|
||||
.into_owned(),
|
||||
);
|
||||
}
|
||||
|
||||
fn take_last_error() -> Option<String> {
|
||||
unsafe {
|
||||
let mut error_ptr: *const c_char = std::ptr::null();
|
||||
get_error_msg(&mut error_ptr);
|
||||
if error_ptr.is_null() {
|
||||
None
|
||||
} else {
|
||||
let error = CStr::from_ptr(error_ptr).to_string_lossy().into_owned();
|
||||
free_string(error_ptr);
|
||||
Some(error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn free_key_value_pairs(infos: &[KeyValuePair]) {
|
||||
for info in infos {
|
||||
free_string(info.key);
|
||||
free_string(info.value);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn list_instance_returns_instance_names_and_ids() {
|
||||
let instance_id = Uuid::new_v4();
|
||||
let instance_name = format!("list-instance-{}", instance_id);
|
||||
let cfg = TomlConfigLoader::default();
|
||||
cfg.set_id(instance_id);
|
||||
cfg.set_inst_name(instance_name.clone());
|
||||
ffi_context()
|
||||
.manager
|
||||
.run_network_instance(cfg, ConfigFileControl::STATIC_CONFIG)
|
||||
.unwrap();
|
||||
|
||||
let mut infos = vec![
|
||||
KeyValuePair {
|
||||
key: std::ptr::null(),
|
||||
value: std::ptr::null(),
|
||||
};
|
||||
16
|
||||
];
|
||||
let count = unsafe { list_instance(infos.as_mut_ptr(), infos.len()) };
|
||||
assert!(count > 0);
|
||||
|
||||
let mut found = false;
|
||||
for info in infos.iter().take(count as usize) {
|
||||
let key = unsafe { CStr::from_ptr(info.key) }.to_string_lossy();
|
||||
let value = unsafe { CStr::from_ptr(info.value) }.to_string_lossy();
|
||||
if key == instance_name {
|
||||
assert_eq!(value, instance_id.to_string());
|
||||
found = true;
|
||||
}
|
||||
}
|
||||
|
||||
free_key_value_pairs(&infos[..count as usize]);
|
||||
ffi_context()
|
||||
.runtime
|
||||
.block_on(
|
||||
ffi_context()
|
||||
.manager
|
||||
.delete_network_instances([instance_id]),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(found);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn list_instance_allows_zero_length() {
|
||||
assert_eq!(unsafe { list_instance(std::ptr::null_mut(), 0) }, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn list_instance_rejects_null_output_pointer() {
|
||||
assert_eq!(unsafe { list_instance(std::ptr::null_mut(), 1) }, -1);
|
||||
assert!(take_last_error().unwrap().contains("infos is null"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn call_json_rpc_returns_logger_response() {
|
||||
let service = CString::new("api.logger.LoggerRpcService").unwrap();
|
||||
let method = CString::new("get_logger_config").unwrap();
|
||||
let payload = CString::new("{}").unwrap();
|
||||
let mut response_ptr: *const c_char = std::ptr::null();
|
||||
|
||||
assert_eq!(
|
||||
unsafe {
|
||||
call_json_rpc(
|
||||
service.as_ptr(),
|
||||
method.as_ptr(),
|
||||
std::ptr::null(),
|
||||
payload.as_ptr(),
|
||||
&mut response_ptr,
|
||||
)
|
||||
},
|
||||
0
|
||||
);
|
||||
assert!(!response_ptr.is_null());
|
||||
let response = unsafe { CStr::from_ptr(response_ptr) }
|
||||
.to_string_lossy()
|
||||
.into_owned();
|
||||
free_string(response_ptr);
|
||||
let response: Value = serde_json::from_str(&response).unwrap();
|
||||
assert!(response.get("level").is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn call_json_rpc_rejects_instance_management_service() {
|
||||
let service = CString::new("api.manage.WebClientService").unwrap();
|
||||
let method = CString::new("list_network_instance").unwrap();
|
||||
let payload = CString::new("{}").unwrap();
|
||||
let mut response_ptr: *const c_char = std::ptr::null();
|
||||
|
||||
assert_eq!(
|
||||
unsafe {
|
||||
call_json_rpc(
|
||||
service.as_ptr(),
|
||||
method.as_ptr(),
|
||||
std::ptr::null(),
|
||||
payload.as_ptr(),
|
||||
&mut response_ptr,
|
||||
)
|
||||
},
|
||||
-1
|
||||
);
|
||||
assert!(response_ptr.is_null());
|
||||
assert!(take_last_error().unwrap().contains("not exposed"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn call_json_rpc_rejects_malformed_payload_json() {
|
||||
let service = CString::new("api.logger.LoggerRpcService").unwrap();
|
||||
let method = CString::new("get_logger_config").unwrap();
|
||||
let payload = CString::new("{").unwrap();
|
||||
let mut response_ptr: *const c_char = std::ptr::null();
|
||||
|
||||
assert_eq!(
|
||||
unsafe {
|
||||
call_json_rpc(
|
||||
service.as_ptr(),
|
||||
method.as_ptr(),
|
||||
std::ptr::null(),
|
||||
payload.as_ptr(),
|
||||
&mut response_ptr,
|
||||
)
|
||||
},
|
||||
-1
|
||||
);
|
||||
assert!(response_ptr.is_null());
|
||||
assert!(
|
||||
take_last_error()
|
||||
.unwrap()
|
||||
.contains("failed to parse payload_json")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn call_json_rpc_rejects_null_output_pointer() {
|
||||
let service = CString::new("api.logger.LoggerRpcService").unwrap();
|
||||
let method = CString::new("get_logger_config").unwrap();
|
||||
let payload = CString::new("{}").unwrap();
|
||||
|
||||
assert_eq!(
|
||||
unsafe {
|
||||
call_json_rpc(
|
||||
service.as_ptr(),
|
||||
method.as_ptr(),
|
||||
std::ptr::null(),
|
||||
payload.as_ptr(),
|
||||
std::ptr::null_mut(),
|
||||
)
|
||||
},
|
||||
-1
|
||||
);
|
||||
assert!(
|
||||
take_last_error()
|
||||
.unwrap()
|
||||
.contains("out_response_json is null")
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn config_server_hooks_emit_run_event() {
|
||||
let events: Mutex<Vec<String>> = Mutex::new(Vec::new());
|
||||
let hooks = ManagedConfigServerClientHooks::new(
|
||||
Some(record_config_server_event),
|
||||
&events as *const _ as *mut c_void,
|
||||
);
|
||||
let instance_id = Uuid::new_v4();
|
||||
let cfg = TomlConfigLoader::default();
|
||||
cfg.set_id(instance_id);
|
||||
let inst_name = format!("test-{}", instance_id);
|
||||
cfg.set_inst_name(inst_name.clone());
|
||||
hooks.pre_run_network_instance(&cfg).await.unwrap();
|
||||
ffi_context()
|
||||
.manager
|
||||
.run_network_instance(cfg, ConfigFileControl::STATIC_CONFIG)
|
||||
.unwrap();
|
||||
|
||||
hooks.post_run_network_instance(&instance_id).await.unwrap();
|
||||
|
||||
let duplicate_cfg = TomlConfigLoader::default();
|
||||
duplicate_cfg.set_inst_name(inst_name);
|
||||
duplicate_cfg.set_id(Uuid::new_v4());
|
||||
assert!(
|
||||
hooks
|
||||
.pre_run_network_instance(&duplicate_cfg)
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
|
||||
assert_eq!(hooks.tracked_instance_ids(), vec![instance_id]);
|
||||
let events = events.lock().unwrap().clone();
|
||||
assert_eq!(events.len(), 1);
|
||||
let event: Value = serde_json::from_str(&events[0]).unwrap();
|
||||
assert_eq!(event["event"], "run_network_instance");
|
||||
assert_eq!(event["success"], true);
|
||||
assert_eq!(event["instance_id"], instance_id.to_string());
|
||||
assert!(event["error"].is_null());
|
||||
ffi_context()
|
||||
.manager
|
||||
.delete_network_instances([instance_id])
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn config_server_hooks_emit_delete_events_for_tracked_instances() {
|
||||
let events: Mutex<Vec<String>> = Mutex::new(Vec::new());
|
||||
let hooks = ManagedConfigServerClientHooks::new(
|
||||
Some(record_config_server_event),
|
||||
&events as *const _ as *mut c_void,
|
||||
);
|
||||
let instance_id_1 = Uuid::new_v4();
|
||||
let instance_id_2 = Uuid::new_v4();
|
||||
let unknown_instance_id = Uuid::new_v4();
|
||||
for id in [instance_id_1, instance_id_2] {
|
||||
let cfg = TomlConfigLoader::default();
|
||||
cfg.set_id(id);
|
||||
cfg.set_inst_name(format!("test-{}", id));
|
||||
hooks.pre_run_network_instance(&cfg).await.unwrap();
|
||||
ffi_context()
|
||||
.manager
|
||||
.run_network_instance(cfg, ConfigFileControl::STATIC_CONFIG)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
hooks
|
||||
.post_run_network_instance(&instance_id_1)
|
||||
.await
|
||||
.unwrap();
|
||||
hooks
|
||||
.post_run_network_instance(&instance_id_2)
|
||||
.await
|
||||
.unwrap();
|
||||
events.lock().unwrap().clear();
|
||||
|
||||
hooks
|
||||
.post_remove_network_instances(&[instance_id_1, unknown_instance_id, instance_id_2])
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert!(hooks.tracked_instance_ids().is_empty());
|
||||
let events = events.lock().unwrap().clone();
|
||||
assert_eq!(events.len(), 2);
|
||||
let event_ids = events
|
||||
.iter()
|
||||
.map(|event| {
|
||||
let event: Value = serde_json::from_str(event).unwrap();
|
||||
assert_eq!(event["event"], "delete_network_instance");
|
||||
assert_eq!(event["success"], true);
|
||||
assert!(event["error"].is_null());
|
||||
event["instance_id"].as_str().unwrap().to_string()
|
||||
})
|
||||
.collect::<HashSet<_>>();
|
||||
assert_eq!(
|
||||
event_ids,
|
||||
HashSet::from([instance_id_1.to_string(), instance_id_2.to_string()])
|
||||
);
|
||||
ffi_context()
|
||||
.manager
|
||||
.delete_network_instances([instance_id_1, instance_id_2])
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn config_server_hooks_ignore_untracked_instance_without_event() {
|
||||
let events: Mutex<Vec<String>> = Mutex::new(Vec::new());
|
||||
let hooks = ManagedConfigServerClientHooks::new(
|
||||
Some(record_config_server_event),
|
||||
&events as *const _ as *mut c_void,
|
||||
);
|
||||
let local_id = Uuid::new_v4();
|
||||
|
||||
hooks
|
||||
.post_remove_network_instances(&[local_id])
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert!(events.lock().unwrap().is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn config_server_hooks_reject_duplicate_instance_name() {
|
||||
let hooks = ManagedConfigServerClientHooks::new(None, std::ptr::null_mut());
|
||||
let inst_name = format!("test-{}", Uuid::new_v4());
|
||||
let existing_id = Uuid::new_v4();
|
||||
let new_id = Uuid::new_v4();
|
||||
let existing_cfg = TomlConfigLoader::default();
|
||||
existing_cfg.set_inst_name(inst_name.clone());
|
||||
existing_cfg.set_id(existing_id);
|
||||
ffi_context()
|
||||
.manager
|
||||
.run_network_instance(existing_cfg, ConfigFileControl::STATIC_CONFIG)
|
||||
.unwrap();
|
||||
|
||||
let cfg = TomlConfigLoader::default();
|
||||
cfg.set_inst_name(inst_name.clone());
|
||||
cfg.set_id(new_id);
|
||||
|
||||
assert!(hooks.pre_run_network_instance(&cfg).await.is_err());
|
||||
assert_eq!(find_instance_id_by_name(&inst_name), Some(existing_id));
|
||||
ffi_context()
|
||||
.manager
|
||||
.delete_network_instances([existing_id])
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn config_server_hooks_remove_overwritten_id_before_duplicate_name_error() {
|
||||
let events: Mutex<Vec<String>> = Mutex::new(Vec::new());
|
||||
let hooks = ManagedConfigServerClientHooks::new(
|
||||
Some(record_config_server_event),
|
||||
&events as *const _ as *mut c_void,
|
||||
);
|
||||
let old_name = format!("old-{}", Uuid::new_v4());
|
||||
let duplicate_name = format!("duplicate-{}", Uuid::new_v4());
|
||||
let overwritten_id = Uuid::new_v4();
|
||||
let duplicate_id = Uuid::new_v4();
|
||||
hooks.instance_ids.lock().unwrap().insert(overwritten_id);
|
||||
for (id, name) in [
|
||||
(overwritten_id, old_name.clone()),
|
||||
(duplicate_id, duplicate_name.clone()),
|
||||
] {
|
||||
let cfg = TomlConfigLoader::default();
|
||||
cfg.set_id(id);
|
||||
cfg.set_inst_name(name);
|
||||
ffi_context()
|
||||
.manager
|
||||
.run_network_instance(cfg, ConfigFileControl::STATIC_CONFIG)
|
||||
.unwrap();
|
||||
}
|
||||
ffi_context()
|
||||
.manager
|
||||
.delete_network_instances([overwritten_id])
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
hooks
|
||||
.post_remove_network_instances(&[overwritten_id])
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let cfg = TomlConfigLoader::default();
|
||||
cfg.set_inst_name(duplicate_name.clone());
|
||||
cfg.set_id(overwritten_id);
|
||||
|
||||
assert!(hooks.pre_run_network_instance(&cfg).await.is_err());
|
||||
assert!(hooks.tracked_instance_ids().is_empty());
|
||||
assert!(find_instance_id_by_name(&old_name).is_none());
|
||||
assert_eq!(
|
||||
find_instance_id_by_name(&duplicate_name),
|
||||
Some(duplicate_id)
|
||||
);
|
||||
assert_eq!(events.lock().unwrap().len(), 1);
|
||||
ffi_context()
|
||||
.manager
|
||||
.delete_network_instances([duplicate_id])
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn config_server_hooks_remove_tracked_state_before_overwrite_retry() {
|
||||
let hooks = ManagedConfigServerClientHooks::new(None, std::ptr::null_mut());
|
||||
let inst_name = format!("test-{}", Uuid::new_v4());
|
||||
let instance_id = Uuid::new_v4();
|
||||
hooks.instance_ids.lock().unwrap().insert(instance_id);
|
||||
|
||||
let cfg = TomlConfigLoader::default();
|
||||
cfg.set_inst_name(inst_name.clone());
|
||||
cfg.set_id(instance_id);
|
||||
ffi_context()
|
||||
.manager
|
||||
.run_network_instance(cfg.clone(), ConfigFileControl::STATIC_CONFIG)
|
||||
.unwrap();
|
||||
ffi_context()
|
||||
.manager
|
||||
.delete_network_instances([instance_id])
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
hooks
|
||||
.post_remove_network_instances(&[instance_id])
|
||||
.await
|
||||
.unwrap();
|
||||
hooks.pre_run_network_instance(&cfg).await.unwrap();
|
||||
|
||||
assert!(hooks.tracked_instance_ids().is_empty());
|
||||
assert!(find_instance_id_by_name(&inst_name).is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn config_server_hooks_reject_post_run_after_external_delete() {
|
||||
let hooks = ManagedConfigServerClientHooks::new(None, std::ptr::null_mut());
|
||||
let instance_id = Uuid::new_v4();
|
||||
let cfg = TomlConfigLoader::default();
|
||||
cfg.set_id(instance_id);
|
||||
cfg.set_inst_name(format!("test-{}", instance_id));
|
||||
hooks.pre_run_network_instance(&cfg).await.unwrap();
|
||||
ffi_context()
|
||||
.manager
|
||||
.run_network_instance(cfg, ConfigFileControl::STATIC_CONFIG)
|
||||
.unwrap();
|
||||
ffi_context()
|
||||
.manager
|
||||
.delete_network_instances([instance_id])
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert!(hooks.post_run_network_instance(&instance_id).await.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn find_instance_id_by_name_resolves_uncommitted_manager_instance_name() {
|
||||
let instance_id = Uuid::new_v4();
|
||||
let inst_name = format!("test-{}", instance_id);
|
||||
let cfg = TomlConfigLoader::default();
|
||||
cfg.set_id(instance_id);
|
||||
cfg.set_inst_name(inst_name.clone());
|
||||
ffi_context()
|
||||
.manager
|
||||
.run_network_instance(cfg, ConfigFileControl::STATIC_CONFIG)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(find_instance_id_by_name(&inst_name), Some(instance_id));
|
||||
ffi_context()
|
||||
.runtime
|
||||
.block_on(
|
||||
ffi_context()
|
||||
.manager
|
||||
.delete_network_instances([instance_id]),
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn delete_network_instance_removes_only_named_instances() {
|
||||
let keep_id = Uuid::new_v4();
|
||||
let delete_id = Uuid::new_v4();
|
||||
let keep_name = format!("keep-{}", keep_id);
|
||||
let delete_name = format!("delete-{}", delete_id);
|
||||
|
||||
for (id, name) in [
|
||||
(keep_id, keep_name.clone()),
|
||||
(delete_id, delete_name.clone()),
|
||||
] {
|
||||
let cfg = TomlConfigLoader::default();
|
||||
cfg.set_id(id);
|
||||
cfg.set_inst_name(name.clone());
|
||||
ffi_context()
|
||||
.manager
|
||||
.run_network_instance(cfg, ConfigFileControl::STATIC_CONFIG)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
let delete_name = CString::new(delete_name.clone()).unwrap();
|
||||
let inst_names = [delete_name.as_ptr()];
|
||||
assert_eq!(
|
||||
unsafe { delete_network_instance(inst_names.as_ptr(), inst_names.len()) },
|
||||
0
|
||||
);
|
||||
|
||||
assert_eq!(find_instance_id_by_name(&keep_name), Some(keep_id));
|
||||
assert!(find_instance_id_by_name(delete_name.to_str().unwrap()).is_none());
|
||||
|
||||
ffi_context()
|
||||
.runtime
|
||||
.block_on(ffi_context().manager.delete_network_instances([keep_id]))
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn retain_and_delete_network_instance_reject_invalid_name_pointers() {
|
||||
assert_eq!(unsafe { retain_network_instance(std::ptr::null(), 1) }, -1);
|
||||
assert_eq!(unsafe { delete_network_instance(std::ptr::null(), 1) }, -1);
|
||||
|
||||
let inst_names = [std::ptr::null()];
|
||||
assert_eq!(
|
||||
unsafe { retain_network_instance(inst_names.as_ptr(), inst_names.len()) },
|
||||
-1
|
||||
);
|
||||
assert_eq!(
|
||||
unsafe { delete_network_instance(inst_names.as_ptr(), inst_names.len()) },
|
||||
-1
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ffi_process_management_uses_manager_mutation_lock() {
|
||||
let manager_guard = ffi_context().manager.mutation_lock().blocking_lock_owned();
|
||||
let (done_tx, done_rx) = mpsc::channel();
|
||||
let waiter = std::thread::spawn(move || {
|
||||
ffi_context()
|
||||
.runtime
|
||||
.block_on(
|
||||
ffi_context()
|
||||
.process_management
|
||||
.delete_owned_network_instances(Vec::new()),
|
||||
)
|
||||
.unwrap();
|
||||
done_tx.send(()).unwrap();
|
||||
});
|
||||
|
||||
assert!(done_rx.recv_timeout(Duration::from_millis(100)).is_err());
|
||||
drop(manager_guard);
|
||||
done_rx.recv_timeout(Duration::from_secs(5)).unwrap();
|
||||
waiter.join().unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn config_server_hooks_reject_late_runs_for_core_rollback() {
|
||||
let events: Mutex<Vec<String>> = Mutex::new(Vec::new());
|
||||
let hooks = ManagedConfigServerClientHooks::new(
|
||||
Some(record_config_server_event),
|
||||
&events as *const _ as *mut c_void,
|
||||
);
|
||||
hooks.start_stopping();
|
||||
|
||||
assert!(
|
||||
hooks
|
||||
.post_run_network_instance(&Uuid::new_v4())
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
|
||||
assert!(hooks.tracked_instance_ids().is_empty());
|
||||
assert!(events.lock().unwrap().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn delete_network_instance_rejects_an_ambiguous_name() {
|
||||
let duplicate_name = format!("duplicate-{}", Uuid::new_v4());
|
||||
let instance_ids = [Uuid::new_v4(), Uuid::new_v4()];
|
||||
for instance_id in instance_ids {
|
||||
let config = TomlConfigLoader::default();
|
||||
config.set_id(instance_id);
|
||||
config.set_inst_name(duplicate_name.clone());
|
||||
ffi_context()
|
||||
.manager
|
||||
.run_network_instance(config, ConfigFileControl::STATIC_CONFIG)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
let duplicate_name = CString::new(duplicate_name).unwrap();
|
||||
let names = [duplicate_name.as_ptr()];
|
||||
assert_eq!(
|
||||
unsafe { delete_network_instance(names.as_ptr(), names.len()) },
|
||||
-1
|
||||
);
|
||||
assert!(take_last_error().unwrap().contains("2 instances match"));
|
||||
assert!(
|
||||
instance_ids
|
||||
.iter()
|
||||
.all(|id| ffi_context().manager.instance(*id).is_some())
|
||||
);
|
||||
|
||||
ffi_context()
|
||||
.runtime
|
||||
.block_on(
|
||||
ffi_context()
|
||||
.process_management
|
||||
.delete_owned_network_instances(instance_ids.to_vec()),
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn config_server_callback_context_rejects_nested_blocking_ffi_calls() {
|
||||
let _callback_scope = ConfigServerCallbackScope::enter();
|
||||
assert_eq!(is_config_server_client_connected(), 0);
|
||||
let service = CString::new("api.logger.LoggerRpcService").unwrap();
|
||||
let method = CString::new("get_logger_config").unwrap();
|
||||
let payload = CString::new("{}").unwrap();
|
||||
let mut response_ptr: *const c_char = std::ptr::null();
|
||||
assert_eq!(
|
||||
unsafe {
|
||||
call_json_rpc(
|
||||
service.as_ptr(),
|
||||
method.as_ptr(),
|
||||
std::ptr::null(),
|
||||
payload.as_ptr(),
|
||||
&mut response_ptr,
|
||||
)
|
||||
},
|
||||
-1
|
||||
);
|
||||
assert!(response_ptr.is_null());
|
||||
assert_eq!(
|
||||
unsafe { collect_network_infos(std::ptr::null_mut(), 0) },
|
||||
-1
|
||||
);
|
||||
assert_eq!(unsafe { list_instance(std::ptr::null_mut(), 0) }, -1);
|
||||
let cfg = CString::new("inst_name = \"callback-test\"\nlisteners = []").unwrap();
|
||||
assert_eq!(unsafe { run_network_instance(cfg.as_ptr()) }, -1);
|
||||
assert_eq!(unsafe { retain_network_instance(std::ptr::null(), 0) }, -1);
|
||||
assert_eq!(unsafe { delete_network_instance(std::ptr::null(), 0) }, -1);
|
||||
let url = CString::new("ring://test/token").unwrap();
|
||||
let machine_id = CString::new("test-machine").unwrap();
|
||||
assert_eq!(
|
||||
unsafe {
|
||||
start_config_server_client(
|
||||
url.as_ptr(),
|
||||
std::ptr::null(),
|
||||
machine_id.as_ptr(),
|
||||
false,
|
||||
None,
|
||||
std::ptr::null_mut(),
|
||||
)
|
||||
},
|
||||
-1
|
||||
);
|
||||
assert_eq!(stop_config_server_client(), -1);
|
||||
|
||||
#[cfg(feature = "ffi-dataplane")]
|
||||
{
|
||||
let mut session = 0;
|
||||
assert_eq!(
|
||||
unsafe { data_plane_session_open(std::ptr::null(), &mut session) },
|
||||
-(easytier_core::gateway::DataPlaneErrorKind::Io as c_int)
|
||||
);
|
||||
assert_eq!(session, 0);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "ffi-dataplane")]
|
||||
#[test]
|
||||
fn active_config_server_rejects_data_plane() {
|
||||
set_active_for_test(true);
|
||||
|
||||
let name = CString::new("missing").unwrap();
|
||||
let mut session = 0;
|
||||
assert_eq!(
|
||||
unsafe { data_plane_session_open(name.as_ptr(), &mut session) },
|
||||
-(easytier_core::gateway::DataPlaneErrorKind::Io as c_int)
|
||||
);
|
||||
assert_eq!(session, 0);
|
||||
|
||||
set_active_for_test(false);
|
||||
}
|
||||
|
||||
#[cfg(feature = "ffi-dataplane")]
|
||||
#[test]
|
||||
fn data_plane_invalid_handle_errors_are_stable() {
|
||||
let closed = -(easytier_core::gateway::DataPlaneErrorKind::HandleClosed as c_int);
|
||||
assert_eq!(data_plane_completion_wait(u64::MAX, 0), closed);
|
||||
assert_eq!(data_plane_operation_cancel(u64::MAX, 1), closed);
|
||||
assert_eq!(data_plane_operation_free(u64::MAX, 1), closed);
|
||||
assert_eq!(data_plane_resource_close(u64::MAX, 1), closed);
|
||||
assert_eq!(
|
||||
data_plane_resource_deadline_set(u64::MAX, 1, DATA_PLANE_DEADLINE_READ, 0),
|
||||
closed
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
use std::ffi::{c_char, c_void};
|
||||
|
||||
#[repr(C)]
|
||||
#[derive(Clone, Copy)]
|
||||
pub struct KeyValuePair {
|
||||
pub key: *const c_char,
|
||||
pub value: *const c_char,
|
||||
}
|
||||
|
||||
pub type ConfigServerEventCallback = Option<unsafe extern "C" fn(*const c_char, *mut c_void)>;
|
||||
|
||||
#[repr(C)]
|
||||
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
|
||||
pub struct DataPlaneSocketAddr {
|
||||
/// `4` for IPv4. Other families are reserved for later ABI versions.
|
||||
pub family: u16,
|
||||
/// Native-endian port number.
|
||||
pub port: u16,
|
||||
/// Network-order address bytes. IPv4 uses the first four bytes.
|
||||
pub address: [u8; 16],
|
||||
}
|
||||
|
||||
#[repr(C)]
|
||||
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
|
||||
pub struct DataPlaneCompletion {
|
||||
pub operation_id: u64,
|
||||
pub operation_kind: u16,
|
||||
/// `0` for success, otherwise a stable `DataPlaneErrorKind` value.
|
||||
pub status: u16,
|
||||
}
|
||||
@@ -99,7 +99,7 @@ while true; do
|
||||
# 启动后的扫尾工作
|
||||
if pgrep -f "${EASYTIER}" >/dev/null; then
|
||||
|
||||
if ! ip rule show | grep -q "lookup main"; then
|
||||
if ! ip rule show | grep -qE '^[0-9]+:[[:space:]]+from all lookup main$'; then
|
||||
ip rule add from all lookup main
|
||||
fi
|
||||
|
||||
@@ -109,4 +109,4 @@ while true; do
|
||||
fi
|
||||
|
||||
sleep 10s
|
||||
done
|
||||
done
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
id=easytier_magisk
|
||||
name=EasyTier_Magisk
|
||||
version=v2.6.3
|
||||
version=v2.6.4
|
||||
versionCode=1
|
||||
author=EasyTier
|
||||
description=easytier magisk module @EasyTier(https://github.com/EasyTier/EasyTier)
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
[package]
|
||||
name = "easytier-mini"
|
||||
description = "Minimal native EasyTier node with TCP/UDP tunnels, TUN and UDP hole punching."
|
||||
version = "2.6.4"
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
license-file = "../../LICENSE"
|
||||
build = "build.rs"
|
||||
|
||||
[dependencies]
|
||||
anyhow = "1.0"
|
||||
easytier = { path = "../../easytier", version = "2.6.4", default-features = false, features = [
|
||||
"aes-gcm",
|
||||
"dhcp-ipv4",
|
||||
"logging",
|
||||
"proxy-cidr-monitor",
|
||||
"smoltcp",
|
||||
"tun",
|
||||
"web-client",
|
||||
] }
|
||||
tokio = { version = "1", default-features = false, features = ["macros", "rt", "signal"] }
|
||||
@@ -0,0 +1,113 @@
|
||||
# easytier-mini
|
||||
|
||||
`easytier-mini` is a native EasyTier POC binary. It shares EasyTier's TOML
|
||||
configuration model, peer protocol, TCP/UDP tunnel implementations, TUN,
|
||||
dynamic IPv4 allocation, the smoltcp userspace path and STUN/UDP hole-punching
|
||||
core with the full binary. It includes AES-GCM so its default encryption
|
||||
setting interoperates with the full binary's default configuration.
|
||||
|
||||
Build it with:
|
||||
|
||||
```sh
|
||||
cargo build --release -p easytier-mini
|
||||
```
|
||||
|
||||
For the static size target used by this POC:
|
||||
|
||||
```sh
|
||||
cargo build --profile mini --target x86_64-unknown-linux-musl -p easytier-mini
|
||||
```
|
||||
|
||||
MIPS targets use the repository's existing musl-cross toolchains. The helper
|
||||
builds the standard library for size, applies immediate-abort only to the mini
|
||||
MIPS target graph, and can build either or both byte orders:
|
||||
|
||||
```sh
|
||||
./easytier-contrib/easytier-mini/build-mips.sh all
|
||||
./easytier-contrib/easytier-mini/build-mips.sh mips
|
||||
./easytier-contrib/easytier-mini/build-mips.sh mipsel
|
||||
```
|
||||
|
||||
The `mini` profile derives from `release` and applies `opt-level=z` to the
|
||||
entire compact binary dependency graph. Full EasyTier release builds retain
|
||||
their normal `opt-level=3` profile. The musl builds use a mini-only static
|
||||
linker policy to stay below 5,000,000 bytes on x86-64 and 5,500,000 bytes on
|
||||
MIPS without UPX or another executable compressor. The compact x86-64 linker
|
||||
policy retains static PIE, packs relative relocations and folds identical code.
|
||||
MIPS builds omit standard-library backtrace support and use immediate abort;
|
||||
normal workspace MIPS builds are not affected. Compact linker policies omit
|
||||
unwind tables.
|
||||
|
||||
Start it with a normal EasyTier TOML file:
|
||||
|
||||
```sh
|
||||
easytier-mini --config mini.toml
|
||||
```
|
||||
|
||||
`-c` is accepted as the short form of `--config`.
|
||||
|
||||
Start it as an EasyTier Web managed node with a complete config-server URL:
|
||||
|
||||
```sh
|
||||
easytier-mini --config-server udp://config-server.easytier.cn:22020/TOKEN
|
||||
```
|
||||
|
||||
`--machine-id`, `--hostname`, and `--secure-mode` match the full client's Web
|
||||
identity and transport options. `--config` and `--config-server` may be used
|
||||
together: the local instance remains static while Web-owned instances are
|
||||
created, updated, retained, and deleted independently.
|
||||
|
||||
The node also exposes the native EasyTier management RPC protocol on
|
||||
`127.0.0.1:15888`, so the full `easytier-cli` can inspect it:
|
||||
|
||||
```sh
|
||||
easytier-cli node info
|
||||
easytier-cli peer
|
||||
easytier-cli route
|
||||
easytier-cli connector list
|
||||
```
|
||||
|
||||
For example:
|
||||
|
||||
```toml
|
||||
instance_name = "mini"
|
||||
ipv4 = "10.147.0.2"
|
||||
listeners = ["tcp://0.0.0.0:11010", "udp://0.0.0.0:11010"]
|
||||
|
||||
[network_identity]
|
||||
network_name = "mini-poc"
|
||||
network_secret = "change-me"
|
||||
|
||||
[[peer]]
|
||||
uri = "tcp://example.net:11010"
|
||||
```
|
||||
|
||||
Local TOML and Web configuration both retain the complete authoritative model.
|
||||
The compact runtime silently omits unsupported capabilities while normalizing
|
||||
that model into live runtime state. EasyTier Web therefore sees every accepted
|
||||
configuration value unchanged and its consistency checks converge. This also
|
||||
applies to hot patches: for example, a port-forward patch remains visible to
|
||||
the controller while no port-forward service starts in mini. ChaCha20 falls
|
||||
back to AES-GCM rather than plaintext.
|
||||
|
||||
The compact runtime supports `tcp://` and `udp://` listener, mapped-listener
|
||||
and peer URLs. `no_tun = true` runs through smoltcp without an OS TUN device,
|
||||
and `dhcp = true` allocates the virtual IPv4 address dynamically.
|
||||
|
||||
The mini feature set keeps STUN collection, UDP hole punching, Web heartbeats,
|
||||
Web instance lifecycle management and the config hot-patch RPC. It omits TCP
|
||||
hole punching, endpoint discovery (`http://`, `https://`, `txt://` and
|
||||
`srv://` peers), protobuf reflection, logger control and the rest of the full
|
||||
management surface. Unsupported connector URLs are accepted as no-ops. Its
|
||||
local RPC surface remains read-only for node, peer, route and connector
|
||||
queries. OSPF route messages keep their original protobuf wire data, so fields
|
||||
added by future EasyTier versions are forwarded without requiring
|
||||
`prost-reflect`.
|
||||
|
||||
For size, this POC reads one file directly and does not support configuration
|
||||
from stdin or `${VAR}` expansion. It omits the process-management event journal,
|
||||
while the console logger still reports runtime events such as peer, connection,
|
||||
listener, TUN and DHCP changes. The RPC address is currently fixed, so only one
|
||||
mini process can use the default portal on a host. The x86-64 musl POC cannot
|
||||
provide reliable stack backtraces because its release binary has no unwind
|
||||
tables.
|
||||
Executable
+61
@@ -0,0 +1,61 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
|
||||
# Cargo invokes this same file as a rustc wrapper during compact MIPS builds.
|
||||
# Applying immediate-abort here keeps the size policy scoped to easytier-mini;
|
||||
# normal MIPS builds elsewhere in the workspace retain their panic behavior.
|
||||
if [ "${EASYTIER_MINI_MIPS_RUSTC_WRAPPER:-}" = "1" ]; then
|
||||
mini_rustc=$1
|
||||
shift
|
||||
for mini_rustc_arg in "$@"; do
|
||||
case "$mini_rustc_arg" in
|
||||
mips-unknown-linux-musl|mipsel-unknown-linux-musl)
|
||||
exec "$mini_rustc" "$@" \
|
||||
-Zunstable-options \
|
||||
-Cpanic=immediate-abort
|
||||
;;
|
||||
esac
|
||||
done
|
||||
exec "$mini_rustc" "$@"
|
||||
fi
|
||||
|
||||
mini_script_dir=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
|
||||
mini_repo_dir=$(CDPATH= cd -- "$mini_script_dir/../.." && pwd)
|
||||
mini_requested_target=${1:-all}
|
||||
cd "$mini_repo_dir"
|
||||
|
||||
build_mips_target() {
|
||||
mini_target=$1
|
||||
mini_toolchain=$2
|
||||
PATH="$mini_repo_dir/musl_gcc/$mini_toolchain/bin:$PATH" \
|
||||
EASYTIER_MINI_MIPS_RUSTC_WRAPPER=1 \
|
||||
RUSTC_BOOTSTRAP=1 \
|
||||
RUSTC_WRAPPER="$mini_script_dir/build-mips.sh" \
|
||||
cargo build \
|
||||
--manifest-path "$mini_repo_dir/Cargo.toml" \
|
||||
--profile mini \
|
||||
--target "$mini_target" \
|
||||
-Z build-std=std \
|
||||
-Z build-std-features=optimize_for_size \
|
||||
-p easytier-mini
|
||||
}
|
||||
|
||||
case "$mini_requested_target" in
|
||||
all)
|
||||
build_mips_target mips-unknown-linux-musl mips-unknown-linux-muslsf
|
||||
build_mips_target mipsel-unknown-linux-musl mipsel-unknown-linux-muslsf
|
||||
;;
|
||||
mips|mips-unknown-linux-musl)
|
||||
build_mips_target mips-unknown-linux-musl mips-unknown-linux-muslsf
|
||||
;;
|
||||
mipsel|mipsel-unknown-linux-musl)
|
||||
build_mips_target mipsel-unknown-linux-musl mipsel-unknown-linux-muslsf
|
||||
;;
|
||||
-h|--help)
|
||||
echo "usage: $0 [all|mips|mipsel]"
|
||||
;;
|
||||
*)
|
||||
echo "unsupported MIPS target: $mini_requested_target" >&2
|
||||
exit 2
|
||||
;;
|
||||
esac
|
||||
@@ -0,0 +1,32 @@
|
||||
use std::env;
|
||||
use std::path::PathBuf;
|
||||
|
||||
fn main() {
|
||||
let target = env::var("TARGET").unwrap_or_default();
|
||||
let profile = env::var("PROFILE").unwrap_or_default();
|
||||
if !matches!(profile.as_str(), "release" | "mini")
|
||||
|| !matches!(
|
||||
target.as_str(),
|
||||
"x86_64-unknown-linux-musl" | "mips-unknown-linux-musl" | "mipsel-unknown-linux-musl"
|
||||
)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
let script =
|
||||
PathBuf::from(env::var_os("CARGO_MANIFEST_DIR").unwrap()).join("easytier-mini-musl.ld");
|
||||
println!("cargo:rerun-if-changed={}", script.display());
|
||||
// The release-derived mini profile already aborts panics. Keep the compact
|
||||
// binary's linker policy local so full EasyTier musl builds retain their
|
||||
// normal PIE/unwind settings.
|
||||
println!("cargo:rustc-link-arg-bin=easytier-mini=-Wl,--build-id=none");
|
||||
if target == "x86_64-unknown-linux-musl" {
|
||||
println!("cargo:rustc-link-arg-bin=easytier-mini=-Wl,--pack-dyn-relocs=relr");
|
||||
println!("cargo:rustc-link-arg-bin=easytier-mini=-Wl,--icf=all");
|
||||
}
|
||||
println!("cargo:rustc-link-arg-bin=easytier-mini=-Wl,--no-eh-frame-hdr");
|
||||
println!(
|
||||
"cargo:rustc-link-arg-bin=easytier-mini=-Wl,-T,{}",
|
||||
script.display()
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
SECTIONS
|
||||
{
|
||||
.eh_frame :
|
||||
{
|
||||
KEEP(*crtbegin.o(.eh_frame))
|
||||
KEEP(*crtend.o(.eh_frame))
|
||||
}
|
||||
/DISCARD/ :
|
||||
{
|
||||
*(EXCLUDE_FILE (*crtbegin.o *crtend.o) .eh_frame)
|
||||
*(.eh_frame_hdr)
|
||||
}
|
||||
}
|
||||
INSERT AFTER .data;
|
||||
@@ -0,0 +1,264 @@
|
||||
use std::{ffi::OsString, path::PathBuf, sync::Arc};
|
||||
|
||||
use anyhow::Context as _;
|
||||
use easytier::common::MachineIdOptions;
|
||||
use easytier::{
|
||||
common::config::{ConfigFileControl, load_toml_config_from_path},
|
||||
instance::factory::native_compact_instance_manager_with_runtime,
|
||||
rpc_service::ReadOnlyApiRpcServer,
|
||||
web_client::{WebClientHooks, parse_config_server_endpoint, run_web_client},
|
||||
};
|
||||
|
||||
enum Command {
|
||||
Run(RunOptions),
|
||||
Exit,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, PartialEq, Eq)]
|
||||
struct RunOptions {
|
||||
config: Option<PathBuf>,
|
||||
config_server: Option<String>,
|
||||
machine_id: Option<String>,
|
||||
hostname: Option<String>,
|
||||
secure_mode: bool,
|
||||
}
|
||||
|
||||
const USAGE: &str = "usage: easytier-mini [--config <FILE>] [--config-server <URL>] \
|
||||
[--machine-id <ID>] [--hostname <NAME>] [--secure-mode]";
|
||||
|
||||
fn required_value(
|
||||
args: &mut impl Iterator<Item = OsString>,
|
||||
option: &str,
|
||||
) -> anyhow::Result<OsString> {
|
||||
args.next()
|
||||
.with_context(|| format!("{option} requires a value"))
|
||||
}
|
||||
|
||||
fn parse_args(mut args: impl Iterator<Item = OsString>) -> anyhow::Result<Command> {
|
||||
let mut options = RunOptions::default();
|
||||
while let Some(arg) = args.next() {
|
||||
if arg == "-h" || arg == "--help" {
|
||||
println!(
|
||||
"easytier-mini {}\n\nUsage: {USAGE}",
|
||||
env!("CARGO_PKG_VERSION")
|
||||
);
|
||||
return Ok(Command::Exit);
|
||||
}
|
||||
if arg == "-V" || arg == "--version" {
|
||||
println!("easytier-mini {}", env!("CARGO_PKG_VERSION"));
|
||||
return Ok(Command::Exit);
|
||||
}
|
||||
if arg == "-c" || arg == "--config" {
|
||||
if options.config.is_some() {
|
||||
anyhow::bail!("--config may only be specified once");
|
||||
}
|
||||
options.config = Some(PathBuf::from(required_value(&mut args, "--config")?));
|
||||
continue;
|
||||
}
|
||||
if arg == "-w" || arg == "--config-server" {
|
||||
if options.config_server.is_some() {
|
||||
anyhow::bail!("--config-server may only be specified once");
|
||||
}
|
||||
options.config_server = Some(
|
||||
required_value(&mut args, "--config-server")?
|
||||
.into_string()
|
||||
.map_err(|_| anyhow::anyhow!("--config-server must be valid UTF-8"))?,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
if arg == "--machine-id" {
|
||||
options.machine_id = Some(
|
||||
required_value(&mut args, "--machine-id")?
|
||||
.into_string()
|
||||
.map_err(|_| anyhow::anyhow!("--machine-id must be valid UTF-8"))?,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
if arg == "--hostname" {
|
||||
options.hostname = Some(
|
||||
required_value(&mut args, "--hostname")?
|
||||
.into_string()
|
||||
.map_err(|_| anyhow::anyhow!("--hostname must be valid UTF-8"))?,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
if arg == "--secure-mode" {
|
||||
options.secure_mode = true;
|
||||
continue;
|
||||
}
|
||||
anyhow::bail!("unknown argument {arg:?}; {USAGE}");
|
||||
}
|
||||
if options.config.is_none() && options.config_server.is_none() {
|
||||
anyhow::bail!("either --config or --config-server is required; {USAGE}");
|
||||
}
|
||||
Ok(Command::Run(options))
|
||||
}
|
||||
|
||||
fn require_tcp_or_udp(scheme: &str, source: &str) -> anyhow::Result<()> {
|
||||
match scheme {
|
||||
"tcp" | "udp" => Ok(()),
|
||||
scheme => anyhow::bail!(
|
||||
"{source} uses unsupported tunnel scheme {scheme:?}; easytier-mini supports only tcp:// and udp://"
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_config_server(config_server: &str) -> anyhow::Result<()> {
|
||||
let endpoint = parse_config_server_endpoint(config_server)?;
|
||||
require_tcp_or_udp(endpoint.connect_url().scheme(), "config server")
|
||||
}
|
||||
|
||||
struct MiniWebClientHooks;
|
||||
|
||||
impl WebClientHooks for MiniWebClientHooks {
|
||||
fn manages_remote_config_instances(&self) -> bool {
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::main(flavor = "current_thread")]
|
||||
async fn main() -> anyhow::Result<()> {
|
||||
let Command::Run(options) = parse_args(std::env::args_os().skip(1))? else {
|
||||
return Ok(());
|
||||
};
|
||||
easytier::common::log::init_console()?;
|
||||
let local_config = options
|
||||
.config
|
||||
.as_ref()
|
||||
.map(|config_path| {
|
||||
load_toml_config_from_path(config_path)
|
||||
.with_context(|| format!("failed to load {}", config_path.display()))
|
||||
})
|
||||
.transpose()?;
|
||||
if let Some(config_server) = options.config_server.as_deref() {
|
||||
validate_config_server(config_server)?;
|
||||
}
|
||||
|
||||
let instances = Arc::new(native_compact_instance_manager_with_runtime(
|
||||
tokio::runtime::Handle::current(),
|
||||
));
|
||||
let local_instance_id = local_config
|
||||
.map(|config| instances.run_network_instance(config, ConfigFileControl::STATIC_CONFIG))
|
||||
.transpose()?;
|
||||
let _web_client = if let Some(config_server) = options.config_server.as_deref() {
|
||||
Some(
|
||||
run_web_client(
|
||||
config_server,
|
||||
MachineIdOptions {
|
||||
explicit_machine_id: options.machine_id,
|
||||
state_dir: None,
|
||||
},
|
||||
options.hostname,
|
||||
options.secure_mode,
|
||||
instances.clone(),
|
||||
Some(Arc::new(MiniWebClientHooks)),
|
||||
)
|
||||
.await?,
|
||||
)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let _rpc_server =
|
||||
ReadOnlyApiRpcServer::new(Some("127.0.0.1:15888".to_owned()), None, instances.clone())?
|
||||
.serve()
|
||||
.await?;
|
||||
eprintln!(
|
||||
"easytier-mini started: local={local_instance_id:?}, web={}; RPC: 127.0.0.1:15888",
|
||||
options.config_server.is_some()
|
||||
);
|
||||
|
||||
let stopped_unexpectedly = tokio::select! {
|
||||
signal = tokio::signal::ctrl_c() => {
|
||||
signal.context("failed to listen for Ctrl-C")?;
|
||||
false
|
||||
},
|
||||
_ = instances.wait() => true,
|
||||
};
|
||||
|
||||
for instance in instances.instances() {
|
||||
instance.stop().await;
|
||||
}
|
||||
if stopped_unexpectedly {
|
||||
anyhow::bail!("EasyTier instance stopped unexpectedly");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use easytier::common::config::{ConfigLoader as _, TomlConfigLoader};
|
||||
|
||||
#[test]
|
||||
fn parses_minimal_config_argument() {
|
||||
let Command::Run(options) =
|
||||
parse_args([OsString::from("--config"), OsString::from("mini.toml")].into_iter())
|
||||
.unwrap()
|
||||
else {
|
||||
panic!("expected run command");
|
||||
};
|
||||
|
||||
assert_eq!(options.config, Some(PathBuf::from("mini.toml")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_web_client_arguments_without_a_local_config() {
|
||||
let Command::Run(options) = parse_args(
|
||||
[
|
||||
OsString::from("--config-server"),
|
||||
OsString::from("token"),
|
||||
OsString::from("--machine-id"),
|
||||
OsString::from("machine"),
|
||||
OsString::from("--hostname"),
|
||||
OsString::from("mini"),
|
||||
OsString::from("--secure-mode"),
|
||||
]
|
||||
.into_iter(),
|
||||
)
|
||||
.unwrap() else {
|
||||
panic!("expected run command");
|
||||
};
|
||||
|
||||
assert_eq!(options.config_server.as_deref(), Some("token"));
|
||||
assert_eq!(options.machine_id.as_deref(), Some("machine"));
|
||||
assert_eq!(options.hostname.as_deref(), Some("mini"));
|
||||
assert!(options.secure_mode);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_unknown_arguments() {
|
||||
let result = parse_args([OsString::from("extra")].into_iter());
|
||||
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accepts_tcp_udp_config_server() {
|
||||
assert!(validate_config_server("udp://127.0.0.1:22020/token").is_ok());
|
||||
assert!(validate_config_server("quic://127.0.0.1:22020/token").is_err());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn compact_factory_accepts_unsupported_config_without_changing_it() {
|
||||
let config = TomlConfigLoader::new_from_str(
|
||||
r#"
|
||||
dhcp = true
|
||||
listeners = ["quic://127.0.0.1:11010"]
|
||||
proxy_network = [{ cidr = "10.20.0.0/16" }]
|
||||
|
||||
[flags]
|
||||
encryption_algorithm = "chacha20"
|
||||
data_compress_algo = "Zstd"
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
config.get_id();
|
||||
let before = config.dump();
|
||||
let manager =
|
||||
native_compact_instance_manager_with_runtime(tokio::runtime::Handle::current());
|
||||
|
||||
let instance = manager.create(config, ()).unwrap();
|
||||
|
||||
assert_eq!(instance.toml_config().unwrap().dump(), before);
|
||||
}
|
||||
}
|
||||
+858
-487
File diff suppressed because it is too large
Load Diff
@@ -7,7 +7,19 @@ edition = "2024"
|
||||
crate-type=["cdylib"]
|
||||
|
||||
[dependencies]
|
||||
ohos-hilog-binding = {version = "*", features = ["redirect"]}
|
||||
anyhow = "1.0"
|
||||
async-trait = "0.1"
|
||||
base64 = "0.22"
|
||||
bytes = "1.5"
|
||||
easytier-core = { path = "../../easytier-core", default-features = false }
|
||||
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 = [
|
||||
@@ -26,10 +38,16 @@ napi-ohos = { version = "1.1", default-features = false, features = [
|
||||
"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"
|
||||
tokio = { version = "1", features = ["rt-multi-thread", "sync", "time"] }
|
||||
url = "2.5"
|
||||
uuid = { version = "1.5.0", features = [
|
||||
"v4",
|
||||
"fast-rng",
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
pub(crate) mod repository;
|
||||
pub(crate) mod services;
|
||||
pub(crate) mod storage;
|
||||
pub(crate) mod types;
|
||||
@@ -0,0 +1,13 @@
|
||||
#[path = "../../config_repo/field_store.rs"]
|
||||
mod field_store;
|
||||
#[path = "../../config_repo/import_export.rs"]
|
||||
mod import_export;
|
||||
#[path = "../../config_repo/legacy_migration.rs"]
|
||||
mod legacy_migration;
|
||||
#[path = "../../config_repo/validation.rs"]
|
||||
mod validation;
|
||||
|
||||
#[path = "../../config_repo.rs"]
|
||||
mod repo;
|
||||
|
||||
pub use repo::*;
|
||||
@@ -0,0 +1,2 @@
|
||||
pub(crate) mod schema_service;
|
||||
pub(crate) mod share_link_service;
|
||||
@@ -0,0 +1,414 @@
|
||||
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,
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
#[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>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[napi(object)]
|
||||
pub struct ConfigFieldMapping {
|
||||
pub field_name: String,
|
||||
pub field_number: i32,
|
||||
}
|
||||
|
||||
static DESCRIPTOR_POOL: Lazy<DescriptorPool> = Lazy::new(|| {
|
||||
DescriptorPool::decode(ALL_DESCRIPTOR_BYTES)
|
||||
.expect("easytier descriptor pool should decode from embedded protobuf descriptors")
|
||||
});
|
||||
|
||||
const NETWORK_CONFIG_MESSAGE_NAME: &str = "api.manage.NetworkConfig";
|
||||
|
||||
fn descriptor_pool() -> &'static DescriptorPool {
|
||||
&DESCRIPTOR_POOL
|
||||
}
|
||||
|
||||
fn network_config_descriptor() -> MessageDescriptor {
|
||||
descriptor_pool()
|
||||
.get_message_by_name(NETWORK_CONFIG_MESSAGE_NAME)
|
||||
.expect("api.manage.NetworkConfig descriptor should exist")
|
||||
}
|
||||
|
||||
fn field_default_value_text(field: &FieldDescriptor) -> Option<String> {
|
||||
if field.is_list() || field.is_map() {
|
||||
return Some("[]".to_string());
|
||||
}
|
||||
|
||||
match field.kind() {
|
||||
Kind::Bool => Some("false".to_string()),
|
||||
Kind::String => Some("\"\"".to_string()),
|
||||
Kind::Bytes => Some("\"\"".to_string()),
|
||||
Kind::Int32
|
||||
| Kind::Sint32
|
||||
| Kind::Sfixed32
|
||||
| Kind::Int64
|
||||
| Kind::Sint64
|
||||
| Kind::Sfixed64
|
||||
| Kind::Uint32
|
||||
| Kind::Fixed32
|
||||
| Kind::Uint64
|
||||
| Kind::Fixed64
|
||||
| Kind::Float
|
||||
| Kind::Double => Some("0".to_string()),
|
||||
Kind::Enum(enum_desc) => enum_desc
|
||||
.get_value(0)
|
||||
.map(|value| value.number().to_string()),
|
||||
Kind::Message(_) => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn field_type_name(field: &FieldDescriptor) -> Option<String> {
|
||||
match field.kind() {
|
||||
Kind::Enum(enum_desc) => Some(enum_desc.full_name().to_string()),
|
||||
Kind::Message(message_desc) => Some(message_desc.full_name().to_string()),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn field_semantic_type(field: &FieldDescriptor) -> Option<String> {
|
||||
match field.name() {
|
||||
"virtual_ipv4" => Some("cidr_ip".to_string()),
|
||||
"network_length" => Some("cidr_mask".to_string()),
|
||||
"peer_urls" => Some("peer[]".to_string()),
|
||||
"proxy_cidrs" => Some("cidr[]".to_string()),
|
||||
"listener_urls" => Some("listener[]".to_string()),
|
||||
"routes" => Some("route[]".to_string()),
|
||||
"exit_nodes" => Some("ip[]".to_string()),
|
||||
"relay_network_whitelist" => Some("network_name[]".to_string()),
|
||||
"mapped_listeners" => Some("mapped_listener[]".to_string()),
|
||||
"port_forwards" => Some("port_forward[]".to_string()),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn enum_options(kind: Kind) -> Vec<FieldOption> {
|
||||
match kind {
|
||||
Kind::Enum(enum_desc) => enum_desc
|
||||
.values()
|
||||
.map(|value| FieldOption {
|
||||
label: value.name().to_string(),
|
||||
value: value.number().to_string(),
|
||||
})
|
||||
.collect(),
|
||||
_ => Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn should_expose_field(field: &FieldDescriptor) -> bool {
|
||||
match field.containing_oneof() {
|
||||
Some(_) => field
|
||||
.field_descriptor_proto()
|
||||
.proto3_optional
|
||||
.unwrap_or(false),
|
||||
None => true,
|
||||
}
|
||||
}
|
||||
|
||||
fn build_validations(field: &FieldDescriptor) -> Vec<ValidationRule> {
|
||||
if field.cardinality() == Cardinality::Required {
|
||||
return vec![ValidationRule {
|
||||
rule_type: "required".to_string(),
|
||||
arg: String::new(),
|
||||
message: format!("{} is required", field.name()),
|
||||
}];
|
||||
}
|
||||
|
||||
Vec::new()
|
||||
}
|
||||
|
||||
fn kind_to_value_kind(field: &FieldDescriptor) -> String {
|
||||
if field.is_map() {
|
||||
return "object".to_string();
|
||||
}
|
||||
|
||||
match field.kind() {
|
||||
Kind::Bool => "boolean".to_string(),
|
||||
Kind::String | Kind::Bytes => "string".to_string(),
|
||||
Kind::Int32
|
||||
| Kind::Sint32
|
||||
| Kind::Sfixed32
|
||||
| Kind::Int64
|
||||
| Kind::Sint64
|
||||
| Kind::Sfixed64
|
||||
| Kind::Uint32
|
||||
| Kind::Fixed32
|
||||
| Kind::Uint64
|
||||
| Kind::Fixed64
|
||||
| Kind::Float
|
||||
| Kind::Double => "number".to_string(),
|
||||
Kind::Enum(_) => "enum".to_string(),
|
||||
Kind::Message(_) => "object".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
fn build_node(
|
||||
node_kind: &str,
|
||||
name: String,
|
||||
field_number: i32,
|
||||
type_name: Option<String>,
|
||||
semantic_type: Option<String>,
|
||||
value_kind: String,
|
||||
is_list: bool,
|
||||
required: bool,
|
||||
default_value_text: Option<String>,
|
||||
enum_options: Vec<FieldOption>,
|
||||
validations: Vec<ValidationRule>,
|
||||
children: Vec<NetworkConfigSchema>,
|
||||
definitions: Vec<NetworkConfigSchema>,
|
||||
) -> NetworkConfigSchema {
|
||||
NetworkConfigSchema {
|
||||
node_kind: node_kind.to_string(),
|
||||
name,
|
||||
field_number,
|
||||
type_name,
|
||||
semantic_type,
|
||||
value_kind,
|
||||
is_list,
|
||||
required,
|
||||
default_value_text,
|
||||
enum_options,
|
||||
validations,
|
||||
children,
|
||||
definitions,
|
||||
}
|
||||
}
|
||||
|
||||
fn build_map_entry_node(message_desc: &MessageDescriptor) -> NetworkConfigSchema {
|
||||
let key_field = message_desc.map_entry_key_field();
|
||||
let value_field = message_desc.map_entry_value_field();
|
||||
|
||||
build_node(
|
||||
"object",
|
||||
message_desc.name().to_string(),
|
||||
0,
|
||||
Some(message_desc.full_name().to_string()),
|
||||
None,
|
||||
"object".to_string(),
|
||||
false,
|
||||
true,
|
||||
None,
|
||||
Vec::new(),
|
||||
Vec::new(),
|
||||
vec![
|
||||
build_schema_field_node(&key_field),
|
||||
build_schema_field_node(&value_field),
|
||||
],
|
||||
Vec::new(),
|
||||
)
|
||||
}
|
||||
|
||||
fn field_children(field: &FieldDescriptor) -> Vec<NetworkConfigSchema> {
|
||||
if field.is_map() {
|
||||
if let Kind::Message(message_desc) = field.kind() {
|
||||
return vec![build_map_entry_node(&message_desc)];
|
||||
}
|
||||
}
|
||||
|
||||
match field.kind() {
|
||||
Kind::Message(message_desc) => build_message_children(&message_desc),
|
||||
_ => Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn build_message_children(message_desc: &MessageDescriptor) -> Vec<NetworkConfigSchema> {
|
||||
message_desc
|
||||
.fields()
|
||||
.filter(should_expose_field)
|
||||
.map(|field| build_schema_field_node(&field))
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn build_schema_field_node(field: &FieldDescriptor) -> NetworkConfigSchema {
|
||||
build_node(
|
||||
"field",
|
||||
field.name().to_string(),
|
||||
field.number() as i32,
|
||||
field_type_name(field),
|
||||
field_semantic_type(field),
|
||||
kind_to_value_kind(field),
|
||||
field.is_list() || field.is_map(),
|
||||
field.cardinality() == Cardinality::Required,
|
||||
field_default_value_text(field),
|
||||
enum_options(field.kind()),
|
||||
build_validations(field),
|
||||
field_children(field),
|
||||
Vec::new(),
|
||||
)
|
||||
}
|
||||
|
||||
fn collect_definitions() -> Vec<NetworkConfigSchema> {
|
||||
let mut definitions = Vec::new();
|
||||
|
||||
for message_desc in descriptor_pool().all_messages() {
|
||||
let full_name = message_desc.full_name();
|
||||
if full_name == NETWORK_CONFIG_MESSAGE_NAME || message_desc.is_map_entry() {
|
||||
continue;
|
||||
}
|
||||
|
||||
definitions.push(build_node(
|
||||
"object",
|
||||
full_name.to_string(),
|
||||
0,
|
||||
Some(full_name.to_string()),
|
||||
None,
|
||||
"object".to_string(),
|
||||
false,
|
||||
true,
|
||||
None,
|
||||
Vec::new(),
|
||||
Vec::new(),
|
||||
build_message_children(&message_desc),
|
||||
Vec::new(),
|
||||
));
|
||||
}
|
||||
|
||||
for enum_desc in descriptor_pool().all_enums() {
|
||||
definitions.push(build_node(
|
||||
"enum",
|
||||
enum_desc.full_name().to_string(),
|
||||
0,
|
||||
Some(enum_desc.full_name().to_string()),
|
||||
None,
|
||||
"enum".to_string(),
|
||||
false,
|
||||
false,
|
||||
None,
|
||||
enum_options(Kind::Enum(enum_desc.clone())),
|
||||
Vec::new(),
|
||||
Vec::new(),
|
||||
Vec::new(),
|
||||
));
|
||||
}
|
||||
|
||||
definitions.sort_by(|a, b| a.name.cmp(&b.name));
|
||||
definitions
|
||||
}
|
||||
|
||||
fn build_network_config_schema() -> NetworkConfigSchema {
|
||||
let network_config = network_config_descriptor();
|
||||
build_node(
|
||||
"schema",
|
||||
network_config.name().to_string(),
|
||||
0,
|
||||
Some(network_config.full_name().to_string()),
|
||||
None,
|
||||
"object".to_string(),
|
||||
false,
|
||||
true,
|
||||
None,
|
||||
Vec::new(),
|
||||
Vec::new(),
|
||||
build_message_children(&network_config),
|
||||
collect_definitions(),
|
||||
)
|
||||
}
|
||||
|
||||
fn build_network_config_field_mappings() -> Vec<ConfigFieldMapping> {
|
||||
network_config_descriptor()
|
||||
.fields()
|
||||
.filter(should_expose_field)
|
||||
.map(|field| ConfigFieldMapping {
|
||||
field_name: field.name().to_string(),
|
||||
field_number: field.number() as i32,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn get_network_config_schema() -> NetworkConfigSchema {
|
||||
build_network_config_schema()
|
||||
}
|
||||
|
||||
pub fn get_network_config_field_mappings() -> Vec<ConfigFieldMapping> {
|
||||
build_network_config_field_mappings()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn schema_is_exposed_as_single_tree_type() {
|
||||
let schema = get_network_config_schema();
|
||||
assert_eq!(schema.node_kind, "schema");
|
||||
assert_eq!(schema.name, "NetworkConfig");
|
||||
assert_eq!(
|
||||
schema.type_name.as_deref(),
|
||||
Some("api.manage.NetworkConfig")
|
||||
);
|
||||
|
||||
let virtual_ipv4 = schema
|
||||
.children
|
||||
.iter()
|
||||
.find(|field| field.name == "virtual_ipv4")
|
||||
.expect("virtual_ipv4 field");
|
||||
assert_eq!(virtual_ipv4.semantic_type.as_deref(), Some("cidr_ip"));
|
||||
|
||||
let secure_mode = schema
|
||||
.children
|
||||
.iter()
|
||||
.find(|field| field.name == "secure_mode")
|
||||
.expect("secure_mode field");
|
||||
assert!(
|
||||
secure_mode
|
||||
.children
|
||||
.iter()
|
||||
.any(|field| field.name == "enabled")
|
||||
);
|
||||
|
||||
let secure_mode_definition = schema
|
||||
.definitions
|
||||
.iter()
|
||||
.find(|definition| definition.name == "common.SecureModeConfig")
|
||||
.expect("secure mode definition");
|
||||
assert!(
|
||||
secure_mode_definition
|
||||
.children
|
||||
.iter()
|
||||
.any(|field| field.name == "local_private_key")
|
||||
);
|
||||
|
||||
let networking_method_definition = schema
|
||||
.definitions
|
||||
.iter()
|
||||
.find(|definition| definition.name == "api.manage.NetworkingMethod")
|
||||
.expect("networking method enum definition");
|
||||
assert!(
|
||||
networking_method_definition
|
||||
.enum_options
|
||||
.iter()
|
||||
.any(|option| option.label == "PublicServer")
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
use crate::config::repository::{get_config_record, save_config_record};
|
||||
use crate::config::services::schema_service::get_network_config_field_mappings;
|
||||
use crate::config::types::stored_config::SharedConfigLinkPayload;
|
||||
use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD};
|
||||
use easytier::proto::api::manage::NetworkConfig;
|
||||
use flate2::{Compression, read::ZlibDecoder, write::ZlibEncoder};
|
||||
use gethostname::gethostname;
|
||||
use std::collections::HashMap;
|
||||
use std::io::{Read, Write};
|
||||
use url::Url;
|
||||
use uuid::Uuid;
|
||||
|
||||
const SHARE_LINK_HOST: &str = "easytier.cn";
|
||||
const SHARE_LINK_PATH: &str = "/comp_cfg";
|
||||
|
||||
fn field_name_to_id_map() -> HashMap<String, String> {
|
||||
get_network_config_field_mappings()
|
||||
.into_iter()
|
||||
.map(|mapping| (mapping.field_name, mapping.field_number.to_string()))
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn field_id_to_name_map() -> HashMap<String, String> {
|
||||
get_network_config_field_mappings()
|
||||
.into_iter()
|
||||
.map(|mapping| (mapping.field_number.to_string(), mapping.field_name))
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn prune_empty(value: &serde_json::Value) -> Option<serde_json::Value> {
|
||||
match value {
|
||||
serde_json::Value::Null => None,
|
||||
serde_json::Value::Array(values) if values.is_empty() => None,
|
||||
_ => Some(value.clone()),
|
||||
}
|
||||
}
|
||||
|
||||
fn map_config_json(config: &NetworkConfig) -> Result<String, String> {
|
||||
let field_name_to_id = field_name_to_id_map();
|
||||
let raw = serde_json::to_value(config).map_err(|err| err.to_string())?;
|
||||
let mut mapped = serde_json::Map::new();
|
||||
|
||||
for (key, value) in raw.as_object().cloned().unwrap_or_default() {
|
||||
let Some(value) = prune_empty(&value) else {
|
||||
continue;
|
||||
};
|
||||
let mapped_key = field_name_to_id.get(&key).cloned().unwrap_or(key);
|
||||
mapped.insert(mapped_key, value);
|
||||
}
|
||||
|
||||
serde_json::to_string(&mapped).map_err(|err| err.to_string())
|
||||
}
|
||||
|
||||
fn unmap_config_json(raw: &str) -> Result<NetworkConfig, String> {
|
||||
let field_id_to_name = field_id_to_name_map();
|
||||
let value = serde_json::from_str::<serde_json::Value>(raw).map_err(|err| err.to_string())?;
|
||||
let mut mapped = serde_json::Map::new();
|
||||
for (key, value) in value.as_object().cloned().unwrap_or_default() {
|
||||
let field_name = field_id_to_name.get(&key).cloned().unwrap_or(key);
|
||||
mapped.insert(field_name, value);
|
||||
}
|
||||
serde_json::from_value(serde_json::Value::Object(mapped)).map_err(|err| err.to_string())
|
||||
}
|
||||
|
||||
fn compress_to_base64url(raw: &str) -> Result<String, String> {
|
||||
let mut encoder = ZlibEncoder::new(Vec::new(), Compression::best());
|
||||
encoder
|
||||
.write_all(raw.as_bytes())
|
||||
.map_err(|err| err.to_string())?;
|
||||
let compressed = encoder.finish().map_err(|err| err.to_string())?;
|
||||
Ok(URL_SAFE_NO_PAD.encode(compressed))
|
||||
}
|
||||
|
||||
fn decompress_from_base64url(raw: &str) -> Result<String, String> {
|
||||
let compressed = URL_SAFE_NO_PAD.decode(raw).map_err(|err| err.to_string())?;
|
||||
let mut decoder = ZlibDecoder::new(compressed.as_slice());
|
||||
let mut out = String::new();
|
||||
decoder
|
||||
.read_to_string(&mut out)
|
||||
.map_err(|err| err.to_string())?;
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
pub fn build_config_share_link(
|
||||
config_id: &str,
|
||||
display_name: Option<String>,
|
||||
only_start: bool,
|
||||
) -> Option<String> {
|
||||
let record = get_config_record(config_id)?;
|
||||
let config = serde_json::from_str::<NetworkConfig>(&record.config_json).ok()?;
|
||||
let mapped_json = map_config_json(&config).ok()?;
|
||||
let compressed = compress_to_base64url(&mapped_json).ok()?;
|
||||
let final_name = display_name
|
||||
.or(Some(record.meta.display_name))
|
||||
.filter(|name| !name.is_empty());
|
||||
|
||||
let mut url = Url::parse(&format!("https://{SHARE_LINK_HOST}{SHARE_LINK_PATH}")).ok()?;
|
||||
url.query_pairs_mut().append_pair("cfg", &compressed);
|
||||
if let Some(name) = final_name {
|
||||
url.query_pairs_mut().append_pair("name", &name);
|
||||
}
|
||||
if only_start {
|
||||
url.query_pairs_mut().append_pair("only_start", "true");
|
||||
}
|
||||
Some(url.to_string())
|
||||
}
|
||||
|
||||
pub fn parse_config_share_link(share_link: &str) -> Option<SharedConfigLinkPayload> {
|
||||
let url = Url::parse(share_link).ok()?;
|
||||
if url.host_str()? != SHARE_LINK_HOST || url.path() != SHARE_LINK_PATH {
|
||||
return None;
|
||||
}
|
||||
|
||||
let cfg = url
|
||||
.query_pairs()
|
||||
.find(|(key, _)| key == "cfg")?
|
||||
.1
|
||||
.to_string();
|
||||
let mapped_json = decompress_from_base64url(&cfg).ok()?;
|
||||
let mut config = unmap_config_json(&mapped_json).ok()?;
|
||||
config.instance_id = Some(Uuid::new_v4().to_string());
|
||||
let hostname = gethostname().to_string_lossy().to_string();
|
||||
if !hostname.is_empty() {
|
||||
config.hostname = Some(hostname);
|
||||
}
|
||||
|
||||
let config_json = serde_json::to_string(&config).ok()?;
|
||||
let display_name = url
|
||||
.query_pairs()
|
||||
.find(|(key, _)| key == "name")
|
||||
.map(|(_, value)| value.to_string())
|
||||
.filter(|name| !name.is_empty());
|
||||
let only_start = url
|
||||
.query_pairs()
|
||||
.find(|(key, _)| key == "only_start")
|
||||
.map(|(_, value)| value == "true")
|
||||
.unwrap_or(false);
|
||||
|
||||
Some(SharedConfigLinkPayload {
|
||||
config_json,
|
||||
display_name,
|
||||
only_start,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn import_config_share_link(
|
||||
share_link: &str,
|
||||
display_name_override: Option<String>,
|
||||
) -> Option<String> {
|
||||
let payload = parse_config_share_link(share_link)?;
|
||||
let config = serde_json::from_str::<NetworkConfig>(&payload.config_json).ok()?;
|
||||
let config_id = config.instance_id.clone()?;
|
||||
let display_name = display_name_override
|
||||
.filter(|name| !name.is_empty())
|
||||
.or(payload.display_name)
|
||||
.unwrap_or_else(|| config_id.clone());
|
||||
|
||||
save_config_record(config_id.clone(), display_name, payload.config_json)?;
|
||||
Some(config_id)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::config_repo::{create_config_record, init_config_store};
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
fn test_root() -> String {
|
||||
let unique = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_nanos();
|
||||
std::env::temp_dir()
|
||||
.join(format!("easytier_ohrs_share_test_{unique}"))
|
||||
.to_string_lossy()
|
||||
.into_owned()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn share_link_roundtrip_works() {
|
||||
assert!(init_config_store(test_root()));
|
||||
create_config_record("cfg-share".to_string(), "share-demo".to_string())
|
||||
.expect("create config");
|
||||
|
||||
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("cfg-share"));
|
||||
|
||||
let imported_id = import_config_share_link(&link, None).expect("import link");
|
||||
assert_ne!(imported_id, "cfg-share");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,777 @@
|
||||
use crate::config::types::stored_config::{
|
||||
SnapshotImportResult, StoredConfigList, StoredConfigMeta,
|
||||
};
|
||||
use once_cell::sync::Lazy;
|
||||
use rusqlite::{Connection, OptionalExtension, params};
|
||||
use std::collections::HashSet;
|
||||
use std::ops::{Deref, DerefMut};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::{Mutex, MutexGuard};
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
static CONFIG_DB_PATH: Mutex<Option<PathBuf>> = Mutex::new(None);
|
||||
static CONFIG_DB_CONNECTION: Lazy<Mutex<Option<CachedConfigDb>>> = Lazy::new(|| Mutex::new(None));
|
||||
const CONFIG_DB_FILE_NAME: &str = "easytier-config-store.db";
|
||||
|
||||
struct CachedConfigDb {
|
||||
path: PathBuf,
|
||||
conn: Connection,
|
||||
}
|
||||
|
||||
pub(crate) struct ConfigDbGuard<'a> {
|
||||
guard: MutexGuard<'a, Option<CachedConfigDb>>,
|
||||
}
|
||||
|
||||
impl Deref for ConfigDbGuard<'_> {
|
||||
type Target = Connection;
|
||||
|
||||
fn deref(&self) -> &Self::Target {
|
||||
&self
|
||||
.guard
|
||||
.as_ref()
|
||||
.expect("config db connection guard must contain a connection")
|
||||
.conn
|
||||
}
|
||||
}
|
||||
|
||||
impl DerefMut for ConfigDbGuard<'_> {
|
||||
fn deref_mut(&mut self) -> &mut Self::Target {
|
||||
&mut self
|
||||
.guard
|
||||
.as_mut()
|
||||
.expect("config db connection guard must contain a connection")
|
||||
.conn
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct StoredConfigMetaRecord {
|
||||
config_id: String,
|
||||
display_name: String,
|
||||
created_at: String,
|
||||
updated_at: String,
|
||||
favorite: bool,
|
||||
temporary: bool,
|
||||
}
|
||||
|
||||
type SnapshotFieldRow = (String, String, String, String);
|
||||
|
||||
fn snapshot_import_ok() -> SnapshotImportResult {
|
||||
SnapshotImportResult {
|
||||
ok: true,
|
||||
error_code: String::new(),
|
||||
error_message: String::new(),
|
||||
snapshot_invalid: false,
|
||||
}
|
||||
}
|
||||
|
||||
fn snapshot_import_err(
|
||||
error_code: &str,
|
||||
error_message: impl Into<String>,
|
||||
snapshot_invalid: bool,
|
||||
) -> SnapshotImportResult {
|
||||
SnapshotImportResult {
|
||||
ok: false,
|
||||
error_code: error_code.to_string(),
|
||||
error_message: error_message.into(),
|
||||
snapshot_invalid,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn now_ts_string() -> String {
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map(|d| d.as_secs().to_string())
|
||||
.unwrap_or_else(|_| "0".to_string())
|
||||
}
|
||||
|
||||
fn db_file_path() -> Option<PathBuf> {
|
||||
CONFIG_DB_PATH
|
||||
.lock()
|
||||
.ok()
|
||||
.and_then(|guard| guard.as_ref().cloned())
|
||||
}
|
||||
|
||||
fn init_schema(conn: &Connection) -> rusqlite::Result<()> {
|
||||
conn.execute_batch(
|
||||
"PRAGMA foreign_keys = ON;
|
||||
CREATE TABLE IF NOT EXISTS stored_configs (
|
||||
config_id TEXT PRIMARY KEY,
|
||||
display_name TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
favorite INTEGER NOT NULL DEFAULT 0,
|
||||
temporary INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS stored_config_fields (
|
||||
config_id TEXT NOT NULL,
|
||||
field_name TEXT NOT NULL,
|
||||
field_json TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
PRIMARY KEY (config_id, field_name),
|
||||
FOREIGN KEY (config_id) REFERENCES stored_configs(config_id) ON DELETE CASCADE
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_stored_config_fields_config_id
|
||||
ON stored_config_fields(config_id);",
|
||||
)?;
|
||||
|
||||
ensure_column(
|
||||
conn,
|
||||
"stored_configs",
|
||||
"favorite",
|
||||
"ALTER TABLE stored_configs ADD COLUMN favorite INTEGER NOT NULL DEFAULT 0;",
|
||||
)?;
|
||||
ensure_column(
|
||||
conn,
|
||||
"stored_configs",
|
||||
"temporary",
|
||||
"ALTER TABLE stored_configs ADD COLUMN temporary INTEGER NOT NULL DEFAULT 0;",
|
||||
)?;
|
||||
ensure_column(
|
||||
conn,
|
||||
"stored_config_fields",
|
||||
"updated_at",
|
||||
"ALTER TABLE stored_config_fields ADD COLUMN updated_at TEXT NOT NULL DEFAULT '0';",
|
||||
)?;
|
||||
|
||||
if !validate_store_schema(conn)? {
|
||||
return Err(rusqlite::Error::InvalidQuery);
|
||||
}
|
||||
|
||||
conn.execute_batch("PRAGMA user_version = 1;")
|
||||
}
|
||||
|
||||
fn table_columns(conn: &Connection, table_name: &str) -> rusqlite::Result<HashSet<String>> {
|
||||
let mut stmt = conn.prepare(&format!("PRAGMA table_info({})", table_name))?;
|
||||
let rows = stmt.query_map([], |row| row.get::<_, String>(1))?;
|
||||
let mut columns = HashSet::new();
|
||||
for row in rows {
|
||||
columns.insert(row?);
|
||||
}
|
||||
Ok(columns)
|
||||
}
|
||||
|
||||
fn ensure_column(
|
||||
conn: &Connection,
|
||||
table_name: &str,
|
||||
column_name: &str,
|
||||
alter_sql: &str,
|
||||
) -> rusqlite::Result<()> {
|
||||
let columns = table_columns(conn, table_name)?;
|
||||
if !columns.contains(column_name) {
|
||||
conn.execute_batch(alter_sql)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn validate_store_schema(conn: &Connection) -> rusqlite::Result<bool> {
|
||||
let meta_columns = table_columns(conn, "stored_configs")?;
|
||||
let field_columns = table_columns(conn, "stored_config_fields")?;
|
||||
let required_meta = [
|
||||
"config_id",
|
||||
"display_name",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
"favorite",
|
||||
"temporary",
|
||||
];
|
||||
let required_fields = ["config_id", "field_name", "field_json", "updated_at"];
|
||||
|
||||
Ok(required_meta
|
||||
.iter()
|
||||
.all(|column| meta_columns.contains(*column))
|
||||
&& required_fields
|
||||
.iter()
|
||||
.all(|column| field_columns.contains(*column)))
|
||||
}
|
||||
|
||||
fn move_db_file_if_exists(path: &Path) -> bool {
|
||||
if !path.exists() {
|
||||
return true;
|
||||
}
|
||||
let target = PathBuf::from(format!(
|
||||
"{}.corrupt.{}",
|
||||
path.to_string_lossy(),
|
||||
now_ts_string()
|
||||
));
|
||||
match std::fs::rename(path, &target) {
|
||||
Ok(_) => true,
|
||||
Err(e) => {
|
||||
ohrs_log_error!(
|
||||
"[Rust] failed to move corrupt config db {} to {}: {}",
|
||||
path.display(),
|
||||
target.display(),
|
||||
e
|
||||
);
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn recover_config_db_files(path: &Path) -> bool {
|
||||
let main_ok = move_db_file_if_exists(path);
|
||||
let wal_ok = move_db_file_if_exists(Path::new(&format!("{}-wal", path.to_string_lossy())));
|
||||
let shm_ok = move_db_file_if_exists(Path::new(&format!("{}-shm", path.to_string_lossy())));
|
||||
main_ok && wal_ok && shm_ok
|
||||
}
|
||||
|
||||
fn open_connection(path: &Path) -> Option<Connection> {
|
||||
let conn = match Connection::open(path) {
|
||||
Ok(conn) => conn,
|
||||
Err(e) => {
|
||||
ohrs_log_error!("[Rust] failed to open config db {}: {}", path.display(), e);
|
||||
return None;
|
||||
}
|
||||
};
|
||||
|
||||
if let Err(e) = init_schema(&conn) {
|
||||
ohrs_log_error!(
|
||||
"[Rust] failed to initialize config db {}: {}",
|
||||
path.display(),
|
||||
e
|
||||
);
|
||||
drop(conn);
|
||||
if !recover_config_db_files(path) {
|
||||
return None;
|
||||
}
|
||||
|
||||
let recovered = match Connection::open(path) {
|
||||
Ok(conn) => conn,
|
||||
Err(e) => {
|
||||
ohrs_log_error!(
|
||||
"[Rust] failed to open recovered config db {}: {}",
|
||||
path.display(),
|
||||
e
|
||||
);
|
||||
return None;
|
||||
}
|
||||
};
|
||||
if let Err(e) = init_schema(&recovered) {
|
||||
ohrs_log_error!(
|
||||
"[Rust] failed to initialize recovered config db {}: {}",
|
||||
path.display(),
|
||||
e
|
||||
);
|
||||
return None;
|
||||
}
|
||||
return Some(recovered);
|
||||
}
|
||||
|
||||
Some(conn)
|
||||
}
|
||||
|
||||
pub(crate) fn open_db() -> Option<ConfigDbGuard<'static>> {
|
||||
let path = db_file_path()?;
|
||||
let mut guard = match CONFIG_DB_CONNECTION.lock() {
|
||||
Ok(guard) => guard,
|
||||
Err(e) => {
|
||||
ohrs_log_error!("[Rust] failed to lock config db connection: {}", e);
|
||||
return None;
|
||||
}
|
||||
};
|
||||
|
||||
let should_open = guard
|
||||
.as_ref()
|
||||
.map(|cached| cached.path != path || !cached.path.exists())
|
||||
.unwrap_or(true);
|
||||
if should_open {
|
||||
let conn = open_connection(&path)?;
|
||||
|
||||
*guard = Some(CachedConfigDb { path, conn });
|
||||
}
|
||||
|
||||
Some(ConfigDbGuard { guard })
|
||||
}
|
||||
|
||||
fn row_to_meta(row: &rusqlite::Row<'_>) -> rusqlite::Result<StoredConfigMetaRecord> {
|
||||
Ok(StoredConfigMetaRecord {
|
||||
config_id: row.get(0)?,
|
||||
display_name: row.get(1)?,
|
||||
created_at: row.get(2)?,
|
||||
updated_at: row.get(3)?,
|
||||
favorite: row.get::<_, i64>(4)? != 0,
|
||||
temporary: row.get::<_, i64>(5)? != 0,
|
||||
})
|
||||
}
|
||||
|
||||
fn load_meta_record(conn: &Connection, config_id: &str) -> Option<StoredConfigMetaRecord> {
|
||||
conn.query_row(
|
||||
"SELECT config_id, display_name, created_at, updated_at, favorite, temporary
|
||||
FROM stored_configs WHERE config_id = ?1",
|
||||
params![config_id],
|
||||
row_to_meta,
|
||||
)
|
||||
.optional()
|
||||
.ok()
|
||||
.flatten()
|
||||
}
|
||||
|
||||
fn validate_snapshot_schema(conn: &Connection) -> bool {
|
||||
let has_stored_configs = conn
|
||||
.query_row(
|
||||
"SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'stored_configs'",
|
||||
[],
|
||||
|row| row.get::<_, i64>(0),
|
||||
)
|
||||
.optional()
|
||||
.ok()
|
||||
.flatten()
|
||||
.is_some();
|
||||
let has_stored_fields = conn
|
||||
.query_row(
|
||||
"SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'stored_config_fields'",
|
||||
[],
|
||||
|row| row.get::<_, i64>(0),
|
||||
)
|
||||
.optional()
|
||||
.ok()
|
||||
.flatten()
|
||||
.is_some();
|
||||
has_stored_configs && has_stored_fields
|
||||
}
|
||||
|
||||
fn read_snapshot_tables(
|
||||
src: &Connection,
|
||||
) -> rusqlite::Result<(Vec<StoredConfigMetaRecord>, Vec<SnapshotFieldRow>)> {
|
||||
src.execute_batch("BEGIN DEFERRED TRANSACTION")?;
|
||||
|
||||
let mut meta_rows = Vec::<StoredConfigMetaRecord>::new();
|
||||
let mut field_rows = Vec::<SnapshotFieldRow>::new();
|
||||
|
||||
let read_result = (|| -> rusqlite::Result<()> {
|
||||
{
|
||||
let mut stmt = src.prepare(
|
||||
"SELECT config_id, display_name, created_at, updated_at, favorite, temporary
|
||||
FROM stored_configs",
|
||||
)?;
|
||||
let rows = stmt.query_map([], row_to_meta)?;
|
||||
for row in rows {
|
||||
meta_rows.push(row?);
|
||||
}
|
||||
}
|
||||
|
||||
{
|
||||
let mut stmt = src.prepare(
|
||||
"SELECT config_id, field_name, field_json, updated_at
|
||||
FROM stored_config_fields",
|
||||
)?;
|
||||
let rows = stmt.query_map([], |row| {
|
||||
Ok((
|
||||
row.get::<_, String>(0)?,
|
||||
row.get::<_, String>(1)?,
|
||||
row.get::<_, String>(2)?,
|
||||
row.get::<_, String>(3)?,
|
||||
))
|
||||
})?;
|
||||
for row in rows {
|
||||
field_rows.push(row?);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
})();
|
||||
|
||||
match read_result {
|
||||
Ok(()) => {
|
||||
src.execute_batch("COMMIT")?;
|
||||
Ok((meta_rows, field_rows))
|
||||
}
|
||||
Err(err) => {
|
||||
let _ = src.execute_batch("ROLLBACK");
|
||||
Err(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn write_snapshot_tables(
|
||||
dst: &mut Connection,
|
||||
meta_rows: Vec<StoredConfigMetaRecord>,
|
||||
field_rows: Vec<SnapshotFieldRow>,
|
||||
) -> rusqlite::Result<()> {
|
||||
let tx = dst.unchecked_transaction()?;
|
||||
tx.execute("DELETE FROM stored_config_fields", [])?;
|
||||
tx.execute("DELETE FROM stored_configs", [])?;
|
||||
|
||||
for row in meta_rows {
|
||||
tx.execute(
|
||||
"INSERT INTO stored_configs (
|
||||
config_id, display_name, created_at, updated_at, favorite, temporary
|
||||
) VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
|
||||
params![
|
||||
row.config_id,
|
||||
row.display_name,
|
||||
row.created_at,
|
||||
row.updated_at,
|
||||
if row.favorite { 1 } else { 0 },
|
||||
if row.temporary { 1 } else { 0 }
|
||||
],
|
||||
)?;
|
||||
}
|
||||
|
||||
for (config_id, field_name, field_json, updated_at) in field_rows {
|
||||
tx.execute(
|
||||
"INSERT INTO stored_config_fields (config_id, field_name, field_json, updated_at)
|
||||
VALUES (?1, ?2, ?3, ?4)",
|
||||
params![config_id, field_name, field_json, updated_at],
|
||||
)?;
|
||||
}
|
||||
|
||||
tx.commit()
|
||||
}
|
||||
|
||||
fn copy_snapshot_tables(src: &Connection, dst: &mut Connection) -> rusqlite::Result<()> {
|
||||
let (meta_rows, field_rows) = read_snapshot_tables(src)?;
|
||||
write_snapshot_tables(dst, meta_rows, field_rows)
|
||||
}
|
||||
|
||||
fn ensure_parent_dir(path: &Path) -> bool {
|
||||
match path.parent() {
|
||||
Some(parent) => match std::fs::create_dir_all(parent) {
|
||||
Ok(_) => true,
|
||||
Err(e) => {
|
||||
ohrs_log_error!(
|
||||
"[Rust] failed to create snapshot parent {}: {}",
|
||||
parent.display(),
|
||||
e
|
||||
);
|
||||
false
|
||||
}
|
||||
},
|
||||
None => true,
|
||||
}
|
||||
}
|
||||
|
||||
fn to_meta(record: StoredConfigMetaRecord) -> StoredConfigMeta {
|
||||
StoredConfigMeta {
|
||||
config_id: record.config_id,
|
||||
display_name: record.display_name,
|
||||
created_at: record.created_at,
|
||||
updated_at: record.updated_at,
|
||||
favorite: record.favorite,
|
||||
temporary: record.temporary,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn init_config_meta_store(root_dir: String) -> bool {
|
||||
let root = PathBuf::from(root_dir);
|
||||
if let Err(e) = std::fs::create_dir_all(&root) {
|
||||
ohrs_log_error!(
|
||||
"[Rust] failed to create config db dir {}: {}",
|
||||
root.display(),
|
||||
e
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
let db_path = root.join(CONFIG_DB_FILE_NAME);
|
||||
match CONFIG_DB_PATH.lock() {
|
||||
Ok(mut guard) => {
|
||||
*guard = Some(db_path.clone());
|
||||
}
|
||||
Err(e) => {
|
||||
ohrs_log_error!("[Rust] failed to lock config db path: {}", e);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if open_db().is_none() {
|
||||
return false;
|
||||
}
|
||||
|
||||
ohrs_log_debug!("[Rust] initialized config db at {}", db_path.display());
|
||||
true
|
||||
}
|
||||
|
||||
pub fn export_config_store_snapshot(target_path: String) -> bool {
|
||||
let target = PathBuf::from(target_path);
|
||||
if !ensure_parent_dir(&target) {
|
||||
return false;
|
||||
}
|
||||
let Some(src) = open_db() else {
|
||||
return false;
|
||||
};
|
||||
let mut dst = match Connection::open(&target) {
|
||||
Ok(conn) => conn,
|
||||
Err(e) => {
|
||||
ohrs_log_error!(
|
||||
"[Rust] failed to open snapshot target {}: {}",
|
||||
target.display(),
|
||||
e
|
||||
);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
if let Err(e) = init_schema(&dst) {
|
||||
ohrs_log_error!(
|
||||
"[Rust] failed to init snapshot schema {}: {}",
|
||||
target.display(),
|
||||
e
|
||||
);
|
||||
return false;
|
||||
}
|
||||
match copy_snapshot_tables(&src, &mut dst) {
|
||||
Ok(_) => true,
|
||||
Err(e) => {
|
||||
ohrs_log_error!(
|
||||
"[Rust] failed to export snapshot {}: {}",
|
||||
target.display(),
|
||||
e
|
||||
);
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn import_config_store_snapshot_with_result(source_path: String) -> SnapshotImportResult {
|
||||
let source = PathBuf::from(source_path);
|
||||
let src = match Connection::open(&source) {
|
||||
Ok(conn) => conn,
|
||||
Err(e) => {
|
||||
ohrs_log_error!(
|
||||
"[Rust] failed to open snapshot source {}: {}",
|
||||
source.display(),
|
||||
e
|
||||
);
|
||||
return snapshot_import_err("source_open_failed", e.to_string(), false);
|
||||
}
|
||||
};
|
||||
if !validate_snapshot_schema(&src) {
|
||||
ohrs_log_error!("[Rust] invalid snapshot schema {}", source.display());
|
||||
return snapshot_import_err(
|
||||
"invalid_snapshot_schema",
|
||||
format!("invalid snapshot schema: {}", source.display()),
|
||||
true,
|
||||
);
|
||||
}
|
||||
let (meta_rows, field_rows) = match read_snapshot_tables(&src) {
|
||||
Ok(rows) => rows,
|
||||
Err(e) => {
|
||||
ohrs_log_error!(
|
||||
"[Rust] failed to read snapshot source {}: {}",
|
||||
source.display(),
|
||||
e
|
||||
);
|
||||
return snapshot_import_err("invalid_snapshot_data", e.to_string(), true);
|
||||
}
|
||||
};
|
||||
let Some(mut dst) = open_db() else {
|
||||
return snapshot_import_err(
|
||||
"destination_open_failed",
|
||||
"failed to open local config store",
|
||||
false,
|
||||
);
|
||||
};
|
||||
match write_snapshot_tables(&mut dst, meta_rows, field_rows) {
|
||||
Ok(_) => snapshot_import_ok(),
|
||||
Err(e) => {
|
||||
ohrs_log_error!(
|
||||
"[Rust] failed to import snapshot {}: {}",
|
||||
source.display(),
|
||||
e
|
||||
);
|
||||
snapshot_import_err("destination_write_failed", e.to_string(), false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn import_config_store_snapshot(source_path: String) -> bool {
|
||||
import_config_store_snapshot_with_result(source_path).ok
|
||||
}
|
||||
|
||||
pub fn reset_config_meta_store() -> bool {
|
||||
let Some(conn) = open_db() else {
|
||||
return false;
|
||||
};
|
||||
let tx = match conn.unchecked_transaction() {
|
||||
Ok(tx) => tx,
|
||||
Err(e) => {
|
||||
ohrs_log_error!(
|
||||
"[Rust] failed to start config store reset transaction: {}",
|
||||
e
|
||||
);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
if let Err(e) = tx.execute("DELETE FROM stored_config_fields", []) {
|
||||
ohrs_log_error!("[Rust] failed to reset config fields: {}", e);
|
||||
let _ = tx.rollback();
|
||||
return false;
|
||||
}
|
||||
if let Err(e) = tx.execute("DELETE FROM stored_configs", []) {
|
||||
ohrs_log_error!("[Rust] failed to reset config meta: {}", e);
|
||||
let _ = tx.rollback();
|
||||
return false;
|
||||
}
|
||||
|
||||
match tx.commit() {
|
||||
Ok(_) => true,
|
||||
Err(e) => {
|
||||
ohrs_log_error!("[Rust] failed to commit config store reset: {}", e);
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn list_config_meta_entries() -> StoredConfigList {
|
||||
let Some(conn) = open_db() else {
|
||||
return StoredConfigList { configs: vec![] };
|
||||
};
|
||||
|
||||
let mut stmt = match conn.prepare(
|
||||
"SELECT config_id, display_name, created_at, updated_at, favorite, temporary
|
||||
FROM stored_configs
|
||||
ORDER BY updated_at DESC, display_name ASC",
|
||||
) {
|
||||
Ok(stmt) => stmt,
|
||||
Err(e) => {
|
||||
ohrs_log_error!("[Rust] failed to prepare list meta query: {}", e);
|
||||
return StoredConfigList { configs: vec![] };
|
||||
}
|
||||
};
|
||||
|
||||
let rows = match stmt.query_map([], row_to_meta) {
|
||||
Ok(rows) => rows,
|
||||
Err(e) => {
|
||||
ohrs_log_error!("[Rust] failed to list config meta rows: {}", e);
|
||||
return StoredConfigList { configs: vec![] };
|
||||
}
|
||||
};
|
||||
|
||||
let configs = rows.filter_map(Result::ok).map(to_meta).collect();
|
||||
StoredConfigList { configs }
|
||||
}
|
||||
|
||||
pub fn get_config_display_name(config_id: &str) -> Option<String> {
|
||||
let conn = open_db()?;
|
||||
load_meta_record(&conn, config_id).map(|record| record.display_name)
|
||||
}
|
||||
|
||||
pub fn get_config_meta(config_id: &str) -> Option<StoredConfigMeta> {
|
||||
let conn = open_db()?;
|
||||
load_meta_record(&conn, config_id).map(to_meta)
|
||||
}
|
||||
|
||||
pub(crate) fn upsert_config_meta_in_tx(
|
||||
tx: &rusqlite::Transaction<'_>,
|
||||
config_id: String,
|
||||
display_name: String,
|
||||
favorite: bool,
|
||||
temporary: bool,
|
||||
) -> Option<StoredConfigMeta> {
|
||||
let now = now_ts_string();
|
||||
let created_at = tx
|
||||
.query_row(
|
||||
"SELECT config_id, display_name, created_at, updated_at, favorite, temporary
|
||||
FROM stored_configs WHERE config_id = ?1",
|
||||
params![config_id],
|
||||
row_to_meta,
|
||||
)
|
||||
.optional()
|
||||
.ok()
|
||||
.flatten()
|
||||
.map(|record| record.created_at)
|
||||
.unwrap_or_else(|| now.clone());
|
||||
|
||||
tx.execute(
|
||||
"INSERT INTO stored_configs (
|
||||
config_id, display_name, created_at, updated_at, favorite, temporary
|
||||
) VALUES (?1, ?2, ?3, ?4, ?5, ?6)
|
||||
ON CONFLICT(config_id) DO UPDATE SET
|
||||
display_name = excluded.display_name,
|
||||
updated_at = excluded.updated_at,
|
||||
favorite = excluded.favorite,
|
||||
temporary = excluded.temporary",
|
||||
params![
|
||||
config_id,
|
||||
display_name,
|
||||
created_at,
|
||||
now,
|
||||
if favorite { 1 } else { 0 },
|
||||
if temporary { 1 } else { 0 }
|
||||
],
|
||||
)
|
||||
.ok()?;
|
||||
|
||||
tx.query_row(
|
||||
"SELECT config_id, display_name, created_at, updated_at, favorite, temporary
|
||||
FROM stored_configs WHERE config_id = ?1",
|
||||
params![config_id],
|
||||
row_to_meta,
|
||||
)
|
||||
.optional()
|
||||
.ok()
|
||||
.flatten()
|
||||
.map(to_meta)
|
||||
.or(Some(StoredConfigMeta {
|
||||
config_id,
|
||||
display_name,
|
||||
created_at,
|
||||
updated_at: now,
|
||||
favorite,
|
||||
temporary,
|
||||
}))
|
||||
}
|
||||
|
||||
pub fn set_config_display_name(
|
||||
config_id: String,
|
||||
display_name: String,
|
||||
) -> Option<StoredConfigMeta> {
|
||||
let conn = open_db()?;
|
||||
let mut record = load_meta_record(&conn, &config_id)?;
|
||||
record.display_name = display_name;
|
||||
record.updated_at = now_ts_string();
|
||||
|
||||
conn.execute(
|
||||
"UPDATE stored_configs
|
||||
SET display_name = ?2, updated_at = ?3
|
||||
WHERE config_id = ?1",
|
||||
params![config_id, record.display_name, record.updated_at],
|
||||
)
|
||||
.ok()?;
|
||||
|
||||
Some(to_meta(record))
|
||||
}
|
||||
|
||||
pub fn set_config_favorite(config_id: String, favorite: bool) -> Option<StoredConfigMeta> {
|
||||
let conn = open_db()?;
|
||||
let now = now_ts_string();
|
||||
let tx = conn.unchecked_transaction().ok()?;
|
||||
|
||||
if favorite {
|
||||
tx.execute(
|
||||
"UPDATE stored_configs
|
||||
SET favorite = 0,
|
||||
updated_at = CASE WHEN favorite != 0 THEN ?1 ELSE updated_at END
|
||||
WHERE favorite != 0 AND config_id <> ?2",
|
||||
params![now, config_id.clone()],
|
||||
)
|
||||
.ok()?;
|
||||
}
|
||||
|
||||
let rows = tx
|
||||
.execute(
|
||||
"UPDATE stored_configs
|
||||
SET favorite = ?2, updated_at = ?3
|
||||
WHERE config_id = ?1",
|
||||
params![config_id.clone(), if favorite { 1 } else { 0 }, now],
|
||||
)
|
||||
.ok()?;
|
||||
if rows == 0 {
|
||||
return None;
|
||||
}
|
||||
|
||||
let meta = tx
|
||||
.query_row(
|
||||
"SELECT config_id, display_name, created_at, updated_at, favorite, temporary
|
||||
FROM stored_configs WHERE config_id = ?1",
|
||||
params![config_id],
|
||||
row_to_meta,
|
||||
)
|
||||
.optional()
|
||||
.ok()
|
||||
.flatten()
|
||||
.map(to_meta)?;
|
||||
tx.commit().ok()?;
|
||||
Some(meta)
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
pub(crate) mod config_meta;
|
||||
@@ -0,0 +1 @@
|
||||
pub(crate) mod stored_config;
|
||||
@@ -0,0 +1,70 @@
|
||||
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,
|
||||
pub created_at: String,
|
||||
pub updated_at: String,
|
||||
pub favorite: bool,
|
||||
pub temporary: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[napi(object)]
|
||||
pub struct StoredConfigRecord {
|
||||
pub meta: StoredConfigMeta,
|
||||
pub config_json: String,
|
||||
}
|
||||
|
||||
#[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>,
|
||||
pub only_start: bool,
|
||||
}
|
||||
|
||||
#[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,
|
||||
}
|
||||
|
||||
#[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,
|
||||
}
|
||||
@@ -0,0 +1,457 @@
|
||||
use super::{field_store, import_export, legacy_migration, validation};
|
||||
use crate::config::storage::config_meta::{
|
||||
get_config_meta, init_config_meta_store, list_config_meta_entries, open_db,
|
||||
reset_config_meta_store, upsert_config_meta_in_tx,
|
||||
};
|
||||
use crate::config::types::stored_config::{ExportTomlResult, StoredConfigRecord};
|
||||
use easytier::proto::api::manage::NetworkConfig;
|
||||
use once_cell::sync::Lazy;
|
||||
use rusqlite::params;
|
||||
use serde_json::Value;
|
||||
use std::collections::HashMap;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Mutex;
|
||||
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(crate) const CONFIG_DIR_NAME: &str = "easytier-configs";
|
||||
pub(crate) const KERNEL_SOCKET_FILE_NAME: &str = "easytier-kernel.sock";
|
||||
|
||||
#[derive(Clone)]
|
||||
pub(crate) struct RuntimeConfigSnapshot {
|
||||
pub display_name: String,
|
||||
pub config: NetworkConfig,
|
||||
}
|
||||
|
||||
pub(crate) fn cache_runtime_config_snapshot(
|
||||
config_id: String,
|
||||
display_name: String,
|
||||
config: NetworkConfig,
|
||||
) {
|
||||
if let Ok(mut guard) = RUNTIME_CONFIG_SNAPSHOTS.lock() {
|
||||
guard.insert(
|
||||
config_id,
|
||||
RuntimeConfigSnapshot {
|
||||
display_name,
|
||||
config,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn clear_runtime_config_snapshot(config_id: &str) {
|
||||
if let Ok(mut guard) = RUNTIME_CONFIG_SNAPSHOTS.lock() {
|
||||
guard.remove(config_id);
|
||||
}
|
||||
}
|
||||
|
||||
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(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(crate) fn kernel_socket_path() -> Option<PathBuf> {
|
||||
config_root_dir().map(|root| root.join(KERNEL_SOCKET_FILE_NAME))
|
||||
}
|
||||
|
||||
pub(crate) fn legacy_config_file_path(config_id: &str) -> Option<PathBuf> {
|
||||
legacy_migration::legacy_config_file_path(&config_root_dir(), CONFIG_DIR_NAME, config_id)
|
||||
}
|
||||
|
||||
pub fn init_config_store(root_dir: String) -> bool {
|
||||
let root = PathBuf::from(root_dir);
|
||||
let configs_dir = root.join(CONFIG_DIR_NAME);
|
||||
if let Err(e) = std::fs::create_dir_all(&configs_dir) {
|
||||
ohrs_log_error!(
|
||||
"[Rust] failed to create config dir {}: {}",
|
||||
configs_dir.display(),
|
||||
e
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
match CONFIG_ROOT_DIR.lock() {
|
||||
Ok(mut guard) => {
|
||||
*guard = Some(root.clone());
|
||||
}
|
||||
Err(e) => {
|
||||
ohrs_log_error!("[Rust] failed to lock config root dir: {}", e);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if !init_config_meta_store(root.to_string_lossy().into_owned()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
ohrs_log_debug!(
|
||||
"[Rust] initialized config repo at {}",
|
||||
configs_dir.display()
|
||||
);
|
||||
true
|
||||
}
|
||||
|
||||
pub fn reset_config_store() -> bool {
|
||||
if !reset_config_meta_store() {
|
||||
return false;
|
||||
}
|
||||
if let Ok(mut guard) = RUNTIME_CONFIG_SNAPSHOTS.lock() {
|
||||
guard.clear();
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
fn migrate_legacy_file_if_needed(config_id: &str) -> Option<()> {
|
||||
if validation::validate_config_id(config_id).is_err() {
|
||||
return None;
|
||||
}
|
||||
legacy_migration::migrate_legacy_file_if_needed(
|
||||
&config_root_dir(),
|
||||
CONFIG_DIR_NAME,
|
||||
config_id,
|
||||
save_config_record,
|
||||
)
|
||||
}
|
||||
|
||||
pub fn save_config_record(
|
||||
config_id: String,
|
||||
display_name: String,
|
||||
config_json: String,
|
||||
) -> Option<StoredConfigRecord> {
|
||||
let config = match validation::validate_config_json(&config_json, config_id.clone()) {
|
||||
Ok(config) => config,
|
||||
Err(e) => {
|
||||
ohrs_log_error!("[Rust] save_config_record failed {}", e);
|
||||
return None;
|
||||
}
|
||||
};
|
||||
|
||||
let normalized_json = match serde_json::to_string(&config) {
|
||||
Ok(raw) => raw,
|
||||
Err(e) => {
|
||||
ohrs_log_error!(
|
||||
"[Rust] failed to serialize normalized config {}: {}",
|
||||
config_id,
|
||||
e
|
||||
);
|
||||
return None;
|
||||
}
|
||||
};
|
||||
|
||||
let fields = match validation::config_to_top_level_map(&config) {
|
||||
Some(fields) => fields,
|
||||
None => return None,
|
||||
};
|
||||
|
||||
let conn = open_db()?;
|
||||
let tx = conn.unchecked_transaction().ok()?;
|
||||
let existing_meta = tx
|
||||
.query_row(
|
||||
"SELECT favorite, temporary FROM stored_configs WHERE config_id = ?1",
|
||||
params![config_id.clone()],
|
||||
|row| Ok((row.get::<_, i64>(0)? != 0, row.get::<_, i64>(1)? != 0)),
|
||||
)
|
||||
.ok();
|
||||
let favorite = existing_meta.map(|meta| meta.0).unwrap_or(false);
|
||||
let temporary = existing_meta.map(|meta| meta.1).unwrap_or(false);
|
||||
let meta = upsert_config_meta_in_tx(&tx, config_id.clone(), display_name, favorite, temporary)?;
|
||||
|
||||
field_store::replace_config_fields(&tx, &config_id, fields)?;
|
||||
|
||||
tx.commit().ok()?;
|
||||
|
||||
if let Some(legacy_path) = legacy_config_file_path(&config_id) {
|
||||
if legacy_path.exists() {
|
||||
let _ = std::fs::remove_file(legacy_path);
|
||||
}
|
||||
}
|
||||
|
||||
Some(StoredConfigRecord {
|
||||
meta,
|
||||
config_json: normalized_json,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn load_config_json(config_id: &str) -> Option<String> {
|
||||
validation::validate_config_id(config_id).ok()?;
|
||||
migrate_legacy_file_if_needed(config_id)?;
|
||||
let object = field_store::load_config_map_from_db(config_id)?;
|
||||
serde_json::to_string(&Value::Object(object)).ok()
|
||||
}
|
||||
|
||||
pub fn get_config_record(config_id: &str) -> Option<StoredConfigRecord> {
|
||||
validation::validate_config_id(config_id).ok()?;
|
||||
let config_json = load_config_json(config_id)?;
|
||||
let meta = get_config_meta(config_id)?;
|
||||
Some(StoredConfigRecord { meta, config_json })
|
||||
}
|
||||
|
||||
pub fn get_config_field_value(config_id: &str, field: &str) -> Option<String> {
|
||||
let total_start = Instant::now();
|
||||
validation::validate_config_id(config_id).ok()?;
|
||||
migrate_legacy_file_if_needed(config_id)?;
|
||||
let open_start = Instant::now();
|
||||
let conn = open_db()?;
|
||||
let open_elapsed = open_start.elapsed();
|
||||
let query_start = Instant::now();
|
||||
let result = conn
|
||||
.query_row(
|
||||
"SELECT field_json FROM stored_config_fields
|
||||
WHERE config_id = ?1 AND field_name = ?2",
|
||||
params![config_id, field],
|
||||
|row| row.get::<_, String>(0),
|
||||
)
|
||||
.ok();
|
||||
ohrs_log_debug!(
|
||||
"[Rust] get_config_field_value config={} field={} found={} open_ms={} query_ms={} total_ms={} len={}",
|
||||
config_id,
|
||||
field,
|
||||
result.is_some(),
|
||||
open_elapsed.as_millis(),
|
||||
query_start.elapsed().as_millis(),
|
||||
total_start.elapsed().as_millis(),
|
||||
result.as_ref().map(|value| value.len()).unwrap_or(0)
|
||||
);
|
||||
result
|
||||
}
|
||||
|
||||
pub fn set_config_field_value(config_id: &str, field: &str, json_value: &str) -> bool {
|
||||
if validation::validate_config_id(config_id).is_err() {
|
||||
return false;
|
||||
}
|
||||
if field.contains('.') {
|
||||
return false;
|
||||
}
|
||||
|
||||
let raw = match load_config_json(config_id) {
|
||||
Some(raw) => raw,
|
||||
None => return false,
|
||||
};
|
||||
let mut value = match serde_json::from_str::<Value>(&raw) {
|
||||
Ok(value) => value,
|
||||
Err(_) => return false,
|
||||
};
|
||||
let new_field_value = match serde_json::from_str::<Value>(json_value) {
|
||||
Ok(value) => value,
|
||||
Err(_) => return false,
|
||||
};
|
||||
let object = match value.as_object_mut() {
|
||||
Some(object) => object,
|
||||
None => return false,
|
||||
};
|
||||
object.insert(field.to_string(), new_field_value);
|
||||
|
||||
let normalized = match serde_json::to_string(&value) {
|
||||
Ok(raw) => raw,
|
||||
Err(_) => return false,
|
||||
};
|
||||
|
||||
let display_name = get_config_meta(config_id)
|
||||
.map(|meta| meta.display_name)
|
||||
.unwrap_or_else(|| config_id.to_string());
|
||||
|
||||
save_config_record(config_id.to_string(), display_name, normalized).is_some()
|
||||
}
|
||||
|
||||
pub fn get_default_config_json() -> Option<String> {
|
||||
crate::build_default_network_config_json().ok()
|
||||
}
|
||||
|
||||
pub fn create_config_record(config_id: String, display_name: String) -> Option<StoredConfigRecord> {
|
||||
validation::validate_config_id(&config_id).ok()?;
|
||||
let raw = get_default_config_json()?;
|
||||
let mut config = serde_json::from_str::<NetworkConfig>(&raw).ok()?;
|
||||
config.instance_id = Some(config_id.clone());
|
||||
let normalized_json = serde_json::to_string(&config).ok()?;
|
||||
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())
|
||||
}
|
||||
|
||||
pub fn delete_config_record(config_id: &str) -> bool {
|
||||
if validation::validate_config_id(config_id).is_err() {
|
||||
return false;
|
||||
}
|
||||
if let Some(path) = legacy_config_file_path(config_id) {
|
||||
if path.exists() {
|
||||
let _ = std::fs::remove_file(path);
|
||||
}
|
||||
}
|
||||
|
||||
let conn = match open_db() {
|
||||
Some(conn) => conn,
|
||||
None => return false,
|
||||
};
|
||||
if let Err(e) = conn.execute(
|
||||
"DELETE FROM stored_config_fields WHERE config_id = ?1",
|
||||
params![config_id],
|
||||
) {
|
||||
ohrs_log_error!("[Rust] failed to delete config fields {}: {}", config_id, e);
|
||||
return false;
|
||||
}
|
||||
|
||||
match conn.execute(
|
||||
"DELETE FROM stored_configs WHERE config_id = ?1",
|
||||
params![config_id],
|
||||
) {
|
||||
Ok(rows) => rows > 0,
|
||||
Err(e) => {
|
||||
ohrs_log_error!("[Rust] failed to delete config meta {}: {}", config_id, e);
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn export_config_toml(config_id: &str) -> Option<ExportTomlResult> {
|
||||
validation::validate_config_id(config_id).ok()?;
|
||||
let record = get_config_record(config_id)?;
|
||||
import_export::export_config_toml_from_record(&record)
|
||||
}
|
||||
|
||||
pub fn import_toml_config(
|
||||
toml_text: String,
|
||||
display_name: Option<String>,
|
||||
) -> Option<StoredConfigRecord> {
|
||||
import_export::import_toml_to_record(toml_text, display_name, save_config_record)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use rusqlite::params;
|
||||
use std::path::PathBuf;
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
fn test_root() -> String {
|
||||
let unique = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_nanos();
|
||||
let dir = std::env::temp_dir().join(format!("easytier_ohrs_test_{}", unique));
|
||||
dir.to_string_lossy().into_owned()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn save_get_export_delete_roundtrip() {
|
||||
let root = test_root();
|
||||
assert!(init_config_store(root.clone()));
|
||||
|
||||
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, "cfg-1");
|
||||
assert_eq!(saved.meta.display_name, "test-config");
|
||||
|
||||
let loaded = get_config_record("cfg-1").expect("load config");
|
||||
assert_eq!(loaded.meta.display_name, "test-config");
|
||||
assert!(loaded.config_json.contains("cfg-1"));
|
||||
|
||||
let legacy_json_path = PathBuf::from(&root)
|
||||
.join(CONFIG_DIR_NAME)
|
||||
.join("cfg-1.json");
|
||||
assert!(
|
||||
!legacy_json_path.exists(),
|
||||
"config should no longer be persisted as a per-config json file"
|
||||
);
|
||||
|
||||
let conn = open_db().expect("db should be open");
|
||||
let field_count: i64 = conn
|
||||
.query_row(
|
||||
"SELECT COUNT(*) FROM stored_config_fields WHERE config_id = ?1",
|
||||
params!["cfg-1"],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.expect("count config fields");
|
||||
assert!(field_count > 0, "config fields should be stored in sqlite");
|
||||
|
||||
let exported = export_config_toml("cfg-1").expect("export toml");
|
||||
assert!(exported.toml_text.contains("instance_id"));
|
||||
|
||||
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() {
|
||||
let root = test_root();
|
||||
assert!(init_config_store(root));
|
||||
|
||||
let config_json = crate::build_default_network_config_json().expect("default config");
|
||||
save_config_record(
|
||||
"cfg-field".to_string(),
|
||||
"field-config".to_string(),
|
||||
config_json,
|
||||
)
|
||||
.expect("save config");
|
||||
|
||||
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(
|
||||
"cfg-field",
|
||||
"network_name",
|
||||
"\"changed-network\""
|
||||
));
|
||||
|
||||
assert_eq!(
|
||||
get_config_field_value("cfg-field", "network_name"),
|
||||
Some("\"changed-network\"".to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
get_config_field_value("cfg-field", "instance_id"),
|
||||
Some(before_instance_id)
|
||||
);
|
||||
assert_ne!(
|
||||
get_config_field_value("cfg-field", "network_name"),
|
||||
before_network_name
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
use crate::config::storage::config_meta::{now_ts_string, open_db};
|
||||
use rusqlite::{Connection, params};
|
||||
use serde_json::{Map, Value};
|
||||
|
||||
pub(super) fn load_config_map_from_db(config_id: &str) -> Option<Map<String, Value>> {
|
||||
let conn = open_db()?;
|
||||
let mut stmt = conn
|
||||
.prepare(
|
||||
"SELECT field_name, field_json
|
||||
FROM stored_config_fields
|
||||
WHERE config_id = ?1",
|
||||
)
|
||||
.ok()?;
|
||||
let rows = stmt
|
||||
.query_map(params![config_id], |row| {
|
||||
let field_name: String = row.get(0)?;
|
||||
let field_json: String = row.get(1)?;
|
||||
Ok((field_name, field_json))
|
||||
})
|
||||
.ok()?;
|
||||
|
||||
let mut object = Map::new();
|
||||
for row in rows {
|
||||
let (field_name, field_json) = row.ok()?;
|
||||
let value = serde_json::from_str::<Value>(&field_json).ok()?;
|
||||
object.insert(field_name, value);
|
||||
}
|
||||
|
||||
if object.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(object)
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn replace_config_fields(
|
||||
tx: &Connection,
|
||||
config_id: &str,
|
||||
fields: Map<String, Value>,
|
||||
) -> Option<()> {
|
||||
if let Err(e) = tx.execute(
|
||||
"DELETE FROM stored_config_fields WHERE config_id = ?1",
|
||||
params![config_id],
|
||||
) {
|
||||
ohrs_log_error!(
|
||||
"[Rust] failed to clear existing config fields {}: {}",
|
||||
config_id,
|
||||
e
|
||||
);
|
||||
return None;
|
||||
}
|
||||
|
||||
for (field_name, value) in fields {
|
||||
let field_json = serde_json::to_string(&value).ok()?;
|
||||
if let Err(e) = tx.execute(
|
||||
"INSERT INTO stored_config_fields (config_id, field_name, field_json, updated_at)
|
||||
VALUES (?1, ?2, ?3, ?4)",
|
||||
params![config_id, field_name, field_json, now_ts_string()],
|
||||
) {
|
||||
ohrs_log_error!("[Rust] failed to persist config field {}: {}", config_id, e);
|
||||
return None;
|
||||
}
|
||||
}
|
||||
|
||||
Some(())
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
use crate::config::types::stored_config::{ExportTomlResult, StoredConfigRecord};
|
||||
use easytier::common::config::NetworkConfigExt;
|
||||
use easytier::common::config::{ConfigLoader, TomlConfigLoader};
|
||||
use easytier::proto::api::manage::NetworkConfig;
|
||||
|
||||
pub(super) fn export_config_toml_from_record(
|
||||
record: &StoredConfigRecord,
|
||||
) -> Option<ExportTomlResult> {
|
||||
let config = serde_json::from_str::<NetworkConfig>(&record.config_json).ok()?;
|
||||
let toml = config.gen_config().ok()?;
|
||||
Some(ExportTomlResult {
|
||||
toml_text: toml.dump(),
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn import_toml_to_record(
|
||||
toml_text: String,
|
||||
display_name: Option<String>,
|
||||
save_config_record: impl Fn(String, String, String) -> Option<StoredConfigRecord>,
|
||||
) -> Option<StoredConfigRecord> {
|
||||
let config =
|
||||
NetworkConfig::new_from_config(TomlConfigLoader::new_from_str(&toml_text).ok()?).ok()?;
|
||||
|
||||
let config_id = config.instance_id.clone()?;
|
||||
let name_from_toml = toml_text
|
||||
.lines()
|
||||
.find_map(|line| {
|
||||
let trimmed = line.trim();
|
||||
if !trimmed.starts_with("instance_name") {
|
||||
return None;
|
||||
}
|
||||
trimmed.split_once('=').map(|(_, value)| {
|
||||
value
|
||||
.trim()
|
||||
.trim_matches('"')
|
||||
.trim_matches('\'')
|
||||
.to_string()
|
||||
})
|
||||
})
|
||||
.filter(|name| !name.is_empty());
|
||||
|
||||
let final_name = display_name
|
||||
.filter(|name| !name.is_empty())
|
||||
.or(name_from_toml)
|
||||
.unwrap_or_else(|| config_id.clone());
|
||||
|
||||
let config_json = serde_json::to_string(&config).ok()?;
|
||||
save_config_record(config_id, final_name, config_json)
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
use crate::config::storage::config_meta::get_config_meta;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use super::validation;
|
||||
|
||||
pub(super) fn legacy_config_file_path(
|
||||
root_dir: &Option<PathBuf>,
|
||||
config_dir_name: &str,
|
||||
config_id: &str,
|
||||
) -> Option<PathBuf> {
|
||||
if !validation::is_valid_config_id(config_id) {
|
||||
ohrs_log_error!("[Rust] invalid legacy config_id {}", config_id);
|
||||
return None;
|
||||
}
|
||||
root_dir.as_ref().map(|root| {
|
||||
root.join(config_dir_name)
|
||||
.join(format!("{}.json", config_id))
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn migrate_legacy_file_if_needed(
|
||||
root_dir: &Option<PathBuf>,
|
||||
config_dir_name: &str,
|
||||
config_id: &str,
|
||||
save_config_record: impl Fn(
|
||||
String,
|
||||
String,
|
||||
String,
|
||||
) -> Option<crate::config::types::stored_config::StoredConfigRecord>,
|
||||
) -> Option<()> {
|
||||
let legacy_path = legacy_config_file_path(root_dir, config_dir_name, config_id)?;
|
||||
if !legacy_path.exists() {
|
||||
return Some(());
|
||||
}
|
||||
|
||||
let raw = std::fs::read_to_string(&legacy_path).ok()?;
|
||||
let display_name = get_config_meta(config_id)
|
||||
.map(|meta| meta.display_name)
|
||||
.unwrap_or_else(|| config_id.to_string());
|
||||
save_config_record(config_id.to_string(), display_name, raw)?;
|
||||
|
||||
if let Err(e) = std::fs::remove_file(&legacy_path) {
|
||||
ohrs_log_error!(
|
||||
"[Rust] failed to remove legacy config file {}: {}",
|
||||
legacy_path.display(),
|
||||
e
|
||||
);
|
||||
}
|
||||
Some(())
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
use easytier::common::config::NetworkConfigExt;
|
||||
use easytier::proto::api::manage::NetworkConfig;
|
||||
use serde_json::{Map, Value};
|
||||
use uuid::Uuid;
|
||||
|
||||
pub(super) fn validate_config_id(config_id: &str) -> Result<(), String> {
|
||||
if config_id.is_empty() {
|
||||
return Err("config_id is required".to_string());
|
||||
}
|
||||
Uuid::parse_str(config_id)
|
||||
.map(|_| ())
|
||||
.map_err(|e| format!("invalid config_id {}: {}", config_id, e))
|
||||
}
|
||||
|
||||
pub(super) fn is_valid_config_id(config_id: &str) -> bool {
|
||||
validate_config_id(config_id).is_ok()
|
||||
}
|
||||
|
||||
pub(super) fn normalize_config_id(
|
||||
mut config: NetworkConfig,
|
||||
requested_id: String,
|
||||
) -> Result<NetworkConfig, String> {
|
||||
validate_config_id(&requested_id)?;
|
||||
config.instance_id = Some(requested_id);
|
||||
Ok(config)
|
||||
}
|
||||
|
||||
pub(super) fn validate_config_json(
|
||||
config_json: &str,
|
||||
config_id: String,
|
||||
) -> Result<NetworkConfig, String> {
|
||||
let config = serde_json::from_str::<NetworkConfig>(config_json)
|
||||
.map_err(|e| format!("parse config json failed: {}", e))?;
|
||||
let config = normalize_config_id(config, config_id)?;
|
||||
config
|
||||
.gen_config()
|
||||
.map_err(|e| format!("generate toml failed: {}", e))?;
|
||||
Ok(config)
|
||||
}
|
||||
|
||||
pub(super) fn config_to_top_level_map(config: &NetworkConfig) -> Option<Map<String, Value>> {
|
||||
serde_json::to_value(config).ok()?.as_object().cloned()
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
pub(crate) mod config_api;
|
||||
pub(crate) mod runtime_api;
|
||||
@@ -0,0 +1,69 @@
|
||||
use crate::config;
|
||||
use crate::config::types::stored_config::SnapshotImportResult;
|
||||
|
||||
pub(crate) fn init_config_store(root_dir: String) -> bool {
|
||||
config::repository::init_config_store(root_dir)
|
||||
}
|
||||
|
||||
pub(crate) fn reset_config_store() -> bool {
|
||||
config::repository::reset_config_store()
|
||||
}
|
||||
|
||||
pub(crate) fn list_configs() -> String {
|
||||
config::repository::list_config_meta_json()
|
||||
}
|
||||
|
||||
pub(crate) fn save_config(config_id: String, display_name: String, config_json: String) -> bool {
|
||||
config::repository::save_config_record(config_id, display_name, config_json).is_some()
|
||||
}
|
||||
|
||||
pub(crate) fn create_config(config_id: String, display_name: String) -> bool {
|
||||
config::repository::create_config_record(config_id, display_name).is_some()
|
||||
}
|
||||
|
||||
pub(crate) fn delete_stored_config_meta(config_id: String) -> bool {
|
||||
config::repository::delete_config_record(&config_id)
|
||||
}
|
||||
|
||||
pub(crate) fn get_config(config_id: String) -> Option<String> {
|
||||
config::repository::load_config_json(&config_id)
|
||||
}
|
||||
|
||||
pub(crate) fn get_default_config() -> Option<String> {
|
||||
config::repository::get_default_config_json()
|
||||
}
|
||||
|
||||
pub(crate) fn get_config_field(config_id: String, field: String) -> Option<String> {
|
||||
config::repository::get_config_field_value(&config_id, &field)
|
||||
}
|
||||
|
||||
pub(crate) fn set_config_field(config_id: String, field: String, json_value: String) -> bool {
|
||||
config::repository::set_config_field_value(&config_id, &field, &json_value)
|
||||
}
|
||||
|
||||
pub(crate) fn set_config_favorite(config_id: String, favorite: bool) -> bool {
|
||||
config::storage::config_meta::set_config_favorite(config_id, favorite).is_some()
|
||||
}
|
||||
|
||||
pub(crate) fn import_toml(toml_text: String, display_name: Option<String>) -> Option<String> {
|
||||
config::repository::import_toml_config(toml_text, display_name)
|
||||
.map(|record| record.meta.config_id)
|
||||
}
|
||||
|
||||
pub(crate) fn export_toml(config_id: String) -> Option<String> {
|
||||
config::repository::export_config_toml(&config_id).map(|ret| ret.toml_text)
|
||||
}
|
||||
|
||||
pub(crate) fn export_config_store_snapshot(target_path: String) -> bool {
|
||||
config::storage::config_meta::export_config_store_snapshot(target_path)
|
||||
}
|
||||
|
||||
pub(crate) fn import_config_store_snapshot(source_path: String) -> bool {
|
||||
config::storage::config_meta::import_config_store_snapshot(source_path)
|
||||
}
|
||||
|
||||
pub(crate) fn import_config_store_snapshot_with_result(
|
||||
source_path: String,
|
||||
) -> SnapshotImportResult {
|
||||
config::storage::config_meta::import_config_store_snapshot_with_result(source_path)
|
||||
}
|
||||
@@ -0,0 +1,228 @@
|
||||
use crate::config::repository::{clear_runtime_config_snapshot, get_runtime_config_snapshot};
|
||||
use crate::config::types::stored_config::KeyValuePair;
|
||||
use crate::kernel_bridge::{
|
||||
aggregate_requested_tun_routes, start_local_socket_server as start_local_socket_server_inner,
|
||||
stop_local_socket_server as stop_local_socket_server_inner,
|
||||
};
|
||||
use crate::runtime::state::runtime_state::{
|
||||
RuntimeAggregateState, RuntimeInstanceState, TunAggregateState, clear_tun_attached,
|
||||
is_tun_attached, mark_tun_attached, runtime_instance_from_config_snapshot,
|
||||
runtime_instance_from_running_info,
|
||||
};
|
||||
use crate::{ASYNC_RUNTIME, INSTANCE_MANAGER, WEB_CLIENTS};
|
||||
|
||||
pub(crate) fn start_kernel(
|
||||
config_id: String,
|
||||
start_kernel_with_config_id: impl Fn(&str) -> bool,
|
||||
) -> bool {
|
||||
start_kernel_with_config_id(&config_id)
|
||||
}
|
||||
|
||||
pub(crate) fn stop_kernel(
|
||||
config_id: String,
|
||||
stop_web_client: impl Fn(&str) -> bool,
|
||||
parse_instance_uuid: impl Fn(&str) -> Option<uuid::Uuid>,
|
||||
maybe_stop_local_socket_server: impl Fn(),
|
||||
) -> bool {
|
||||
clear_tun_attached(&config_id);
|
||||
if stop_web_client(&config_id) {
|
||||
clear_runtime_config_snapshot(&config_id);
|
||||
return true;
|
||||
}
|
||||
|
||||
let _ = stop_local_socket_server_inner();
|
||||
|
||||
let Some(instance_id) = parse_instance_uuid(&config_id) else {
|
||||
return false;
|
||||
};
|
||||
|
||||
let ret = ASYNC_RUNTIME
|
||||
.block_on(INSTANCE_MANAGER.delete_network_instances([instance_id]))
|
||||
.map(|_| true)
|
||||
.unwrap_or_else(|err| {
|
||||
ohrs_log_error!("[Rust] stop_kernel failed {}: {}", config_id, err);
|
||||
false
|
||||
});
|
||||
if ret {
|
||||
clear_runtime_config_snapshot(&config_id);
|
||||
}
|
||||
let has_active_instances = !INSTANCE_MANAGER.instance_ids().is_empty();
|
||||
let has_web_clients = WEB_CLIENTS
|
||||
.lock()
|
||||
.map(|guard| !guard.is_empty())
|
||||
.unwrap_or(false);
|
||||
if has_active_instances || has_web_clients {
|
||||
let _ = start_local_socket_server_inner();
|
||||
}
|
||||
maybe_stop_local_socket_server();
|
||||
ret
|
||||
}
|
||||
|
||||
pub(crate) fn stop_network_instance(
|
||||
config_ids: Vec<String>,
|
||||
stop_kernel: impl Fn(String) -> bool,
|
||||
) -> bool {
|
||||
let mut ok = true;
|
||||
for config_id in config_ids {
|
||||
ok = stop_kernel(config_id) && ok;
|
||||
}
|
||||
ok
|
||||
}
|
||||
|
||||
pub(crate) fn collect_network_infos() -> Vec<KeyValuePair> {
|
||||
let infos = match ASYNC_RUNTIME.block_on(INSTANCE_MANAGER.collect_network_infos()) {
|
||||
Ok(infos) => infos,
|
||||
Err(err) => {
|
||||
ohrs_log_error!("[Rust] collect network infos failed {}", err);
|
||||
return vec![];
|
||||
}
|
||||
};
|
||||
|
||||
infos
|
||||
.into_iter()
|
||||
.filter_map(|(key, value)| {
|
||||
serde_json::to_string(&value)
|
||||
.ok()
|
||||
.map(|value_json| KeyValuePair {
|
||||
key: key.to_string(),
|
||||
value: value_json,
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub(crate) fn set_tun_fd(
|
||||
config_id: String,
|
||||
fd: i32,
|
||||
parse_instance_uuid: impl Fn(&str) -> Option<uuid::Uuid>,
|
||||
) -> bool {
|
||||
let Some(instance_id) = parse_instance_uuid(&config_id) else {
|
||||
ohrs_log_error!("[Rust] set_tun_fd invalid instance id: {}", config_id);
|
||||
return false;
|
||||
};
|
||||
|
||||
INSTANCE_MANAGER
|
||||
.attach_tun_fd(instance_id, fd)
|
||||
.map(|_| {
|
||||
mark_tun_attached(&config_id);
|
||||
ohrs_log_info!(
|
||||
"[Rust] set_tun_fd success instance={} fd={} marked_attached=true",
|
||||
config_id,
|
||||
fd
|
||||
);
|
||||
true
|
||||
})
|
||||
.unwrap_or_else(|err| {
|
||||
ohrs_log_error!("[Rust] set_tun_fd failed {}: {}", config_id, err);
|
||||
false
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn collect_runtime_state() -> RuntimeAggregateState {
|
||||
let infos = match ASYNC_RUNTIME.block_on(INSTANCE_MANAGER.collect_network_infos()) {
|
||||
Ok(infos) => infos,
|
||||
Err(err) => {
|
||||
ohrs_log_error!("[Rust] collect network infos failed {}", err);
|
||||
return RuntimeAggregateState {
|
||||
instances: vec![],
|
||||
tun: TunAggregateState {
|
||||
active: false,
|
||||
attached_instance_ids: vec![],
|
||||
aggregated_routes: vec![],
|
||||
dns_servers: vec![],
|
||||
need_rebuild: false,
|
||||
},
|
||||
running_instance_count: 0,
|
||||
};
|
||||
}
|
||||
};
|
||||
let mut live_infos = infos
|
||||
.into_iter()
|
||||
.map(|(instance_id, info)| (instance_id.to_string(), info))
|
||||
.collect::<std::collections::HashMap<_, _>>();
|
||||
let mut active_config_ids = live_infos.keys().cloned().collect::<Vec<_>>();
|
||||
if let Ok(guard) = WEB_CLIENTS.lock() {
|
||||
for config_id in guard.keys() {
|
||||
if !active_config_ids.iter().any(|value| value == config_id) {
|
||||
active_config_ids.push(config_id.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let mut instances = Vec::with_capacity(active_config_ids.len());
|
||||
for config_id in active_config_ids {
|
||||
if let Some(info) = live_infos.remove(&config_id) {
|
||||
let snapshot = get_runtime_config_snapshot(&config_id);
|
||||
let display_name = snapshot
|
||||
.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,
|
||||
magic_dns_enabled,
|
||||
need_exit_node,
|
||||
info,
|
||||
));
|
||||
} else if let Some(snapshot) = get_runtime_config_snapshot(&config_id) {
|
||||
instances.push(runtime_instance_from_config_snapshot(
|
||||
config_id,
|
||||
snapshot.display_name,
|
||||
snapshot.config,
|
||||
true,
|
||||
));
|
||||
} else {
|
||||
let tun_attached = is_tun_attached(&config_id);
|
||||
instances.push(RuntimeInstanceState {
|
||||
config_id: config_id.clone(),
|
||||
instance_id: config_id.clone(),
|
||||
display_name: config_id.clone(),
|
||||
running: true,
|
||||
tun_required: tun_attached,
|
||||
tun_attached,
|
||||
magic_dns_enabled: false,
|
||||
need_exit_node: false,
|
||||
error_message: None,
|
||||
my_node_info: None,
|
||||
events: Vec::new(),
|
||||
routes: Vec::new(),
|
||||
peers: Vec::new(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
instances.sort_by(|a, b| {
|
||||
a.display_name
|
||||
.cmp(&b.display_name)
|
||||
.then_with(|| a.instance_id.cmp(&b.instance_id))
|
||||
});
|
||||
let attached_instance_ids = instances
|
||||
.iter()
|
||||
.filter(|instance| instance.tun_required)
|
||||
.map(|instance| instance.instance_id.clone())
|
||||
.collect::<Vec<_>>();
|
||||
let aggregated_routes = aggregate_requested_tun_routes(&instances);
|
||||
let running_instance_count =
|
||||
instances.iter().filter(|instance| instance.running).count() as i32;
|
||||
let tun_active = !attached_instance_ids.is_empty();
|
||||
|
||||
RuntimeAggregateState {
|
||||
instances,
|
||||
tun: TunAggregateState {
|
||||
active: tun_active,
|
||||
attached_instance_ids,
|
||||
aggregated_routes,
|
||||
dns_servers: vec![],
|
||||
need_rebuild: false,
|
||||
},
|
||||
running_instance_count,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
mod protocol;
|
||||
mod routing;
|
||||
mod socket_server;
|
||||
|
||||
pub(crate) use routing::aggregate_requested_tun_routes;
|
||||
pub use socket_server::{start_local_socket_server, stop_local_socket_server};
|
||||
@@ -0,0 +1,93 @@
|
||||
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(crate) struct TunRequestPayload {
|
||||
pub config_id: String,
|
||||
pub instance_id: String,
|
||||
pub display_name: String,
|
||||
pub virtual_ipv4: Option<String>,
|
||||
pub virtual_ipv4_cidr: Option<String>,
|
||||
pub aggregated_routes: Vec<String>,
|
||||
pub magic_dns_enabled: bool,
|
||||
pub need_exit_node: bool,
|
||||
}
|
||||
|
||||
pub(crate) fn send_local_socket_message(
|
||||
stream: &mut UnixStream,
|
||||
message_type: &str,
|
||||
payload_json: String,
|
||||
) -> std::io::Result<()> {
|
||||
let message = LocalSocketSyncMessage {
|
||||
message_type: message_type.to_string(),
|
||||
payload_json,
|
||||
};
|
||||
let mut raw = serde_json::to_vec(&message)
|
||||
.map_err(|err| Error::new(ErrorKind::InvalidData, err.to_string()))?;
|
||||
raw.push(b'\n');
|
||||
stream.write_all(&raw)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn shrink_clients_if_sparse(clients: &mut Vec<UnixStream>) {
|
||||
let sparse_limit = clients.len().saturating_mul(2).max(4);
|
||||
if clients.capacity() > sparse_limit {
|
||||
clients.shrink_to_fit();
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn broadcast_local_socket_message(
|
||||
clients: &mut Vec<UnixStream>,
|
||||
message_type: &str,
|
||||
payload_json: &str,
|
||||
) -> bool {
|
||||
let mut active_clients = Vec::with_capacity(clients.len());
|
||||
let mut delivered = false;
|
||||
for mut client in clients.drain(..) {
|
||||
if send_local_socket_message(&mut client, message_type, payload_json.to_string()).is_ok() {
|
||||
delivered = true;
|
||||
active_clients.push(client);
|
||||
}
|
||||
}
|
||||
shrink_clients_if_sparse(&mut active_clients);
|
||||
*clients = active_clients;
|
||||
delivered
|
||||
}
|
||||
|
||||
pub(crate) fn send_local_socket_json_payload_message(
|
||||
stream: &mut UnixStream,
|
||||
message_type: &str,
|
||||
payload_json: &str,
|
||||
) -> std::io::Result<()> {
|
||||
let message_type_json = serde_json::to_string(message_type)
|
||||
.map_err(|err| Error::new(ErrorKind::InvalidData, err.to_string()))?;
|
||||
let mut raw = Vec::with_capacity(message_type_json.len() + payload_json.len() + 38);
|
||||
raw.extend_from_slice(b"{\"messageType\":");
|
||||
raw.extend_from_slice(message_type_json.as_bytes());
|
||||
raw.extend_from_slice(b",\"payloadJson\":");
|
||||
raw.extend_from_slice(payload_json.as_bytes());
|
||||
raw.extend_from_slice(b"}\n");
|
||||
stream.write_all(&raw)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn broadcast_local_socket_json_payload_message(
|
||||
clients: &mut Vec<UnixStream>,
|
||||
message_type: &str,
|
||||
payload_json: &str,
|
||||
) -> bool {
|
||||
let mut active_clients = Vec::with_capacity(clients.len());
|
||||
let mut delivered = false;
|
||||
for mut client in clients.drain(..) {
|
||||
if send_local_socket_json_payload_message(&mut client, message_type, payload_json).is_ok() {
|
||||
delivered = true;
|
||||
active_clients.push(client);
|
||||
}
|
||||
}
|
||||
shrink_clients_if_sparse(&mut active_clients);
|
||||
*clients = active_clients;
|
||||
delivered
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
use crate::config::repository::get_runtime_config_route_overrides;
|
||||
use crate::runtime::state::runtime_state::RuntimeInstanceState;
|
||||
use ipnet::IpNet;
|
||||
use std::collections::HashSet;
|
||||
use std::net::IpAddr;
|
||||
|
||||
fn normalize_route_cidr(route: &str) -> Option<String> {
|
||||
let normalized = route.split("->").next().unwrap_or(route).trim();
|
||||
normalized
|
||||
.parse::<IpNet>()
|
||||
.ok()
|
||||
.map(|network| match network {
|
||||
IpNet::V4(net) => net.trunc().to_string(),
|
||||
IpNet::V6(net) => net.trunc().to_string(),
|
||||
})
|
||||
.or_else(|| {
|
||||
normalized.parse::<IpAddr>().ok().map(|addr| match addr {
|
||||
IpAddr::V4(ip) => format!("{}/32", ip),
|
||||
IpAddr::V6(ip) => format!("{}/128", ip),
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
fn simplify_routes(routes: Vec<String>) -> Vec<String> {
|
||||
let mut parsed = routes
|
||||
.into_iter()
|
||||
.filter_map(|route| normalize_route_cidr(&route))
|
||||
.filter_map(|route| route.parse::<IpNet>().ok())
|
||||
.collect::<Vec<_>>();
|
||||
parsed.sort_by(|left, right| {
|
||||
left.prefix_len()
|
||||
.cmp(&right.prefix_len())
|
||||
.then_with(|| left.network().to_string().cmp(&right.network().to_string()))
|
||||
});
|
||||
|
||||
let mut simplified = Vec::<IpNet>::new();
|
||||
'outer: for route in parsed {
|
||||
for existing in &simplified {
|
||||
if existing.contains(&route.network()) && existing.prefix_len() <= route.prefix_len() {
|
||||
continue 'outer;
|
||||
}
|
||||
}
|
||||
simplified.retain(|existing| {
|
||||
!(route.contains(&existing.network()) && route.prefix_len() <= existing.prefix_len())
|
||||
});
|
||||
simplified.push(route);
|
||||
}
|
||||
|
||||
let mut seen = HashSet::new();
|
||||
simplified
|
||||
.into_iter()
|
||||
.map(|route| route.to_string())
|
||||
.filter(|route| seen.insert(route.clone()))
|
||||
.collect()
|
||||
}
|
||||
|
||||
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()
|
||||
.flat_map(|route| route.proxy_cidrs.iter().cloned())
|
||||
.collect::<Vec<_>>();
|
||||
let mut raw_routes = Vec::new();
|
||||
|
||||
if let Some(cidr) = virtual_ipv4_cidr.clone() {
|
||||
raw_routes.push(cidr);
|
||||
}
|
||||
|
||||
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(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) {
|
||||
for route in aggregate_tun_routes(instance) {
|
||||
if seen_routes.insert(route.clone()) {
|
||||
aggregated_routes.push(route);
|
||||
}
|
||||
}
|
||||
}
|
||||
aggregated_routes
|
||||
}
|
||||
@@ -0,0 +1,580 @@
|
||||
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 once_cell::sync::Lazy;
|
||||
use serde::Serialize;
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::hash::Hash;
|
||||
use std::io::ErrorKind;
|
||||
use std::os::unix::net::{UnixListener, UnixStream};
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Mutex;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::thread::{self, JoinHandle};
|
||||
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
|
||||
|
||||
struct LocalSocketState {
|
||||
stop_flag: std::sync::Arc<AtomicBool>,
|
||||
socket_path: PathBuf,
|
||||
worker: JoinHandle<()>,
|
||||
}
|
||||
|
||||
static LOCAL_SOCKET_STATE: Lazy<Mutex<Option<LocalSocketState>>> = Lazy::new(|| Mutex::new(None));
|
||||
const SOCKET_TICK_INTERVAL: Duration = Duration::from_millis(250);
|
||||
const TRAFFIC_STATS_INTERVAL: Duration = Duration::from_secs(1);
|
||||
const INSTANCE_POLL_INTERVAL: Duration = Duration::from_secs(1);
|
||||
const TUN_FAST_CHECK_WINDOW: Duration = Duration::from_secs(8);
|
||||
const EVENT_RECEIVER_SYNC_INTERVAL: Duration = Duration::from_secs(1);
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct TrafficStatsPayload {
|
||||
sampled_at_ms: i64,
|
||||
instances: Vec<InstanceTrafficStats>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct InstanceTrafficStats {
|
||||
config_id: String,
|
||||
instance_id: String,
|
||||
rx_bytes: i64,
|
||||
tx_bytes: i64,
|
||||
peers: Vec<PeerTrafficStats>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct PeerTrafficStats {
|
||||
peer_id: i64,
|
||||
rx_bytes: i64,
|
||||
tx_bytes: i64,
|
||||
total_bytes: i64,
|
||||
latency_us: i64,
|
||||
loss_rate: f64,
|
||||
}
|
||||
|
||||
struct PendingPeerEvent {
|
||||
event: &'static str,
|
||||
instance_id: String,
|
||||
peer_id: i64,
|
||||
conn: Option<RuntimePeerConnInfo>,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct DrainedKernelEvents {
|
||||
tun_refresh: bool,
|
||||
topology_lost: bool,
|
||||
peer_events: Vec<PendingPeerEvent>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct RuntimePeerEventPayload {
|
||||
event: &'static str,
|
||||
config_id: String,
|
||||
instance_id: String,
|
||||
peer_id: i64,
|
||||
conn: Option<RuntimePeerConnInfo>,
|
||||
}
|
||||
|
||||
fn shrink_hash_map_if_sparse<K: Eq + Hash, V>(map: &mut HashMap<K, V>) {
|
||||
let sparse_limit = map.len().saturating_mul(2).max(8);
|
||||
if map.capacity() > sparse_limit {
|
||||
map.shrink_to_fit();
|
||||
}
|
||||
}
|
||||
|
||||
fn shrink_hash_set_if_sparse<T: Eq + Hash>(set: &mut HashSet<T>) {
|
||||
let sparse_limit = set.len().saturating_mul(2).max(8);
|
||||
if set.capacity() > sparse_limit {
|
||||
set.shrink_to_fit();
|
||||
}
|
||||
}
|
||||
|
||||
fn sync_tun_event_receivers(receivers: &mut HashMap<String, EventBusSubscriber>) {
|
||||
let mut active_instance_ids = HashSet::new();
|
||||
for instance in INSTANCE_MANAGER.instances() {
|
||||
let instance_id = instance.instance_id().to_string();
|
||||
active_instance_ids.insert(instance_id.clone());
|
||||
if !receivers.contains_key(&instance_id)
|
||||
&& let Some(receiver) = subscribe_native_instance_event(&instance)
|
||||
{
|
||||
receivers.insert(instance_id, receiver);
|
||||
}
|
||||
}
|
||||
receivers.retain(|instance_id, _| active_instance_ids.contains(instance_id));
|
||||
shrink_hash_map_if_sparse(receivers);
|
||||
}
|
||||
|
||||
fn event_needs_tun_refresh(event: &GlobalCtxEvent) -> bool {
|
||||
matches!(
|
||||
event,
|
||||
GlobalCtxEvent::DhcpIpv4Changed(_, _)
|
||||
| GlobalCtxEvent::ProxyCidrsUpdated(_, _)
|
||||
| GlobalCtxEvent::PublicIpv6RoutesUpdated(_, _)
|
||||
)
|
||||
}
|
||||
|
||||
fn drain_kernel_events(receivers: &mut HashMap<String, EventBusSubscriber>) -> DrainedKernelEvents {
|
||||
let mut drained = DrainedKernelEvents::default();
|
||||
let mut closed_receivers = Vec::new();
|
||||
for (instance_id, receiver) in receivers.iter_mut() {
|
||||
loop {
|
||||
match receiver.try_recv() {
|
||||
Ok(event) => {
|
||||
drained.tun_refresh = event_needs_tun_refresh(&event) || drained.tun_refresh;
|
||||
match event {
|
||||
GlobalCtxEvent::PeerAdded(peer_id) => {
|
||||
drained.peer_events.push(PendingPeerEvent {
|
||||
event: "peer_added",
|
||||
instance_id: instance_id.clone(),
|
||||
peer_id: peer_id as i64,
|
||||
conn: None,
|
||||
});
|
||||
}
|
||||
GlobalCtxEvent::PeerRemoved(peer_id) => {
|
||||
drained.peer_events.push(PendingPeerEvent {
|
||||
event: "peer_removed",
|
||||
instance_id: instance_id.clone(),
|
||||
peer_id: peer_id as i64,
|
||||
conn: None,
|
||||
});
|
||||
}
|
||||
GlobalCtxEvent::PeerConnAdded(conn_info) => {
|
||||
let peer_id = conn_info.peer_id as i64;
|
||||
drained.peer_events.push(PendingPeerEvent {
|
||||
event: "peer_conn_added",
|
||||
instance_id: instance_id.clone(),
|
||||
peer_id,
|
||||
conn: Some(peer_conn_to_view(conn_info)),
|
||||
});
|
||||
}
|
||||
GlobalCtxEvent::PeerConnRemoved(conn_info) => {
|
||||
let peer_id = conn_info.peer_id as i64;
|
||||
drained.peer_events.push(PendingPeerEvent {
|
||||
event: "peer_conn_removed",
|
||||
instance_id: instance_id.clone(),
|
||||
peer_id,
|
||||
conn: Some(peer_conn_to_view(conn_info)),
|
||||
});
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
Err(tokio::sync::broadcast::error::TryRecvError::Empty) => break,
|
||||
Err(tokio::sync::broadcast::error::TryRecvError::Lagged(_)) => {
|
||||
drained.topology_lost = true;
|
||||
continue;
|
||||
}
|
||||
Err(tokio::sync::broadcast::error::TryRecvError::Closed) => {
|
||||
closed_receivers.push(instance_id.clone());
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
for instance_id in closed_receivers {
|
||||
receivers.remove(&instance_id);
|
||||
}
|
||||
drained
|
||||
}
|
||||
|
||||
fn broadcast_runtime_peer_events(
|
||||
clients: &mut Vec<UnixStream>,
|
||||
peer_events: Vec<PendingPeerEvent>,
|
||||
) {
|
||||
for event in peer_events {
|
||||
let payload = RuntimePeerEventPayload {
|
||||
event: event.event,
|
||||
config_id: event.instance_id.clone(),
|
||||
instance_id: event.instance_id,
|
||||
peer_id: event.peer_id,
|
||||
conn: event.conn,
|
||||
};
|
||||
match serde_json::to_string(&payload) {
|
||||
Ok(json) => {
|
||||
let _ = broadcast_local_socket_json_payload_message(
|
||||
clients,
|
||||
"runtime_peer_event",
|
||||
&json,
|
||||
);
|
||||
}
|
||||
Err(err) => {
|
||||
ohrs_log_error!("[Rust] serialize runtime peer event failed: {}", err);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn tun_candidate_ids(snapshot: &RuntimeAggregateState) -> HashSet<String> {
|
||||
snapshot
|
||||
.instances
|
||||
.iter()
|
||||
.filter(|instance| instance.running && instance.tun_required)
|
||||
.map(|instance| instance.instance_id.clone())
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn collect_traffic_stats(sampled_at_ms: i64) -> TrafficStatsPayload {
|
||||
let running_instances = INSTANCE_MANAGER
|
||||
.instances()
|
||||
.into_iter()
|
||||
.filter(|instance| instance.is_ready())
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
let instances = ASYNC_RUNTIME.block_on(async {
|
||||
let mut instances = Vec::new();
|
||||
for instance in running_instances {
|
||||
let instance_id = instance.instance_id().to_string();
|
||||
let peers = instance.peer_snapshots().await;
|
||||
|
||||
let mut instance_rx_bytes = 0i64;
|
||||
let mut instance_tx_bytes = 0i64;
|
||||
let mut peer_stats = Vec::with_capacity(peers.len());
|
||||
|
||||
for peer in peers {
|
||||
let mut peer_rx_bytes = 0i64;
|
||||
let mut peer_tx_bytes = 0i64;
|
||||
let mut latency_us = i64::MAX;
|
||||
let mut loss_rate = 0f64;
|
||||
|
||||
for conn in peer.conns {
|
||||
if let Some(stats) = conn.stats {
|
||||
let rx_bytes = stats.rx_bytes as i64;
|
||||
let tx_bytes = stats.tx_bytes as i64;
|
||||
peer_rx_bytes += rx_bytes;
|
||||
peer_tx_bytes += tx_bytes;
|
||||
latency_us = latency_us.min(stats.latency_us as i64);
|
||||
}
|
||||
loss_rate = loss_rate.max(conn.loss_rate as f64);
|
||||
}
|
||||
|
||||
instance_rx_bytes += peer_rx_bytes;
|
||||
instance_tx_bytes += peer_tx_bytes;
|
||||
peer_stats.push(PeerTrafficStats {
|
||||
peer_id: peer.peer_id as i64,
|
||||
rx_bytes: peer_rx_bytes,
|
||||
tx_bytes: peer_tx_bytes,
|
||||
total_bytes: peer_rx_bytes + peer_tx_bytes,
|
||||
latency_us: if latency_us == i64::MAX {
|
||||
-1
|
||||
} else {
|
||||
latency_us
|
||||
},
|
||||
loss_rate,
|
||||
});
|
||||
}
|
||||
|
||||
instances.push(InstanceTrafficStats {
|
||||
config_id: instance_id.clone(),
|
||||
instance_id,
|
||||
rx_bytes: instance_rx_bytes,
|
||||
tx_bytes: instance_tx_bytes,
|
||||
peers: peer_stats,
|
||||
});
|
||||
}
|
||||
instances
|
||||
});
|
||||
|
||||
TrafficStatsPayload {
|
||||
sampled_at_ms,
|
||||
instances,
|
||||
}
|
||||
}
|
||||
|
||||
fn unix_time_millis() -> i64 {
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map(|duration| duration.as_millis().min(i64::MAX as u128) as i64)
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
pub fn start_local_socket_server() -> bool {
|
||||
let socket_path = match kernel_socket_path() {
|
||||
Some(path) => path,
|
||||
None => {
|
||||
ohrs_log_error!("[Rust] kernel socket path unavailable");
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
match LOCAL_SOCKET_STATE.lock() {
|
||||
Ok(guard) if guard.is_some() => return true,
|
||||
Ok(_) => {}
|
||||
Err(err) => {
|
||||
ohrs_log_error!("[Rust] lock localsocket state failed: {}", err);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if socket_path.exists() {
|
||||
let _ = std::fs::remove_file(&socket_path);
|
||||
}
|
||||
|
||||
let listener = match UnixListener::bind(&socket_path) {
|
||||
Ok(listener) => listener,
|
||||
Err(err) => {
|
||||
ohrs_log_error!(
|
||||
"[Rust] bind localsocket failed {}: {}",
|
||||
socket_path.display(),
|
||||
err
|
||||
);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
if let Err(err) = listener.set_nonblocking(true) {
|
||||
ohrs_log_error!("[Rust] set localsocket nonblocking failed: {}", err);
|
||||
let _ = std::fs::remove_file(&socket_path);
|
||||
return false;
|
||||
}
|
||||
|
||||
let stop_flag = std::sync::Arc::new(AtomicBool::new(false));
|
||||
let worker_stop_flag = stop_flag.clone();
|
||||
let worker = thread::spawn(move || {
|
||||
let mut last_topology_json = String::new();
|
||||
let mut delivered_tun_requests = HashSet::new();
|
||||
let mut last_tun_route_signatures = HashMap::<String, String>::new();
|
||||
let mut tun_fast_until = Instant::now() + TUN_FAST_CHECK_WINDOW;
|
||||
let mut tun_bootstrap_done = false;
|
||||
let mut last_event_receiver_sync_at: Option<Instant> = None;
|
||||
let mut last_traffic_stats_at: Option<Instant> = None;
|
||||
let mut last_instance_poll_at: Option<Instant> = None;
|
||||
let mut tun_event_receivers = HashMap::<String, EventBusSubscriber>::new();
|
||||
let mut clients = Vec::<UnixStream>::new();
|
||||
|
||||
while !worker_stop_flag.load(Ordering::Relaxed) {
|
||||
let mut full_topology_dirty = false;
|
||||
let mut accepted_client = false;
|
||||
loop {
|
||||
match listener.accept() {
|
||||
Ok((stream, _addr)) => {
|
||||
accepted_client = true;
|
||||
full_topology_dirty = true;
|
||||
clients.push(stream);
|
||||
tun_fast_until = Instant::now() + TUN_FAST_CHECK_WINDOW;
|
||||
tun_bootstrap_done = false;
|
||||
}
|
||||
Err(err) if err.kind() == ErrorKind::WouldBlock => break,
|
||||
Err(err) => {
|
||||
ohrs_log_error!("[Rust] accept localsocket failed: {}", err);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if clients.is_empty() {
|
||||
if !last_topology_json.is_empty() {
|
||||
last_topology_json.clear();
|
||||
last_topology_json.shrink_to_fit();
|
||||
}
|
||||
delivered_tun_requests.clear();
|
||||
shrink_hash_set_if_sparse(&mut delivered_tun_requests);
|
||||
last_tun_route_signatures.clear();
|
||||
shrink_hash_map_if_sparse(&mut last_tun_route_signatures);
|
||||
tun_event_receivers.clear();
|
||||
shrink_hash_map_if_sparse(&mut tun_event_receivers);
|
||||
clients.shrink_to_fit();
|
||||
last_event_receiver_sync_at = None;
|
||||
last_traffic_stats_at = None;
|
||||
last_instance_poll_at = None;
|
||||
tun_bootstrap_done = false;
|
||||
thread::sleep(SOCKET_TICK_INTERVAL);
|
||||
continue;
|
||||
}
|
||||
|
||||
let now = Instant::now();
|
||||
let should_sync_event_receivers = accepted_client
|
||||
|| last_event_receiver_sync_at
|
||||
.map(|last| now.duration_since(last) >= EVENT_RECEIVER_SYNC_INTERVAL)
|
||||
.unwrap_or(true);
|
||||
if should_sync_event_receivers {
|
||||
sync_tun_event_receivers(&mut tun_event_receivers);
|
||||
last_event_receiver_sync_at = Some(now);
|
||||
}
|
||||
let drained_events = drain_kernel_events(&mut tun_event_receivers);
|
||||
let tun_refresh = drained_events.tun_refresh;
|
||||
let topology_lost = drained_events.topology_lost;
|
||||
let peer_events = drained_events.peer_events;
|
||||
if topology_lost {
|
||||
full_topology_dirty = true;
|
||||
}
|
||||
if tun_refresh {
|
||||
tun_bootstrap_done = false;
|
||||
tun_fast_until = now + TUN_FAST_CHECK_WINDOW;
|
||||
}
|
||||
if !peer_events.is_empty() {
|
||||
broadcast_runtime_peer_events(&mut clients, peer_events);
|
||||
}
|
||||
let should_collect_traffic_stats = last_traffic_stats_at
|
||||
.map(|last| now.duration_since(last) >= TRAFFIC_STATS_INTERVAL)
|
||||
.unwrap_or(true);
|
||||
if should_collect_traffic_stats {
|
||||
last_traffic_stats_at = Some(now);
|
||||
match serde_json::to_string(&collect_traffic_stats(unix_time_millis())) {
|
||||
Ok(json) => {
|
||||
let _ = broadcast_local_socket_json_payload_message(
|
||||
&mut clients,
|
||||
"traffic_stats",
|
||||
&json,
|
||||
);
|
||||
}
|
||||
Err(err) => {
|
||||
ohrs_log_error!("[Rust] serialize traffic stats failed: {}", err);
|
||||
}
|
||||
}
|
||||
}
|
||||
let should_poll_instance = last_instance_poll_at
|
||||
.map(|last| now.duration_since(last) >= INSTANCE_POLL_INTERVAL)
|
||||
.unwrap_or(true);
|
||||
let should_collect_topology = accepted_client
|
||||
|| full_topology_dirty
|
||||
|| tun_refresh
|
||||
|| should_poll_instance
|
||||
|| (!tun_bootstrap_done && now < tun_fast_until);
|
||||
if !should_collect_topology {
|
||||
thread::sleep(SOCKET_TICK_INTERVAL);
|
||||
continue;
|
||||
}
|
||||
|
||||
let snapshot = collect_runtime_state_inner();
|
||||
last_instance_poll_at = Some(now);
|
||||
match serde_json::to_string(&snapshot) {
|
||||
Ok(json) => {
|
||||
if accepted_client || full_topology_dirty || json != last_topology_json {
|
||||
let _ = broadcast_local_socket_json_payload_message(
|
||||
&mut clients,
|
||||
"runtime_topology",
|
||||
&json,
|
||||
);
|
||||
last_topology_json = json;
|
||||
}
|
||||
}
|
||||
Err(err) => {
|
||||
ohrs_log_error!("[Rust] serialize runtime topology failed: {}", err);
|
||||
}
|
||||
}
|
||||
|
||||
let active_tun_candidate_ids = tun_candidate_ids(&snapshot);
|
||||
delivered_tun_requests
|
||||
.retain(|instance_id| active_tun_candidate_ids.contains(instance_id));
|
||||
last_tun_route_signatures
|
||||
.retain(|instance_id, _| active_tun_candidate_ids.contains(instance_id));
|
||||
shrink_hash_set_if_sparse(&mut delivered_tun_requests);
|
||||
shrink_hash_map_if_sparse(&mut last_tun_route_signatures);
|
||||
let mut saw_running_instance = false;
|
||||
let mut saw_tun_candidate = false;
|
||||
for instance in snapshot.instances.iter() {
|
||||
if instance.running {
|
||||
saw_running_instance = true;
|
||||
}
|
||||
if !(instance.running && instance.tun_required) {
|
||||
continue;
|
||||
}
|
||||
|
||||
saw_tun_candidate = true;
|
||||
let virtual_ipv4 = instance
|
||||
.my_node_info
|
||||
.as_ref()
|
||||
.and_then(|info| info.virtual_ipv4.clone());
|
||||
let virtual_ipv4_cidr = instance
|
||||
.my_node_info
|
||||
.as_ref()
|
||||
.and_then(|info| info.virtual_ipv4_cidr.clone());
|
||||
if clients.is_empty() {
|
||||
continue;
|
||||
}
|
||||
if virtual_ipv4.is_none() || virtual_ipv4_cidr.is_none() {
|
||||
continue;
|
||||
}
|
||||
let aggregated_routes = aggregate_tun_routes(instance);
|
||||
let route_signature = serde_json::to_string(&(
|
||||
&virtual_ipv4,
|
||||
&virtual_ipv4_cidr,
|
||||
&aggregated_routes,
|
||||
instance.magic_dns_enabled,
|
||||
instance.need_exit_node,
|
||||
))
|
||||
.unwrap_or_else(|_| "[]".to_string());
|
||||
let should_send = !delivered_tun_requests.contains(&instance.instance_id)
|
||||
|| last_tun_route_signatures
|
||||
.get(&instance.instance_id)
|
||||
.map(|value| value != &route_signature)
|
||||
.unwrap_or(true);
|
||||
if !should_send {
|
||||
continue;
|
||||
}
|
||||
let payload = TunRequestPayload {
|
||||
config_id: instance.config_id.clone(),
|
||||
instance_id: instance.instance_id.clone(),
|
||||
display_name: instance.display_name.clone(),
|
||||
virtual_ipv4,
|
||||
virtual_ipv4_cidr,
|
||||
aggregated_routes,
|
||||
magic_dns_enabled: instance.magic_dns_enabled,
|
||||
need_exit_node: instance.need_exit_node,
|
||||
};
|
||||
let payload_json = match serde_json::to_string(&payload) {
|
||||
Ok(json) => json,
|
||||
Err(err) => {
|
||||
ohrs_log_error!("[Rust] serialize tun request failed: {}", err);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
if broadcast_local_socket_message(&mut clients, "tun_request", &payload_json) {
|
||||
delivered_tun_requests.insert(instance.instance_id.clone());
|
||||
last_tun_route_signatures.insert(instance.instance_id.clone(), route_signature);
|
||||
}
|
||||
}
|
||||
if !delivered_tun_requests.is_empty()
|
||||
|| (saw_running_instance && !saw_tun_candidate)
|
||||
|| now >= tun_fast_until
|
||||
{
|
||||
tun_bootstrap_done = true;
|
||||
}
|
||||
|
||||
thread::sleep(SOCKET_TICK_INTERVAL);
|
||||
}
|
||||
});
|
||||
|
||||
match LOCAL_SOCKET_STATE.lock() {
|
||||
Ok(mut guard) => {
|
||||
*guard = Some(LocalSocketState {
|
||||
stop_flag,
|
||||
socket_path,
|
||||
worker,
|
||||
});
|
||||
true
|
||||
}
|
||||
Err(err) => {
|
||||
ohrs_log_error!("[Rust] lock localsocket state failed: {}", err);
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn stop_local_socket_server() -> bool {
|
||||
let state = match LOCAL_SOCKET_STATE.lock() {
|
||||
Ok(mut guard) => guard.take(),
|
||||
Err(err) => {
|
||||
ohrs_log_error!("[Rust] lock localsocket state failed: {}", err);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
if let Some(state) = state {
|
||||
state.stop_flag.store(true, Ordering::Relaxed);
|
||||
let _ = state.worker.join();
|
||||
let _ = std::fs::remove_file(state.socket_path);
|
||||
}
|
||||
true
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1 @@
|
||||
pub(crate) mod logging;
|
||||
@@ -0,0 +1,393 @@
|
||||
use napi_derive_ohos::napi;
|
||||
use once_cell::sync::Lazy;
|
||||
use std::collections::VecDeque;
|
||||
use std::fs::{self, Metadata, OpenOptions};
|
||||
use std::io::Write;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Mutex;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
const LOG_DIR_NAME: &str = "easytier-logs";
|
||||
const LOG_FILE_PREFIX: &str = "easytier-";
|
||||
const LOG_FILE_SUFFIX: &str = ".log";
|
||||
const MAX_LOG_FILES: usize = 10;
|
||||
const MAX_MEMORY_LINES: usize = 500;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
#[napi(object)]
|
||||
pub struct LogFileInfo {
|
||||
pub file_name: String,
|
||||
pub display_name: String,
|
||||
pub size_bytes: i64,
|
||||
pub modified_ms: i64,
|
||||
pub active: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct LogOptions {
|
||||
core_log: bool,
|
||||
debug_log: bool,
|
||||
}
|
||||
|
||||
impl Default for LogOptions {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
core_log: false,
|
||||
debug_log: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct LogManagerState {
|
||||
log_dir: Option<PathBuf>,
|
||||
active_file: Option<PathBuf>,
|
||||
lines: VecDeque<String>,
|
||||
options: LogOptions,
|
||||
}
|
||||
|
||||
static LOG_MANAGER: Lazy<Mutex<LogManagerState>> =
|
||||
Lazy::new(|| Mutex::new(LogManagerState::default()));
|
||||
static CORE_LOG_ENABLED: AtomicBool = AtomicBool::new(false);
|
||||
static DEBUG_LOG_ENABLED: AtomicBool = AtomicBool::new(false);
|
||||
|
||||
fn now_millis() -> u128 {
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map(|duration| duration.as_millis())
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
fn sanitize_name(raw: &str) -> String {
|
||||
let value = raw
|
||||
.chars()
|
||||
.map(|ch| {
|
||||
if ch.is_ascii_alphanumeric() || ch == '-' || ch == '_' {
|
||||
ch
|
||||
} else {
|
||||
'-'
|
||||
}
|
||||
})
|
||||
.collect::<String>();
|
||||
if value.is_empty() {
|
||||
"process".to_string()
|
||||
} else {
|
||||
value
|
||||
}
|
||||
}
|
||||
|
||||
fn log_dir(root_dir: &str) -> PathBuf {
|
||||
Path::new(root_dir).join(LOG_DIR_NAME)
|
||||
}
|
||||
|
||||
fn is_log_file(path: &Path) -> bool {
|
||||
path.file_name()
|
||||
.and_then(|name| name.to_str())
|
||||
.map(|name| name.starts_with(LOG_FILE_PREFIX) && name.ends_with(LOG_FILE_SUFFIX))
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
fn sorted_log_files(dir: &Path) -> Vec<PathBuf> {
|
||||
let mut files = fs::read_dir(dir)
|
||||
.ok()
|
||||
.into_iter()
|
||||
.flat_map(|entries| entries.filter_map(|entry| entry.ok()))
|
||||
.map(|entry| entry.path())
|
||||
.filter(|path| is_log_file(path))
|
||||
.collect::<Vec<_>>();
|
||||
files.sort_by(|left, right| {
|
||||
left.file_name()
|
||||
.and_then(|name| name.to_str())
|
||||
.unwrap_or_default()
|
||||
.cmp(
|
||||
right
|
||||
.file_name()
|
||||
.and_then(|name| name.to_str())
|
||||
.unwrap_or_default(),
|
||||
)
|
||||
});
|
||||
files
|
||||
}
|
||||
|
||||
fn current_log_state() -> Option<(PathBuf, Option<PathBuf>)> {
|
||||
LOG_MANAGER.lock().ok().and_then(|guard| {
|
||||
guard
|
||||
.log_dir
|
||||
.clone()
|
||||
.map(|dir| (dir, guard.active_file.clone()))
|
||||
})
|
||||
}
|
||||
|
||||
fn file_name(path: &Path) -> Option<String> {
|
||||
path.file_name()
|
||||
.and_then(|value| value.to_str())
|
||||
.map(|value| value.to_string())
|
||||
}
|
||||
|
||||
fn latest_process_log_file(dir: &Path, process_name: &str) -> Option<PathBuf> {
|
||||
let suffix = format!("-{}{}", sanitize_name(process_name), LOG_FILE_SUFFIX);
|
||||
sorted_log_files(dir).into_iter().rev().find(|path| {
|
||||
path.file_name()
|
||||
.and_then(|value| value.to_str())
|
||||
.map(|value| value.ends_with(&suffix))
|
||||
.unwrap_or(false)
|
||||
})
|
||||
}
|
||||
|
||||
fn modified_millis(metadata: &Metadata) -> i64 {
|
||||
metadata
|
||||
.modified()
|
||||
.ok()
|
||||
.and_then(|time| time.duration_since(UNIX_EPOCH).ok())
|
||||
.map(|duration| duration.as_millis().min(i64::MAX as u128) as i64)
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
fn resolve_log_file(dir: &Path, requested_name: &str) -> Option<PathBuf> {
|
||||
if requested_name.contains('/')
|
||||
|| requested_name.contains('\\')
|
||||
|| requested_name.contains("..")
|
||||
{
|
||||
return None;
|
||||
}
|
||||
sorted_log_files(dir).into_iter().find(|path| {
|
||||
path.file_name()
|
||||
.and_then(|value| value.to_str())
|
||||
.map(|value| value == requested_name)
|
||||
.unwrap_or(false)
|
||||
})
|
||||
}
|
||||
|
||||
fn cleanup_old_logs(dir: &Path) {
|
||||
let files = sorted_log_files(dir);
|
||||
let overflow = files.len().saturating_sub(MAX_LOG_FILES);
|
||||
for path in files.into_iter().take(overflow) {
|
||||
let _ = fs::remove_file(path);
|
||||
}
|
||||
}
|
||||
|
||||
fn push_memory_line(state: &mut LogManagerState, line: String) {
|
||||
state.lines.push_back(line);
|
||||
while state.lines.len() > MAX_MEMORY_LINES {
|
||||
state.lines.pop_front();
|
||||
}
|
||||
}
|
||||
|
||||
fn append_log_file(path: &Path, line: &str) {
|
||||
if let Ok(mut file) = OpenOptions::new().create(true).append(true).open(path) {
|
||||
let _ = writeln!(file, "{}", line);
|
||||
}
|
||||
}
|
||||
|
||||
fn should_record_debug(level: i32) -> bool {
|
||||
level <= 3
|
||||
}
|
||||
|
||||
fn format_line(level: i32, target: &str, message: &str) -> String {
|
||||
format!("{}[{}] {}", level, target, message.replace('\n', "\\n"))
|
||||
}
|
||||
|
||||
pub(crate) fn configure(core_log: bool, debug_log: bool) {
|
||||
CORE_LOG_ENABLED.store(core_log, Ordering::Relaxed);
|
||||
DEBUG_LOG_ENABLED.store(debug_log, Ordering::Relaxed);
|
||||
if let Ok(mut guard) = LOG_MANAGER.lock() {
|
||||
guard.options.core_log = core_log;
|
||||
guard.options.debug_log = debug_log;
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn app_log_enabled(level: i32) -> bool {
|
||||
!should_record_debug(level) || DEBUG_LOG_ENABLED.load(Ordering::Relaxed)
|
||||
}
|
||||
|
||||
pub(crate) fn core_log_enabled(level: i32) -> bool {
|
||||
CORE_LOG_ENABLED.load(Ordering::Relaxed) && app_log_enabled(level)
|
||||
}
|
||||
|
||||
pub(crate) fn record_app_log(level: i32, target: &str, message: &str) {
|
||||
if !app_log_enabled(level) {
|
||||
return;
|
||||
}
|
||||
if let Ok(mut guard) = LOG_MANAGER.lock() {
|
||||
let line = format_line(level, target, message);
|
||||
if let Some(path) = guard.active_file.as_ref() {
|
||||
append_log_file(path, &line);
|
||||
}
|
||||
push_memory_line(&mut guard, line);
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn record_core_log(level: i32, target: &str, message: &str) {
|
||||
if !core_log_enabled(level) {
|
||||
return;
|
||||
}
|
||||
if let Ok(mut guard) = LOG_MANAGER.lock() {
|
||||
let line = format_line(level, target, message);
|
||||
if let Some(path) = guard.active_file.as_ref() {
|
||||
append_log_file(path, &line);
|
||||
}
|
||||
push_memory_line(&mut guard, line);
|
||||
}
|
||||
}
|
||||
|
||||
#[napi]
|
||||
pub fn init_log_manager(root_dir: String, process_name: String) -> bool {
|
||||
let dir = log_dir(&root_dir);
|
||||
if fs::create_dir_all(&dir).is_err() {
|
||||
return false;
|
||||
}
|
||||
if LOG_MANAGER
|
||||
.lock()
|
||||
.map(|guard| guard.active_file.is_some())
|
||||
.unwrap_or(false)
|
||||
{
|
||||
cleanup_old_logs(&dir);
|
||||
return true;
|
||||
}
|
||||
|
||||
let sanitized_process_name = sanitize_name(&process_name);
|
||||
let active_file = if sanitized_process_name == "ui" {
|
||||
dir.join(format!(
|
||||
"{}{}-{}-{}{}",
|
||||
LOG_FILE_PREFIX,
|
||||
now_millis(),
|
||||
std::process::id(),
|
||||
sanitized_process_name,
|
||||
LOG_FILE_SUFFIX
|
||||
))
|
||||
} else if let Some(path) = latest_process_log_file(&dir, "ui") {
|
||||
path
|
||||
} else {
|
||||
dir.join(format!(
|
||||
"{}{}-{}-{}{}",
|
||||
LOG_FILE_PREFIX,
|
||||
now_millis(),
|
||||
std::process::id(),
|
||||
sanitized_process_name,
|
||||
LOG_FILE_SUFFIX
|
||||
))
|
||||
};
|
||||
if OpenOptions::new()
|
||||
.create(true)
|
||||
.append(true)
|
||||
.open(&active_file)
|
||||
.is_err()
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if let Ok(mut guard) = LOG_MANAGER.lock() {
|
||||
guard.log_dir = Some(dir.clone());
|
||||
guard.active_file = Some(active_file);
|
||||
guard.lines.clear();
|
||||
}
|
||||
cleanup_old_logs(&dir);
|
||||
true
|
||||
}
|
||||
|
||||
#[napi]
|
||||
pub fn configure_log_manager(core_log: bool, debug_log: bool) {
|
||||
configure(core_log, debug_log);
|
||||
}
|
||||
|
||||
#[napi]
|
||||
pub fn write_app_log(level: i32, target: String, message: String) {
|
||||
record_app_log(level, &target, &message);
|
||||
}
|
||||
|
||||
#[napi]
|
||||
pub fn drain_log_lines() -> Vec<String> {
|
||||
LOG_MANAGER
|
||||
.lock()
|
||||
.map(|mut guard| guard.lines.drain(..).collect())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
#[napi]
|
||||
pub fn list_log_files() -> Vec<LogFileInfo> {
|
||||
let Some((log_dir, active_file)) = current_log_state() else {
|
||||
return Vec::new();
|
||||
};
|
||||
|
||||
let active_name = active_file.as_ref().and_then(|path| file_name(path));
|
||||
let mut files = sorted_log_files(&log_dir);
|
||||
files.reverse();
|
||||
files
|
||||
.into_iter()
|
||||
.filter_map(|path| {
|
||||
let file_name = file_name(&path)?;
|
||||
let active = active_name
|
||||
.as_ref()
|
||||
.map(|name| name == &file_name)
|
||||
.unwrap_or(false);
|
||||
let metadata = fs::metadata(&path).ok();
|
||||
Some(LogFileInfo {
|
||||
file_name,
|
||||
display_name: if active {
|
||||
"当前启动日志".to_string()
|
||||
} else {
|
||||
"历史日志".to_string()
|
||||
},
|
||||
size_bytes: metadata
|
||||
.as_ref()
|
||||
.map(|value| value.len().min(i64::MAX as u64) as i64)
|
||||
.unwrap_or(0),
|
||||
modified_ms: metadata.as_ref().map(modified_millis).unwrap_or_default(),
|
||||
active,
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[napi]
|
||||
pub fn read_log_file(file_name: String) -> Option<String> {
|
||||
let (log_dir, _) = current_log_state()?;
|
||||
let path = resolve_log_file(&log_dir, &file_name)?;
|
||||
fs::read_to_string(path).ok()
|
||||
}
|
||||
|
||||
#[napi]
|
||||
pub fn export_log_file(file_name: String, target_path: String) -> bool {
|
||||
let Some((log_dir, _)) = current_log_state() else {
|
||||
return false;
|
||||
};
|
||||
let Some(path) = resolve_log_file(&log_dir, &file_name) else {
|
||||
return false;
|
||||
};
|
||||
fs::copy(path, target_path).is_ok()
|
||||
}
|
||||
|
||||
#[napi]
|
||||
pub fn export_log_archive(target_path: String) -> bool {
|
||||
let log_dir = LOG_MANAGER
|
||||
.lock()
|
||||
.ok()
|
||||
.and_then(|guard| guard.log_dir.clone());
|
||||
let Some(log_dir) = log_dir else {
|
||||
return false;
|
||||
};
|
||||
|
||||
let files = sorted_log_files(&log_dir);
|
||||
let mut output = match OpenOptions::new()
|
||||
.create(true)
|
||||
.write(true)
|
||||
.truncate(true)
|
||||
.open(&target_path)
|
||||
{
|
||||
Ok(file) => file,
|
||||
Err(_) => return false,
|
||||
};
|
||||
|
||||
for path in files {
|
||||
let name = path
|
||||
.file_name()
|
||||
.and_then(|value| value.to_str())
|
||||
.unwrap_or("unknown.log");
|
||||
let _ = writeln!(output, "===== {} =====", name);
|
||||
if let Ok(content) = fs::read_to_string(&path) {
|
||||
let _ = writeln!(output, "{}", content);
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
pub(crate) mod log_manager;
|
||||
pub(crate) mod native_log;
|
||||
+38
-34
@@ -1,7 +1,5 @@
|
||||
use super::log_manager;
|
||||
use napi_derive_ohos::napi;
|
||||
use ohos_hilog_binding::{
|
||||
LogOptions, hilog_debug, hilog_error, hilog_info, hilog_warn, set_global_options,
|
||||
};
|
||||
use std::collections::HashMap;
|
||||
use std::panic;
|
||||
use tracing::{Event, Subscriber};
|
||||
@@ -10,8 +8,9 @@ use tracing_subscriber::layer::{Context, Layer};
|
||||
use tracing_subscriber::prelude::*;
|
||||
|
||||
static INITIALIZED: std::sync::Once = std::sync::Once::new();
|
||||
static TRACING_INITIALIZED: std::sync::Once = std::sync::Once::new();
|
||||
fn panic_hook(info: &panic::PanicHookInfo) {
|
||||
hilog_error!("RUST PANIC: {}", info);
|
||||
log_manager::record_core_log(5, "RustPanic", &format!("{}", info));
|
||||
}
|
||||
|
||||
#[napi]
|
||||
@@ -23,45 +22,40 @@ pub fn init_panic_hook() {
|
||||
|
||||
#[napi]
|
||||
pub fn hilog_global_options(domain: u32, tag: String) {
|
||||
ohos_hilog_binding::forward_stdio_to_hilog();
|
||||
set_global_options(LogOptions {
|
||||
domain,
|
||||
tag: Box::leak(tag.clone().into_boxed_str()),
|
||||
})
|
||||
let _ = domain;
|
||||
let _ = tag;
|
||||
}
|
||||
|
||||
#[napi]
|
||||
pub fn init_tracing_subscriber() {
|
||||
tracing_subscriber::registry()
|
||||
.with(CallbackLayer {
|
||||
callback: Box::new(tracing_callback),
|
||||
})
|
||||
.init();
|
||||
TRACING_INITIALIZED.call_once(|| {
|
||||
let _ = tracing_subscriber::registry()
|
||||
.with(CallbackLayer {
|
||||
callback: Box::new(tracing_callback),
|
||||
})
|
||||
.try_init();
|
||||
});
|
||||
}
|
||||
|
||||
fn tracing_callback(event: &Event, fields: HashMap<String, String>) {
|
||||
let metadata = event.metadata();
|
||||
#[cfg(target_env = "ohos")]
|
||||
{
|
||||
let loc = metadata.target().split("::").last().unwrap();
|
||||
match *metadata.level() {
|
||||
Level::TRACE => {
|
||||
hilog_debug!("[{}] {:?}", loc, fields.values().collect::<Vec<_>>());
|
||||
}
|
||||
Level::DEBUG => {
|
||||
hilog_debug!("[{}] {:?}", loc, fields.values().collect::<Vec<_>>());
|
||||
}
|
||||
Level::INFO => {
|
||||
hilog_info!("[{}] {:?}", loc, fields.values().collect::<Vec<_>>());
|
||||
}
|
||||
Level::WARN => {
|
||||
hilog_warn!("[{}] {:?}", loc, fields.values().collect::<Vec<_>>());
|
||||
}
|
||||
Level::ERROR => {
|
||||
hilog_error!("[{}] {:?}", loc, fields.values().collect::<Vec<_>>());
|
||||
}
|
||||
}
|
||||
let loc = metadata
|
||||
.target()
|
||||
.split("::")
|
||||
.last()
|
||||
.unwrap_or(metadata.target());
|
||||
let level = match *metadata.level() {
|
||||
Level::TRACE => 2,
|
||||
Level::DEBUG => 3,
|
||||
Level::INFO => 4,
|
||||
Level::WARN => 6,
|
||||
Level::ERROR => 5,
|
||||
};
|
||||
if !log_manager::core_log_enabled(level) {
|
||||
return;
|
||||
}
|
||||
let values = fields.values().cloned().collect::<Vec<_>>().join(" ");
|
||||
log_manager::record_core_log(level, &format!("Rust:{}", loc), &values);
|
||||
}
|
||||
|
||||
struct CallbackLayer {
|
||||
@@ -70,6 +64,16 @@ struct CallbackLayer {
|
||||
|
||||
impl<S: Subscriber> Layer<S> for CallbackLayer {
|
||||
fn on_event(&self, event: &Event, _ctx: Context<S>) {
|
||||
let level = match *event.metadata().level() {
|
||||
Level::TRACE => 2,
|
||||
Level::DEBUG => 3,
|
||||
Level::INFO => 4,
|
||||
Level::WARN => 6,
|
||||
Level::ERROR => 5,
|
||||
};
|
||||
if !log_manager::core_log_enabled(level) {
|
||||
return;
|
||||
}
|
||||
// 使用 fmt::format::FmtSpan 提取字段值
|
||||
let mut fields = HashMap::new();
|
||||
let mut visitor = FieldCollector(&mut fields);
|
||||
@@ -0,0 +1 @@
|
||||
pub(crate) mod state;
|
||||
@@ -0,0 +1 @@
|
||||
pub(crate) mod runtime_state;
|
||||
@@ -0,0 +1,464 @@
|
||||
use easytier::proto::{api, common};
|
||||
use napi_derive_ohos::napi;
|
||||
use serde::Serialize;
|
||||
use std::collections::HashSet;
|
||||
use std::sync::Mutex;
|
||||
use url::Url;
|
||||
|
||||
static ATTACHED_TUN_INSTANCE_IDS: once_cell::sync::Lazy<Mutex<HashSet<String>>> =
|
||||
once_cell::sync::Lazy::new(|| Mutex::new(HashSet::new()));
|
||||
|
||||
pub fn mark_tun_attached(instance_id: &str) {
|
||||
if let Ok(mut guard) = ATTACHED_TUN_INSTANCE_IDS.lock() {
|
||||
guard.insert(instance_id.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
pub fn clear_tun_attached(instance_id: &str) {
|
||||
if let Ok(mut guard) = ATTACHED_TUN_INSTANCE_IDS.lock() {
|
||||
guard.remove(instance_id);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn is_tun_attached(instance_id: &str) -> bool {
|
||||
ATTACHED_TUN_INSTANCE_IDS
|
||||
.lock()
|
||||
.map(|guard| guard.contains(instance_id))
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
#[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,
|
||||
}
|
||||
|
||||
#[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>,
|
||||
}
|
||||
|
||||
#[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>,
|
||||
}
|
||||
|
||||
#[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>,
|
||||
}
|
||||
|
||||
#[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>,
|
||||
}
|
||||
|
||||
#[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>,
|
||||
}
|
||||
|
||||
#[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,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[napi(object)]
|
||||
pub struct RuntimeAggregateState {
|
||||
pub instances: Vec<RuntimeInstanceState>,
|
||||
pub tun: TunAggregateState,
|
||||
pub running_instance_count: i32,
|
||||
}
|
||||
|
||||
fn stringify_ipv4_inet(value: Option<common::Ipv4Inet>) -> Option<String> {
|
||||
value.map(|v| v.to_string())
|
||||
}
|
||||
|
||||
fn stringify_ipv6_inet(value: Option<common::Ipv6Inet>) -> Option<String> {
|
||||
value.map(|v| v.to_string())
|
||||
}
|
||||
|
||||
fn stringify_url(value: Option<common::Url>) -> Option<String> {
|
||||
value.map(|v| v.to_string())
|
||||
}
|
||||
|
||||
fn stringify_uuid(value: Option<common::Uuid>) -> Option<String> {
|
||||
value.map(|v| v.to_string())
|
||||
}
|
||||
|
||||
fn non_empty_string(value: Option<String>) -> Option<String> {
|
||||
value.and_then(|raw| {
|
||||
let trimmed = raw.trim();
|
||||
if trimmed.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(trimmed.to_string())
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn config_virtual_ipv4_cidr(config: &api::manage::NetworkConfig) -> Option<String> {
|
||||
non_empty_string(config.virtual_ipv4.clone())
|
||||
.map(|ipv4| format!("{}/{}", ipv4, config.network_length.unwrap_or(24)))
|
||||
}
|
||||
|
||||
fn config_endpoint_urls(config: &api::manage::NetworkConfig) -> Vec<String> {
|
||||
let mut urls = Vec::new();
|
||||
let mut seen = HashSet::new();
|
||||
if let Some(url) = non_empty_string(config.public_server_url.clone())
|
||||
&& seen.insert(url.clone())
|
||||
{
|
||||
urls.push(url);
|
||||
}
|
||||
for raw in &config.peer_urls {
|
||||
let trimmed = raw.trim();
|
||||
if trimmed.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let value = trimmed.to_string();
|
||||
if seen.insert(value.clone()) {
|
||||
urls.push(value);
|
||||
}
|
||||
}
|
||||
urls
|
||||
}
|
||||
|
||||
fn endpoint_url(url: &str) -> Option<Url> {
|
||||
Url::parse(url).ok()
|
||||
}
|
||||
|
||||
fn endpoint_scheme(url: &str) -> Option<String> {
|
||||
endpoint_url(url)
|
||||
.map(|parsed| parsed.scheme().to_string())
|
||||
.or_else(|| {
|
||||
let scheme = url.split("://").next().unwrap_or("").trim();
|
||||
(!scheme.is_empty()).then_some(scheme.to_string())
|
||||
})
|
||||
}
|
||||
|
||||
fn endpoint_label(url: &str) -> String {
|
||||
if let Some(parsed) = endpoint_url(url)
|
||||
&& let Some(host) = parsed.host_str()
|
||||
{
|
||||
return format!("[Config] {}", host);
|
||||
}
|
||||
format!("[Config] {}", url)
|
||||
}
|
||||
|
||||
fn endpoint_remote_display(url: &str) -> String {
|
||||
if let Some(parsed) = endpoint_url(url)
|
||||
&& let Some(host) = parsed.host_str()
|
||||
{
|
||||
return parsed
|
||||
.port()
|
||||
.map(|port| format!("{}:{}", host, port))
|
||||
.unwrap_or_else(|| host.to_string());
|
||||
}
|
||||
url.to_string()
|
||||
}
|
||||
|
||||
fn configured_peer_id(index: usize) -> i64 {
|
||||
9_000_000 + index as i64
|
||||
}
|
||||
|
||||
fn configured_route_views(endpoints: &[String], public_server_url: Option<&str>) -> Vec<RouteView> {
|
||||
endpoints
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(index, endpoint)| RouteView {
|
||||
peer_id: configured_peer_id(index),
|
||||
hostname: Some(endpoint_label(endpoint)),
|
||||
ipv4: Some(endpoint_remote_display(endpoint)),
|
||||
ipv4_cidr: None,
|
||||
ipv6_cidr: None,
|
||||
proxy_cidrs: Vec::new(),
|
||||
next_hop_peer_id: None,
|
||||
cost: Some(0),
|
||||
path_latency: None,
|
||||
udp_nat_type: None,
|
||||
tcp_nat_type: None,
|
||||
inst_id: None,
|
||||
version: None,
|
||||
is_public_server: public_server_url.map(|url| url == endpoint),
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn configured_peer_views(endpoints: &[String]) -> Vec<PeerInfo> {
|
||||
endpoints
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(index, endpoint)| {
|
||||
let conn_id = format!("configured-peer-{}", index);
|
||||
PeerInfo {
|
||||
peer_id: configured_peer_id(index),
|
||||
default_conn_id: Some(conn_id.clone()),
|
||||
directly_connected_conns: vec![conn_id.clone()],
|
||||
conns: vec![PeerConnInfo {
|
||||
conn_id,
|
||||
my_peer_id: 0,
|
||||
peer_id: configured_peer_id(index),
|
||||
features: Vec::new(),
|
||||
tunnel_type: endpoint_scheme(endpoint),
|
||||
local_addr: None,
|
||||
remote_addr: Some(endpoint.clone()),
|
||||
resolved_remote_addr: Some(endpoint_remote_display(endpoint)),
|
||||
stats: None,
|
||||
loss_rate: None,
|
||||
is_client: true,
|
||||
network_name: None,
|
||||
is_closed: false,
|
||||
secure_auth_level: None,
|
||||
peer_identity_type: None,
|
||||
}],
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn optional_u32_to_i64(value: Option<u32>) -> Option<i64> {
|
||||
value.map(|v| v as i64)
|
||||
}
|
||||
|
||||
fn optional_i32_to_i64(value: Option<i32>) -> Option<i64> {
|
||||
value.map(|v| v as i64)
|
||||
}
|
||||
|
||||
fn route_to_view(route: api::instance::Route) -> RouteView {
|
||||
let stun = route.stun_info;
|
||||
let feature_flag = route.feature_flag;
|
||||
RouteView {
|
||||
peer_id: route.peer_id as i64,
|
||||
hostname: (!route.hostname.is_empty()).then_some(route.hostname),
|
||||
ipv4: route
|
||||
.ipv4_addr
|
||||
.as_ref()
|
||||
.and_then(|inet| inet.address.as_ref())
|
||||
.map(|addr| addr.to_string()),
|
||||
ipv4_cidr: stringify_ipv4_inet(route.ipv4_addr),
|
||||
ipv6_cidr: stringify_ipv6_inet(route.ipv6_addr),
|
||||
proxy_cidrs: route.proxy_cidrs,
|
||||
next_hop_peer_id: optional_u32_to_i64(route.next_hop_peer_id_latency_first)
|
||||
.or_else(|| Some(route.next_hop_peer_id as i64)),
|
||||
cost: Some(route.cost),
|
||||
path_latency: optional_i32_to_i64(route.path_latency_latency_first)
|
||||
.or_else(|| Some(route.path_latency as i64)),
|
||||
udp_nat_type: stun.as_ref().map(|info| info.udp_nat_type),
|
||||
tcp_nat_type: stun.as_ref().map(|info| info.tcp_nat_type),
|
||||
inst_id: (!route.inst_id.is_empty()).then_some(route.inst_id),
|
||||
version: (!route.version.is_empty()).then_some(route.version),
|
||||
is_public_server: feature_flag.map(|flag| flag.is_public_server),
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
rx_packets: stats.rx_packets as i64,
|
||||
tx_packets: stats.tx_packets as i64,
|
||||
latency_us: stats.latency_us as i64,
|
||||
});
|
||||
|
||||
PeerConnInfo {
|
||||
conn_id: conn.conn_id,
|
||||
my_peer_id: conn.my_peer_id as i64,
|
||||
peer_id: conn.peer_id as i64,
|
||||
features: conn.features,
|
||||
tunnel_type: conn.tunnel.as_ref().map(|t| t.tunnel_type.clone()),
|
||||
local_addr: conn
|
||||
.tunnel
|
||||
.as_ref()
|
||||
.and_then(|t| stringify_url(t.local_addr.clone())),
|
||||
remote_addr: conn
|
||||
.tunnel
|
||||
.as_ref()
|
||||
.and_then(|t| stringify_url(t.remote_addr.clone())),
|
||||
resolved_remote_addr: conn
|
||||
.tunnel
|
||||
.as_ref()
|
||||
.and_then(|t| stringify_url(t.resolved_remote_addr.clone())),
|
||||
stats,
|
||||
loss_rate: Some(conn.loss_rate as f64),
|
||||
is_client: conn.is_client,
|
||||
network_name: (!conn.network_name.is_empty()).then_some(conn.network_name),
|
||||
is_closed: conn.is_closed,
|
||||
secure_auth_level: Some(conn.secure_auth_level),
|
||||
peer_identity_type: Some(conn.peer_identity_type),
|
||||
}
|
||||
}
|
||||
|
||||
fn peer_to_view(peer: api::instance::PeerInfo) -> PeerInfo {
|
||||
PeerInfo {
|
||||
peer_id: peer.peer_id as i64,
|
||||
default_conn_id: stringify_uuid(peer.default_conn_id),
|
||||
directly_connected_conns: peer
|
||||
.directly_connected_conns
|
||||
.into_iter()
|
||||
.map(|id| id.to_string())
|
||||
.collect(),
|
||||
conns: peer.conns.into_iter().map(peer_conn_to_view).collect(),
|
||||
}
|
||||
}
|
||||
|
||||
fn my_node_info_to_view(info: api::manage::MyNodeInfo) -> MyNodeInfo {
|
||||
MyNodeInfo {
|
||||
virtual_ipv4: info
|
||||
.virtual_ipv4
|
||||
.as_ref()
|
||||
.and_then(|inet| inet.address.as_ref())
|
||||
.map(|addr| addr.to_string()),
|
||||
virtual_ipv4_cidr: stringify_ipv4_inet(info.virtual_ipv4),
|
||||
hostname: (!info.hostname.is_empty()).then_some(info.hostname),
|
||||
version: (!info.version.is_empty()).then_some(info.version),
|
||||
peer_id: Some(info.peer_id as i64),
|
||||
listeners: info
|
||||
.listeners
|
||||
.into_iter()
|
||||
.map(|url| url.to_string())
|
||||
.collect(),
|
||||
vpn_portal_cfg: info.vpn_portal_cfg,
|
||||
udp_nat_type: info.stun_info.as_ref().map(|stun| stun.udp_nat_type),
|
||||
tcp_nat_type: info.stun_info.as_ref().map(|stun| stun.tcp_nat_type),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn runtime_instance_from_running_info(
|
||||
config_id: String,
|
||||
display_name: String,
|
||||
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);
|
||||
|
||||
RuntimeInstanceState {
|
||||
config_id: config_id.clone(),
|
||||
instance_id: config_id,
|
||||
display_name,
|
||||
running: info.running,
|
||||
tun_required,
|
||||
tun_attached,
|
||||
magic_dns_enabled,
|
||||
need_exit_node,
|
||||
error_message: info.error_msg,
|
||||
my_node_info: info.my_node_info.map(my_node_info_to_view),
|
||||
events: info.events,
|
||||
routes: info.routes.into_iter().map(route_to_view).collect(),
|
||||
peers: info.peers.into_iter().map(peer_to_view).collect(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn runtime_instance_from_config_snapshot(
|
||||
config_id: String,
|
||||
display_name: String,
|
||||
config: api::manage::NetworkConfig,
|
||||
running: bool,
|
||||
) -> RuntimeInstanceState {
|
||||
let tun_attached = running && is_tun_attached(&config_id);
|
||||
let tun_required =
|
||||
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 my_node_info = MyNodeInfo {
|
||||
virtual_ipv4: non_empty_string(config.virtual_ipv4.clone()),
|
||||
virtual_ipv4_cidr: config_virtual_ipv4_cidr(&config),
|
||||
hostname: non_empty_string(config.hostname.clone()),
|
||||
version: None,
|
||||
peer_id: None,
|
||||
listeners: config.listener_urls.clone(),
|
||||
vpn_portal_cfg: None,
|
||||
udp_nat_type: None,
|
||||
tcp_nat_type: None,
|
||||
};
|
||||
|
||||
RuntimeInstanceState {
|
||||
config_id: config_id.clone(),
|
||||
instance_id: config_id,
|
||||
display_name,
|
||||
running,
|
||||
tun_required,
|
||||
tun_attached,
|
||||
magic_dns_enabled: config.enable_magic_dns.unwrap_or(false),
|
||||
need_exit_node: !config.exit_nodes.is_empty(),
|
||||
error_message: None,
|
||||
my_node_info: Some(my_node_info),
|
||||
events: Vec::new(),
|
||||
routes: configured_route_views(&endpoint_urls, public_server_url.as_deref()),
|
||||
peers: configured_peer_views(&endpoint_urls),
|
||||
}
|
||||
}
|
||||
@@ -10,9 +10,8 @@ use easytier::{
|
||||
common::config::{
|
||||
ConfigFileControl, ConfigLoader, NetworkIdentity, PeerConfig, TomlConfigLoader,
|
||||
},
|
||||
instance_manager::NetworkInstanceManager,
|
||||
instance::factory::{NativeInstanceManager, native_instance_manager},
|
||||
};
|
||||
use guarden::defer;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sqlx::any;
|
||||
use tokio_util::task::AbortOnDropHandle;
|
||||
@@ -28,6 +27,32 @@ pub struct HealthCheckOneNode {
|
||||
node_id: String,
|
||||
}
|
||||
|
||||
struct InstanceCleanupGuard {
|
||||
manager: Arc<NativeInstanceManager>,
|
||||
instance_id: Option<uuid::Uuid>,
|
||||
runtime: tokio::runtime::Handle,
|
||||
}
|
||||
|
||||
impl InstanceCleanupGuard {
|
||||
async fn cleanup(mut self) {
|
||||
let instance_id = self.instance_id.unwrap();
|
||||
let _ = self.manager.delete_network_instances([instance_id]).await;
|
||||
self.instance_id = None;
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for InstanceCleanupGuard {
|
||||
fn drop(&mut self) {
|
||||
let Some(instance_id) = self.instance_id.take() else {
|
||||
return;
|
||||
};
|
||||
let manager = self.manager.clone();
|
||||
self.runtime.spawn(async move {
|
||||
let _ = manager.delete_network_instances([instance_id]).await;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const HEALTH_CHECK_RING_GRANULARITY_SEC: usize = 60 * 15; // 15分钟
|
||||
const HEALTH_CHECK_RING_MAX_DURATION_SEC: usize = 60 * 60 * 24; // 最多一天
|
||||
|
||||
@@ -238,7 +263,7 @@ impl HealthyMemRecord {
|
||||
|
||||
pub struct HealthChecker {
|
||||
db: Db,
|
||||
instance_mgr: Arc<NetworkInstanceManager>,
|
||||
instance_mgr: Arc<NativeInstanceManager>,
|
||||
inst_id_map: DashMap<i32, uuid::Uuid>,
|
||||
node_tasks: DashMap<i32, AbortOnDropHandle<()>>,
|
||||
node_records: Arc<DashMap<i32, HealthyMemRecord>>,
|
||||
@@ -247,7 +272,7 @@ pub struct HealthChecker {
|
||||
|
||||
impl HealthChecker {
|
||||
pub fn new(db: Db) -> Self {
|
||||
let instance_mgr = Arc::new(NetworkInstanceManager::new());
|
||||
let instance_mgr = Arc::new(native_instance_manager());
|
||||
Self {
|
||||
db,
|
||||
instance_mgr,
|
||||
@@ -387,33 +412,38 @@ impl HealthChecker {
|
||||
max_time: Duration,
|
||||
) -> anyhow::Result<()> {
|
||||
let cfg = self.get_node_cfg_with_model(node_info, None).await?;
|
||||
defer!({
|
||||
let _ = self
|
||||
.instance_mgr
|
||||
.delete_network_instance(vec![cfg.get_id()]);
|
||||
});
|
||||
self.instance_mgr
|
||||
.run_network_instance(cfg.clone(), false, ConfigFileControl::STATIC_CONFIG)
|
||||
.run_network_instance(cfg.clone(), ConfigFileControl::STATIC_CONFIG)
|
||||
.with_context(|| "failed to run network instance")?;
|
||||
let cleanup = InstanceCleanupGuard {
|
||||
manager: self.instance_mgr.clone(),
|
||||
instance_id: Some(cfg.get_id()),
|
||||
runtime: tokio::runtime::Handle::current(),
|
||||
};
|
||||
|
||||
let now = Instant::now();
|
||||
let mut err = None;
|
||||
while now.elapsed() < max_time {
|
||||
match Self::test_node_healthy(cfg.get_id(), self.instance_mgr.clone()).await {
|
||||
Ok(_) => {
|
||||
return Ok(());
|
||||
}
|
||||
Err(e) => {
|
||||
warn!(
|
||||
"test node healthy failed, node_info: {:?}, err: {}",
|
||||
node_info, e
|
||||
);
|
||||
err = Some(e);
|
||||
let result = async {
|
||||
let now = Instant::now();
|
||||
let mut err = None;
|
||||
while now.elapsed() < max_time {
|
||||
match Self::test_node_healthy(cfg.get_id(), self.instance_mgr.clone()).await {
|
||||
Ok(_) => {
|
||||
return Ok(());
|
||||
}
|
||||
Err(e) => {
|
||||
warn!(
|
||||
"test node healthy failed, node_info: {:?}, err: {}",
|
||||
node_info, e
|
||||
);
|
||||
err = Some(e);
|
||||
}
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(100)).await;
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(100)).await;
|
||||
Err(anyhow::anyhow!("test node healthy failed, err: {:?}", err))
|
||||
}
|
||||
Err(anyhow::anyhow!("test node healthy failed, err: {:?}", err))
|
||||
.await;
|
||||
cleanup.cleanup().await;
|
||||
result
|
||||
}
|
||||
|
||||
async fn get_node_cfg(
|
||||
@@ -437,7 +467,7 @@ impl HealthChecker {
|
||||
);
|
||||
|
||||
self.instance_mgr
|
||||
.run_network_instance(cfg.clone(), true, ConfigFileControl::STATIC_CONFIG)
|
||||
.run_network_instance(cfg.clone(), ConfigFileControl::STATIC_CONFIG)
|
||||
.with_context(|| "failed to run network instance")?;
|
||||
self.inst_id_map.insert(node_id, cfg.get_id());
|
||||
|
||||
@@ -481,7 +511,10 @@ impl HealthChecker {
|
||||
pub async fn remove_node(&self, node_id: i32) -> anyhow::Result<()> {
|
||||
self.node_tasks.remove(&node_id);
|
||||
if let Some(inst_id) = self.inst_id_map.remove(&node_id) {
|
||||
let _ = self.instance_mgr.delete_network_instance(vec![inst_id.1]);
|
||||
let _ = self
|
||||
.instance_mgr
|
||||
.delete_network_instances([inst_id.1])
|
||||
.await;
|
||||
}
|
||||
self.node_cfg.remove(&node_id);
|
||||
// 保留内存记录,不删除,以便后续查询历史数据
|
||||
@@ -495,10 +528,10 @@ impl HealthChecker {
|
||||
#[instrument(err, ret, skip(instance_mgr))]
|
||||
async fn test_node_healthy(
|
||||
inst_id: uuid::Uuid,
|
||||
instance_mgr: Arc<NetworkInstanceManager>,
|
||||
instance_mgr: Arc<NativeInstanceManager>,
|
||||
// return version, response time on healthy, conn_count
|
||||
) -> anyhow::Result<(String, u64, u32)> {
|
||||
let Some(instance) = instance_mgr.get_network_info(&inst_id).await else {
|
||||
let Some(instance) = instance_mgr.network_info(inst_id).await else {
|
||||
anyhow::bail!("healthy check node is not started");
|
||||
};
|
||||
|
||||
@@ -566,7 +599,7 @@ impl HealthChecker {
|
||||
async fn node_health_check_task(
|
||||
node_id: i32,
|
||||
inst_id: uuid::Uuid,
|
||||
instance_mgr: Arc<NetworkInstanceManager>,
|
||||
instance_mgr: Arc<NativeInstanceManager>,
|
||||
db: Db,
|
||||
node_records: Arc<DashMap<i32, HealthyMemRecord>>,
|
||||
) {
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
[package]
|
||||
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.6.4"
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
authors = ["kkrainbow"]
|
||||
keywords = ["vpn", "p2p", "network", "easytier"]
|
||||
categories = ["network-programming"]
|
||||
license-file = "../LICENSE"
|
||||
|
||||
[lib]
|
||||
crate-type = ["rlib", "cdylib"]
|
||||
|
||||
[package.metadata.wasm-pack.profile.release]
|
||||
wasm-opt = ["-Oz", "--enable-bulk-memory", "--enable-nontrapping-float-to-int"]
|
||||
|
||||
[dependencies]
|
||||
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 = { 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 = { 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.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 = { version = "1.5.0", features = ["js"] }
|
||||
wasm-bindgen = "0.2"
|
||||
|
||||
[target.'cfg(not(all(target_arch = "wasm32", target_os = "unknown")))'.dependencies]
|
||||
snow = "0.10.0"
|
||||
|
||||
[features]
|
||||
default = ["aes-gcm", "endpoint-discovery", "extended-services", "management", "tcp-hole-punch"]
|
||||
aes-gcm = ["dep:aes-gcm"]
|
||||
browser-config = ["config-write", "easytier-proto/api", "easytier-proto/json-rpc"]
|
||||
chacha20 = ["dep:chacha20poly1305"]
|
||||
openssl-crypto = ["dep:openssl"]
|
||||
ring-crypto = ["dep:ring"]
|
||||
wasi-crypto-offload = ["ring-crypto"]
|
||||
config-write = []
|
||||
endpoint-discovery = [
|
||||
"dep:http-body-util",
|
||||
"dep:hyper",
|
||||
"dep:hyper-util",
|
||||
"dep:rustls",
|
||||
"dep:tokio-rustls",
|
||||
"dep:webpki-roots",
|
||||
]
|
||||
dhcp-ipv4 = []
|
||||
public-ipv6-provider = []
|
||||
vpn-portal = []
|
||||
wrapped-transport = []
|
||||
extended-services = [
|
||||
"dhcp-ipv4",
|
||||
"public-ipv6-provider",
|
||||
"vpn-portal",
|
||||
"wrapped-transport",
|
||||
"proxy-cidr-monitor",
|
||||
]
|
||||
web-client = ["management-rpc", "config-write"]
|
||||
management = ["web-client", "extended-services", "rich-config-errors", "easytier-proto/json-rpc"]
|
||||
management-rpc = ["easytier-proto/api"]
|
||||
proxy-cidr-monitor = []
|
||||
rich-config-errors = ["dep:ariadne"]
|
||||
tcp-hole-punch = []
|
||||
proxy-packet = [
|
||||
"wrapped-transport",
|
||||
"dep:smoltcp",
|
||||
"smoltcp/std",
|
||||
"smoltcp/proto-ipv4",
|
||||
"smoltcp/proto-ipv4-fragmentation",
|
||||
"smoltcp/fragmentation-buffer-size-65536",
|
||||
"smoltcp/assembler-max-segment-count-16",
|
||||
"smoltcp/reassembly-buffer-size-65536",
|
||||
"smoltcp/reassembly-buffer-count-16",
|
||||
]
|
||||
proxy-smoltcp-stack = [
|
||||
"proxy-packet",
|
||||
"smoltcp/medium-ip",
|
||||
"smoltcp/socket-tcp",
|
||||
"smoltcp/socket-udp",
|
||||
"smoltcp/proto-ipv6",
|
||||
"smoltcp/async",
|
||||
]
|
||||
test-utils = []
|
||||
tracing-log = ["tracing/log"]
|
||||
zstd = ["dep:zstd"]
|
||||
|
||||
[target.'cfg(not(target_os = "wasi"))'.dev-dependencies]
|
||||
tokio = { version = "1", default-features = false, features = ["rt-multi-thread"] }
|
||||
@@ -0,0 +1,166 @@
|
||||
//! Portable conversion between the shared TOML model and management schema.
|
||||
|
||||
use easytier_proto::api::manage::{
|
||||
self, NetworkConfig, NetworkingMethod, PortForwardConfig as ApiPortForwardConfig,
|
||||
};
|
||||
|
||||
use super::toml::{ConfigLoader as _, TomlConfig};
|
||||
|
||||
pub fn network_config_from_toml(config: &TomlConfig) -> NetworkConfig {
|
||||
let default_config = TomlConfig::default();
|
||||
let mut result = NetworkConfig {
|
||||
instance_id: Some(config.get_id().to_string()),
|
||||
dhcp: Some(config.get_dhcp()),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
if config.get_hostname() != default_config.get_hostname() {
|
||||
result.hostname = Some(config.get_hostname());
|
||||
}
|
||||
|
||||
let network_identity = config.get_network_identity();
|
||||
result.network_name = Some(network_identity.network_name);
|
||||
result.network_secret = network_identity.network_secret;
|
||||
|
||||
if let Some(ipv4) = config.get_ipv4() {
|
||||
result.virtual_ipv4 = Some(ipv4.address().to_string());
|
||||
result.network_length = Some(ipv4.network_length() as i32);
|
||||
}
|
||||
|
||||
if config.get_ipv6_public_addr_provider() != default_config.get_ipv6_public_addr_provider() {
|
||||
result.ipv6_public_addr_provider = Some(config.get_ipv6_public_addr_provider());
|
||||
}
|
||||
if config.get_ipv6_public_addr_auto() != default_config.get_ipv6_public_addr_auto() {
|
||||
result.ipv6_public_addr_auto = Some(config.get_ipv6_public_addr_auto());
|
||||
}
|
||||
result.ipv6_public_addr_prefix = config
|
||||
.get_ipv6_public_addr_prefix()
|
||||
.map(|prefix| prefix.to_string());
|
||||
|
||||
let peers = config.get_peers();
|
||||
result.networking_method = Some(NetworkingMethod::Manual as i32);
|
||||
if !peers.is_empty() {
|
||||
result.peer_urls = peers.iter().map(|peer| peer.uri.to_string()).collect();
|
||||
result.peers = peers
|
||||
.iter()
|
||||
.map(|peer| manage::NetworkPeerConfig {
|
||||
uri: peer.uri.to_string(),
|
||||
peer_public_key: peer.peer_public_key.clone(),
|
||||
})
|
||||
.collect();
|
||||
}
|
||||
|
||||
result.listener_urls = config
|
||||
.get_listeners()
|
||||
.unwrap_or_default()
|
||||
.iter()
|
||||
.map(ToString::to_string)
|
||||
.collect();
|
||||
result.proxy_cidrs = config
|
||||
.get_proxy_cidrs()
|
||||
.iter()
|
||||
.map(|proxy| match proxy.mapped_cidr {
|
||||
Some(mapped) => format!("{}->{}", proxy.cidr, mapped),
|
||||
None => proxy.cidr.to_string(),
|
||||
})
|
||||
.collect();
|
||||
|
||||
let port_forwards = config.get_port_forwards();
|
||||
if !port_forwards.is_empty() {
|
||||
result.port_forwards = port_forwards
|
||||
.iter()
|
||||
.map(|forward| ApiPortForwardConfig {
|
||||
proto: forward.proto.clone(),
|
||||
bind_ip: forward.bind_addr.ip().to_string(),
|
||||
bind_port: forward.bind_addr.port() as u32,
|
||||
dst_ip: forward.dst_addr.ip().to_string(),
|
||||
dst_port: forward.dst_addr.port() as u32,
|
||||
})
|
||||
.collect();
|
||||
}
|
||||
|
||||
if let Some(vpn_config) = config.get_vpn_portal_config() {
|
||||
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()
|
||||
&& !routes.is_empty()
|
||||
{
|
||||
result.enable_manual_routes = Some(true);
|
||||
result.routes = routes.iter().map(ToString::to_string).collect();
|
||||
}
|
||||
let exit_nodes = config.get_exit_nodes();
|
||||
if !exit_nodes.is_empty() {
|
||||
result.exit_nodes = exit_nodes.iter().map(ToString::to_string).collect();
|
||||
}
|
||||
if let Some(socks5_portal) = config.get_socks5_portal() {
|
||||
result.enable_socks5 = Some(true);
|
||||
result.socks5_port = socks5_portal.port().map(|port| port as i32);
|
||||
}
|
||||
let mapped_listeners = config.get_mapped_listeners();
|
||||
if !mapped_listeners.is_empty() {
|
||||
result.mapped_listeners = mapped_listeners.iter().map(ToString::to_string).collect();
|
||||
}
|
||||
|
||||
result.secure_mode = config.get_secure_mode();
|
||||
result.credential_file = config
|
||||
.get_credential_file()
|
||||
.map(|path| path.to_string_lossy().into_owned());
|
||||
|
||||
let flags = config.get_flags();
|
||||
let default_flags = default_config.get_flags();
|
||||
result.latency_first = Some(flags.latency_first);
|
||||
result.dev_name = Some(flags.dev_name.clone());
|
||||
result.use_smoltcp = Some(flags.use_smoltcp);
|
||||
result.disable_ipv6 = Some(!flags.enable_ipv6);
|
||||
result.enable_kcp_proxy = Some(flags.enable_kcp_proxy);
|
||||
result.disable_kcp_input = Some(flags.disable_kcp_input);
|
||||
result.enable_quic_proxy = Some(flags.enable_quic_proxy);
|
||||
result.disable_quic_input = Some(flags.disable_quic_input);
|
||||
result.disable_p2p = Some(flags.disable_p2p);
|
||||
result.p2p_only = Some(flags.p2p_only);
|
||||
result.lazy_p2p = Some(flags.lazy_p2p);
|
||||
result.bind_device = Some(flags.bind_device);
|
||||
result.socket_mark = flags.socket_mark;
|
||||
result.no_tun = Some(flags.no_tun);
|
||||
result.enable_exit_node = Some(flags.enable_exit_node);
|
||||
result.relay_all_peer_rpc = Some(flags.relay_all_peer_rpc);
|
||||
result.need_p2p = Some(flags.need_p2p);
|
||||
result.multi_thread = Some(flags.multi_thread);
|
||||
result.proxy_forward_by_system = Some(flags.proxy_forward_by_system);
|
||||
result.disable_encryption = Some(!flags.enable_encryption);
|
||||
result.disable_tcp_hole_punching = Some(flags.disable_tcp_hole_punching);
|
||||
result.disable_udp_hole_punching = Some(flags.disable_udp_hole_punching);
|
||||
result.disable_upnp = Some(flags.disable_upnp);
|
||||
result.disable_relay_data = Some(flags.disable_relay_data);
|
||||
result.enable_udp_broadcast_relay = Some(flags.enable_udp_broadcast_relay);
|
||||
result.disable_sym_hole_punching = Some(flags.disable_sym_hole_punching);
|
||||
result.enable_magic_dns = Some(flags.accept_dns);
|
||||
result.mtu = Some(flags.mtu as i32);
|
||||
result.data_compress_algo = (flags.data_compress_algo != default_flags.data_compress_algo)
|
||||
.then_some(flags.data_compress_algo);
|
||||
result.encryption_algorithm = (flags.encryption_algorithm
|
||||
!= default_flags.encryption_algorithm)
|
||||
.then_some(flags.encryption_algorithm);
|
||||
result.instance_recv_bps_limit =
|
||||
(flags.instance_recv_bps_limit != u64::MAX).then_some(flags.instance_recv_bps_limit);
|
||||
result.enable_private_mode = Some(flags.private_mode);
|
||||
result.acl = config.get_acl();
|
||||
|
||||
if flags.relay_network_whitelist == "*" {
|
||||
result.enable_relay_network_whitelist = Some(false);
|
||||
} else {
|
||||
result.enable_relay_network_whitelist = Some(true);
|
||||
result.relay_network_whitelist = flags
|
||||
.relay_network_whitelist
|
||||
.split_whitespace()
|
||||
.map(ToOwned::to_owned)
|
||||
.collect();
|
||||
}
|
||||
|
||||
result
|
||||
}
|
||||
@@ -0,0 +1,652 @@
|
||||
//! Conversion between the management NetworkConfig schema and shared TOML.
|
||||
|
||||
use std::net::SocketAddr;
|
||||
|
||||
use anyhow::Context;
|
||||
use easytier_proto::api::manage;
|
||||
|
||||
use crate::config::{
|
||||
MappedListenerPolicy, normalize_secure_mode_config,
|
||||
toml::{
|
||||
ConfigLoader, NetworkIdentity, PeerConfig, PortForwardConfig, TomlConfigLoader,
|
||||
VpnPortalConfig, gen_default_flags,
|
||||
},
|
||||
};
|
||||
|
||||
fn parse_mapped_listener_urls(mapped_listeners: &[String]) -> Result<Vec<url::Url>, anyhow::Error> {
|
||||
MappedListenerPolicy::new(["tcp", "udp", "wg", "quic", "ws", "wss", "faketcp"])
|
||||
.parse_urls(mapped_listeners)
|
||||
}
|
||||
|
||||
pub fn add_proxy_network_to_config(
|
||||
proxy_network: &str,
|
||||
cfg: &TomlConfigLoader,
|
||||
) -> Result<(), anyhow::Error> {
|
||||
let parts: Vec<&str> = proxy_network.split("->").collect();
|
||||
let real_cidr = parts[0]
|
||||
.parse()
|
||||
.with_context(|| format!("failed to parse proxy network: {}", parts[0]))?;
|
||||
|
||||
if parts.len() > 2 {
|
||||
return Err(anyhow::anyhow!(
|
||||
"invalid proxy network format: {}, support format: <real_cidr> or <real_cidr>-><mapped_cidr>, example:
|
||||
10.0.0.0/24 or 10.0.0.0/24->192.168.0.0/24",
|
||||
proxy_network
|
||||
));
|
||||
}
|
||||
|
||||
let mapped_cidr = if parts.len() == 2 {
|
||||
Some(
|
||||
parts[1]
|
||||
.parse()
|
||||
.with_context(|| format!("failed to parse mapped network: {}", parts[1]))?,
|
||||
)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
cfg.add_proxy_cidr(real_cidr, mapped_cidr)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub type NetworkingMethod = easytier_proto::api::manage::NetworkingMethod;
|
||||
pub type NetworkConfig = easytier_proto::api::manage::NetworkConfig;
|
||||
|
||||
pub trait NetworkConfigExt {
|
||||
fn gen_config(&self) -> Result<TomlConfigLoader, anyhow::Error>;
|
||||
fn new_from_config(config: impl ConfigLoader) -> Result<NetworkConfig, anyhow::Error>;
|
||||
}
|
||||
|
||||
fn parse_peer(peer: &manage::NetworkPeerConfig) -> Result<Option<PeerConfig>, anyhow::Error> {
|
||||
let uri = peer.uri.trim();
|
||||
if uri.is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
Ok(Some(PeerConfig {
|
||||
uri: uri
|
||||
.parse()
|
||||
.with_context(|| format!("failed to parse peer uri: {}", uri))?,
|
||||
peer_public_key: peer.peer_public_key.clone(),
|
||||
}))
|
||||
}
|
||||
|
||||
fn parse_peers(peers: &[manage::NetworkPeerConfig]) -> Result<Vec<PeerConfig>, anyhow::Error> {
|
||||
let mut ret = Vec::new();
|
||||
for peer in peers {
|
||||
if let Some(peer) = parse_peer(peer)? {
|
||||
ret.push(peer);
|
||||
}
|
||||
}
|
||||
Ok(ret)
|
||||
}
|
||||
|
||||
fn parse_peer_urls(peer_urls: &[String]) -> Result<Vec<PeerConfig>, anyhow::Error> {
|
||||
let mut peers = vec![];
|
||||
for peer_url in peer_urls.iter() {
|
||||
let peer_url = peer_url.trim();
|
||||
if peer_url.is_empty() {
|
||||
continue;
|
||||
}
|
||||
peers.push(PeerConfig {
|
||||
uri: peer_url
|
||||
.parse()
|
||||
.with_context(|| format!("failed to parse peer uri: {}", peer_url))?,
|
||||
peer_public_key: None,
|
||||
});
|
||||
}
|
||||
Ok(peers)
|
||||
}
|
||||
|
||||
impl NetworkConfigExt for NetworkConfig {
|
||||
fn gen_config(&self) -> Result<TomlConfigLoader, anyhow::Error> {
|
||||
let cfg = TomlConfigLoader::default();
|
||||
cfg.set_id(
|
||||
self.instance_id
|
||||
.clone()
|
||||
.unwrap_or(uuid::Uuid::new_v4().to_string())
|
||||
.parse()
|
||||
.with_context(|| format!("failed to parse instance id: {:?}", self.instance_id))?,
|
||||
);
|
||||
cfg.set_hostname(self.hostname.clone());
|
||||
cfg.set_dhcp(self.dhcp.unwrap_or_default());
|
||||
cfg.set_inst_name(self.network_name.clone().unwrap_or_default());
|
||||
|
||||
// The web UI does not expose credential inputs directly, but imported/saved
|
||||
// NetworkConfig objects still need to preserve credential-mode instances via
|
||||
// secure_mode.local_private_key + empty network_secret.
|
||||
let credential_secret = if self.network_secret.is_some() {
|
||||
None
|
||||
} else {
|
||||
self.secure_mode
|
||||
.as_ref()
|
||||
.and_then(|mode| mode.local_private_key.clone())
|
||||
.filter(|s| !s.is_empty())
|
||||
};
|
||||
|
||||
if credential_secret.is_some() {
|
||||
cfg.set_network_identity(NetworkIdentity::new_credential(
|
||||
self.network_name.clone().unwrap_or_default(),
|
||||
));
|
||||
} else {
|
||||
cfg.set_network_identity(NetworkIdentity::new(
|
||||
self.network_name.clone().unwrap_or_default(),
|
||||
self.network_secret.clone().unwrap_or_default(),
|
||||
));
|
||||
}
|
||||
|
||||
if !cfg.get_dhcp() {
|
||||
let virtual_ipv4 = self.virtual_ipv4.clone().unwrap_or_default();
|
||||
if !virtual_ipv4.is_empty() {
|
||||
let ip = format!("{}/{}", virtual_ipv4, self.network_length.unwrap_or(24))
|
||||
.parse()
|
||||
.with_context(|| {
|
||||
format!(
|
||||
"failed to parse ipv4 inet address: {}, {:?}",
|
||||
virtual_ipv4, self.network_length
|
||||
)
|
||||
})?;
|
||||
cfg.set_ipv4(Some(ip));
|
||||
}
|
||||
}
|
||||
|
||||
match NetworkingMethod::try_from(self.networking_method.unwrap_or_default())
|
||||
.unwrap_or_default()
|
||||
{
|
||||
NetworkingMethod::PublicServer => {
|
||||
let peers = parse_peers(&self.peers)?;
|
||||
if peers.is_empty() {
|
||||
let public_server_url = self.public_server_url.clone().unwrap_or_default();
|
||||
cfg.set_peers(vec![PeerConfig {
|
||||
uri: public_server_url.parse().with_context(|| {
|
||||
format!("failed to parse public server uri: {}", public_server_url)
|
||||
})?,
|
||||
peer_public_key: None,
|
||||
}]);
|
||||
} else {
|
||||
cfg.set_peers(peers);
|
||||
}
|
||||
}
|
||||
NetworkingMethod::Manual => {
|
||||
let mut peers = parse_peers(&self.peers)?;
|
||||
if peers.is_empty() {
|
||||
peers = parse_peer_urls(&self.peer_urls)?;
|
||||
}
|
||||
if !peers.is_empty() {
|
||||
cfg.set_peers(peers);
|
||||
}
|
||||
}
|
||||
NetworkingMethod::Standalone => {}
|
||||
}
|
||||
|
||||
let mut listener_urls = vec![];
|
||||
for listener_url in self.listener_urls.iter() {
|
||||
if listener_url.is_empty() {
|
||||
continue;
|
||||
}
|
||||
listener_urls.push(
|
||||
listener_url
|
||||
.parse()
|
||||
.with_context(|| format!("failed to parse listener uri: {}", listener_url))?,
|
||||
);
|
||||
}
|
||||
cfg.set_listeners(listener_urls);
|
||||
|
||||
for n in self.proxy_cidrs.iter() {
|
||||
add_proxy_network_to_config(n, &cfg)?;
|
||||
}
|
||||
|
||||
if !self.port_forwards.is_empty() {
|
||||
cfg.set_port_forwards(
|
||||
self.port_forwards
|
||||
.iter()
|
||||
.filter(|pf| !pf.bind_ip.is_empty() && !pf.dst_ip.is_empty())
|
||||
.filter_map(|pf| {
|
||||
let bind_addr =
|
||||
format!("{}:{}", pf.bind_ip, pf.bind_port).parse::<SocketAddr>();
|
||||
let dst_addr =
|
||||
format!("{}:{}", pf.dst_ip, pf.dst_port).parse::<SocketAddr>();
|
||||
|
||||
match (bind_addr, dst_addr) {
|
||||
(Ok(bind_addr), Ok(dst_addr)) => Some(PortForwardConfig {
|
||||
bind_addr,
|
||||
dst_addr,
|
||||
proto: pf.proto.clone(),
|
||||
}),
|
||||
_ => None,
|
||||
}
|
||||
})
|
||||
.collect::<Vec<_>>(),
|
||||
);
|
||||
}
|
||||
|
||||
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)
|
||||
);
|
||||
cfg.set_vpn_portal_config(VpnPortalConfig {
|
||||
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 port. {:?}",
|
||||
self.vpn_portal_listen_port
|
||||
)
|
||||
})?,
|
||||
});
|
||||
}
|
||||
|
||||
if self.enable_manual_routes.unwrap_or_default() {
|
||||
let mut routes = Vec::<cidr::Ipv4Cidr>::with_capacity(self.routes.len());
|
||||
for route in self.routes.iter() {
|
||||
routes.push(
|
||||
route
|
||||
.parse()
|
||||
.with_context(|| format!("failed to parse route: {}", route))?,
|
||||
);
|
||||
}
|
||||
cfg.set_routes(Some(routes));
|
||||
}
|
||||
|
||||
if !self.exit_nodes.is_empty() {
|
||||
let mut exit_nodes = Vec::<std::net::IpAddr>::with_capacity(self.exit_nodes.len());
|
||||
for node in self.exit_nodes.iter() {
|
||||
exit_nodes.push(
|
||||
node.parse()
|
||||
.with_context(|| format!("failed to parse exit node: {}", node))?,
|
||||
);
|
||||
}
|
||||
cfg.set_exit_nodes(exit_nodes);
|
||||
}
|
||||
|
||||
if self.enable_socks5.unwrap_or_default()
|
||||
&& let Some(socks5_port) = self.socks5_port
|
||||
{
|
||||
cfg.set_socks5_portal(Some(
|
||||
format!("socks5://0.0.0.0:{}", socks5_port).parse().unwrap(),
|
||||
));
|
||||
}
|
||||
|
||||
if !self.mapped_listeners.is_empty() {
|
||||
let mapped_listeners = parse_mapped_listener_urls(&self.mapped_listeners)?;
|
||||
cfg.set_mapped_listeners(Some(mapped_listeners));
|
||||
}
|
||||
|
||||
if let Some(credential_file) = self
|
||||
.credential_file
|
||||
.as_ref()
|
||||
.filter(|path| !path.is_empty())
|
||||
{
|
||||
cfg.set_credential_file(Some(credential_file.into()));
|
||||
}
|
||||
|
||||
if let Some(credential_secret) = credential_secret {
|
||||
cfg.set_secure_mode(Some(normalize_secure_mode_config(
|
||||
easytier_proto::common::SecureModeConfig {
|
||||
enabled: true,
|
||||
local_private_key: Some(credential_secret),
|
||||
local_public_key: None,
|
||||
},
|
||||
)?));
|
||||
} else {
|
||||
cfg.set_secure_mode(
|
||||
self.secure_mode
|
||||
.clone()
|
||||
.map(normalize_secure_mode_config)
|
||||
.transpose()?,
|
||||
);
|
||||
}
|
||||
|
||||
let mut flags = gen_default_flags();
|
||||
if let Some(latency_first) = self.latency_first {
|
||||
flags.latency_first = latency_first;
|
||||
}
|
||||
|
||||
if let Some(dev_name) = self.dev_name.clone() {
|
||||
flags.dev_name = dev_name;
|
||||
}
|
||||
|
||||
if let Some(use_smoltcp) = self.use_smoltcp {
|
||||
flags.use_smoltcp = use_smoltcp;
|
||||
}
|
||||
|
||||
if let Some(ipv6_public_addr_provider) = self.ipv6_public_addr_provider {
|
||||
cfg.set_ipv6_public_addr_provider(ipv6_public_addr_provider);
|
||||
}
|
||||
|
||||
if let Some(ipv6_public_addr_auto) = self.ipv6_public_addr_auto {
|
||||
cfg.set_ipv6_public_addr_auto(ipv6_public_addr_auto);
|
||||
}
|
||||
|
||||
if let Some(ipv6_public_addr_prefix) = self
|
||||
.ipv6_public_addr_prefix
|
||||
.as_ref()
|
||||
.filter(|prefix| !prefix.is_empty())
|
||||
{
|
||||
cfg.set_ipv6_public_addr_prefix(Some(ipv6_public_addr_prefix.parse().with_context(
|
||||
|| format!("failed to parse ipv6 public address prefix: {ipv6_public_addr_prefix}"),
|
||||
)?));
|
||||
}
|
||||
|
||||
if let Some(disable_ipv6) = self.disable_ipv6 {
|
||||
flags.enable_ipv6 = !disable_ipv6;
|
||||
}
|
||||
|
||||
if let Some(enable_kcp_proxy) = self.enable_kcp_proxy {
|
||||
flags.enable_kcp_proxy = enable_kcp_proxy;
|
||||
}
|
||||
|
||||
if let Some(disable_kcp_input) = self.disable_kcp_input {
|
||||
flags.disable_kcp_input = disable_kcp_input;
|
||||
}
|
||||
|
||||
if let Some(enable_quic_proxy) = self.enable_quic_proxy {
|
||||
flags.enable_quic_proxy = enable_quic_proxy;
|
||||
}
|
||||
|
||||
if let Some(disable_quic_input) = self.disable_quic_input {
|
||||
flags.disable_quic_input = disable_quic_input;
|
||||
}
|
||||
|
||||
if let Some(disable_p2p) = self.disable_p2p {
|
||||
flags.disable_p2p = disable_p2p;
|
||||
}
|
||||
|
||||
if let Some(p2p_only) = self.p2p_only {
|
||||
flags.p2p_only = p2p_only;
|
||||
}
|
||||
|
||||
if let Some(lazy_p2p) = self.lazy_p2p {
|
||||
flags.lazy_p2p = lazy_p2p;
|
||||
}
|
||||
|
||||
if let Some(bind_device) = self.bind_device {
|
||||
flags.bind_device = bind_device;
|
||||
}
|
||||
|
||||
if self.socket_mark.is_some() {
|
||||
flags.socket_mark = self.socket_mark;
|
||||
}
|
||||
|
||||
if let Some(no_tun) = self.no_tun {
|
||||
flags.no_tun = no_tun;
|
||||
}
|
||||
|
||||
if let Some(enable_exit_node) = self.enable_exit_node {
|
||||
flags.enable_exit_node = enable_exit_node;
|
||||
}
|
||||
|
||||
if let Some(relay_all_peer_rpc) = self.relay_all_peer_rpc {
|
||||
flags.relay_all_peer_rpc = relay_all_peer_rpc;
|
||||
}
|
||||
|
||||
if let Some(need_p2p) = self.need_p2p {
|
||||
flags.need_p2p = need_p2p;
|
||||
}
|
||||
|
||||
if let Some(multi_thread) = self.multi_thread {
|
||||
flags.multi_thread = multi_thread;
|
||||
}
|
||||
|
||||
if let Some(proxy_forward_by_system) = self.proxy_forward_by_system {
|
||||
flags.proxy_forward_by_system = proxy_forward_by_system;
|
||||
}
|
||||
|
||||
if let Some(disable_encryption) = self.disable_encryption {
|
||||
flags.enable_encryption = !disable_encryption;
|
||||
}
|
||||
|
||||
if self.enable_relay_network_whitelist.unwrap_or_default() {
|
||||
if !self.relay_network_whitelist.is_empty() {
|
||||
flags.relay_network_whitelist = self.relay_network_whitelist.join(" ");
|
||||
} else {
|
||||
flags.relay_network_whitelist = "".to_string();
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(disable_tcp_hole_punching) = self.disable_tcp_hole_punching {
|
||||
flags.disable_tcp_hole_punching = disable_tcp_hole_punching;
|
||||
}
|
||||
|
||||
if let Some(disable_udp_hole_punching) = self.disable_udp_hole_punching {
|
||||
flags.disable_udp_hole_punching = disable_udp_hole_punching;
|
||||
}
|
||||
|
||||
if let Some(disable_upnp) = self.disable_upnp {
|
||||
flags.disable_upnp = disable_upnp;
|
||||
}
|
||||
|
||||
if let Some(disable_relay_data) = self.disable_relay_data {
|
||||
flags.disable_relay_data = disable_relay_data;
|
||||
}
|
||||
|
||||
if let Some(enable_udp_broadcast_relay) = self.enable_udp_broadcast_relay {
|
||||
flags.enable_udp_broadcast_relay = enable_udp_broadcast_relay;
|
||||
}
|
||||
|
||||
if let Some(disable_sym_hole_punching) = self.disable_sym_hole_punching {
|
||||
flags.disable_sym_hole_punching = disable_sym_hole_punching;
|
||||
}
|
||||
|
||||
if let Some(enable_magic_dns) = self.enable_magic_dns {
|
||||
flags.accept_dns = enable_magic_dns;
|
||||
}
|
||||
|
||||
if let Some(mtu) = self.mtu {
|
||||
flags.mtu = mtu as u32;
|
||||
}
|
||||
|
||||
if let Some(instance_recv_bps_limit) = self.instance_recv_bps_limit {
|
||||
flags.instance_recv_bps_limit = instance_recv_bps_limit;
|
||||
}
|
||||
|
||||
if let Some(enable_private_mode) = self.enable_private_mode {
|
||||
flags.private_mode = enable_private_mode;
|
||||
}
|
||||
|
||||
if let Some(encryption_algorithm) = self.encryption_algorithm.clone() {
|
||||
flags.encryption_algorithm = encryption_algorithm;
|
||||
}
|
||||
|
||||
if let Some(acl) = self.acl.as_ref()
|
||||
&& !acl.is_empty()
|
||||
{
|
||||
cfg.set_acl(Some(acl.clone()));
|
||||
}
|
||||
|
||||
if let Some(data_compress_algo) = self.data_compress_algo {
|
||||
if data_compress_algo < 1 {
|
||||
flags.data_compress_algo = 1;
|
||||
} else {
|
||||
flags.data_compress_algo = data_compress_algo
|
||||
}
|
||||
}
|
||||
|
||||
cfg.set_flags(flags);
|
||||
Ok(cfg)
|
||||
}
|
||||
|
||||
fn new_from_config(config: impl ConfigLoader) -> Result<Self, anyhow::Error> {
|
||||
let default_config = TomlConfigLoader::default();
|
||||
|
||||
let mut result = Self {
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
result.instance_id = Some(config.get_id().to_string());
|
||||
if config.get_hostname() != default_config.get_hostname() {
|
||||
result.hostname = Some(config.get_hostname());
|
||||
}
|
||||
|
||||
result.dhcp = Some(config.get_dhcp());
|
||||
|
||||
let network_identity = config.get_network_identity();
|
||||
result.network_name = Some(network_identity.network_name.clone());
|
||||
result.network_secret = network_identity.network_secret;
|
||||
|
||||
if let Some(ipv4) = config.get_ipv4() {
|
||||
result.virtual_ipv4 = Some(ipv4.address().to_string());
|
||||
result.network_length = Some(ipv4.network_length() as i32);
|
||||
}
|
||||
|
||||
if config.get_ipv6_public_addr_provider() != default_config.get_ipv6_public_addr_provider()
|
||||
{
|
||||
result.ipv6_public_addr_provider = Some(config.get_ipv6_public_addr_provider());
|
||||
}
|
||||
if config.get_ipv6_public_addr_auto() != default_config.get_ipv6_public_addr_auto() {
|
||||
result.ipv6_public_addr_auto = Some(config.get_ipv6_public_addr_auto());
|
||||
}
|
||||
result.ipv6_public_addr_prefix = config
|
||||
.get_ipv6_public_addr_prefix()
|
||||
.map(|prefix| prefix.to_string());
|
||||
|
||||
let peers = config.get_peers();
|
||||
result.networking_method = Some(NetworkingMethod::Manual as i32);
|
||||
if !peers.is_empty() {
|
||||
result.peer_urls = peers.iter().map(|p| p.uri.to_string()).collect();
|
||||
result.peers = peers
|
||||
.iter()
|
||||
.map(|p| manage::NetworkPeerConfig {
|
||||
uri: p.uri.to_string(),
|
||||
peer_public_key: p.peer_public_key.clone(),
|
||||
})
|
||||
.collect();
|
||||
}
|
||||
|
||||
result.listener_urls = config
|
||||
.get_listeners()
|
||||
.unwrap_or_default()
|
||||
.iter()
|
||||
.map(|l| l.to_string())
|
||||
.collect();
|
||||
|
||||
result.proxy_cidrs = config
|
||||
.get_proxy_cidrs()
|
||||
.iter()
|
||||
.map(|c| {
|
||||
if let Some(mapped) = c.mapped_cidr {
|
||||
format!("{}->{}", c.cidr, mapped)
|
||||
} else {
|
||||
c.cidr.to_string()
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
let port_forwards = config.get_port_forwards();
|
||||
if !port_forwards.is_empty() {
|
||||
result.port_forwards = port_forwards
|
||||
.iter()
|
||||
.map(|f| manage::PortForwardConfig {
|
||||
proto: f.proto.clone(),
|
||||
bind_ip: f.bind_addr.ip().to_string(),
|
||||
bind_port: f.bind_addr.port() as u32,
|
||||
dst_ip: f.dst_addr.ip().to_string(),
|
||||
dst_port: f.dst_addr.port() as u32,
|
||||
})
|
||||
.collect();
|
||||
}
|
||||
|
||||
if let Some(vpn_config) = config.get_vpn_portal_config() {
|
||||
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()
|
||||
&& !routes.is_empty()
|
||||
{
|
||||
result.enable_manual_routes = Some(true);
|
||||
result.routes = routes.iter().map(|r| r.to_string()).collect();
|
||||
}
|
||||
|
||||
let exit_nodes = config.get_exit_nodes();
|
||||
if !exit_nodes.is_empty() {
|
||||
result.exit_nodes = exit_nodes.iter().map(|n| n.to_string()).collect();
|
||||
}
|
||||
|
||||
if let Some(socks5_portal) = config.get_socks5_portal() {
|
||||
result.enable_socks5 = Some(true);
|
||||
result.socks5_port = socks5_portal.port().map(|p| p as i32);
|
||||
}
|
||||
|
||||
let mapped_listeners = config.get_mapped_listeners();
|
||||
if !mapped_listeners.is_empty() {
|
||||
result.mapped_listeners = mapped_listeners.iter().map(|l| l.to_string()).collect();
|
||||
}
|
||||
|
||||
result.secure_mode = config.get_secure_mode();
|
||||
result.credential_file = config
|
||||
.get_credential_file()
|
||||
.map(|path| path.to_string_lossy().into_owned());
|
||||
let flags = config.get_flags();
|
||||
let default_flags = default_config.get_flags();
|
||||
result.latency_first = Some(flags.latency_first);
|
||||
result.dev_name = Some(flags.dev_name.clone());
|
||||
result.use_smoltcp = Some(flags.use_smoltcp);
|
||||
result.disable_ipv6 = Some(!flags.enable_ipv6);
|
||||
result.enable_kcp_proxy = Some(flags.enable_kcp_proxy);
|
||||
result.disable_kcp_input = Some(flags.disable_kcp_input);
|
||||
result.enable_quic_proxy = Some(flags.enable_quic_proxy);
|
||||
result.disable_quic_input = Some(flags.disable_quic_input);
|
||||
result.disable_p2p = Some(flags.disable_p2p);
|
||||
result.p2p_only = Some(flags.p2p_only);
|
||||
result.lazy_p2p = Some(flags.lazy_p2p);
|
||||
result.bind_device = Some(flags.bind_device);
|
||||
result.socket_mark = flags.socket_mark;
|
||||
result.no_tun = Some(flags.no_tun);
|
||||
result.enable_exit_node = Some(flags.enable_exit_node);
|
||||
result.relay_all_peer_rpc = Some(flags.relay_all_peer_rpc);
|
||||
result.need_p2p = Some(flags.need_p2p);
|
||||
result.multi_thread = Some(flags.multi_thread);
|
||||
result.proxy_forward_by_system = Some(flags.proxy_forward_by_system);
|
||||
result.disable_encryption = Some(!flags.enable_encryption);
|
||||
result.disable_tcp_hole_punching = Some(flags.disable_tcp_hole_punching);
|
||||
result.disable_udp_hole_punching = Some(flags.disable_udp_hole_punching);
|
||||
result.disable_upnp = Some(flags.disable_upnp);
|
||||
result.disable_relay_data = Some(flags.disable_relay_data);
|
||||
result.enable_udp_broadcast_relay = Some(flags.enable_udp_broadcast_relay);
|
||||
result.disable_sym_hole_punching = Some(flags.disable_sym_hole_punching);
|
||||
result.enable_magic_dns = Some(flags.accept_dns);
|
||||
result.mtu = Some(flags.mtu as i32);
|
||||
result.data_compress_algo = (flags.data_compress_algo != default_flags.data_compress_algo)
|
||||
.then_some(flags.data_compress_algo);
|
||||
result.encryption_algorithm = (flags.encryption_algorithm
|
||||
!= default_flags.encryption_algorithm)
|
||||
.then_some(flags.encryption_algorithm.clone());
|
||||
result.instance_recv_bps_limit =
|
||||
(flags.instance_recv_bps_limit != u64::MAX).then_some(flags.instance_recv_bps_limit);
|
||||
result.enable_private_mode = Some(flags.private_mode);
|
||||
|
||||
result.acl = config.get_acl();
|
||||
|
||||
if flags.relay_network_whitelist == "*" {
|
||||
result.enable_relay_network_whitelist = Some(false);
|
||||
} else {
|
||||
result.enable_relay_network_whitelist = Some(true);
|
||||
if flags.relay_network_whitelist.is_empty() {
|
||||
result.relay_network_whitelist = vec![];
|
||||
} else {
|
||||
result.relay_network_whitelist = flags
|
||||
.relay_network_whitelist
|
||||
.split_whitespace()
|
||||
.map(|s| s.to_string())
|
||||
.collect();
|
||||
}
|
||||
}
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
use wasm_bindgen::prelude::*;
|
||||
|
||||
use super::{
|
||||
api_input::{NetworkConfig, NetworkConfigExt},
|
||||
toml::{ConfigLoader, TomlConfig},
|
||||
};
|
||||
|
||||
fn js_error(error: impl std::fmt::Debug) -> JsValue {
|
||||
JsValue::from_str(&format!("{error:?}"))
|
||||
}
|
||||
|
||||
#[wasm_bindgen]
|
||||
pub fn generate_config(config_json: &str) -> Result<String, JsValue> {
|
||||
let config: NetworkConfig = serde_json::from_str(config_json).map_err(js_error)?;
|
||||
config
|
||||
.gen_config()
|
||||
.map(|config| config.dump())
|
||||
.map_err(js_error)
|
||||
}
|
||||
|
||||
#[wasm_bindgen]
|
||||
pub fn parse_config(toml_config: &str) -> Result<String, JsValue> {
|
||||
let config = TomlConfig::new_from_str(toml_config)
|
||||
.and_then(|config| NetworkConfig::new_from_config(&config))
|
||||
.map_err(js_error)?;
|
||||
serde_json::to_string(&config).map_err(js_error)
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
use std::{fmt, str::FromStr};
|
||||
|
||||
use strum::VariantArray;
|
||||
|
||||
/// Stable configuration vocabulary for every known encryption algorithm.
|
||||
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, VariantArray)]
|
||||
pub enum EncryptionAlgorithm {
|
||||
Xor,
|
||||
#[default]
|
||||
AesGcm,
|
||||
Aes256Gcm,
|
||||
ChaCha20,
|
||||
}
|
||||
|
||||
impl EncryptionAlgorithm {
|
||||
pub const fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::Xor => "xor",
|
||||
Self::AesGcm => "aes-gcm",
|
||||
Self::Aes256Gcm => "aes-256-gcm",
|
||||
Self::ChaCha20 => "chacha20",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for EncryptionAlgorithm {
|
||||
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
formatter.write_str(self.as_str())
|
||||
}
|
||||
}
|
||||
|
||||
impl FromStr for EncryptionAlgorithm {
|
||||
type Err = ();
|
||||
|
||||
fn from_str(value: &str) -> Result<Self, Self::Err> {
|
||||
match value.to_ascii_lowercase().as_str() {
|
||||
"xor" => Ok(Self::Xor),
|
||||
"aes-gcm" | "openssl-aes-gcm" => Ok(Self::AesGcm),
|
||||
"aes-256-gcm" | "openssl-aes-256-gcm" => Ok(Self::Aes256Gcm),
|
||||
"chacha20" | "chacha20-poly1305" | "openssl-chacha20" => Ok(Self::ChaCha20),
|
||||
_ => Err(()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn known_algorithm_names_are_stable() {
|
||||
let cases = [
|
||||
("xor", EncryptionAlgorithm::Xor),
|
||||
("aes-gcm", EncryptionAlgorithm::AesGcm),
|
||||
("aes-256-gcm", EncryptionAlgorithm::Aes256Gcm),
|
||||
("chacha20", EncryptionAlgorithm::ChaCha20),
|
||||
("chacha20-poly1305", EncryptionAlgorithm::ChaCha20),
|
||||
("openssl-aes-gcm", EncryptionAlgorithm::AesGcm),
|
||||
("openssl-aes-256-gcm", EncryptionAlgorithm::Aes256Gcm),
|
||||
("openssl-chacha20", EncryptionAlgorithm::ChaCha20),
|
||||
];
|
||||
|
||||
for (name, expected) in cases {
|
||||
assert_eq!(name.parse(), Ok(expected));
|
||||
}
|
||||
assert_eq!(EncryptionAlgorithm::ChaCha20.to_string(), "chacha20");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn aes_is_the_stable_default() {
|
||||
assert_eq!(EncryptionAlgorithm::default(), EncryptionAlgorithm::AesGcm);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
use std::net::SocketAddr;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use easytier_proto::common::{PortForwardConfigPb, SocketType};
|
||||
|
||||
/// Runtime configuration for the core-owned SOCKS and port-forward gateway.
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct GatewayRuntimeConfig {
|
||||
pub socks5_bind: Option<SocketAddr>,
|
||||
pub port_forwards: Vec<PortForwardConfig>,
|
||||
}
|
||||
|
||||
/// One TCP or UDP port-forward rule.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
pub struct PortForwardConfig {
|
||||
pub bind_addr: SocketAddr,
|
||||
pub dst_addr: SocketAddr,
|
||||
pub proto: String,
|
||||
}
|
||||
|
||||
impl From<PortForwardConfigPb> for PortForwardConfig {
|
||||
fn from(config: PortForwardConfigPb) -> Self {
|
||||
Self {
|
||||
bind_addr: config.bind_addr.unwrap_or_default().into(),
|
||||
dst_addr: config.dst_addr.unwrap_or_default().into(),
|
||||
proto: match SocketType::try_from(config.socket_type) {
|
||||
Ok(SocketType::Tcp) => "tcp".to_string(),
|
||||
Ok(SocketType::Udp) => "udp".to_string(),
|
||||
_ => "tcp".to_string(),
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<PortForwardConfig> for PortForwardConfigPb {
|
||||
fn from(config: PortForwardConfig) -> Self {
|
||||
Self {
|
||||
bind_addr: Some(config.bind_addr.into()),
|
||||
dst_addr: Some(config.dst_addr.into()),
|
||||
socket_type: match config.proto.to_lowercase().as_str() {
|
||||
"tcp" => SocketType::Tcp as i32,
|
||||
"udp" => SocketType::Udp as i32,
|
||||
_ => SocketType::Tcp as i32,
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct ProxyRuntimeConfig {
|
||||
pub enable_exit_node: bool,
|
||||
pub no_tun: bool,
|
||||
pub forward_by_system: bool,
|
||||
pub force_smoltcp: bool,
|
||||
pub icmp_failure_is_fatal: bool,
|
||||
pub udp_response_ipv4_mtu: usize,
|
||||
}
|
||||
|
||||
impl ProxyRuntimeConfig {
|
||||
pub fn should_start(self, has_proxy_networks: bool) -> bool {
|
||||
if !has_proxy_networks && !self.enable_exit_node && !self.no_tun {
|
||||
return false;
|
||||
}
|
||||
|
||||
!self.forward_by_system || self.no_tun
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for ProxyRuntimeConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
enable_exit_node: false,
|
||||
no_tun: false,
|
||||
forward_by_system: false,
|
||||
force_smoltcp: false,
|
||||
icmp_failure_is_fatal: false,
|
||||
udp_response_ipv4_mtu: 1280,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::ProxyRuntimeConfig;
|
||||
|
||||
#[test]
|
||||
fn proxy_startup_policy_preserves_runtime_modes() {
|
||||
assert!(!ProxyRuntimeConfig::default().should_start(false));
|
||||
assert!(ProxyRuntimeConfig::default().should_start(true));
|
||||
assert!(
|
||||
ProxyRuntimeConfig {
|
||||
enable_exit_node: true,
|
||||
..Default::default()
|
||||
}
|
||||
.should_start(false)
|
||||
);
|
||||
assert!(
|
||||
ProxyRuntimeConfig {
|
||||
no_tun: true,
|
||||
..Default::default()
|
||||
}
|
||||
.should_start(false)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn proxy_startup_policy_preserves_system_forwarding_rules() {
|
||||
assert!(
|
||||
!ProxyRuntimeConfig {
|
||||
forward_by_system: true,
|
||||
..Default::default()
|
||||
}
|
||||
.should_start(true)
|
||||
);
|
||||
assert!(
|
||||
!ProxyRuntimeConfig {
|
||||
enable_exit_node: true,
|
||||
forward_by_system: true,
|
||||
..Default::default()
|
||||
}
|
||||
.should_start(false)
|
||||
);
|
||||
assert!(
|
||||
ProxyRuntimeConfig {
|
||||
no_tun: true,
|
||||
forward_by_system: true,
|
||||
..Default::default()
|
||||
}
|
||||
.should_start(false)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn proxy_runtime_defaults_preserve_udp_mtu() {
|
||||
assert_eq!(ProxyRuntimeConfig::default().udp_response_ipv4_mtu, 1280);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,810 @@
|
||||
//! Static configuration schema plus the live runtime configuration store.
|
||||
|
||||
#[cfg(feature = "web-client")]
|
||||
pub mod api;
|
||||
#[cfg(any(feature = "web-client", feature = "browser-config"))]
|
||||
pub mod api_input;
|
||||
#[cfg(all(
|
||||
feature = "browser-config",
|
||||
target_arch = "wasm32",
|
||||
target_os = "unknown"
|
||||
))]
|
||||
mod browser;
|
||||
mod encryption;
|
||||
pub mod gateway;
|
||||
pub mod peers;
|
||||
pub mod runtime;
|
||||
pub mod toml;
|
||||
|
||||
pub use encryption::EncryptionAlgorithm;
|
||||
|
||||
pub(crate) const DEFAULT_UDP_STUN_SERVERS: &[&str] = &[
|
||||
"txt:stun.easytier.cn",
|
||||
"stun.miwifi.com",
|
||||
"stun.chat.bilibili.com",
|
||||
"stun.hitv.com",
|
||||
];
|
||||
pub(crate) const DEFAULT_TCP_STUN_SERVERS: &[&str] = &[
|
||||
"stun.hot-chilli.net",
|
||||
"stun.fitauto.ru",
|
||||
"fwa.lifesizecloud.com",
|
||||
"global.turn.twilio.com",
|
||||
"turn.cloudflare.com",
|
||||
"stun.voip.blackberry.com",
|
||||
"stun.radiojar.com",
|
||||
];
|
||||
pub(crate) const DEFAULT_UDP_V6_STUN_SERVERS: &[&str] = &["txt:stun-v6.easytier.cn"];
|
||||
|
||||
pub(crate) fn default_stun_servers(servers: &[&str]) -> Vec<String> {
|
||||
servers.iter().map(ToString::to_string).collect()
|
||||
}
|
||||
|
||||
use std::{
|
||||
collections::{BTreeSet, hash_map::DefaultHasher},
|
||||
hash::{Hash, Hasher},
|
||||
net::IpAddr,
|
||||
};
|
||||
|
||||
use anyhow::Context as _;
|
||||
use base64::{Engine as _, prelude::BASE64_STANDARD};
|
||||
use easytier_proto::{common as common_pb, core_config as pb};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use url::Url;
|
||||
|
||||
pub type PeerId = u32;
|
||||
|
||||
pub type NetworkSecretDigest = [u8; 32];
|
||||
|
||||
/// Host capabilities used by the portable mapped-listener validation rule.
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
||||
pub struct MappedListenerPolicy {
|
||||
implicit_port_schemes: BTreeSet<String>,
|
||||
}
|
||||
|
||||
impl MappedListenerPolicy {
|
||||
pub fn new<I, S>(implicit_port_schemes: I) -> Self
|
||||
where
|
||||
I: IntoIterator<Item = S>,
|
||||
S: Into<String>,
|
||||
{
|
||||
Self {
|
||||
implicit_port_schemes: implicit_port_schemes
|
||||
.into_iter()
|
||||
.map(Into::into)
|
||||
.map(|scheme: String| scheme.to_ascii_lowercase())
|
||||
.collect(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn validate(&self, url: &Url) -> anyhow::Result<()> {
|
||||
if url.port().is_none() && !self.implicit_port_schemes.contains(url.scheme()) {
|
||||
anyhow::bail!("mapped listener port is missing: {}", url);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn parse_urls(&self, mapped_listeners: &[String]) -> anyhow::Result<Vec<Url>> {
|
||||
mapped_listeners
|
||||
.iter()
|
||||
.map(|value| {
|
||||
let url: Url = value
|
||||
.parse()
|
||||
.with_context(|| format!("mapped listener is not a valid url: {}", value))?;
|
||||
self.validate(&url)?;
|
||||
Ok(url)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
/// Completes and validates the portable secure-mode key configuration.
|
||||
pub fn normalize_secure_mode_config(
|
||||
mut config: common_pb::SecureModeConfig,
|
||||
) -> anyhow::Result<common_pb::SecureModeConfig> {
|
||||
if !config.enabled {
|
||||
return Ok(config);
|
||||
}
|
||||
|
||||
let private_key = if config.local_private_key.is_none() {
|
||||
let private = x25519_dalek::StaticSecret::random_from_rng(rand::rngs::OsRng);
|
||||
config.local_private_key = Some(BASE64_STANDARD.encode(private.as_bytes()));
|
||||
private
|
||||
} else {
|
||||
config.private_key()?
|
||||
};
|
||||
let generated_public_key = x25519_dalek::PublicKey::from(&private_key);
|
||||
let generated_public_key = BASE64_STANDARD.encode(generated_public_key.as_bytes());
|
||||
|
||||
match config.local_public_key.as_ref() {
|
||||
None => config.local_public_key = Some(generated_public_key),
|
||||
Some(configured_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,
|
||||
canonical_public_key
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(config)
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct NetworkIdentity {
|
||||
pub network_name: String,
|
||||
pub network_secret: Option<String>,
|
||||
pub network_secret_digest: Option<NetworkSecretDigest>,
|
||||
}
|
||||
|
||||
impl NetworkIdentity {
|
||||
pub fn new(network_name: String, network_secret: String) -> Self {
|
||||
Self {
|
||||
network_secret_digest: Some(network_secret_digest(&network_name, &network_secret)),
|
||||
network_name,
|
||||
network_secret: Some(network_secret),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn new_credential(network_name: String) -> Self {
|
||||
Self {
|
||||
network_name,
|
||||
network_secret: None,
|
||||
network_secret_digest: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn secret_digest(&self) -> Option<NetworkSecretDigest> {
|
||||
if self.network_secret_digest.is_some() {
|
||||
self.network_secret_digest
|
||||
} else if let Some(network_secret) = &self.network_secret {
|
||||
let mut network_secret_digest = [0u8; 32];
|
||||
generate_digest_from_str(
|
||||
&self.network_name,
|
||||
network_secret,
|
||||
&mut network_secret_digest,
|
||||
);
|
||||
Some(network_secret_digest)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
pub fn with_secret_digest(mut self) -> Self {
|
||||
self.network_secret_digest = self.secret_digest();
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Eq, PartialEq, Hash)]
|
||||
struct NetworkIdentityWithOnlyDigest {
|
||||
network_name: String,
|
||||
network_secret_digest: Option<NetworkSecretDigest>,
|
||||
}
|
||||
|
||||
fn generate_digest_from_str(str1: &str, str2: &str, digest: &mut [u8]) {
|
||||
let mut hasher = DefaultHasher::new();
|
||||
hasher.write(str1.as_bytes());
|
||||
hasher.write(str2.as_bytes());
|
||||
|
||||
assert_eq!(digest.len() % 8, 0, "digest length must be multiple of 8");
|
||||
|
||||
let shard_count = digest.len() / 8;
|
||||
for i in 0..shard_count {
|
||||
digest[i * 8..(i + 1) * 8].copy_from_slice(&hasher.finish().to_be_bytes());
|
||||
hasher.write(&digest[..(i + 1) * 8]);
|
||||
}
|
||||
}
|
||||
|
||||
fn network_secret_digest(network_name: &str, network_secret: &str) -> NetworkSecretDigest {
|
||||
let mut digest = [0u8; 32];
|
||||
generate_digest_from_str(network_name, network_secret, &mut digest);
|
||||
digest
|
||||
}
|
||||
|
||||
impl From<NetworkIdentity> for NetworkIdentityWithOnlyDigest {
|
||||
fn from(identity: NetworkIdentity) -> Self {
|
||||
Self {
|
||||
network_secret_digest: identity.secret_digest(),
|
||||
network_name: identity.network_name,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl PartialEq for NetworkIdentity {
|
||||
fn eq(&self, other: &Self) -> bool {
|
||||
let self_with_digest = NetworkIdentityWithOnlyDigest::from(self.clone());
|
||||
let other_with_digest = NetworkIdentityWithOnlyDigest::from(other.clone());
|
||||
self_with_digest == other_with_digest
|
||||
}
|
||||
}
|
||||
|
||||
impl Eq for NetworkIdentity {}
|
||||
|
||||
impl Hash for NetworkIdentity {
|
||||
fn hash<H: Hasher>(&self, state: &mut H) {
|
||||
let self_with_digest = NetworkIdentityWithOnlyDigest::from(self.clone());
|
||||
self_with_digest.hash(state);
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for NetworkIdentity {
|
||||
fn default() -> Self {
|
||||
Self::new("default".to_string(), "".to_string())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
|
||||
pub struct CoreConfig {
|
||||
pub node: NodeConfig,
|
||||
pub routes: RouteConfig,
|
||||
pub peer_policy: PeerPolicyConfig,
|
||||
pub traffic: TrafficConfig,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
|
||||
pub struct NodeConfig {
|
||||
pub peer_id: Option<PeerId>,
|
||||
pub instance_id: Option<[u8; 16]>,
|
||||
pub hostname: Option<String>,
|
||||
pub network_name: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
|
||||
pub struct RouteConfig {
|
||||
pub ipv4: Option<IpPrefix>,
|
||||
pub ipv6: Option<IpPrefix>,
|
||||
pub advertised_routes: Vec<IpPrefix>,
|
||||
pub proxy_networks: Vec<ProxyNetworkConfig>,
|
||||
pub foreign_networks: Vec<ForeignNetworkConfig>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct IpPrefix {
|
||||
pub address: IpAddr,
|
||||
pub prefix_len: u8,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct ProxyNetworkConfig {
|
||||
pub real: IpPrefix,
|
||||
pub mapped: Option<IpPrefix>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct ForeignNetworkConfig {
|
||||
pub name: String,
|
||||
pub cidrs: Vec<IpPrefix>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct PeerPolicyConfig {
|
||||
pub p2p_enabled: bool,
|
||||
pub relay_peer_rpc: bool,
|
||||
pub relay_data: bool,
|
||||
pub latency_first: bool,
|
||||
pub encryption_required: bool,
|
||||
}
|
||||
|
||||
impl Default for PeerPolicyConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
p2p_enabled: true,
|
||||
relay_peer_rpc: false,
|
||||
relay_data: true,
|
||||
latency_first: false,
|
||||
encryption_required: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||
pub struct P2pPolicyFlags {
|
||||
pub disable_udp_hole_punching: bool,
|
||||
pub disable_sym_hole_punching: bool,
|
||||
pub disable_upnp: bool,
|
||||
pub lazy_p2p: bool,
|
||||
pub disable_p2p: bool,
|
||||
pub need_p2p: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
|
||||
pub struct TrafficConfig {
|
||||
pub mtu: Option<u16>,
|
||||
pub instance_recv_bps_limit: Option<u64>,
|
||||
pub foreign_relay_bps_limit: Option<u64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
|
||||
pub enum ConfigError {
|
||||
#[error("missing required field: {0}")]
|
||||
MissingField(&'static str),
|
||||
#[error("invalid IPv4 prefix length: {0}")]
|
||||
InvalidIpv4Prefix(u8),
|
||||
#[error("invalid IPv6 prefix length: {0}")]
|
||||
InvalidIpv6Prefix(u8),
|
||||
#[error("invalid MTU: {0}")]
|
||||
InvalidMtu(u32),
|
||||
}
|
||||
|
||||
impl IpPrefix {
|
||||
pub fn new(address: IpAddr, prefix_len: u8) -> Result<Self, ConfigError> {
|
||||
match address {
|
||||
IpAddr::V4(_) if prefix_len <= 32 => Ok(Self {
|
||||
address,
|
||||
prefix_len,
|
||||
}),
|
||||
IpAddr::V4(_) => Err(ConfigError::InvalidIpv4Prefix(prefix_len)),
|
||||
IpAddr::V6(_) if prefix_len <= 128 => Ok(Self {
|
||||
address,
|
||||
prefix_len,
|
||||
}),
|
||||
IpAddr::V6(_) => Err(ConfigError::InvalidIpv6Prefix(prefix_len)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl TryFrom<pb::CoreConfig> for CoreConfig {
|
||||
type Error = ConfigError;
|
||||
|
||||
fn try_from(value: pb::CoreConfig) -> Result<Self, Self::Error> {
|
||||
Ok(Self {
|
||||
node: value
|
||||
.node
|
||||
.map(TryInto::try_into)
|
||||
.transpose()?
|
||||
.unwrap_or_default(),
|
||||
routes: value
|
||||
.routes
|
||||
.map(TryInto::try_into)
|
||||
.transpose()?
|
||||
.unwrap_or_default(),
|
||||
peer_policy: value.peer_policy.map(Into::into).unwrap_or_default(),
|
||||
traffic: value
|
||||
.traffic
|
||||
.map(TryInto::try_into)
|
||||
.transpose()?
|
||||
.unwrap_or_default(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl From<CoreConfig> for pb::CoreConfig {
|
||||
fn from(value: CoreConfig) -> Self {
|
||||
Self {
|
||||
node: Some(value.node.into()),
|
||||
routes: Some(value.routes.into()),
|
||||
peer_policy: Some(value.peer_policy.into()),
|
||||
traffic: Some(value.traffic.into()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl TryFrom<pb::NodeConfig> for NodeConfig {
|
||||
type Error = ConfigError;
|
||||
|
||||
fn try_from(value: pb::NodeConfig) -> Result<Self, Self::Error> {
|
||||
Ok(Self {
|
||||
peer_id: value.peer_id,
|
||||
instance_id: value.instance_id.map(uuid_to_bytes),
|
||||
hostname: value.hostname,
|
||||
network_name: value.network_name,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl From<NodeConfig> for pb::NodeConfig {
|
||||
fn from(value: NodeConfig) -> Self {
|
||||
Self {
|
||||
peer_id: value.peer_id,
|
||||
instance_id: value.instance_id.map(uuid_from_bytes),
|
||||
hostname: value.hostname,
|
||||
network_name: value.network_name,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl TryFrom<pb::RouteConfig> for RouteConfig {
|
||||
type Error = ConfigError;
|
||||
|
||||
fn try_from(value: pb::RouteConfig) -> Result<Self, Self::Error> {
|
||||
Ok(Self {
|
||||
ipv4: value.ipv4.map(TryInto::try_into).transpose()?,
|
||||
ipv6: value.ipv6.map(TryInto::try_into).transpose()?,
|
||||
advertised_routes: value
|
||||
.advertised_routes
|
||||
.into_iter()
|
||||
.map(TryInto::try_into)
|
||||
.collect::<Result<_, _>>()?,
|
||||
proxy_networks: value
|
||||
.proxy_networks
|
||||
.into_iter()
|
||||
.map(TryInto::try_into)
|
||||
.collect::<Result<_, _>>()?,
|
||||
foreign_networks: value
|
||||
.foreign_networks
|
||||
.into_iter()
|
||||
.map(TryInto::try_into)
|
||||
.collect::<Result<_, _>>()?,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl From<RouteConfig> for pb::RouteConfig {
|
||||
fn from(value: RouteConfig) -> Self {
|
||||
Self {
|
||||
ipv4: value.ipv4.map(Into::into),
|
||||
ipv6: value.ipv6.map(Into::into),
|
||||
advertised_routes: value
|
||||
.advertised_routes
|
||||
.into_iter()
|
||||
.map(Into::into)
|
||||
.collect(),
|
||||
proxy_networks: value.proxy_networks.into_iter().map(Into::into).collect(),
|
||||
foreign_networks: value.foreign_networks.into_iter().map(Into::into).collect(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl TryFrom<pb::IpPrefix> for IpPrefix {
|
||||
type Error = ConfigError;
|
||||
|
||||
fn try_from(value: pb::IpPrefix) -> Result<Self, Self::Error> {
|
||||
let address = pb_ip_addr_to_std(
|
||||
value
|
||||
.address
|
||||
.ok_or(ConfigError::MissingField("IpPrefix.address"))?,
|
||||
)?;
|
||||
let prefix_len = u8::try_from(value.prefix_len)
|
||||
.map_err(|_| invalid_prefix_for_address(address, value.prefix_len))?;
|
||||
Self::new(address, prefix_len)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<IpPrefix> for pb::IpPrefix {
|
||||
fn from(value: IpPrefix) -> Self {
|
||||
Self {
|
||||
address: Some(value.address.into()),
|
||||
prefix_len: value.prefix_len.into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl TryFrom<pb::ProxyNetworkConfig> for ProxyNetworkConfig {
|
||||
type Error = ConfigError;
|
||||
|
||||
fn try_from(value: pb::ProxyNetworkConfig) -> Result<Self, Self::Error> {
|
||||
Ok(Self {
|
||||
real: value
|
||||
.real
|
||||
.ok_or(ConfigError::MissingField("ProxyNetworkConfig.real"))?
|
||||
.try_into()?,
|
||||
mapped: value.mapped.map(TryInto::try_into).transpose()?,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl From<ProxyNetworkConfig> for pb::ProxyNetworkConfig {
|
||||
fn from(value: ProxyNetworkConfig) -> Self {
|
||||
Self {
|
||||
real: Some(value.real.into()),
|
||||
mapped: value.mapped.map(Into::into),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl TryFrom<pb::ForeignNetworkConfig> for ForeignNetworkConfig {
|
||||
type Error = ConfigError;
|
||||
|
||||
fn try_from(value: pb::ForeignNetworkConfig) -> Result<Self, Self::Error> {
|
||||
Ok(Self {
|
||||
name: value.name,
|
||||
cidrs: value
|
||||
.cidrs
|
||||
.into_iter()
|
||||
.map(TryInto::try_into)
|
||||
.collect::<Result<_, _>>()?,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl From<ForeignNetworkConfig> for pb::ForeignNetworkConfig {
|
||||
fn from(value: ForeignNetworkConfig) -> Self {
|
||||
Self {
|
||||
name: value.name,
|
||||
cidrs: value.cidrs.into_iter().map(Into::into).collect(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<pb::PeerPolicyConfig> for PeerPolicyConfig {
|
||||
fn from(value: pb::PeerPolicyConfig) -> Self {
|
||||
let default = Self::default();
|
||||
Self {
|
||||
p2p_enabled: value.p2p_enabled.unwrap_or(default.p2p_enabled),
|
||||
relay_peer_rpc: value.relay_peer_rpc.unwrap_or(default.relay_peer_rpc),
|
||||
relay_data: value.relay_data.unwrap_or(default.relay_data),
|
||||
latency_first: value.latency_first.unwrap_or(default.latency_first),
|
||||
encryption_required: value
|
||||
.encryption_required
|
||||
.unwrap_or(default.encryption_required),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<PeerPolicyConfig> for pb::PeerPolicyConfig {
|
||||
fn from(value: PeerPolicyConfig) -> Self {
|
||||
Self {
|
||||
p2p_enabled: Some(value.p2p_enabled),
|
||||
relay_peer_rpc: Some(value.relay_peer_rpc),
|
||||
relay_data: Some(value.relay_data),
|
||||
latency_first: Some(value.latency_first),
|
||||
encryption_required: Some(value.encryption_required),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl TryFrom<pb::TrafficConfig> for TrafficConfig {
|
||||
type Error = ConfigError;
|
||||
|
||||
fn try_from(value: pb::TrafficConfig) -> Result<Self, Self::Error> {
|
||||
Ok(Self {
|
||||
mtu: value
|
||||
.mtu
|
||||
.map(|mtu| u16::try_from(mtu).map_err(|_| ConfigError::InvalidMtu(mtu)))
|
||||
.transpose()?,
|
||||
instance_recv_bps_limit: value.instance_recv_bps_limit,
|
||||
foreign_relay_bps_limit: value.foreign_relay_bps_limit,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl From<TrafficConfig> for pb::TrafficConfig {
|
||||
fn from(value: TrafficConfig) -> Self {
|
||||
Self {
|
||||
mtu: value.mtu.map(Into::into),
|
||||
instance_recv_bps_limit: value.instance_recv_bps_limit,
|
||||
foreign_relay_bps_limit: value.foreign_relay_bps_limit,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn pb_ip_addr_to_std(value: common_pb::IpAddr) -> Result<IpAddr, ConfigError> {
|
||||
match value.ip.ok_or(ConfigError::MissingField("IpAddr.ip"))? {
|
||||
common_pb::ip_addr::Ip::Ipv4(addr) => Ok(IpAddr::V4(addr.into())),
|
||||
common_pb::ip_addr::Ip::Ipv6(addr) => Ok(IpAddr::V6(addr.into())),
|
||||
}
|
||||
}
|
||||
|
||||
fn invalid_prefix_for_address(address: IpAddr, prefix_len: u32) -> ConfigError {
|
||||
let prefix_len = u8::try_from(prefix_len).unwrap_or(u8::MAX);
|
||||
match address {
|
||||
IpAddr::V4(_) => ConfigError::InvalidIpv4Prefix(prefix_len),
|
||||
IpAddr::V6(_) => ConfigError::InvalidIpv6Prefix(prefix_len),
|
||||
}
|
||||
}
|
||||
|
||||
fn uuid_to_bytes(value: common_pb::Uuid) -> [u8; 16] {
|
||||
let mut bytes = [0; 16];
|
||||
bytes[0..4].copy_from_slice(&value.part1.to_be_bytes());
|
||||
bytes[4..8].copy_from_slice(&value.part2.to_be_bytes());
|
||||
bytes[8..12].copy_from_slice(&value.part3.to_be_bytes());
|
||||
bytes[12..16].copy_from_slice(&value.part4.to_be_bytes());
|
||||
bytes
|
||||
}
|
||||
|
||||
fn uuid_from_bytes(bytes: [u8; 16]) -> common_pb::Uuid {
|
||||
common_pb::Uuid {
|
||||
part1: u32::from_be_bytes([bytes[0], bytes[1], bytes[2], bytes[3]]),
|
||||
part2: u32::from_be_bytes([bytes[4], bytes[5], bytes[6], bytes[7]]),
|
||||
part3: u32::from_be_bytes([bytes[8], bytes[9], bytes[10], bytes[11]]),
|
||||
part4: u32::from_be_bytes([bytes[12], bytes[13], bytes[14], bytes[15]]),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use base64::prelude::BASE64_STANDARD;
|
||||
use x25519_dalek::{PublicKey, StaticSecret};
|
||||
|
||||
fn digest(network_name: &str, network_secret: &str) -> NetworkSecretDigest {
|
||||
let mut digest = [0u8; 32];
|
||||
generate_digest_from_str(network_name, network_secret, &mut digest);
|
||||
digest
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn network_identity_matches_secret_to_digest_identity() {
|
||||
let local = NetworkIdentity {
|
||||
network_name: "net".to_string(),
|
||||
network_secret: Some("secret".to_string()),
|
||||
network_secret_digest: None,
|
||||
};
|
||||
let remote = NetworkIdentity {
|
||||
network_name: "net".to_string(),
|
||||
network_secret: None,
|
||||
network_secret_digest: Some(digest("net", "secret")),
|
||||
};
|
||||
|
||||
assert_eq!(local, remote);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn network_identity_rejects_different_digest() {
|
||||
let local = NetworkIdentity {
|
||||
network_name: "net".to_string(),
|
||||
network_secret: Some("secret".to_string()),
|
||||
network_secret_digest: None,
|
||||
};
|
||||
let remote = NetworkIdentity {
|
||||
network_name: "net".to_string(),
|
||||
network_secret: None,
|
||||
network_secret_digest: Some(digest("net", "other")),
|
||||
};
|
||||
|
||||
assert_ne!(local, remote);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn network_identity_equal_values_have_equal_hash() {
|
||||
let local = NetworkIdentity {
|
||||
network_name: "net".to_string(),
|
||||
network_secret: Some("secret".to_string()),
|
||||
network_secret_digest: None,
|
||||
};
|
||||
let remote = NetworkIdentity {
|
||||
network_name: "net".to_string(),
|
||||
network_secret: None,
|
||||
network_secret_digest: Some(digest("net", "secret")),
|
||||
};
|
||||
let mut local_hasher = DefaultHasher::new();
|
||||
let mut remote_hasher = DefaultHasher::new();
|
||||
|
||||
local.hash(&mut local_hasher);
|
||||
remote.hash(&mut remote_hasher);
|
||||
|
||||
assert_eq!(local_hasher.finish(), remote_hasher.finish());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn network_identity_derives_digest_from_plaintext_secret() {
|
||||
let identity = NetworkIdentity {
|
||||
network_name: "net".to_string(),
|
||||
network_secret: Some("secret".to_string()),
|
||||
network_secret_digest: None,
|
||||
};
|
||||
|
||||
assert_eq!(identity.secret_digest(), Some(digest("net", "secret")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn network_identity_default_matches_native_default_network() {
|
||||
assert_eq!(
|
||||
NetworkIdentity::default(),
|
||||
NetworkIdentity::new("default".to_string(), "".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mapped_listener_policy_uses_explicit_host_capabilities() {
|
||||
let policy = MappedListenerPolicy::new(["tcp", "ws", "wss"]);
|
||||
let parsed = policy
|
||||
.parse_urls(&[
|
||||
"tcp://127.0.0.1".to_string(),
|
||||
"ws://example.com".to_string(),
|
||||
"wss://example.com/path".to_string(),
|
||||
"ring://peer-id:1000".to_string(),
|
||||
])
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(parsed.len(), 4);
|
||||
assert_eq!(parsed[0].scheme(), "tcp");
|
||||
assert_eq!(parsed[1].scheme(), "ws");
|
||||
assert_eq!(parsed[2].scheme(), "wss");
|
||||
assert_eq!(parsed[3].port(), Some(1000));
|
||||
|
||||
let error = policy
|
||||
.parse_urls(&["ring://peer-id".to_string()])
|
||||
.unwrap_err();
|
||||
assert!(
|
||||
error
|
||||
.to_string()
|
||||
.contains("mapped listener port is missing")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn secure_mode_normalization_generates_missing_key_pair() {
|
||||
let normalized = normalize_secure_mode_config(common_pb::SecureModeConfig {
|
||||
enabled: true,
|
||||
local_private_key: None,
|
||||
local_public_key: None,
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
let private_key = normalized.private_key().unwrap();
|
||||
let public_key = normalized.public_key().unwrap();
|
||||
assert_eq!(public_key, PublicKey::from(&private_key));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn secure_mode_normalization_preserves_existing_key_configuration() {
|
||||
let private_key = StaticSecret::from([7; 32]);
|
||||
let public_key = PublicKey::from(&private_key);
|
||||
let config = common_pb::SecureModeConfig {
|
||||
enabled: true,
|
||||
local_private_key: Some(BASE64_STANDARD.encode(private_key.as_bytes())),
|
||||
local_public_key: Some(BASE64_STANDARD.encode(public_key.as_bytes())),
|
||||
};
|
||||
|
||||
assert_eq!(
|
||||
normalize_secure_mode_config(config.clone()).unwrap(),
|
||||
config
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn disabled_secure_mode_does_not_validate_keys() {
|
||||
let config = common_pb::SecureModeConfig {
|
||||
enabled: false,
|
||||
local_private_key: Some("not-base64".to_string()),
|
||||
local_public_key: Some("not-base64".to_string()),
|
||||
};
|
||||
|
||||
assert_eq!(
|
||||
normalize_secure_mode_config(config.clone()).unwrap(),
|
||||
config
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validates_ip_prefix_lengths() {
|
||||
assert!(IpPrefix::new("10.0.0.1".parse().unwrap(), 24).is_ok());
|
||||
assert_eq!(
|
||||
IpPrefix::new("10.0.0.1".parse().unwrap(), 33),
|
||||
Err(ConfigError::InvalidIpv4Prefix(33))
|
||||
);
|
||||
assert!(IpPrefix::new("2001:db8::1".parse().unwrap(), 64).is_ok());
|
||||
assert_eq!(
|
||||
IpPrefix::new("2001:db8::1".parse().unwrap(), 129),
|
||||
Err(ConfigError::InvalidIpv6Prefix(129))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn converts_core_config_from_proto_defaults() {
|
||||
let config = CoreConfig::try_from(pb::CoreConfig {
|
||||
node: Some(pb::NodeConfig {
|
||||
peer_id: Some(7),
|
||||
instance_id: None,
|
||||
hostname: Some("node-a".to_string()),
|
||||
network_name: "net".to_string(),
|
||||
}),
|
||||
routes: None,
|
||||
peer_policy: None,
|
||||
traffic: Some(pb::TrafficConfig {
|
||||
mtu: Some(1380),
|
||||
instance_recv_bps_limit: Some(100),
|
||||
foreign_relay_bps_limit: None,
|
||||
}),
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(config.node.peer_id, Some(7));
|
||||
assert_eq!(config.node.hostname.as_deref(), Some("node-a"));
|
||||
assert!(config.peer_policy.p2p_enabled);
|
||||
assert_eq!(config.traffic.mtu, Some(1380));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn converts_ip_prefix_round_trip() {
|
||||
let prefix = IpPrefix::new("10.1.0.1".parse().unwrap(), 16).unwrap();
|
||||
let pb: pb::IpPrefix = prefix.clone().into();
|
||||
assert_eq!(IpPrefix::try_from(pb).unwrap(), prefix);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,315 @@
|
||||
//! Peer-flavored configuration data owned by the config layer.
|
||||
//!
|
||||
//! These types are pure serializable configuration snapshots. Normalization
|
||||
//! and derivation behavior that depends on peer-domain logic stays in
|
||||
//! `crate::peers`.
|
||||
|
||||
use anyhow::Context as _;
|
||||
use cidr::{Ipv4Cidr, Ipv6Cidr};
|
||||
use easytier_proto::common::{FlagsInConfig, PeerFeatureFlag, SecureModeConfig, StunInfo};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::proto::acl::{Acl, AclV1, Action, Chain, ChainType, GroupInfo, Protocol, Rule};
|
||||
|
||||
use super::{CoreConfig, NetworkIdentity};
|
||||
|
||||
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
|
||||
pub struct AclRuleConfig {
|
||||
pub acl: Option<Acl>,
|
||||
pub tcp_whitelist: Vec<String>,
|
||||
pub udp_whitelist: Vec<String>,
|
||||
pub whitelist_priority: Option<u32>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
||||
pub struct AclWhitelistSnapshot {
|
||||
pub tcp_ports: Vec<String>,
|
||||
pub udp_ports: Vec<String>,
|
||||
}
|
||||
|
||||
impl From<&AclRuleConfig> for AclWhitelistSnapshot {
|
||||
fn from(config: &AclRuleConfig) -> Self {
|
||||
Self {
|
||||
tcp_ports: config.tcp_whitelist.clone(),
|
||||
udp_ports: config.udp_whitelist.clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl AclRuleConfig {
|
||||
fn parse_port_list(port_list: &[String]) -> anyhow::Result<Vec<String>> {
|
||||
let mut ports = Vec::new();
|
||||
|
||||
for port_spec in port_list {
|
||||
if port_spec.contains('-') {
|
||||
let parts: Vec<&str> = port_spec.split('-').collect();
|
||||
if parts.len() != 2 {
|
||||
return Err(anyhow::anyhow!("Invalid port range format: {}", port_spec));
|
||||
}
|
||||
|
||||
let start: u16 = parts[0]
|
||||
.parse()
|
||||
.with_context(|| format!("Invalid start port in range: {}", port_spec))?;
|
||||
let end: u16 = parts[1]
|
||||
.parse()
|
||||
.with_context(|| format!("Invalid end port in range: {}", port_spec))?;
|
||||
|
||||
if start > end {
|
||||
return Err(anyhow::anyhow!(
|
||||
"Start port must be <= end port in range: {}",
|
||||
port_spec
|
||||
));
|
||||
}
|
||||
ports.push(port_spec.clone());
|
||||
} else {
|
||||
let port: u16 = port_spec
|
||||
.parse()
|
||||
.with_context(|| format!("Invalid port number: {}", port_spec))?;
|
||||
ports.push(port.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
Ok(ports)
|
||||
}
|
||||
|
||||
fn generate_acl_from_whitelists(&mut self) -> anyhow::Result<()> {
|
||||
if self.tcp_whitelist.is_empty() && self.udp_whitelist.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let mut inbound_chain = Chain {
|
||||
name: "inbound_whitelist".to_string(),
|
||||
chain_type: ChainType::Inbound as i32,
|
||||
description: "Auto-generated inbound whitelist from CLI".to_string(),
|
||||
enabled: true,
|
||||
rules: vec![],
|
||||
default_action: Action::Allow as i32,
|
||||
};
|
||||
|
||||
let mut rule_priority = self.whitelist_priority.unwrap_or(1000u32);
|
||||
|
||||
if !self.tcp_whitelist.is_empty() {
|
||||
let tcp_ports = Self::parse_port_list(&self.tcp_whitelist)?;
|
||||
inbound_chain.rules.push(Rule {
|
||||
name: "tcp_whitelist".to_string(),
|
||||
description: "Auto-generated TCP whitelist rule".to_string(),
|
||||
priority: rule_priority,
|
||||
enabled: true,
|
||||
protocol: Protocol::Tcp as i32,
|
||||
ports: tcp_ports,
|
||||
source_ips: vec![],
|
||||
destination_ips: vec![],
|
||||
source_ports: vec![],
|
||||
action: Action::Allow as i32,
|
||||
rate_limit: 0,
|
||||
burst_limit: 0,
|
||||
stateful: true,
|
||||
source_groups: vec![],
|
||||
destination_groups: vec![],
|
||||
});
|
||||
inbound_chain.rules.push(Rule {
|
||||
name: "tcp_whitelist_deny_other".to_string(),
|
||||
description: "Auto-generated TCP whitelist rule to deny other ports".to_string(),
|
||||
priority: 0,
|
||||
enabled: true,
|
||||
protocol: Protocol::Tcp as i32,
|
||||
ports: vec!["0-65535".to_string()],
|
||||
source_ips: vec![],
|
||||
destination_ips: vec![],
|
||||
source_ports: vec![],
|
||||
action: Action::Drop as i32,
|
||||
rate_limit: 0,
|
||||
burst_limit: 0,
|
||||
stateful: false,
|
||||
source_groups: vec![],
|
||||
destination_groups: vec![],
|
||||
});
|
||||
rule_priority -= 1;
|
||||
}
|
||||
|
||||
if !self.udp_whitelist.is_empty() {
|
||||
let udp_ports = Self::parse_port_list(&self.udp_whitelist)?;
|
||||
inbound_chain.rules.push(Rule {
|
||||
name: "udp_whitelist".to_string(),
|
||||
description: "Auto-generated UDP whitelist rule".to_string(),
|
||||
priority: rule_priority,
|
||||
enabled: true,
|
||||
protocol: Protocol::Udp as i32,
|
||||
ports: udp_ports,
|
||||
source_ips: vec![],
|
||||
destination_ips: vec![],
|
||||
source_ports: vec![],
|
||||
action: Action::Allow as i32,
|
||||
rate_limit: 0,
|
||||
burst_limit: 0,
|
||||
stateful: false,
|
||||
source_groups: vec![],
|
||||
destination_groups: vec![],
|
||||
});
|
||||
inbound_chain.rules.push(Rule {
|
||||
name: "udp_whitelist_deny_other".to_string(),
|
||||
description: "Auto-generated UDP whitelist rule to deny other ports".to_string(),
|
||||
priority: 0,
|
||||
enabled: true,
|
||||
protocol: Protocol::Udp as i32,
|
||||
ports: vec!["0-65535".to_string()],
|
||||
source_ips: vec![],
|
||||
destination_ips: vec![],
|
||||
source_ports: vec![],
|
||||
action: Action::Drop as i32,
|
||||
rate_limit: 0,
|
||||
burst_limit: 0,
|
||||
stateful: false,
|
||||
source_groups: vec![],
|
||||
destination_groups: vec![],
|
||||
});
|
||||
}
|
||||
|
||||
if self.acl.is_none() {
|
||||
self.acl = Some(Acl::default());
|
||||
}
|
||||
|
||||
let acl = self.acl.as_mut().expect("ACL was initialized above");
|
||||
if let Some(acl_v1) = acl.acl_v1.as_mut() {
|
||||
acl_v1.chains.push(inbound_chain);
|
||||
} else {
|
||||
acl.acl_v1 = Some(AclV1 {
|
||||
chains: vec![inbound_chain],
|
||||
group: Some(GroupInfo {
|
||||
declares: vec![],
|
||||
members: vec![],
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn build(&self) -> anyhow::Result<Option<Acl>> {
|
||||
let mut config = self.clone();
|
||||
config.generate_acl_from_whitelists()?;
|
||||
Ok(config.acl)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct PublicIpv6ProviderConfig {
|
||||
pub provider_enabled: bool,
|
||||
pub configured_prefix: Option<Ipv6Cidr>,
|
||||
pub provider_supported: bool,
|
||||
}
|
||||
|
||||
impl PublicIpv6ProviderConfig {
|
||||
pub fn should_run_reconcile(self) -> bool {
|
||||
self.provider_enabled
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct PeerRuntimeConfig {
|
||||
pub core: CoreConfig,
|
||||
pub network_identity: NetworkIdentity,
|
||||
pub stun_info: StunInfo,
|
||||
pub feature_flags: PeerFeatureFlag,
|
||||
pub secure_mode: Option<SecureModeConfig>,
|
||||
pub host_routing: HostRoutingPolicy,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct HostRoutingPolicy {
|
||||
/// Route otherwise-unreachable external IPv4 traffic through this node and
|
||||
/// keep self-delivered packets eligible for the host TUN/proxy path.
|
||||
pub local_exit_node_fallback: bool,
|
||||
}
|
||||
|
||||
/// One normalized peer configuration version submitted by a host.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct PeerRuntimeSnapshot {
|
||||
pub runtime: PeerRuntimeConfig,
|
||||
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>,
|
||||
pub ospf_update_my_foreign_network_interval_sec: u64,
|
||||
pub max_direct_conns_per_peer_in_foreign_network: usize,
|
||||
pub hmac_secret_digest: bool,
|
||||
}
|
||||
|
||||
impl PeerRuntimeSnapshot {
|
||||
pub fn new(runtime: PeerRuntimeConfig, flags: FlagsInConfig) -> Self {
|
||||
let avoid_relay_data_preference = runtime.feature_flags.avoid_relay_data;
|
||||
Self {
|
||||
runtime,
|
||||
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(),
|
||||
ospf_update_my_foreign_network_interval_sec: 10,
|
||||
max_direct_conns_per_peer_in_foreign_network: 3,
|
||||
hmac_secret_digest: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for PeerRuntimeSnapshot {
|
||||
fn default() -> Self {
|
||||
Self::new(
|
||||
PeerRuntimeConfig {
|
||||
core: CoreConfig::default(),
|
||||
network_identity: NetworkIdentity::default(),
|
||||
stun_info: StunInfo::default(),
|
||||
feature_flags: PeerFeatureFlag::default(),
|
||||
secure_mode: None,
|
||||
host_routing: HostRoutingPolicy::default(),
|
||||
},
|
||||
FlagsInConfig::default(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct PeerGroupIdentity {
|
||||
pub group_name: String,
|
||||
pub group_secret: String,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn whitelist_rules_are_built_in_core() {
|
||||
let acl = AclRuleConfig {
|
||||
tcp_whitelist: vec!["80".to_string(), "8000-9000".to_string()],
|
||||
udp_whitelist: vec!["53".to_string()],
|
||||
..Default::default()
|
||||
}
|
||||
.build()
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
|
||||
let chain = &acl.acl_v1.unwrap().chains[0];
|
||||
assert_eq!(chain.name, "inbound_whitelist");
|
||||
assert_eq!(chain.rules.len(), 4);
|
||||
assert_eq!(chain.rules[0].ports, ["80", "8000-9000"]);
|
||||
assert_eq!(chain.rules[2].ports, ["53"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_whitelist_range_is_rejected() {
|
||||
let error = AclRuleConfig {
|
||||
tcp_whitelist: vec!["9000-8000".to_string()],
|
||||
..Default::default()
|
||||
}
|
||||
.build()
|
||||
.unwrap_err();
|
||||
|
||||
assert!(error.to_string().contains("Start port must be <= end port"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,275 @@
|
||||
//! Atomic runtime configuration owned by one core instance.
|
||||
|
||||
use std::{collections::BTreeSet, sync::Arc};
|
||||
|
||||
use arc_swap::ArcSwap;
|
||||
use cidr::Ipv4Cidr;
|
||||
use parking_lot::Mutex;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use super::{
|
||||
gateway::{GatewayRuntimeConfig, ProxyRuntimeConfig},
|
||||
peers::{AclRuleConfig, PeerRuntimeSnapshot, PublicIpv6ProviderConfig},
|
||||
};
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct CoreRuntimeConfig {
|
||||
pub acl: AclRuleConfig,
|
||||
pub dhcp_ipv4: bool,
|
||||
pub gateway: GatewayRuntimeConfig,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub manual_routes: Option<BTreeSet<Ipv4Cidr>>,
|
||||
pub proxy: ProxyRuntimeConfig,
|
||||
#[serde(default)]
|
||||
pub public_ipv6_auto: bool,
|
||||
pub public_ipv6_provider: PublicIpv6ProviderConfig,
|
||||
}
|
||||
|
||||
impl Default for CoreRuntimeConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
acl: AclRuleConfig::default(),
|
||||
dhcp_ipv4: false,
|
||||
gateway: GatewayRuntimeConfig::default(),
|
||||
manual_routes: None,
|
||||
proxy: ProxyRuntimeConfig::default(),
|
||||
public_ipv6_auto: false,
|
||||
public_ipv6_provider: PublicIpv6ProviderConfig {
|
||||
provider_enabled: false,
|
||||
configured_prefix: None,
|
||||
provider_supported: false,
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct CoreInstanceRuntimeConfig {
|
||||
pub services: CoreRuntimeConfig,
|
||||
pub peer: Arc<PeerRuntimeSnapshot>,
|
||||
}
|
||||
|
||||
struct CoreRuntimeConfigStoreInner {
|
||||
snapshot: ArcSwap<CoreInstanceRuntimeConfig>,
|
||||
update: Mutex<()>,
|
||||
peer_changes: tokio::sync::watch::Sender<u64>,
|
||||
service_changes: tokio::sync::watch::Sender<u64>,
|
||||
}
|
||||
|
||||
/// Atomic configuration authority shared by one core instance and its peer
|
||||
/// context. Readers always observe a complete submitted version.
|
||||
#[derive(Clone)]
|
||||
pub struct CoreRuntimeConfigStore {
|
||||
inner: Arc<CoreRuntimeConfigStoreInner>,
|
||||
}
|
||||
|
||||
impl CoreRuntimeConfigStore {
|
||||
pub fn new(services: CoreRuntimeConfig, peer: Arc<PeerRuntimeSnapshot>) -> Self {
|
||||
let (peer_changes, _) = tokio::sync::watch::channel(0);
|
||||
let (service_changes, _) = tokio::sync::watch::channel(0);
|
||||
Self {
|
||||
inner: Arc::new(CoreRuntimeConfigStoreInner {
|
||||
snapshot: ArcSwap::from_pointee(CoreInstanceRuntimeConfig { services, peer }),
|
||||
update: Mutex::new(()),
|
||||
peer_changes,
|
||||
service_changes,
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn snapshot(&self) -> Arc<CoreInstanceRuntimeConfig> {
|
||||
self.inner.snapshot.load_full()
|
||||
}
|
||||
|
||||
pub(crate) fn with_snapshot<T>(&self, read: impl FnOnce(&CoreInstanceRuntimeConfig) -> T) -> T {
|
||||
let snapshot = self.inner.snapshot.load();
|
||||
read(&snapshot)
|
||||
}
|
||||
|
||||
pub fn replace(&self, config: CoreInstanceRuntimeConfig) {
|
||||
let _update = self.inner.update.lock();
|
||||
self.inner.snapshot.store(Arc::new(config));
|
||||
self.inner.peer_changes.send_modify(|version| *version += 1);
|
||||
self.inner
|
||||
.service_changes
|
||||
.send_modify(|version| *version += 1);
|
||||
}
|
||||
|
||||
pub(crate) fn replace_with_current(
|
||||
&self,
|
||||
mut config: CoreInstanceRuntimeConfig,
|
||||
merge: impl FnOnce(&CoreInstanceRuntimeConfig, &mut CoreInstanceRuntimeConfig),
|
||||
) -> Arc<CoreInstanceRuntimeConfig> {
|
||||
let _update = self.inner.update.lock();
|
||||
let current = self.inner.snapshot.load_full();
|
||||
merge(¤t, &mut config);
|
||||
let config = Arc::new(config);
|
||||
self.inner.snapshot.store(config.clone());
|
||||
self.inner.peer_changes.send_modify(|version| *version += 1);
|
||||
self.inner
|
||||
.service_changes
|
||||
.send_modify(|version| *version += 1);
|
||||
config
|
||||
}
|
||||
|
||||
pub fn update_services(&self, update: impl FnOnce(&mut CoreRuntimeConfig)) {
|
||||
let _update = self.inner.update.lock();
|
||||
let mut config = self.inner.snapshot.load_full().as_ref().clone();
|
||||
update(&mut config.services);
|
||||
self.inner.snapshot.store(Arc::new(config));
|
||||
self.inner
|
||||
.service_changes
|
||||
.send_modify(|version| *version += 1);
|
||||
}
|
||||
|
||||
pub fn update_peer(&self, peer: Arc<PeerRuntimeSnapshot>) {
|
||||
let _update = self.inner.update.lock();
|
||||
let mut config = self.inner.snapshot.load_full().as_ref().clone();
|
||||
config.peer = peer;
|
||||
self.inner.snapshot.store(Arc::new(config));
|
||||
self.inner.peer_changes.send_modify(|version| *version += 1);
|
||||
}
|
||||
|
||||
pub(crate) fn update_peer_with(&self, update: impl FnOnce(&mut PeerRuntimeSnapshot)) {
|
||||
let _update = self.inner.update.lock();
|
||||
let mut config = self.inner.snapshot.load_full().as_ref().clone();
|
||||
update(Arc::make_mut(&mut config.peer));
|
||||
self.inner.snapshot.store(Arc::new(config));
|
||||
self.inner.peer_changes.send_modify(|version| *version += 1);
|
||||
}
|
||||
|
||||
pub fn subscribe_peer_runtime_changes(&self) -> tokio::sync::watch::Receiver<u64> {
|
||||
self.inner.peer_changes.subscribe()
|
||||
}
|
||||
|
||||
pub fn subscribe_service_runtime_changes(&self) -> tokio::sync::watch::Receiver<u64> {
|
||||
self.inner.service_changes.subscribe()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn replaces_service_and_peer_as_one_version() {
|
||||
let mut before_peer = PeerRuntimeSnapshot::default();
|
||||
before_peer.runtime.core.node.hostname = Some("before".to_owned());
|
||||
let store =
|
||||
CoreRuntimeConfigStore::new(CoreRuntimeConfig::default(), Arc::new(before_peer));
|
||||
let before = store.snapshot();
|
||||
|
||||
let after_services = CoreRuntimeConfig {
|
||||
dhcp_ipv4: true,
|
||||
..Default::default()
|
||||
};
|
||||
let mut after_peer = PeerRuntimeSnapshot::default();
|
||||
after_peer.runtime.core.node.hostname = Some("after".to_owned());
|
||||
store.replace(CoreInstanceRuntimeConfig {
|
||||
services: after_services,
|
||||
peer: Arc::new(after_peer),
|
||||
});
|
||||
|
||||
assert!(!before.services.dhcp_ipv4);
|
||||
assert_eq!(
|
||||
before.peer.runtime.core.node.hostname.as_deref(),
|
||||
Some("before")
|
||||
);
|
||||
let after = store.snapshot();
|
||||
assert!(after.services.dhcp_ipv4);
|
||||
assert_eq!(
|
||||
after.peer.runtime.core.node.hostname.as_deref(),
|
||||
Some("after")
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn notifies_peer_snapshot_changes() {
|
||||
let store = CoreRuntimeConfigStore::new(
|
||||
CoreRuntimeConfig::default(),
|
||||
Arc::new(PeerRuntimeSnapshot::default()),
|
||||
);
|
||||
let mut changes = store.subscribe_peer_runtime_changes();
|
||||
let mut peer = PeerRuntimeSnapshot::default();
|
||||
peer.runtime.core.node.hostname = Some("updated".to_owned());
|
||||
|
||||
store.update_peer(Arc::new(peer));
|
||||
|
||||
assert!(changes.changed().await.is_ok());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn notifies_service_snapshot_changes() {
|
||||
let store = CoreRuntimeConfigStore::new(
|
||||
CoreRuntimeConfig::default(),
|
||||
Arc::new(PeerRuntimeSnapshot::default()),
|
||||
);
|
||||
let mut changes = store.subscribe_service_runtime_changes();
|
||||
|
||||
store.update_services(|services| services.dhcp_ipv4 = true);
|
||||
|
||||
assert!(changes.changed().await.is_ok());
|
||||
assert!(store.snapshot().services.dhcp_ipv4);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn peer_update_does_not_notify_service_watchers() {
|
||||
let store = CoreRuntimeConfigStore::new(
|
||||
CoreRuntimeConfig::default(),
|
||||
Arc::new(PeerRuntimeSnapshot::default()),
|
||||
);
|
||||
let changes = store.subscribe_service_runtime_changes();
|
||||
let mut peer = PeerRuntimeSnapshot::default();
|
||||
peer.runtime.core.node.hostname = Some("updated".to_owned());
|
||||
|
||||
store.update_peer(Arc::new(peer));
|
||||
|
||||
assert!(!changes.has_changed().unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn peer_in_place_update_preserves_the_rest_of_the_atomic_snapshot() {
|
||||
let services = CoreRuntimeConfig {
|
||||
dhcp_ipv4: true,
|
||||
..Default::default()
|
||||
};
|
||||
let mut peer = PeerRuntimeSnapshot::default();
|
||||
peer.runtime.core.node.hostname = Some("preserved".to_owned());
|
||||
let store = CoreRuntimeConfigStore::new(services, Arc::new(peer));
|
||||
|
||||
store.update_peer_with(|peer| {
|
||||
peer.runtime.core.routes.ipv4 = Some(crate::config::IpPrefix {
|
||||
address: "10.20.30.7".parse().unwrap(),
|
||||
prefix_len: 24,
|
||||
});
|
||||
});
|
||||
|
||||
let snapshot = store.snapshot();
|
||||
assert!(snapshot.services.dhcp_ipv4);
|
||||
assert_eq!(
|
||||
snapshot.peer.runtime.core.node.hostname.as_deref(),
|
||||
Some("preserved")
|
||||
);
|
||||
assert_eq!(
|
||||
snapshot
|
||||
.peer
|
||||
.runtime
|
||||
.core
|
||||
.routes
|
||||
.ipv4
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.address,
|
||||
"10.20.30.7".parse::<std::net::IpAddr>().unwrap()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_manual_routes_preserves_portable_config_compatibility() {
|
||||
let encoded = serde_json::to_value(CoreRuntimeConfig::default()).unwrap();
|
||||
assert!(encoded.get("manual_routes").is_none());
|
||||
|
||||
let decoded: CoreRuntimeConfig = serde_json::from_value(encoded).unwrap();
|
||||
assert_eq!(decoded.manual_routes, None);
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user