mirror of
https://github.com/EasyTier/EasyTier.git
synced 2026-09-20 03:22:05 +00:00
Compare commits
93
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5a0e0cd43b | ||
|
|
8c64949dde | ||
|
|
6e3460db7d | ||
|
|
164e2db6ae | ||
|
|
25f6e2dc5e | ||
|
|
4a10d1c2b9 | ||
|
|
abf03ca521 | ||
|
|
67f2270ee7 | ||
|
|
81487c363a | ||
|
|
3b2aadde8e | ||
|
|
b93e45a215 | ||
|
|
e130e9e4c7 | ||
|
|
4304b065cb | ||
|
|
9f231ee76c | ||
|
|
3fe427bc99 | ||
|
|
8794e12a26 | ||
|
|
62e4fd15e9 | ||
|
|
57eb6908f4 | ||
|
|
f64af13424 | ||
|
|
35845c14e6 | ||
|
|
282cd92f2c | ||
|
|
636390ec38 | ||
|
|
8c15941c44 | ||
|
|
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 | ||
|
|
16b666ad25 | ||
|
|
8909e88484 | ||
|
|
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 |
@@ -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
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
*.sh text eol=lf
|
||||
@@ -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
|
||||
@@ -157,11 +158,17 @@ 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') }}
|
||||
with:
|
||||
tool: cargo-zigbuild
|
||||
# v0.23.3 emits -mcpu=generic+v6+strict_align for
|
||||
# arm-unknown-linux-musleabi, which zig 0.16.0 rejects;
|
||||
# unpin only together with a zig bump.
|
||||
tool: cargo-zigbuild@0.23.2
|
||||
|
||||
- name: Build
|
||||
if: ${{ !contains(matrix.TARGET, 'mips') }}
|
||||
@@ -227,7 +234,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
|
||||
@@ -54,6 +54,7 @@ jobs:
|
||||
- uses: actions-rust-lang/setup-rust-toolchain@v1
|
||||
with:
|
||||
components: rustfmt,clippy
|
||||
target: wasm32-wasip1
|
||||
rustflags: ''
|
||||
|
||||
- uses: taiki-e/install-action@cargo-hack
|
||||
@@ -70,6 +71,12 @@ jobs:
|
||||
if: ${{ !cancelled() }}
|
||||
run: cargo hack check --package easytier --each-feature --exclude-features macos-ne --verbose
|
||||
|
||||
- name: Check WASI
|
||||
if: ${{ !cancelled() }}
|
||||
run: >-
|
||||
cargo check --package easytier-core --lib --target wasm32-wasip1
|
||||
--features management-rpc,proxy-smoltcp-stack,ring-crypto,wasi-crypto-offload
|
||||
|
||||
- name: Check Cargo.lock is up to date
|
||||
if: ${{ !cancelled() }}
|
||||
run: |
|
||||
@@ -98,7 +105,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 +145,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 +165,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
|
||||
|
||||
+92
@@ -0,0 +1,92 @@
|
||||
# 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.
|
||||
|
||||
## Credential grant
|
||||
|
||||
A credential grant contains the authorization constraints shared by generated,
|
||||
imported, managed, and attached-peer credentials: ACL groups, relay permission,
|
||||
allowed proxy CIDRs, and whether concurrent reuse is allowed. It does not own
|
||||
credential identity, key material, lifetime, persistence, or runtime ownership.
|
||||
Each credential intake path normalizes the grant before installing it.
|
||||
|
||||
## Peer Relay advertisement
|
||||
|
||||
A platform peer may prefer an eligible directly connected credential relay by
|
||||
omitting covered credential-leaf edges from only its own advertised OSPF
|
||||
connection row. Its local route calculation still uses the complete physical
|
||||
adjacency so direct-destination fallback remains available. Other peers'
|
||||
source-owned rows and versions are never rewritten, cached for promotion, or
|
||||
otherwise changed by this projection.
|
||||
|
||||
Before a graceful Instance stop, the owner publishes a new-version empty
|
||||
connection row while keeping its physical adjacencies available for route
|
||||
synchronization. It waits for the current direct route Sessions to acknowledge
|
||||
that withdrawal up to a bounded deadline, then continues shutdown. Abrupt
|
||||
process loss cannot publish this withdrawal and retains the normal route
|
||||
expiry behavior.
|
||||
|
||||
Relay eligibility comes from the transport-authenticated credential identity
|
||||
and grant, not self-reported route metadata. The advertisement Module does not
|
||||
support changing a credential's relay permission in place; such a permission
|
||||
change is a credential revocation and new authenticated Session.
|
||||
|
||||
## Attached peer
|
||||
|
||||
An attached peer is an ordinary `PeerManagerCore` connected to another
|
||||
`PeerManagerCore` through an authenticated in-process transport. Each
|
||||
authenticated portal client owns one complete peer manager. The managers are
|
||||
protocol peers; `attached` describes only the local transport and its trusted
|
||||
ingress provenance, not a parent/child peer role.
|
||||
|
||||
An attached peer owns one complete IPv4 CIDR (for example `10.144.0.5/16`).
|
||||
Its address and advertised network are independent of the network manager's
|
||||
own static or DHCP address. A VPN portal derives the attached peer route and
|
||||
the external client's allowed network from that single CIDR; it does not infer
|
||||
either value from the portal-hosting instance.
|
||||
|
||||
An external portal client uses that same IPv4 address on its native tunnel
|
||||
interface. The portal validates the source address and forwards IPv4 packets
|
||||
unchanged between the native tunnel and the attached peer; it does not assign
|
||||
a second tunnel-only address or perform address translation.
|
||||
|
||||
Each manager owns its ACL execution state, route service, RPC endpoint, secure
|
||||
sessions, packet processing, and lifecycle. Portal code supplies raw packets
|
||||
and peer configuration but does not build, reload, or coordinate ACL filters.
|
||||
|
||||
When the network manager uses Secure Mode, an attached peer authenticates as a
|
||||
credential peer. Its portal-owned, in-memory credential grant carries ACL
|
||||
groups and is revoked with the attached runtime; the peer never receives the
|
||||
network secret or ACL group secrets. A non-Secure-Mode network retains the
|
||||
legacy admin-attached identity for compatibility. A credential peer cannot host
|
||||
a portal because it cannot issue credential grants. Each live portal Session
|
||||
owns a fresh attached-peer identity, while the external client key remains
|
||||
stable across Sessions; a replacement Session must never reuse the previous
|
||||
non-reusable credential identity.
|
||||
|
||||
## Compact compatibility Host
|
||||
|
||||
A compact compatibility Host retains accepted values in the authoritative TOML
|
||||
model for management readback, while the shared host-aware normalization path
|
||||
omits capabilities that the compact runtime cannot execute. Omitted settings
|
||||
are silent no-ops and must not be advertised as live network capabilities.
|
||||
+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
+455
-352
File diff suppressed because it is too large
Load Diff
+9
-1
@@ -1,13 +1,16 @@
|
||||
[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",
|
||||
"easytier-contrib/easytier-ios",
|
||||
]
|
||||
default-members = ["easytier", "easytier-web"]
|
||||
exclude = [
|
||||
@@ -28,3 +31,8 @@ lto = true
|
||||
codegen-units = 1
|
||||
opt-level = 3
|
||||
strip = true
|
||||
|
||||
[profile.mini]
|
||||
inherits = "release"
|
||||
opt-level = "z"
|
||||
strip = "symbols"
|
||||
|
||||
@@ -252,8 +252,12 @@ ios <-.-> nodea <--> nodeb <-.-> id1
|
||||
1. Start EasyTier with WireGuard portal enabled:
|
||||
|
||||
```bash
|
||||
# Listen on 0.0.0.0:11013 and use 10.14.14.0/24 subnet for WireGuard clients
|
||||
sudo easytier-core -i 10.144.144.1 --vpn-portal wg://0.0.0.0:11013/10.14.14.0/24
|
||||
# Register one WireGuard client as virtual peer 10.144.144.3
|
||||
sudo easytier-core -i 10.144.144.1 \
|
||||
--network-secret portal-secret \
|
||||
--vpn-portal wg://0.0.0.0:11013 \
|
||||
--vpn-portal-private-key "$(wg genkey)" \
|
||||
--vpn-portal-client phone=10.144.144.3
|
||||
```
|
||||
|
||||
2. Get WireGuard client configuration:
|
||||
@@ -263,10 +267,10 @@ sudo easytier-core -i 10.144.144.1 --vpn-portal wg://0.0.0.0:11013/10.14.14.0/24
|
||||
easytier-cli vpn-portal
|
||||
```
|
||||
|
||||
3. In the output configuration:
|
||||
- Set `Interface.Address` to an available IP from the WireGuard subnet
|
||||
- Set `Peer.Endpoint` to the public IP/domain of your EasyTier node
|
||||
- Import the modified configuration into your WireGuard client
|
||||
3. In the output configuration, replace a wildcard `Peer.Endpoint` with the
|
||||
public IP/domain of your EasyTier node, then import it. `Interface.Address`
|
||||
is local to that WireGuard client and may be changed to any IPv4 address;
|
||||
EasyTier translates it to the registered virtual-peer address.
|
||||
|
||||
#### Self-Hosted Public Shared Node
|
||||
|
||||
|
||||
+9
-6
@@ -250,8 +250,12 @@ ios <-.-> nodea <--> nodeb <-.-> id1
|
||||
1. 启动启用 WireGuard 门户的 EasyTier:
|
||||
|
||||
```bash
|
||||
# 在 0.0.0.0:11013 上监听,并使用 10.14.14.0/24 子网作为 WireGuard 客户端
|
||||
sudo easytier-core -i 10.144.144.1 --vpn-portal wg://0.0.0.0:11013/10.14.14.0/24
|
||||
# 将一个 WireGuard 客户端注册为虚拟 peer 10.144.144.3
|
||||
sudo easytier-core -i 10.144.144.1 \
|
||||
--network-secret portal-secret \
|
||||
--vpn-portal wg://0.0.0.0:11013 \
|
||||
--vpn-portal-private-key "$(wg genkey)" \
|
||||
--vpn-portal-client phone=10.144.144.3
|
||||
```
|
||||
|
||||
2. 获取 WireGuard 客户端配置:
|
||||
@@ -261,10 +265,9 @@ sudo easytier-core -i 10.144.144.1 --vpn-portal wg://0.0.0.0:11013/10.14.14.0/24
|
||||
easytier-cli vpn-portal
|
||||
```
|
||||
|
||||
3. 在输出配置中:
|
||||
- 将 `Interface.Address` 设置为 WireGuard 子网中的可用 IP
|
||||
- 将 `Peer.Endpoint` 设置为您的 EasyTier 节点的公网 IP/域名
|
||||
- 将修改后的配置导入到您的 WireGuard 客户端
|
||||
3. 如果输出配置中的 `Peer.Endpoint` 是通配地址,将其替换为 EasyTier
|
||||
节点的公网 IP/域名后即可导入。`Interface.Address` 只是客户端本地地址,
|
||||
可以改为任意 IPv4 地址;EasyTier 会把它转换成已注册的虚拟 peer 地址。
|
||||
|
||||
#### 自建公共共享节点
|
||||
|
||||
|
||||
@@ -0,0 +1,534 @@
|
||||
# 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.
|
||||
|
||||
Each VPN portal client is normalized to one attached-peer IPv4 CIDR. The
|
||||
portable gateway owns that client address and prefix; the hosting network
|
||||
manager's DHCP or static address is not a source of portal client routing
|
||||
facts.
|
||||
|
||||
TUN, raw sockets, transparent-destination lookup, concrete protocol engines,
|
||||
native DNS servers, namespace operations, and route application stay in native
|
||||
Adapters.
|
||||
|
||||
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,563 @@
|
||||
# EasyTier Web Managed Config Incremental Sync Plan
|
||||
|
||||
## Status
|
||||
|
||||
- 状态:Implemented(核心协议、持久化与 Session 增量收敛)
|
||||
- 实施范围:EasyTier Web 的 HTTP 接收、校验、SQLite 持久化和 Session 运行态收敛
|
||||
- 上游依赖:后续由 Console 计算并发送 Patch
|
||||
- 兼容要求:保留现有 Full PUT
|
||||
|
||||
本文记录当前接收端方案。Session 在能够证明 Patch base 与已应用 revision 连续时
|
||||
只收敛 touched instances;重启、通知丢失、revision 断链或并发积压时沿用 Full
|
||||
reconcile。
|
||||
|
||||
## 1. 背景与结论
|
||||
|
||||
当前 `/validate-token` webhook 已经只交换 token、机器信息和 revision,不再
|
||||
携带完整 managed config 集合。剩余的大集合位于独立的配置发布路径:
|
||||
|
||||
```text
|
||||
PUT /api/internal/users/:user-id/machines/:machine-id/networks
|
||||
```
|
||||
|
||||
Console 每次发布都会向该路径发送完整 Exact Set。实例很多时,请求体、JSON
|
||||
解析、现有配置扫描和逐条 SQLite 写入都随实例总数增长。
|
||||
|
||||
第一阶段采用以下方案:
|
||||
|
||||
1. 保留 PUT,作为完整发布、首次同步和冲突恢复路径。
|
||||
2. 在同一路径增加 PATCH;普通变更只发送完整的单实例 upsert 和删除 ID。
|
||||
3. PATCH 使用 `expected_config_revision` 做 compare-and-swap(CAS)。
|
||||
4. Full/Patch 的配置变更与 revision 更新在一个 SQLite transaction 中提交。
|
||||
5. Patch 只查询和写入 touched instances,不扫描完整 Target。
|
||||
6. 写入成功后通知 Session 本次 base、target 和 touched instance IDs。
|
||||
7. Session 仅在 applied revision 精确匹配 base 时增量收敛,否则安全回退 Full。
|
||||
|
||||
普通变更的接收端成本由:
|
||||
|
||||
```text
|
||||
O(total instances)
|
||||
```
|
||||
|
||||
降为:
|
||||
|
||||
```text
|
||||
wire / JSON / persistence transaction / runtime config apply = O(changed instances)
|
||||
```
|
||||
|
||||
冷启动或 revision 冲突仍需要 `O(total)` 的 Full。这是没有可用基线时传递完整
|
||||
目标状态所必需的成本;如果 Full 超过安全的单请求上限,需另行设计 staged
|
||||
snapshot,而不是直接分页写入 live rows。
|
||||
|
||||
## 2. 目标与非目标
|
||||
|
||||
### 2.1 目标
|
||||
|
||||
1. 普通新增、更新和删除只传输、解析、查询并写入变化实例。
|
||||
2. Config rows 与 persisted revision 原子提交。
|
||||
3. Patch 可安全重试,并能确定性处理并发或乱序请求。
|
||||
4. 保持 user-owned 与 web-owned 配置的 ownership 规则。
|
||||
5. 保持 Full Exact Set 的删除和显式空集合语义。
|
||||
6. 为 Full 和 Patch 设置显式且可测试的容量限制。
|
||||
7. 先部署接收端,再允许 Console 使用 Patch。
|
||||
|
||||
### 2.2 非目标
|
||||
|
||||
1. 修改 `/validate-token` request/response。
|
||||
2. 在本阶段实现 Console 的 diff/cache 逻辑。
|
||||
3. 优化 Core heartbeat 中的完整运行实例上报。
|
||||
4. 实现 Full 分页、上传会话或持久化 delivery FSM。
|
||||
5. 让冷启动 Full 的成本低于 `O(total)`。
|
||||
|
||||
## 3. 必须保持的语义
|
||||
|
||||
### 3.1 Full Exact Set
|
||||
|
||||
Full 表示一个 `(user_id, machine_id)` 下全部期望的 web-owned configs:
|
||||
|
||||
- 请求中存在的实例应被创建或更新;
|
||||
- 已存在但请求中缺失的 web-owned 实例应被删除;
|
||||
- 空集合应删除该 Target 下全部 web-owned 实例;
|
||||
- user-owned 实例不能被覆盖或删除。
|
||||
|
||||
### 3.2 Patch
|
||||
|
||||
Patch 只描述从一个已知 revision 到另一个 revision 的变化:
|
||||
|
||||
- `upserts`:新增或变化实例的完整 config;
|
||||
- `delete_instance_ids`:从目标集合中删除的实例 ID;
|
||||
- `expected_config_revision`:receiver 必须已经处于的 base revision;
|
||||
- `config_revision`:提交完成后的 target revision。
|
||||
|
||||
Patch 不是独立的完整目标。当前 revision 与 expected revision 不一致时,必须
|
||||
返回冲突且不做任何写入。
|
||||
|
||||
### 3.3 Revision invariants
|
||||
|
||||
1. 一个 persisted revision 只对应与其一起提交的 web-owned projection。
|
||||
2. Config mutation 和 revision advancement 必须位于同一 transaction。
|
||||
3. Patch 只能应用在完全匹配的 expected revision 上。
|
||||
4. 当前 revision 已等于 target revision 时,返回幂等成功且不重复写入。
|
||||
5. Publisher 不得为不同目标状态复用同一个 target revision。
|
||||
6. 任何其他写路径只要改变 web-owned row,就必须在同一 transaction 中清除
|
||||
managed revision;否则未来 Patch 会基于错误的 base。
|
||||
7. Persisted revision 与 Session applied revision 保持为两个不同事实。HTTP
|
||||
成功只代表本地持久化完成,不代表 Core 已经应用。
|
||||
|
||||
## 4. HTTP contract
|
||||
|
||||
### 4.1 保留 Full PUT
|
||||
|
||||
路径不变:
|
||||
|
||||
```text
|
||||
PUT /api/internal/users/:user-id/machines/:machine-id/networks
|
||||
```
|
||||
|
||||
现有 JSON shape 保持兼容:
|
||||
|
||||
```json
|
||||
{
|
||||
"managed_network_configs": [
|
||||
{
|
||||
"instance_id": "11111111-1111-1111-1111-111111111111",
|
||||
"network_config": {}
|
||||
}
|
||||
],
|
||||
"config_revision": "target-revision",
|
||||
"expected_config_revision": "base-revision"
|
||||
}
|
||||
```
|
||||
|
||||
`expected_config_revision` 保持当前含义:
|
||||
|
||||
- 字段缺失:兼容旧调用者,不检查 base;
|
||||
- 空字符串:要求当前 persisted revision 不存在;
|
||||
- 非空字符串:要求当前 revision 与该值相等。
|
||||
|
||||
新 Console 必须发送 expected revision。省略 expected 的形式只用于旧版本兼容
|
||||
和明确的运维修复。
|
||||
|
||||
`config_revision` 的处理:
|
||||
|
||||
- 非空:配置与 target revision 原子提交;
|
||||
- 缺失:保留旧 Full 请求兼容,但清除已有 managed revision,因此该结果不能
|
||||
作为后续 Patch base;
|
||||
- 空字符串:拒绝为 400。
|
||||
|
||||
Revisioned Full 遇到 user-owned instance ID 冲突时整体失败。Legacy
|
||||
unrevisioned Full 保持当前兼容行为:跳过 user-owned row,且绝不覆盖它。
|
||||
|
||||
### 4.2 新增 Patch
|
||||
|
||||
同一资源增加:
|
||||
|
||||
```text
|
||||
PATCH /api/internal/users/:user-id/machines/:machine-id/networks
|
||||
```
|
||||
|
||||
请求格式:
|
||||
|
||||
```json
|
||||
{
|
||||
"upserts": [
|
||||
{
|
||||
"instance_id": "11111111-1111-1111-1111-111111111111",
|
||||
"network_config": {}
|
||||
}
|
||||
],
|
||||
"delete_instance_ids": [
|
||||
"22222222-2222-2222-2222-222222222222"
|
||||
],
|
||||
"config_revision": "target-revision",
|
||||
"expected_config_revision": "base-revision"
|
||||
}
|
||||
```
|
||||
|
||||
Patch contract:
|
||||
|
||||
1. 两个 revision 字段均必填、非空且不能相同。
|
||||
2. `upserts` 中的 instance ID 不得重复。
|
||||
3. `delete_instance_ids` 中的 ID 不得重复。
|
||||
4. 同一个 ID 不得同时出现在 upsert 和 delete 中。
|
||||
5. 每个 upsert 必须携带该实例的完整 `NetworkConfig`,不支持字段级 JSON
|
||||
Patch。
|
||||
6. `network_config` 内部的 instance ID 不受信任,receiver 使用 envelope 中的
|
||||
`instance_id` 进行归一化。
|
||||
7. 删除不存在的 ID 是幂等 no-op。
|
||||
8. Upsert 或 delete 碰到 user-owned row 时,整个 Patch 返回冲突且不写入。
|
||||
9. 不允许从“receiver revision 不存在”的未知状态直接 Patch;使用 Full 建立
|
||||
Exact Set 和首个 revision。
|
||||
10. 空 Patch 不能把 revision 改成另一个值;这通常表示 publisher revision
|
||||
计算错误,因此返回 400。
|
||||
|
||||
### 4.3 HTTP outcomes
|
||||
|
||||
| 条件 | HTTP | 语义 |
|
||||
| --- | ---: | --- |
|
||||
| Full/Patch 新提交成功 | 204 | Config 和 revision 已持久化 |
|
||||
| Target revision 已经存在 | 204 | 幂等成功,无 row mutation |
|
||||
| Expected revision 不匹配 | 409 | 零写入,调用者重新观察或发送 Full |
|
||||
| User-owned ownership 冲突 | 409 | 零写入,不能自动覆盖 |
|
||||
| 非法 ID、重复、交集或非法 config | 400 | 调用 contract 错误 |
|
||||
| 请求超过 byte limit | 413 | 未进入 reconciliation |
|
||||
| 条目数或单 config 超过限制 | 422 | 超出接收端容量 contract |
|
||||
| SQLite 错误 | 500 | Transaction rollback |
|
||||
|
||||
409 返回机器可读字段:revision 冲突为
|
||||
`code=managed_config_revision_conflict` 并在已知时带
|
||||
`current_config_revision`;ownership 冲突为
|
||||
`code=managed_config_ownership_conflict`。响应不得返回配置内容。日志不得记录
|
||||
token、secret 或完整 config JSON。
|
||||
|
||||
## 5. Receiver architecture
|
||||
|
||||
### 5.1 Module responsibilities
|
||||
|
||||
| Module | 本阶段职责 |
|
||||
| --- | --- |
|
||||
| Internal HTTP Adapter | 内部鉴权、body/count limit、DTO 解析、HTTP 状态映射 |
|
||||
| `ClientManager` | 解析 Target,调用 managed-config Interface,成功后通知 Session |
|
||||
| `client_manager::managed_config` | Full/Patch 规则、归一化、typed outcome |
|
||||
| `Db` Adapter | CAS、ownership fence、批量 mutation、revision transaction |
|
||||
| Session runtime reconciliation | 校验 applied/base/target fence,增量收敛 touched instances;断链时 Full |
|
||||
|
||||
HTTP Adapter 不实现 ownership、diff 或 transaction 逻辑。PUT 和 PATCH 共用
|
||||
managed-config Module,避免两套规则逐渐分叉。
|
||||
|
||||
### 5.2 Internal Interface
|
||||
|
||||
Module 接收两种 intent:
|
||||
|
||||
```text
|
||||
Full {
|
||||
desired_configs,
|
||||
target_revision: Option<Revision>,
|
||||
expected_revision: Any | Exact(Option<Revision>)
|
||||
}
|
||||
|
||||
Patch {
|
||||
upserts,
|
||||
delete_instance_ids,
|
||||
target_revision: Revision,
|
||||
expected_revision: Revision
|
||||
}
|
||||
```
|
||||
|
||||
返回 typed outcome:
|
||||
|
||||
```text
|
||||
Applied {
|
||||
previous_revision,
|
||||
target_revision
|
||||
}
|
||||
|
||||
AlreadyApplied {
|
||||
target_revision
|
||||
}
|
||||
|
||||
RevisionConflict {
|
||||
expected_revision,
|
||||
current_revision
|
||||
}
|
||||
|
||||
OwnershipConflict {
|
||||
instance_id
|
||||
}
|
||||
```
|
||||
|
||||
Validation error 与 database error 保持独立类型。HTTP handler 只负责将这些结果
|
||||
映射到 section 4.3 的状态码。
|
||||
|
||||
## 6. Receiver implementation
|
||||
|
||||
### 6.1 Validation and normalization
|
||||
|
||||
在打开 SQLite write transaction 之前完成:
|
||||
|
||||
- request byte/count/per-entry limit;
|
||||
- UUID、重复 ID 和 upsert/delete 交集校验;
|
||||
- config key 拼写归一化;
|
||||
- envelope instance ID 覆盖 nested identity;
|
||||
- `NetworkConfig` 反序列化。
|
||||
|
||||
这样非法大请求不会长时间占用 SQLite writer lock。Ownership 必须在 transaction
|
||||
内重新查询,因为 transaction 外的结果可能已过期。
|
||||
|
||||
当 request 带 target revision 时,可以先做一次 O(1) revision read;如果当前值
|
||||
已经等于 target,可直接返回 `AlreadyApplied`,避免完整 config 归一化。任何可能
|
||||
写入的请求仍必须在 transaction 内再次检查 revision。
|
||||
|
||||
### 6.2 Full transaction
|
||||
|
||||
在同一个 SQLite connection 上执行:
|
||||
|
||||
1. `BEGIN IMMEDIATE`。
|
||||
2. 读取 `(user_id, machine_id)` 当前 persisted revision。
|
||||
3. 若 supplied target 已经是 current,返回 `AlreadyApplied`。
|
||||
4. 检查 optional expected revision。
|
||||
5. 只读取现有 row 的 `(instance_id, source)`;不加载无关 config JSON。
|
||||
6. 执行 user-owned ownership fence。
|
||||
7. 批量 upsert 全部 desired web-owned rows。
|
||||
8. 计算并批量删除 `existing_web_ids - desired_ids`。
|
||||
9. 最后写入 supplied target revision;legacy unrevisioned Full 则删除旧 revision。
|
||||
10. Commit。
|
||||
|
||||
任一步骤失败都 rollback。Full 仍是 `O(total)`,但不会再逐条独立提交,也不会
|
||||
出现“部分 rows 已更新、revision 仍是旧值”的中间持久状态。
|
||||
|
||||
### 6.3 Patch transaction
|
||||
|
||||
在同一个 SQLite connection 上执行:
|
||||
|
||||
1. `BEGIN IMMEDIATE`。
|
||||
2. 读取 current revision。
|
||||
3. 如果 current 等于 target,返回 `AlreadyApplied`。
|
||||
4. 如果 current 不等于 expected,返回 `RevisionConflict`。
|
||||
5. 只查询 upsert/delete IDs 的 source。
|
||||
6. 任一 touched ID 属于 user 时,返回 `OwnershipConflict`。
|
||||
7. 批量 upsert changed configs。
|
||||
8. 批量删除 requested web-owned IDs。
|
||||
9. 最后写入 target revision。
|
||||
10. Commit。
|
||||
|
||||
Patch 禁止:
|
||||
|
||||
- list 全部 Target rows;
|
||||
- 重算完整 Target digest;
|
||||
- 根据 touched IDs 之外的数据做 stale-row scan。
|
||||
|
||||
因此其数据库工作量只随 `upserts + deletes` 增长。
|
||||
|
||||
### 6.4 Bounded batch SQL
|
||||
|
||||
批量操作不构造无限长 SQL。根据 SQLite bind-variable limit 选取固定 batch size,
|
||||
并在同一个 transaction 内分批执行:
|
||||
|
||||
- multi-row `INSERT ... ON CONFLICT DO UPDATE`;
|
||||
- 带 `source = web` 条件的 batch delete;
|
||||
- 只返回 instance ID/source 的 ownership query。
|
||||
|
||||
Patch statement 数量应为 `O(ceil(delta / batch_size))`;Full 为
|
||||
`O(ceil(total / batch_size))`。每个 accepted request 只有一个 transaction 和
|
||||
一次 revision 写入。
|
||||
|
||||
### 6.5 Alternate-write revision invalidation
|
||||
|
||||
现有其他路径可能 save、delete、disable 或改变 web-owned row。若这些路径修改
|
||||
rows 后仍保留旧 managed revision,Patch CAS 会把错误状态当作正确 base。
|
||||
|
||||
因此所有 config mutation Adapter 必须遵守:
|
||||
|
||||
1. 判断 mutation 是否改变 web-owned row;
|
||||
2. 在一个 transaction 中执行 mutation;
|
||||
3. 在 commit 前删除该 Target 的 managed revision。
|
||||
|
||||
Managed Full/Patch 在同一 transaction 内先完成 mutation,最后写入新的 target
|
||||
revision。只影响 user-owned rows 的操作不清除 managed revision。
|
||||
|
||||
本方案不在 `/validate-token` 读取 revision 时重算完整 digest,否则周期性验证会
|
||||
重新变成 `O(total)`。Revision 完整性由所有写入 Adapter 局部维护。
|
||||
|
||||
### 6.6 Locking, cancellation and notification
|
||||
|
||||
现有 per-target process-local lock 可以保留,用于减少同进程的重复工作,但它不
|
||||
承担正确性。正确性由 SQLite transaction 和 CAS 提供。
|
||||
|
||||
- Transaction 内不执行 Session RPC、网络请求或无关 async 工作。
|
||||
- HTTP future 在 commit 前取消时,transaction drop 必须 rollback。
|
||||
- Commit 后即使 response 或 notification 丢失,persisted state 仍然有效;调用者
|
||||
用同一 target retry 会得到幂等成功。
|
||||
- 只有带 target revision 的 `Applied` 才通知匹配的 live Session;
|
||||
`AlreadyApplied`、legacy unrevisioned Full、conflict 和失败不重复通知。
|
||||
- Notification 必须发生在 commit 之后。
|
||||
- Full notification 清除任何 pending delta,触发完整收敛。
|
||||
- Patch notification 携带 expected revision、target revision、upsert IDs 和本次
|
||||
transaction 实际接受删除的 web-owned IDs。请求删除但数据库原本不存在的 ID
|
||||
仍是 no-op,不能借机删除 Core 中同 ID 的 user-owned 实例。只有 Session applied
|
||||
revision 精确等于 expected revision,且没有更早的 Patch 等待处理时,才保留该
|
||||
delta。
|
||||
- 两次 Patch 在前一次完成前积压时不合并 delta;Session 清除 pending delta,并在
|
||||
最新 heartbeat/revision 上执行一次 Full。这避免引入 Patch queue 或 delivery FSM。
|
||||
- 增量 round 只读取 upsert rows,只删除本次 delete IDs,只对 touched running
|
||||
instances 执行 runtime Patch/Run。完成前再次校验 persisted target revision;只有
|
||||
全部 touched instances 成功且 target 仍相同,才推进 applied revision。
|
||||
- 任何通过 EasyTier Web mutation route 直接 Run、Save、Delete 或切换实例状态的
|
||||
操作在执行前和结束后(包括部分 side effect 后返回错误)都清除 Session applied
|
||||
revision 与 pending delta、增加运行配置 cache epoch,并唤醒一次 Full
|
||||
reconcile。旧 round 只有 epoch 仍匹配时才能推进 applied revision;新一轮不得
|
||||
信任 mutation 前缓存的 runtime config。否则 runtime-only mutation 或 Core 成功、
|
||||
SQLite 失败的复合 mutation 可能在 persisted revision 不变时破坏 Patch base 的
|
||||
完整性。
|
||||
|
||||
## 7. Capacity contract
|
||||
|
||||
当前 route 没有显式 body limit,Axum `Json` 使用依赖版本的默认 2 MiB 限制。
|
||||
生产容量不应依赖框架隐式默认值。
|
||||
|
||||
本阶段定义并测试四个独立限制:
|
||||
|
||||
- decoded request 最大 bytes;
|
||||
- Full entries / Patch upserts 最大数量;
|
||||
- Patch deletes 最大数量;
|
||||
- 单个 `network_config` 最大 bytes。
|
||||
|
||||
限制只应用于 internal managed-config route,不提高其他 public route 的 limit。
|
||||
具体默认值不能拍脑袋确定:先采集 1k/10k representative configs 的 encoded
|
||||
size 和 peak memory,再选择有明确 headroom 的默认值及硬上限。
|
||||
|
||||
提高 Full limit 只是确保 fallback 覆盖已支持的生产规模,不是稳态优化。请求压缩
|
||||
同样只能降低 wire bytes,不能降低 JSON materialization 和 SQLite 工作量,因此
|
||||
不作为 Patch 的前置条件。
|
||||
|
||||
## 8. Failure and recovery
|
||||
|
||||
| Failure | Receiver state | Caller action |
|
||||
| --- | --- | --- |
|
||||
| Invalid payload | Unchanged | 修复请求,不重试相同 payload |
|
||||
| Capacity exceeded | Unchanged | 使用较小 Patch;Full 需检查支持规模 |
|
||||
| Revision conflict | Unchanged | 重新观察;有 base 时重算 Patch,否则 Full |
|
||||
| Ownership conflict | Unchanged | 解决 ownership,不能自动覆盖 |
|
||||
| SQLite error before commit | Rolled back | 从相同 observed revision 重试 |
|
||||
| Response lost after commit | Target committed | 同一 target retry,幂等成功 |
|
||||
| Process exits before revisioned Session notify | Target committed | 现有 revision reconciliation 恢复 |
|
||||
| Alternate web-row mutation | Revision atomically cleared | 下一次观察触发 Full 修复 |
|
||||
| Console cache loss | Receiver unchanged | Console 发布 Full |
|
||||
|
||||
Receiver 不保存 Patch delivery ledger。Publisher 根据自己的完整目标和 receiver
|
||||
当前 revision 重算 Patch 或选择 Full。
|
||||
|
||||
## 9. Rollout and rollback
|
||||
|
||||
### 9.1 Receiver-first rollout
|
||||
|
||||
1. 为现有 Full 行为增加 characterization tests。
|
||||
2. 将 Full rows/revision 改为一个 atomic transaction。
|
||||
3. 为 alternate web-row mutation 增加 revision invalidation。
|
||||
4. 增加 PATCH、typed conflict、capacity limits 和 metrics。
|
||||
5. 在 Console 仍只发送 PUT 时部署到全部 EasyTier Web 实例。
|
||||
6. 完成旧 Console PUT、新 Console PUT/PATCH contract 测试。
|
||||
7. 最后启用 Console Patch 发布。
|
||||
|
||||
Patch capability 不通过 `/validate-token` 协商。部署顺序就是 compatibility gate;
|
||||
这样不会把配置能力重新耦合回鉴权 Interface。
|
||||
|
||||
Console 遇到 409 可以 re-observe 后发送 Full。它不能把 404、401 或 malformed
|
||||
response 当作旧 receiver 并静默换一种 mutation contract;出现 404 表示接收端
|
||||
部署门禁未满足。
|
||||
|
||||
### 9.2 Rollback
|
||||
|
||||
- Console 尚未发送 Patch 时,EasyTier Web 可正常回滚。
|
||||
- Console 已发送 Patch 后,先回滚 Console,使调用恢复为 PUT,再回滚 Web。
|
||||
- PUT 在整个发布周期保持兼容。
|
||||
- Patch 和 Full 写入相同 rows/revision,不需要格式级数据迁移。
|
||||
|
||||
本方案不新增 persistent table。现有 Target/instance unique index 应覆盖 touched-ID
|
||||
查询;若实现时需要新 index,必须先用实际 SQLite query plan 证明。
|
||||
|
||||
## 10. Verification
|
||||
|
||||
### 10.1 Contract tests
|
||||
|
||||
- 现有 Full JSON 继续接受。
|
||||
- 空 Full 删除所有 web-owned rows,保留 user-owned rows。
|
||||
- Patch add/update/delete 与等价 Full 得到相同最终 projection。
|
||||
- Duplicate/overlap/invalid config 返回 400 且零写入。
|
||||
- Patch 缺少 revision 返回 400。
|
||||
- Revision conflict 返回 409 和 current revision,不返回 config。
|
||||
- Byte/count/per-entry limits 分别有确定性测试。
|
||||
|
||||
### 10.2 Transaction and ownership tests
|
||||
|
||||
- 在 upsert 后、delete 后、revision write 前注入错误,rows/revision 全部 rollback。
|
||||
- Revisioned Full/Patch 的 user-owned collision 整体 rollback。
|
||||
- 删除不存在的 ID 幂等成功。
|
||||
- 两个 target 从同一 base 并发时,一个成功、一个 409。
|
||||
- 相同 target retry 只有第一次写入,第二次为 no-op success。
|
||||
- Alternate save/delete/disable web row 与 revision invalidation 原子提交。
|
||||
- User-owned-only mutation 不清除 managed revision。
|
||||
- 数据库重连后,任一 persisted revision 都对应完整一致的 rows。
|
||||
|
||||
### 10.3 Scale tests
|
||||
|
||||
至少使用 1k 和 10k representative entries:
|
||||
|
||||
- 单实例 Patch 的 decoded bytes、row reads、writes 和 statement count 不随 Target
|
||||
总实例数增长;
|
||||
- Patch 不执行 list-all query;
|
||||
- Full 使用 bounded batches 和一个 transaction;
|
||||
- Revision read 保持 O(1);
|
||||
- 超限 Full 稳定返回 413/422,而不是耗尽进程内存;
|
||||
- 并发请求无 deadlock,且 CAS 结果确定。
|
||||
|
||||
Session 测试还必须验证:精确 base/target 使用 touched-instance reconcile;base
|
||||
不匹配、目标 revision 已变化、Full notification 和 Patch backlog 都使用 Full;
|
||||
touched runtime apply 失败不推进 applied revision;删除只作用于本次 delete IDs。
|
||||
运行态 Config Get/Patch/Run/Delete 数量应随 touched instances 增长。为确认运行实例
|
||||
身份而进行的一次 list/meta RPC 可以保留,它不发送或重写所有实例配置。
|
||||
|
||||
## 11. Observability
|
||||
|
||||
每个请求记录结构化字段,但不记录 config 内容:
|
||||
|
||||
- mode:`full` / `patch`;
|
||||
- user/machine scope;
|
||||
- request bytes;
|
||||
- desired/upsert/delete count;
|
||||
- normalization、target-lock wait、transaction duration;
|
||||
- SQL statement/batch count;
|
||||
- result:applied、already-applied、revision-conflict、ownership-conflict、
|
||||
invalid、oversized、database-error;
|
||||
- Session notification 是否发送。
|
||||
|
||||
Rollout acceptance:
|
||||
|
||||
- Console 启用后 Patch 占普通变更的绝大多数;
|
||||
- 单实例变化的 request size 与 SQLite cost 与单实例成比例;
|
||||
- conflict rate 可解释且稳定;
|
||||
- 支持规模内的 Full 没有 413/422;
|
||||
- validate-token latency 不随 Target 实例数增长。
|
||||
|
||||
## 12. 后续优化
|
||||
|
||||
### 12.1 Session runtime delta apply(已实现)
|
||||
|
||||
Patch commit outcome 已携带 touched IDs。Session 只在 applied revision 正好等于
|
||||
Patch base 时执行 touched-instance reconcile;重启、revision 断链、通知丢失或
|
||||
并发 Patch backlog 都退回 Full。接收端不保存 Patch queue,也不合并 delta。
|
||||
|
||||
### 12.2 Chunked Full
|
||||
|
||||
不能把 Full Exact Set 直接分页写入 live rows:接收端无法在中间页判断哪些旧
|
||||
实例最终应删除,crash 也会暴露半套目标。
|
||||
|
||||
如果测量证明单请求 Full 无法覆盖必须支持的冷恢复规模,需要单独设计带
|
||||
snapshot ID、staging rows、expiry、finalize 和 atomic swap 的协议。在出现数据
|
||||
证明前不新增该状态机。
|
||||
|
||||
## 13. Implementation files and checklist
|
||||
|
||||
主要涉及:
|
||||
|
||||
- `easytier-web/src/restful/network.rs`
|
||||
- `easytier-web/src/client_manager/mod.rs`
|
||||
- `easytier-web/src/client_manager/managed_config.rs`
|
||||
- `easytier-web/src/db/mod.rs`
|
||||
- 对应 contract、database 和 managed-config tests
|
||||
|
||||
完成条件:
|
||||
|
||||
- [x] 现有 Full compatibility tests 固定。
|
||||
- [x] Full config rows 与 revision 原子提交。
|
||||
- [x] Alternate web-owned mutations 原子清除 revision。
|
||||
- [x] PATCH contract 和 typed 409 实现。
|
||||
- [x] Patch 只查询、写入 touched IDs。
|
||||
- [ ] Bulk SQL 遵守 tested bind-count bound。
|
||||
- [ ] Route byte/count/per-entry limits 有文档和测试。
|
||||
- [x] User-owned rows 不能被 Full/Patch 覆盖或删除。
|
||||
- [x] Empty Full 语义保持。
|
||||
- [x] Applied/AlreadyApplied/conflict 的通知行为符合设计。
|
||||
- [x] Session 在 revision 连续时只收敛 touched instances,断链时使用 Full。
|
||||
- [ ] 1k/10k scale 与 concurrent CAS tests 通过。
|
||||
- [ ] Receiver-first compatibility matrix 通过。
|
||||
@@ -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 或空数组将停止所有实例
|
||||
@@ -41,14 +73,59 @@ object EasyTierJNI {
|
||||
*/
|
||||
@JvmStatic external fun retainNetworkInstance(instanceNames: Array<String>?): Int
|
||||
|
||||
/**
|
||||
* 停止指定的网络实例,其他实例不受影响
|
||||
* @param instanceName 要停止的实例名称,不存在时为 no-op
|
||||
* @return 0 表示成功,-1 表示失败
|
||||
* @throws RuntimeException 当操作失败时抛出异常
|
||||
*/
|
||||
@JvmStatic external fun deleteNetworkInstance(instanceName: String): Int
|
||||
|
||||
/**
|
||||
* 收集网络信息
|
||||
* @param maxLength 最大返回条目数
|
||||
* @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,271 @@
|
||||
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.
|
||||
//! - `deleteNetworkInstance(instanceName)`: stop exactly one named instance.
|
||||
//! - `listInstances()`: return running instance names and IDs as JSON.
|
||||
//! - `collectNetworkInfos()`: return running instance info as a JSON string.
|
||||
//! - `callJsonRpc(...)`: call an exposed EasyTier RPC service with JSON payload.
|
||||
//!
|
||||
//! 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)
|
||||
}
|
||||
|
||||
/// 收集网络信息
|
||||
/// Stop exactly one named network instance without affecting other instances.
|
||||
///
|
||||
/// Java signature:
|
||||
/// `EasyTierJNI.deleteNetworkInstance(instanceName: String): Int`
|
||||
///
|
||||
/// An unknown name is a no-op. On failure this returns `-1` and throws
|
||||
/// `RuntimeException`.
|
||||
#[unsafe(no_mangle)]
|
||||
pub extern "system" fn Java_com_easytier_jni_EasyTierJNI_deleteNetworkInstance(
|
||||
env: JNIEnv,
|
||||
class: JClass,
|
||||
instance_name: JString,
|
||||
) -> jint {
|
||||
logger::init();
|
||||
network_api::delete_network_instance_jni(env, class, instance_name)
|
||||
}
|
||||
|
||||
/// Collect running network instance information.
|
||||
///
|
||||
/// Java signature:
|
||||
/// `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,285 @@
|
||||
use std::{ffi::CStr, ptr};
|
||||
|
||||
use easytier::proto::api::manage::{NetworkInstanceRunningInfo, NetworkInstanceRunningInfoMap};
|
||||
use easytier_ffi::{
|
||||
KeyValuePair, collect_network_infos, delete_network_instance, free_string, list_instance,
|
||||
parse_config, retain_network_instance, run_network_instance, set_tun_fd,
|
||||
};
|
||||
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 delete_network_instance_jni(
|
||||
mut env: JNIEnv,
|
||||
_class: JClass,
|
||||
instance_name: JString,
|
||||
) -> jint {
|
||||
let instance_name = match jstring_to_cstring(&mut env, &instance_name) {
|
||||
Ok(name) => name,
|
||||
Err(error) => {
|
||||
throw_exception(&mut env, &format!("Invalid instance name: {error}"));
|
||||
return -1;
|
||||
}
|
||||
};
|
||||
let instance_names = [instance_name.as_ptr()];
|
||||
unsafe {
|
||||
let result = delete_network_instance(instance_names.as_ptr(), instance_names.len());
|
||||
if result != 0
|
||||
&& let Some(error) = get_last_error()
|
||||
{
|
||||
throw_exception(&mut env, &error);
|
||||
}
|
||||
result
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn retain_network_instance_jni(
|
||||
mut env: JNIEnv,
|
||||
_class: JClass,
|
||||
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",
|
||||
]
|
||||
|
||||
[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,307 @@
|
||||
use std::ffi::{CString, c_char, c_int};
|
||||
|
||||
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,
|
||||
};
|
||||
|
||||
/// # 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("instance not found");
|
||||
return -1;
|
||||
}
|
||||
Err(error) => {
|
||||
set_error_msg(&error.to_string());
|
||||
return -1;
|
||||
}
|
||||
};
|
||||
|
||||
match ffi_context().manager.attach_tun_fd(inst_id, fd) {
|
||||
Ok(_) => 0,
|
||||
Err(_) => -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,
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
[package]
|
||||
name = "easytier-ios"
|
||||
version = "0.1.0"
|
||||
edition.workspace = true
|
||||
|
||||
[lib]
|
||||
crate-type = ["staticlib", "rlib"]
|
||||
|
||||
[dependencies]
|
||||
serde_json = "1.0"
|
||||
tracing = "0.1"
|
||||
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||
easytier-ffi = { path = "../easytier-ffi", default-features = false, features = [
|
||||
"c-abi",
|
||||
] }
|
||||
|
||||
[dev-dependencies]
|
||||
uuid = "1"
|
||||
tokio = { version = "1", features = ["io-util"] }
|
||||
easytier-core = { path = "../../easytier-core" }
|
||||
easytier-ffi = { path = "../easytier-ffi", default-features = false, features = [
|
||||
"c-abi",
|
||||
"ffi-dataplane",
|
||||
] }
|
||||
+70
@@ -0,0 +1,70 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# Build the easytier-ios static library slices for the Flutter iOS client.
|
||||
#
|
||||
# This script only runs on macOS: it needs the Apple SDK (aarch64-apple-ios*,
|
||||
# x86_64-apple-ios targets) plus `lipo`. Run it from the EasyTier repository
|
||||
# root or from this crate directory.
|
||||
#
|
||||
# rustup target add aarch64-apple-ios aarch64-apple-ios-sim x86_64-apple-ios
|
||||
# ./build-xcframework.sh
|
||||
#
|
||||
# Output (workspace target directory + ./xcframework/sim):
|
||||
# target/aarch64-apple-ios/release/libeasytier_ios.a (device)
|
||||
# xcframework/sim/libeasytier_ios.a (simulator, lipo merged)
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
CRATE_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
# The crate lives in a workspace; build artifacts land in the workspace root
|
||||
# target directory regardless of the current directory.
|
||||
WORKSPACE_ROOT="$(cd "${CRATE_DIR}/../.." && pwd)"
|
||||
TARGET_DIR="${WORKSPACE_ROOT}/target"
|
||||
OUT_DIR="${CRATE_DIR}/xcframework"
|
||||
|
||||
if [[ "$(uname)" != "Darwin" ]]; then
|
||||
echo "error: build-xcframework.sh must run on macOS (needs Apple SDK, lipo)" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
cd "${WORKSPACE_ROOT}"
|
||||
|
||||
# The Rust iOS targets emit a `___chkstk_darwin` stack-probe call but do not
|
||||
# link the compiler-rt archive that provides it. Point the linker at the
|
||||
# matching device or simulator archive shipped inside the Xcode toolchain.
|
||||
CLANG_BIN="$(xcrun --find clang)" # .../Toolchains/XcodeDefault.xctoolchain/usr/bin/clang
|
||||
TOOLCHAIN_USR="${CLANG_BIN%/bin/clang}" # .../XcodeDefault.xctoolchain/usr
|
||||
CLANG_RT_DIR="$(cd "${TOOLCHAIN_USR}/lib/clang" && cd "$(ls | sort -V | tail -1)/lib/darwin" && pwd)"
|
||||
CLANG_RT_RUSTFLAGS="${RUSTFLAGS:-} -C link-arg=-L${CLANG_RT_DIR}"
|
||||
echo "==> using libclang_rt from ${CLANG_RT_DIR}"
|
||||
|
||||
# kcp-sys's bindgen rejects the `-sim` in the aarch64-apple-ios-sim target
|
||||
# triple; give bindgen an explicit simulator target so the C bindings build.
|
||||
SIM_SDK="$(xcrun --sdk iphonesimulator --show-sdk-path)"
|
||||
|
||||
echo "==> building aarch64-apple-ios (device)"
|
||||
RUSTFLAGS="${CLANG_RT_RUSTFLAGS} -C link-arg=-lclang_rt.ios" \
|
||||
cargo build -p easytier-ios --release --target aarch64-apple-ios
|
||||
|
||||
echo "==> building aarch64-apple-ios-sim (Apple Silicon simulator)"
|
||||
BINDGEN_EXTRA_CLANG_ARGS="--target=arm64-apple-ios17.0-simulator -isysroot ${SIM_SDK}" \
|
||||
RUSTFLAGS="${CLANG_RT_RUSTFLAGS} -C link-arg=-lclang_rt.iossim" \
|
||||
cargo build -p easytier-ios --release --target aarch64-apple-ios-sim
|
||||
|
||||
echo "==> building x86_64-apple-ios (Intel simulator)"
|
||||
BINDGEN_EXTRA_CLANG_ARGS="--target=x86_64-apple-ios17.0-simulator -isysroot ${SIM_SDK}" \
|
||||
RUSTFLAGS="${CLANG_RT_RUSTFLAGS} -C link-arg=-lclang_rt.iossim" \
|
||||
cargo build -p easytier-ios --release --target x86_64-apple-ios
|
||||
|
||||
rm -rf "${OUT_DIR}"
|
||||
mkdir -p "${OUT_DIR}/sim"
|
||||
|
||||
echo "==> lipo: merge simulator slices"
|
||||
lipo -create \
|
||||
"${TARGET_DIR}/aarch64-apple-ios-sim/release/libeasytier_ios.a" \
|
||||
"${TARGET_DIR}/x86_64-apple-ios/release/libeasytier_ios.a" \
|
||||
-output "${OUT_DIR}/sim/libeasytier_ios.a"
|
||||
|
||||
echo "==> done:"
|
||||
echo " device: ${TARGET_DIR}/aarch64-apple-ios/release/libeasytier_ios.a"
|
||||
echo " simulator: ${OUT_DIR}/sim/libeasytier_ios.a"
|
||||
@@ -0,0 +1,158 @@
|
||||
/**
|
||||
* @file easytier-ios.h
|
||||
* @brief iOS-facing C ABI for EasyTier.
|
||||
*
|
||||
* This library embeds EasyTier into an iOS app without a TUN device or
|
||||
* NEPacketTunnel: it manages EasyTier instances and bridges to the EasyTier
|
||||
* management RPC surface. Loopback port forwarding into the virtual network
|
||||
* is configured through easytier_ios_call_json_rpc() with
|
||||
* api.config.ConfigRpcService/PatchConfig port-forward patches; there is no
|
||||
* built-in forwarder.
|
||||
*
|
||||
* Error handling: functions returning `int` return 0 on success and -1 on
|
||||
* failure; functions returning `char *` return NULL on failure. Call
|
||||
* easytier_ios_last_error() on the same thread to retrieve details.
|
||||
*
|
||||
* Threading: all functions are safe to call from any thread. The last-error
|
||||
* buffer is thread-local, so query it on the thread that received the
|
||||
* failure.
|
||||
*/
|
||||
|
||||
#ifndef EASYTIER_IOS_H
|
||||
#define EASYTIER_IOS_H
|
||||
|
||||
#include <stdint.h>
|
||||
|
||||
#ifdef __cplusplus
|
||||
extern "C" {
|
||||
#endif
|
||||
|
||||
/**
|
||||
* @brief Configure persistent EasyTier diagnostic logging.
|
||||
*
|
||||
* Enabling writes targeted connection trace/debug events into rotating log
|
||||
* files in `directory`; disabling turns the filter off and flushes output.
|
||||
*
|
||||
* @param directory UTF-8 directory path. Required when enabling; ignored when
|
||||
* disabling.
|
||||
* @param enabled Non-zero to enable, zero to disable.
|
||||
* @return 0 on success, -1 on failure.
|
||||
*/
|
||||
int easytier_ios_configure_diagnostic_logging(const char *directory,
|
||||
int enabled);
|
||||
|
||||
/**
|
||||
* @brief Append a host lifecycle or network-path marker to the active log.
|
||||
*
|
||||
* This is a no-op while diagnostic logging is disabled.
|
||||
*
|
||||
* @param message Non-null NUL-terminated UTF-8 event text.
|
||||
* @return 0 on success, -1 on failure.
|
||||
*/
|
||||
int easytier_ios_append_diagnostic_event(const char *message);
|
||||
|
||||
/** @brief Flush diagnostic log output. */
|
||||
int easytier_ios_flush_diagnostic_logging(void);
|
||||
|
||||
/** @brief Delete all diagnostic log content and reopen the active log. */
|
||||
int easytier_ios_clear_diagnostic_logs(void);
|
||||
|
||||
/**
|
||||
* @brief Start one EasyTier network instance from a TOML config string.
|
||||
*
|
||||
* The config's `instance_name` must be unique among instances started
|
||||
* through this library.
|
||||
*
|
||||
* @param toml Non-null pointer to a NUL-terminated UTF-8 TOML config string.
|
||||
* @return 0 on success, -1 on failure.
|
||||
*/
|
||||
int easytier_ios_run_instance(const char *toml);
|
||||
|
||||
/**
|
||||
* @brief Keep the named instances and stop all others.
|
||||
*
|
||||
* @param names_json Null, empty, or a NUL-terminated JSON array of instance
|
||||
* name strings. Null / empty / `[]` stops every running
|
||||
* instance.
|
||||
* @return 0 on success, -1 on failure.
|
||||
*/
|
||||
int easytier_ios_retain_instances(const char *names_json);
|
||||
|
||||
/**
|
||||
* @brief Stop exactly one named instance without affecting other instances.
|
||||
*
|
||||
* An unknown name is a no-op.
|
||||
*
|
||||
* @param instance_name Non-null NUL-terminated instance name.
|
||||
* @return 0 on success, -1 on failure.
|
||||
*/
|
||||
int easytier_ios_delete_instance(const char *instance_name);
|
||||
|
||||
/**
|
||||
* @brief Collect running instance information as a JSON object.
|
||||
*
|
||||
* The result maps each instance name to its running info JSON object.
|
||||
*
|
||||
* @param max_length Maximum number of instances to report.
|
||||
* @return A newly allocated NUL-terminated JSON string on success, NULL on
|
||||
* failure.
|
||||
*
|
||||
* @ownership The caller owns the returned string and must release it with
|
||||
* easytier_ios_free_string().
|
||||
*/
|
||||
char *easytier_ios_collect_network_infos(int max_length);
|
||||
|
||||
/**
|
||||
* @brief Call an exposed EasyTier management RPC method using protobuf JSON.
|
||||
*
|
||||
* `service_name` is the protobuf service name (e.g.
|
||||
* "api.config.ConfigRpcService"), `method_name` the RPC method name (e.g.
|
||||
* "PatchConfig"). `payload_json` must contain the protobuf JSON request,
|
||||
* including any `instance` selector required by the target RPC.
|
||||
*
|
||||
* Port forwarding into the virtual network is driven through this bridge
|
||||
* with api.config.ConfigRpcService/PatchConfig port-forward patches.
|
||||
*
|
||||
* @param service_name Non-null NUL-terminated RPC service name.
|
||||
* @param method_name Non-null NUL-terminated RPC method name.
|
||||
* @param payload_json Non-null NUL-terminated protobuf JSON request body.
|
||||
* @return A newly allocated NUL-terminated JSON response string on success,
|
||||
* NULL on failure.
|
||||
*
|
||||
* @ownership The caller owns the returned string and must release it with
|
||||
* easytier_ios_free_string().
|
||||
*/
|
||||
char *easytier_ios_call_json_rpc(const char *service_name,
|
||||
const char *method_name,
|
||||
const char *payload_json);
|
||||
|
||||
/**
|
||||
* @brief Return the last error message on this thread.
|
||||
*
|
||||
* Combines wrapper-side errors recorded by this library with the
|
||||
* easytier-ffi last FFI error.
|
||||
*
|
||||
* @return A newly allocated NUL-terminated string, or NULL when there is no
|
||||
* recorded error.
|
||||
*
|
||||
* @ownership The caller owns the returned string and must release it with
|
||||
* easytier_ios_free_string().
|
||||
*/
|
||||
char *easytier_ios_last_error(void);
|
||||
|
||||
/**
|
||||
* @brief Release a string returned by this library.
|
||||
*
|
||||
* Use this for strings returned by easytier_ios_collect_network_infos(),
|
||||
* easytier_ios_call_json_rpc() and easytier_ios_last_error(). Passing NULL
|
||||
* is a no-op. The string must not be used after this call.
|
||||
*
|
||||
* @param s NULL, or a string previously returned by this library.
|
||||
*/
|
||||
void easytier_ios_free_string(char *s);
|
||||
|
||||
#ifdef __cplusplus
|
||||
}
|
||||
#endif
|
||||
|
||||
#endif /* EASYTIER_IOS_H */
|
||||
@@ -0,0 +1,308 @@
|
||||
use std::{
|
||||
fs::{self, File, OpenOptions},
|
||||
io::{self, Write},
|
||||
path::{Path, PathBuf},
|
||||
sync::{Arc, Mutex},
|
||||
};
|
||||
|
||||
use tracing_subscriber::fmt::MakeWriter;
|
||||
|
||||
pub(crate) const MAX_LOG_BYTES: u64 = 5 * 1024 * 1024;
|
||||
pub(crate) const MAX_LOG_FILES: usize = 4;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub(crate) struct DiagnosticMakeWriter {
|
||||
inner: Arc<Mutex<RotatingLog>>,
|
||||
}
|
||||
|
||||
impl DiagnosticMakeWriter {
|
||||
pub(crate) fn new(directory: &Path) -> io::Result<Self> {
|
||||
Ok(Self {
|
||||
inner: Arc::new(Mutex::new(RotatingLog::open(directory)?)),
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn set_directory(&self, directory: &Path) -> io::Result<()> {
|
||||
self.lock()?.set_directory(directory)
|
||||
}
|
||||
|
||||
pub(crate) fn clear(&self) -> io::Result<()> {
|
||||
self.lock()?.clear()
|
||||
}
|
||||
|
||||
pub(crate) fn flush(&self) -> io::Result<()> {
|
||||
self.lock()?.flush()
|
||||
}
|
||||
|
||||
fn lock(&self) -> io::Result<std::sync::MutexGuard<'_, RotatingLog>> {
|
||||
self.inner
|
||||
.lock()
|
||||
.map_err(|_| io::Error::other("diagnostic log lock poisoned"))
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a> MakeWriter<'a> for DiagnosticMakeWriter {
|
||||
type Writer = BufferedEventWriter;
|
||||
|
||||
fn make_writer(&'a self) -> Self::Writer {
|
||||
BufferedEventWriter {
|
||||
target: self.clone(),
|
||||
buffer: Vec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) struct BufferedEventWriter {
|
||||
target: DiagnosticMakeWriter,
|
||||
buffer: Vec<u8>,
|
||||
}
|
||||
|
||||
impl BufferedEventWriter {
|
||||
fn commit(&mut self) -> io::Result<()> {
|
||||
if self.buffer.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
let buffer = std::mem::take(&mut self.buffer);
|
||||
self.target.lock()?.write_event(&buffer)
|
||||
}
|
||||
}
|
||||
|
||||
impl Write for BufferedEventWriter {
|
||||
fn write(&mut self, buffer: &[u8]) -> io::Result<usize> {
|
||||
self.buffer.extend_from_slice(buffer);
|
||||
Ok(buffer.len())
|
||||
}
|
||||
|
||||
fn flush(&mut self) -> io::Result<()> {
|
||||
self.commit()
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for BufferedEventWriter {
|
||||
fn drop(&mut self) {
|
||||
let _ = self.commit();
|
||||
}
|
||||
}
|
||||
|
||||
struct RotatingLog {
|
||||
directory: PathBuf,
|
||||
active: Option<File>,
|
||||
active_bytes: u64,
|
||||
}
|
||||
|
||||
impl RotatingLog {
|
||||
fn open(directory: &Path) -> io::Result<Self> {
|
||||
fs::create_dir_all(directory)?;
|
||||
let mut log = Self {
|
||||
directory: directory.to_owned(),
|
||||
active: None,
|
||||
active_bytes: 0,
|
||||
};
|
||||
log.truncate_oversized_files()?;
|
||||
log.open_active()?;
|
||||
Ok(log)
|
||||
}
|
||||
|
||||
fn set_directory(&mut self, directory: &Path) -> io::Result<()> {
|
||||
if self.directory == directory && self.active.is_some() {
|
||||
return Ok(());
|
||||
}
|
||||
self.flush()?;
|
||||
self.active = None;
|
||||
self.directory = directory.to_owned();
|
||||
fs::create_dir_all(directory)?;
|
||||
self.truncate_oversized_files()?;
|
||||
self.open_active()
|
||||
}
|
||||
|
||||
fn active_path(&self) -> PathBuf {
|
||||
self.directory.join("easytier.log")
|
||||
}
|
||||
|
||||
fn rotated_path(&self, index: usize) -> PathBuf {
|
||||
self.directory.join(format!("easytier.{index}.log"))
|
||||
}
|
||||
|
||||
fn truncate_oversized_files(&self) -> io::Result<()> {
|
||||
let paths = std::iter::once(self.active_path())
|
||||
.chain((1..MAX_LOG_FILES).map(|index| self.rotated_path(index)));
|
||||
for path in paths {
|
||||
if path
|
||||
.metadata()
|
||||
.is_ok_and(|metadata| metadata.len() > MAX_LOG_BYTES)
|
||||
{
|
||||
OpenOptions::new()
|
||||
.write(true)
|
||||
.open(path)?
|
||||
.set_len(MAX_LOG_BYTES)?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn open_active(&mut self) -> io::Result<()> {
|
||||
let path = self.active_path();
|
||||
let file = OpenOptions::new().create(true).append(true).open(&path)?;
|
||||
self.active_bytes = file.metadata()?.len();
|
||||
self.active = Some(file);
|
||||
if self.active_bytes >= MAX_LOG_BYTES {
|
||||
self.rotate()?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn write_event(&mut self, event: &[u8]) -> io::Result<()> {
|
||||
if event.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
if self.active_bytes > 0
|
||||
&& self.active_bytes.saturating_add(event.len() as u64) > MAX_LOG_BYTES
|
||||
{
|
||||
self.rotate()?;
|
||||
}
|
||||
let remaining = MAX_LOG_BYTES.saturating_sub(self.active_bytes) as usize;
|
||||
let event = &event[..event.len().min(remaining)];
|
||||
if let Some(active) = self.active.as_mut() {
|
||||
active.write_all(event)?;
|
||||
self.active_bytes += event.len() as u64;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn rotate(&mut self) -> io::Result<()> {
|
||||
self.flush()?;
|
||||
self.active = None;
|
||||
|
||||
let oldest = self.rotated_path(MAX_LOG_FILES - 1);
|
||||
if oldest.exists() {
|
||||
fs::remove_file(oldest)?;
|
||||
}
|
||||
for index in (1..MAX_LOG_FILES - 1).rev() {
|
||||
let source = self.rotated_path(index);
|
||||
if source.exists() {
|
||||
fs::rename(source, self.rotated_path(index + 1))?;
|
||||
}
|
||||
}
|
||||
let active = self.active_path();
|
||||
if active.exists() {
|
||||
fs::rename(active, self.rotated_path(1))?;
|
||||
}
|
||||
self.active_bytes = 0;
|
||||
self.active = Some(
|
||||
OpenOptions::new()
|
||||
.create(true)
|
||||
.append(true)
|
||||
.open(self.active_path())?,
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn clear(&mut self) -> io::Result<()> {
|
||||
self.flush()?;
|
||||
self.active = None;
|
||||
for index in 1..MAX_LOG_FILES {
|
||||
let path = self.rotated_path(index);
|
||||
if path.exists() {
|
||||
fs::remove_file(path)?;
|
||||
}
|
||||
}
|
||||
let active = self.active_path();
|
||||
if active.exists() {
|
||||
fs::remove_file(&active)?;
|
||||
}
|
||||
self.active_bytes = 0;
|
||||
self.active = Some(OpenOptions::new().create(true).append(true).open(active)?);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn flush(&mut self) -> io::Result<()> {
|
||||
match self.active.as_mut() {
|
||||
Some(active) => active.flush(),
|
||||
None => Ok(()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
struct TempDir(PathBuf);
|
||||
|
||||
impl TempDir {
|
||||
fn new(name: &str) -> Self {
|
||||
let unique = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_nanos();
|
||||
let path = std::env::temp_dir().join(format!("easytier-ios-{name}-{unique}"));
|
||||
fs::create_dir_all(&path).unwrap();
|
||||
Self(path)
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for TempDir {
|
||||
fn drop(&mut self) {
|
||||
let _ = fs::remove_dir_all(&self.0);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rotates_without_exceeding_file_limit() {
|
||||
let directory = TempDir::new("rotation");
|
||||
let mut log = RotatingLog::open(&directory.0).unwrap();
|
||||
let event = vec![b'x'; (MAX_LOG_BYTES / 2 + 1) as usize];
|
||||
|
||||
for _ in 0..6 {
|
||||
log.write_event(&event).unwrap();
|
||||
}
|
||||
log.flush().unwrap();
|
||||
|
||||
let files = fs::read_dir(&directory.0)
|
||||
.unwrap()
|
||||
.collect::<Result<Vec<_>, _>>()
|
||||
.unwrap();
|
||||
assert_eq!(files.len(), MAX_LOG_FILES);
|
||||
assert!(
|
||||
files
|
||||
.iter()
|
||||
.all(|entry| entry.metadata().unwrap().len() <= MAX_LOG_BYTES)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clear_removes_rotated_content_and_keeps_active_file_writable() {
|
||||
let directory = TempDir::new("clear");
|
||||
let mut log = RotatingLog::open(&directory.0).unwrap();
|
||||
let event = vec![b'x'; (MAX_LOG_BYTES / 2 + 1) as usize];
|
||||
log.write_event(&event).unwrap();
|
||||
log.write_event(&event).unwrap();
|
||||
|
||||
log.clear().unwrap();
|
||||
log.write_event(b"after clear\n").unwrap();
|
||||
log.flush().unwrap();
|
||||
|
||||
assert_eq!(fs::read(log.active_path()).unwrap(), b"after clear\n");
|
||||
assert!(!log.rotated_path(1).exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn opening_truncates_oversized_known_files() {
|
||||
let directory = TempDir::new("oversized");
|
||||
for name in ["easytier.log", "easytier.1.log"] {
|
||||
let file = File::create(directory.0.join(name)).unwrap();
|
||||
file.set_len(MAX_LOG_BYTES + 1).unwrap();
|
||||
}
|
||||
|
||||
let log = RotatingLog::open(&directory.0).unwrap();
|
||||
|
||||
for index in 1..MAX_LOG_FILES {
|
||||
let path = log.rotated_path(index);
|
||||
if path.exists() {
|
||||
assert!(path.metadata().unwrap().len() <= MAX_LOG_BYTES);
|
||||
}
|
||||
}
|
||||
assert!(log.active_path().metadata().unwrap().len() <= MAX_LOG_BYTES);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
use std::{
|
||||
cell::RefCell,
|
||||
ffi::{CStr, CString, c_char},
|
||||
ptr,
|
||||
};
|
||||
|
||||
thread_local! {
|
||||
// Thread-local last error for the easytier-ios C ABI. Wrapper-side
|
||||
// argument/JSON failures are recorded here; easytier-ffi records
|
||||
// instance/RPC failures in its own buffer. `last_error` merges both.
|
||||
static LAST_ERROR: RefCell<Vec<u8>> = const { RefCell::new(Vec::new()) };
|
||||
}
|
||||
|
||||
pub(crate) fn set_error(message: &str) {
|
||||
LAST_ERROR.with(|cell| {
|
||||
let mut buffer = cell.borrow_mut();
|
||||
buffer.clear();
|
||||
buffer.extend_from_slice(message.as_bytes());
|
||||
});
|
||||
}
|
||||
|
||||
pub(crate) fn clear_error() {
|
||||
LAST_ERROR.with(|cell| cell.borrow_mut().clear());
|
||||
}
|
||||
|
||||
fn thread_local_error() -> Option<String> {
|
||||
LAST_ERROR.with(|cell| {
|
||||
let buffer = cell.borrow();
|
||||
if buffer.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(String::from_utf8_lossy(&buffer).into_owned())
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn ffi_error() -> Option<String> {
|
||||
unsafe {
|
||||
let mut error_ptr: *const c_char = ptr::null();
|
||||
easytier_ffi::get_error_msg(&mut error_ptr);
|
||||
if error_ptr.is_null() {
|
||||
None
|
||||
} else {
|
||||
let error_str = CStr::from_ptr(error_ptr).to_string_lossy().into_owned();
|
||||
easytier_ffi::free_string(error_ptr);
|
||||
Some(error_str)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Merge both error layers: this wrapper's own thread-local buffer and
|
||||
/// easytier-ffi's last FFI error.
|
||||
pub(crate) fn last_error() -> Option<String> {
|
||||
match (ffi_error(), thread_local_error()) {
|
||||
(Some(ffi_error), Some(local_error)) => Some(format!("{local_error}; {ffi_error}")),
|
||||
(Some(ffi_error), None) => Some(ffi_error),
|
||||
(None, Some(local_error)) => Some(local_error),
|
||||
(None, None) => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Copy the merged last error into a newly allocated C string (null when
|
||||
/// there is no error). The caller owns the result and must release it with
|
||||
/// `easytier_ios_free_string`.
|
||||
pub(crate) fn last_error_raw() -> *mut c_char {
|
||||
match last_error().and_then(|message| CString::new(message).ok()) {
|
||||
Some(message) => message.into_raw(),
|
||||
None => ptr::null_mut(),
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,11 @@
|
||||
use std::ffi::CString;
|
||||
|
||||
/// Build a NUL-terminated C string from a Rust string for FFI calls.
|
||||
pub(crate) fn cstring_for(value: &str, what: &str) -> std::io::Result<CString> {
|
||||
CString::new(value).map_err(|_| {
|
||||
std::io::Error::new(
|
||||
std::io::ErrorKind::InvalidInput,
|
||||
format!("{what} contains a null byte"),
|
||||
)
|
||||
})
|
||||
}
|
||||
@@ -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,430 @@
|
||||
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(),
|
||||
// protobuf JSON uses enum names rather than their numeric wire values.
|
||||
// Returning the number here made ArkTS write (for example) `1`, while
|
||||
// NetworkConfig deserialization expects `"None"`, so field-level saves
|
||||
// were rejected by the repository validation step.
|
||||
value: value.name().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")
|
||||
);
|
||||
|
||||
let data_compress_algo = schema
|
||||
.children
|
||||
.iter()
|
||||
.find(|field| field.name == "data_compress_algo")
|
||||
.expect("data_compress_algo field");
|
||||
let none = data_compress_algo
|
||||
.enum_options
|
||||
.iter()
|
||||
.find(|option| option.label == "None")
|
||||
.expect("compression None option");
|
||||
assert_eq!(none.value, "None");
|
||||
}
|
||||
}
|
||||
@@ -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::repository::{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,454 @@
|
||||
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_manual_routes(config_id: &str) -> Vec<String> {
|
||||
RUNTIME_CONFIG_SNAPSHOTS
|
||||
.lock()
|
||||
.ok()
|
||||
.and_then(|guard| {
|
||||
guard
|
||||
.get(config_id)
|
||||
.map(|snapshot| snapshot.config.routes.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,166 @@
|
||||
use crate::config::repository::get_runtime_config_manual_routes;
|
||||
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 = get_runtime_config_manual_routes(&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());
|
||||
// Locally configured proxy CIDRs are advertisements for networks reached
|
||||
// through this node. Installing them into this node's TUN would recapture
|
||||
// the proxy's own destination sockets instead of using the physical LAN.
|
||||
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
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::config::repository::{cache_runtime_config_snapshot, clear_runtime_config_snapshot};
|
||||
use crate::runtime::state::runtime_state::{MyNodeInfo, RouteView};
|
||||
use easytier::proto::api::manage::NetworkConfig;
|
||||
|
||||
fn runtime_instance(config_id: &str) -> RuntimeInstanceState {
|
||||
RuntimeInstanceState {
|
||||
config_id: config_id.to_string(),
|
||||
instance_id: "test-instance".to_string(),
|
||||
display_name: "test".to_string(),
|
||||
running: true,
|
||||
tun_required: true,
|
||||
tun_attached: false,
|
||||
magic_dns_enabled: false,
|
||||
need_exit_node: false,
|
||||
error_message: None,
|
||||
my_node_info: Some(MyNodeInfo {
|
||||
virtual_ipv4: Some("10.144.144.1".to_string()),
|
||||
virtual_ipv4_cidr: Some("10.144.144.1/24".to_string()),
|
||||
hostname: None,
|
||||
version: None,
|
||||
peer_id: Some(1),
|
||||
listeners: Vec::new(),
|
||||
vpn_portal_cfg: None,
|
||||
udp_nat_type: None,
|
||||
tcp_nat_type: None,
|
||||
}),
|
||||
events: Vec::new(),
|
||||
routes: vec![RouteView {
|
||||
peer_id: 2,
|
||||
hostname: None,
|
||||
ipv4: Some("10.144.144.2".to_string()),
|
||||
ipv4_cidr: Some("10.144.144.2/24".to_string()),
|
||||
ipv6_cidr: None,
|
||||
proxy_cidrs: vec!["10.20.0.0/16".to_string()],
|
||||
next_hop_peer_id: Some(2),
|
||||
cost: Some(1),
|
||||
path_latency: None,
|
||||
udp_nat_type: None,
|
||||
tcp_nat_type: None,
|
||||
inst_id: None,
|
||||
version: None,
|
||||
is_public_server: None,
|
||||
}],
|
||||
peers: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn local_proxy_cidr_is_not_installed_in_tun_routes() {
|
||||
let config_id = "routing-test-local-proxy";
|
||||
cache_runtime_config_snapshot(
|
||||
config_id.to_string(),
|
||||
"test".to_string(),
|
||||
NetworkConfig {
|
||||
routes: vec!["172.16.0.0/16".to_string()],
|
||||
proxy_cidrs: vec!["192.168.1.0/24".to_string()],
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
|
||||
let routes = aggregate_tun_routes(&runtime_instance(config_id));
|
||||
clear_runtime_config_snapshot(config_id);
|
||||
|
||||
assert!(routes.contains(&"10.144.144.0/24".to_string()));
|
||||
assert!(routes.contains(&"172.16.0.0/16".to_string()));
|
||||
assert!(routes.contains(&"10.20.0.0/16".to_string()));
|
||||
assert!(!routes.contains(&"192.168.1.0/24".to_string()));
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user